Merge pull request #1343 from Runfusion/gsxdsm/compound
feat: Compound Engineering plugin with interactive sessions, work bridge, and bidirectional sync
This commit is contained in:
12
.changeset/compound-engineering-plugin-scaffold.md
Normal file
12
.changeset/compound-engineering-plugin-scaffold.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add the Compound Engineering bundled plugin: a dedicated dashboard surface for compound-engineering artifacts and interactive `ce-*` sessions, a work→board bridge, and bidirectional board↔pipeline sync. Sessions are fully multi-session: a Sessions panel lists every run with stage/status/last-activity, lets you open and switch between concurrent sessions (each keeps running server-side), resume interrupted ones, and discard settled ones (`DELETE /sessions/:id` disposes the live handle before deleting the row).
|
||||
|
||||
Sessions show the agent's full working output live (streamed thinking/tool activity with an inactivity-based stall timeout instead of a fixed turn timeout), the user can steer mid-stage with free-text guidance (attached to an answer or sent on its own), and the transcript renders past questions/answers/working traces as a proper chat surface.
|
||||
|
||||
This also adds two reusable host capabilities that any plugin benefits from:
|
||||
|
||||
- **Interactive agent sessions for plugin routes** (`ctx.createInteractiveAiSession`), with skill-discovery forwarding (`requestedSkillNames` / `additionalSkillPaths`) and live mid-turn progress streaming (`onProgress`: thinking/text deltas + tool markers) so a plugin can load a bundled skill into a live session and surface its work in real time.
|
||||
- **Real plugin event push over SSE**: a plugin's `ctx.emitEvent` calls are forwarded to connected `/api/events` clients as project-scoped `plugin:custom` events, and dashboard views can consume them via the new `subscribePluginEvents` view-context capability.
|
||||
20
CONCEPTS.md
20
CONCEPTS.md
@@ -62,6 +62,26 @@ A recurring background scan that detects and repairs stuck Task states — stall
|
||||
### Shared branch group
|
||||
A set of Tasks integrating into a common shared branch instead of each merging straight to the project's default branch. Member integration (task branch → shared branch) is a soft pre-integration step exempt from the global auto-merge gate; promotion (shared branch → default branch) is gated separately.
|
||||
|
||||
## Compound Engineering sessions
|
||||
|
||||
### CE Stage
|
||||
A registered step of the compound-engineering pipeline (e.g. brainstorm, plan, work, compound), each mapped to a bundled skill and a conventional artifact location. Adding a stage is a registry data entry, not new code surface.
|
||||
|
||||
### CE Session
|
||||
A single interactive run of a CE Stage: an agent drives a question/answer flow with the user and produces the stage's artifact on completion. Sessions are independent pipeline runs — many can exist concurrently, each with its own lifecycle (launching, active, awaiting-input, completed, error, interrupted) and conversation history. A completed work-stage CE Session lands derived Tasks on the board, linked back to the session for provenance.
|
||||
|
||||
### Detached turn
|
||||
The execution posture for CE Session agent turns: the request that triggers a turn returns as soon as the session reflects it, and the turn runs in the background while clients converge through push events and polling. A detached turn never rejects — every failure persists into session state and emits an observable event, so progress is never silently lost.
|
||||
|
||||
### Live activity
|
||||
The transient working output of an in-flight agent turn — accumulated thinking, streamed text, and tool execution markers. It is observable while the turn runs but is not session state; when the turn settles or is interrupted, a condensed trace is folded into the conversation history so the transcript keeps the story.
|
||||
|
||||
### Steering
|
||||
The user's mid-stage feedback channel: free-text guidance attached to an answer, or sent on its own without answering the pending question. Agents treat steering as first-class input — incorporate it, adjust course, and either re-ask or proceed.
|
||||
|
||||
### Rehydration
|
||||
Re-establishing a live agent handle for a paused CE Session by replaying its recorded conversation against the model. Replay is side-effect-suppressed: it reconstructs the agent's context without re-emitting events, re-streaming Live activity, or re-writing artifacts.
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "feat: Compound Engineering plugin with end-to-end UI"
|
||||
status: active
|
||||
status: completed
|
||||
type: feat
|
||||
date: 2026-06-02
|
||||
origin: docs/brainstorms/2026-06-02-compound-engineering-plugin-requirements.md
|
||||
|
||||
114
docs/plugins/compound-engineering.md
Normal file
114
docs/plugins/compound-engineering.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Compound Engineering Plugin
|
||||
|
||||
A dedicated dashboard surface for the compound-engineering (CE) workflow — an
|
||||
artifact hub, interactive `ce-*` skill sessions, a work→board bridge, and
|
||||
event-driven bidirectional sync. It runs alongside Fusion's native pipeline.
|
||||
|
||||
## Install
|
||||
|
||||
1. Open **Settings → Plugins → Fusion Plugins**.
|
||||
2. In **Bundled Plugins**, click **Install** for **Compound Engineering**.
|
||||
3. Enable the plugin if it is not already started.
|
||||
|
||||
When installed and enabled, the plugin registers the **Compound Engineering**
|
||||
dashboard view destination and installs its bundled `ce-*` skills into a
|
||||
plugin-local, discoverable directory (never a global `~/.claude/skills` path).
|
||||
|
||||
## Dashboard view
|
||||
|
||||
The Compound Engineering view is registered as a primary plugin destination
|
||||
(`viewId: "compound-engineering"`).
|
||||
|
||||
It provides:
|
||||
- An **artifact hub** that discovers CE artifacts from conventional locations
|
||||
(`STRATEGY.md`, `docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`,
|
||||
`CONCEPTS.md`, `docs/solutions/`) grouped by stage, with explicit
|
||||
empty / partial / error states.
|
||||
- Self-contained artifact previews read through plugin routes under
|
||||
`/api/plugins/fusion-plugin-compound-engineering/`.
|
||||
- A **stage launcher** listing the registered, operator-enabled stages.
|
||||
|
||||
## Sessions
|
||||
|
||||
Each stage maps to a bundled skill via the stage registry
|
||||
(`{ stageId, skillId, artifactLocation, icon, label }`). Launching a stage starts
|
||||
an interactive agent session on the host's `createInteractiveAiSession` seam.
|
||||
|
||||
The orchestrator streams `thinking`/`text` turns, surfaces a structured
|
||||
`question` (pausing in `awaiting_input`), accepts a structured answer, and on
|
||||
`complete` writes the artifact to the stage's conventional location. Lifecycle:
|
||||
`launching → active → awaiting_input → completed`, plus `error` and
|
||||
`interrupted`. Interrupt/error auto-saves progress and emits an observable event;
|
||||
sessions resume/retry back to their current question.
|
||||
|
||||
Turn execution is **detached**: start/answer/resume return as soon as the
|
||||
session row reflects the request, with the agent turn running in the background
|
||||
(failures persist into session state — never an unhandled rejection). While a
|
||||
turn runs, the engine streams mid-turn progress (thinking/text deltas + tool
|
||||
markers) through the seam's `onProgress` option; the orchestrator buffers it
|
||||
and `GET /sessions/:id` attaches it as transient `liveActivity`. The per-turn
|
||||
timeout is **inactivity-based** (progress re-arms it), so long actively-working
|
||||
turns are never killed; on settle/interrupt the working trace is condensed into
|
||||
the conversation history. Users can also **steer** mid-stage: answers may carry
|
||||
free-text guidance (`{value, comment}`) or be guidance-only (`{feedback}`).
|
||||
|
||||
Updates are **pushed** over the shared `/api/events` SSE stream: the orchestrator
|
||||
emits via `ctx.emitEvent`, the host forwards them as project-scoped
|
||||
`plugin:custom` events, and the view subscribes through the host
|
||||
`subscribePluginEvents` capability (no raw `EventSource`). Polling
|
||||
`GET /sessions/:id` remains a fallback. The `projectId` from `start` is threaded
|
||||
through every answer/resume/poll so they resolve the session's owning store.
|
||||
|
||||
HTTP endpoints (under `/api/plugins/fusion-plugin-compound-engineering/`):
|
||||
- `POST /sessions` → start a stage session
|
||||
- `POST /sessions/:id/answer` → answer the awaiting question (send `projectId`)
|
||||
- `POST /sessions/:id/resume` → resume an awaiting/interrupted session (send `projectId`)
|
||||
- `GET /sessions/:id` → current persisted session state (push + poll fallback)
|
||||
- `GET /sessions` → list sessions (filter by status/stage)
|
||||
- `GET /sessions/:id/links` → the work→board pipeline-link records for a session
|
||||
|
||||
## Sync model
|
||||
|
||||
Two separate state machines are kept in sync, never merged:
|
||||
|
||||
- **Board-task ownership** → the task `column`. The **board is authoritative for
|
||||
task state**.
|
||||
- **CE-pipeline ownership** → `ce_pipeline_state.{currentStage, status}`. The
|
||||
**CE flow is authoritative for artifact/pipeline content**.
|
||||
|
||||
**Inbound:** `onTaskMoved` / `onTaskCompleted` hooks resolve the link and enqueue
|
||||
a sync signal under the 5s hook budget — no inline advancement.
|
||||
|
||||
**Reconcile:** `reconcileCePipelines(ctx)` is a single on-demand sweep (not a
|
||||
poll loop). It drains the queue and independently re-derives transitions from
|
||||
live board state, so a dropped or never-enqueued event still converges.
|
||||
|
||||
**Outbound:** when a pipeline advances to a stage that produces board work, the
|
||||
reconciler creates the next-stage board task and links it.
|
||||
|
||||
**Conflict policy:** the reconciler only reads already-terminal board columns and
|
||||
only writes CE-owned fields plus a new board task, so the two writers never
|
||||
contend over the same cell.
|
||||
|
||||
The work bridge tags every CE-originated board task (source `workflow_step` with
|
||||
CE markers in `sourceMetadata`) and records an authoritative pipeline-link row;
|
||||
created tasks then run the normal lifecycle untouched.
|
||||
|
||||
## Settings
|
||||
|
||||
Settings render under **Settings → Plugins → Compound Engineering**.
|
||||
|
||||
**Sessions**
|
||||
- `defaultProvider` (string) — provider for CE interactive sessions; blank uses
|
||||
the host default. Consumed by the orchestrator's factory call.
|
||||
- `defaultModelId` (string) — model within the provider; blank uses the host
|
||||
default. Consumed by the orchestrator's factory call.
|
||||
- `enabledStages` (string[], default = full registry) — only these stage IDs may
|
||||
be launched; the orchestrator rejects others.
|
||||
|
||||
**Sync**
|
||||
- `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep
|
||||
after task move/complete hooks. When off, the hook still enqueues so an
|
||||
on-demand sweep converges later.
|
||||
- `reconcileIntervalMinutes` (number, default `15`) — cadence hint for an
|
||||
on-demand refresh surface; not a continuous poll loop.
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: "Observable long-running agent turns through a blocking plugin-route seam"
|
||||
date: 2026-06-03
|
||||
category: architecture-patterns
|
||||
module: fusion-plugin-compound-engineering
|
||||
problem_type: architecture_pattern
|
||||
component: service_object
|
||||
severity: high
|
||||
applies_when:
|
||||
- "A plugin/HTTP route drives a long-running interactive agent turn behind a blocking request/response seam"
|
||||
- "Mid-turn agent output (thinking, tool calls, streamed text) is swallowed by a pull-based settle-only event API"
|
||||
- "A fixed per-turn timeout risks killing legitimately long, tool-heavy turns that are still actively working"
|
||||
- "Clients need live visibility into agent work without persisting transient activity into durable state"
|
||||
- "A paused, stateful agent session must be resumable across process restarts without re-emitting prior output"
|
||||
symptoms:
|
||||
- "Client blocks on a single POST for minutes with zero visibility into agent progress"
|
||||
- "All mid-turn thinking, tool calls, and streamed text are swallowed; only question/complete/error surface"
|
||||
- "A fixed 120s per-turn timeout interrupts legitimately long tool-heavy turns while the agent is still working"
|
||||
root_cause: async_timing
|
||||
resolution_type: code_fix
|
||||
related_components:
|
||||
- tooling
|
||||
- frontend_stimulus
|
||||
tags:
|
||||
- agent-observability
|
||||
- sse
|
||||
- streaming
|
||||
- detached-execution
|
||||
- plugin-routes
|
||||
- interactive-session
|
||||
- inactivity-timeout
|
||||
- live-activity
|
||||
- compound-engineering
|
||||
---
|
||||
|
||||
# Observable long-running agent turns through a blocking plugin-route seam
|
||||
|
||||
## Context
|
||||
|
||||
The compound-engineering bundled plugin runs interactive CE-stage agent sessions through plugin routes. The host exposes a deliberately minimal **pull-based** interactive seam (`packages/core/src/plugin-types.ts`): the caller drives one `prompt`/`answer` per turn and awaits `nextEvent()`, which resolves only when the turn *settles* (`question` | `complete` | `error`). That contract is simple to drive deterministically from a route or a scripted test — but it had three structural consequences that surfaced as user-visible failures:
|
||||
|
||||
1. **All mid-turn output was swallowed.** `nextEvent()` does not resolve on intermediate thinking/text/tool activity, so a multi-minute tool-heavy turn produced *nothing* observable until it finished.
|
||||
2. **Routes blocked blind.** The POST handler ran the whole turn synchronously inside the request, so clients waited minutes with no feedback (and, for the opening turn, no session id to poll).
|
||||
3. **A fixed 120s turn timeout killed turns that were actively working** — long, legitimately-busy turns hit the wall and died.
|
||||
|
||||
The fix made the agent's work live-streamable, made routes non-blocking (detached turns), made the timeout inactivity-based, and persisted the working trace into the transcript across settle/interrupt and process restarts.
|
||||
|
||||
## Guidance
|
||||
|
||||
### 1. Keep the pull-based settle contract; add a SEPARATE push channel
|
||||
|
||||
Don't convert `nextEvent()` into a stream. Live visibility is a *new, optional, additive* callback (`onProgress`) on the session options — the terminal-only pull semantics are untouched. Scripted test fakes that drive `prompt`/`nextEvent` are completely unaffected, and factories that can't stream simply ignore the option.
|
||||
|
||||
```ts
|
||||
// packages/core/src/plugin-types.ts
|
||||
export interface CreateInteractiveAiSessionOptions {
|
||||
// ...
|
||||
/** Live progress callback, invoked WHILE a turn runs (the pull-based
|
||||
* nextEvent() only resolves once the turn settles). Must not throw —
|
||||
* implementations should swallow callback errors. */
|
||||
onProgress?: (event: InteractiveAiSessionProgressEvent) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Deltas, not snapshots; the consumer accumulates
|
||||
|
||||
Progress events carry incremental *deltas*. The consumer owns accumulation, merge-by-kind, and capping — the protocol stays tiny and the producer holds no buffer state.
|
||||
|
||||
```ts
|
||||
export type InteractiveAiSessionProgressEvent =
|
||||
| { type: "thinking"; delta: string } // incremental DELTA, not a snapshot
|
||||
| { type: "text"; delta: string }
|
||||
| { type: "tool"; name: string; phase: "start" | "end"; isError?: boolean };
|
||||
```
|
||||
|
||||
The engine adapter (`packages/engine/src/index.ts`) maps the underlying agent hooks (`onText`/`onThinking`/`onToolStart`/`onToolEnd`) into these deltas, and **every callback is wrapped in try/catch so a consumer error can never break the agent turn**. The consumer (`orchestrator.handleProgress`) merges consecutive same-kind deltas into one activity turn, opens/closes discrete tool turns, and caps both per-turn chars and turn count, dropping the oldest (the tail is what the user is watching).
|
||||
|
||||
### 3. Detached turns must NEVER reject
|
||||
|
||||
Routes return immediately after the session row exists; the turn runs as a floating background promise (`void turn`). For that to be void-safe, the background promise can have *no* rejection path: factory-create failure, driver throw, and timeout all resolve into a persisted state transition (`failSession` / `interruptSession` / `applyEvent`) plus an emitted observable event. No unhandled rejections, no silent loss.
|
||||
|
||||
```ts
|
||||
// orchestrator.start
|
||||
const turn = this.runOpeningTurn(session.id, stage, opts.openingMessage);
|
||||
if (opts.detach) {
|
||||
void turn; // never rejects (failures persist into state)
|
||||
return { session: this.requireSession(session.id) };
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Inactivity watchdog, not a fixed turn timeout
|
||||
|
||||
The watchdog rejects only after `turnTimeoutMs` of *no progress*; each progress event stamps `lastProgressAt` and re-arms it, so an actively-working turn survives indefinitely. With a non-streaming factory (no progress ever arrives), it degrades cleanly to the old fixed per-turn timeout.
|
||||
|
||||
```ts
|
||||
const check = () => {
|
||||
if (cancelled) return; // cancel() stops the loop when the turn settles
|
||||
const elapsed = Date.now() - (this.lastProgressAt.get(sessionId) ?? 0);
|
||||
if (elapsed >= this.turnTimeoutMs) { reject(new CeTurnTimeoutError(this.turnTimeoutMs)); return; }
|
||||
timer = setTimeout(check, this.turnTimeoutMs - elapsed); // re-arm to the remaining window
|
||||
timer.unref?.();
|
||||
};
|
||||
```
|
||||
|
||||
A watchdog rejection is caught and becomes a preserved-progress `interrupted` state — never silent.
|
||||
|
||||
### 5. Live activity is transient; flush a condensed trace into history on settle/interrupt
|
||||
|
||||
The mid-turn buffer lives only in memory; the GET route reads it from the orchestrator (`getLiveActivity(id)`) and attaches it as a transient `liveActivity` field on the response — never written as session state during the turn.
|
||||
|
||||
On settle (`question`/`complete`/`error`) or interrupt, `flushActivity` writes a condensed copy into conversation history **before** the settling record, so the transcript retains the working trace across restarts.
|
||||
|
||||
### 6. Suppress progress (and all side effects) during rehydration replay
|
||||
|
||||
Resume re-creates a live handle by replaying recorded user turns against the model. That replay re-streams old output — which must not be re-emitted as new work. A `replaying` set gates `handleProgress`, and the replay drains one event per drive but **discards** it (no persist/emit/artifact-write).
|
||||
|
||||
### 7. Throttle push emits and bump the staleness anchor on the same beat
|
||||
|
||||
Progress is high-frequency; per-delta SSE emits would flood clients. Throttle to one emit per interval (500ms here), and on that same beat bump the persisted liveness anchor (`lastActivityAt`) so the stale-session recovery rubric sees an actively-working turn as alive rather than abandoned. The client converges via **push + poll**: an SSE event triggers an immediate refetch (low latency), and a poll interval runs while the turn is mid-flight as a fallback — stopping the moment the session settles.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
- **Observability without protocol churn.** A push side-channel gives live visibility while preserving a settle contract that's trivial to drive deterministically from routes and tests. Converting `nextEvent()` into a stream would have rewritten every consumer and every scripted fake for a purely additive feature.
|
||||
- **Non-blocking routes need void-safe background work.** Detaching a turn is only safe if the background promise has no rejection path. Routing *every* failure into persisted state + an emitted event is what makes `void turn` correct rather than a latent unhandled-rejection bug.
|
||||
- **Activity is the liveness signal.** An inactivity watchdog encodes the real intent ("is it still working?") instead of a proxy ("has it taken too long?"), and folding the same signal into the staleness anchor keeps two independent health rubrics coherent.
|
||||
- **Resilience across restarts.** Persisting a condensed trace on settle/interrupt, plus side-effect-suppressed rehydration, means a paused session resumes in a fresh process with its history intact and without double-streaming.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Surfacing live agent (or any long-running job) work through a request/response or pull-based seam that only resolves on terminal events
|
||||
- A route runs a multi-minute operation and clients currently block with no progress and no handle to poll
|
||||
- A fixed timeout is killing work that is legitimately still active
|
||||
- Resuming a paused, stateful session across process restarts without re-emitting prior output
|
||||
|
||||
Apply the *push-channel-alongside-pull-contract* and *void-safe-detached-turn* patterns together; they're complementary. Don't reach for this when the operation is short and synchronous — the transient buffer, watchdog, and rehydration machinery are overhead you don't need.
|
||||
|
||||
## Examples
|
||||
|
||||
Before/after, distilled:
|
||||
|
||||
- **Before:** route `await`s the entire turn inside POST; client gets nothing for minutes; mid-turn output is dropped because `nextEvent()` only resolves on settle; a fixed 120s timeout kills busy turns.
|
||||
- **After:** POST returns `201 {session}` immediately with `detach: true`; `onProgress` deltas accumulate into a transient buffer attached at GET; an inactivity watchdog re-armed by progress lets busy turns run; failures persist into state + emit; resume rehydrates with replay suppressed.
|
||||
|
||||
The regression tests (`plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts`) lock the load-bearing behaviors:
|
||||
|
||||
- A busy turn pumped with `thinking` deltas at ~45ms intervals for ~3× the 120ms test timeout stays `active`; once quiet, it flips to `interrupted` with the activity trace present in history.
|
||||
- `answer(detach)` returns immediately (`status: active`, `currentQuestion: null`), then the background turn converges to the next question.
|
||||
- `start(detach)` with an exploding factory converges to `status: error` with the message preserved and an observable error event emitted — never silent.
|
||||
|
||||
Failure modes this prevents:
|
||||
|
||||
1. Silent mid-turn blackout (pull-only API resolves nothing until terminal)
|
||||
2. Blocked-blind routes (request held open for the full turn, no handle to poll)
|
||||
3. Killed-while-working timeouts (fixed timeout vs. activity-based liveness)
|
||||
4. Unhandled rejection / silent loss from floating detached turns
|
||||
5. Replay double-streaming during rehydration
|
||||
6. SSE flooding from per-delta emits
|
||||
7. Stale-rubric false positives on busy sessions (liveness not bumped with activity)
|
||||
8. Lost transcript on interrupt/settle (transient buffer never condensed into history)
|
||||
9. Consumer `onProgress` errors breaking the agent turn (guarded at the adapter)
|
||||
|
||||
## Related
|
||||
|
||||
- [Plugin-bundled skills silently fail to load in interactive sessions](../integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md) — sibling learning on the same `CreateInteractiveAiSessionOptions` seam (it added `requestedSkillNames`/`additionalSkillPaths`; this one adds `onProgress`)
|
||||
- `docs/plugins/compound-engineering.md` §Sessions — the reference doc for the CE session transport (push + poll)
|
||||
- Key files: `packages/core/src/plugin-types.ts`, `packages/engine/src/index.ts` (interactive adapter), `plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts`, `src/routes/session-routes.ts`, `src/dashboard/hooks/useCeSession.ts`
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: "Plugin-bundled skills silently fail to load in interactive sessions"
|
||||
date: 2026-06-03
|
||||
category: integration-issues
|
||||
module: packages/engine
|
||||
problem_type: integration_issue
|
||||
component: tooling
|
||||
severity: high
|
||||
symptoms:
|
||||
- "Bundled `ce-*` skills declared via `PluginSkillContribution.skillFiles` never load into live interactive agent sessions"
|
||||
- "No error is raised — the requested skill name silently matches nothing and is dropped"
|
||||
- "The `SKILL.md` files are physically bundled in the plugin yet remain undiscoverable to the session"
|
||||
- "Interim workaround (set session cwd to the install root + name the skill in the system prompt) does not make the skill discoverable"
|
||||
root_cause: incomplete_setup
|
||||
resolution_type: code_fix
|
||||
related_components:
|
||||
- assistant
|
||||
- development_workflow
|
||||
tags:
|
||||
- skills
|
||||
- plugin
|
||||
- skill-resolver
|
||||
- additional-skill-paths
|
||||
- resource-loader
|
||||
- interactive-session
|
||||
- compound-engineering
|
||||
---
|
||||
|
||||
# Plugin-bundled skills silently fail to load in interactive sessions
|
||||
|
||||
## Problem
|
||||
|
||||
Bundled `ce-*` skills declared via `PluginSkillContribution.skillFiles` never loaded into live interactive agent sessions. The engine's skill resolver only *filters* skills it already discovered on disk and never ingests a contribution's `skillFiles`, so a name-only contribution produced no loadable skill — silently.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- A stage session runs, but the agent behaves as if the `ce-*` skill is absent — its instructions are never applied.
|
||||
- The resolver returns an empty/unchanged skill set for the requested name; the filter has nothing matching to keep.
|
||||
- **No error is raised.** A `PluginSkillContribution` is name-only (`{ skillId, name, skillFiles }`), so declaring it is structurally valid; the session just starts without the skill.
|
||||
- Tests using a scripted/fake session pass, hiding the gap — only a *real* resource loader surfaces it.
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
**1. Declaring `skills: PluginSkillContribution[]` alone.** The engine resolver (`skill-resolver.ts`) computes an allow/exclude *filter* over skills the loader already discovered on disk. `createSkillsOverrideFromSelection` returns a callback that only ever runs `base.skills.filter(...)` — it never *adds* skills. If the bundled `SKILL.md` was never physically on a discoverable path, it isn't in `base.skills`, so filtering by its name yields `[]`. The contribution's `skillFiles` are never read for live sessions.
|
||||
|
||||
**2. Setting the session `cwd` to the install root + naming the skill in the system prompt.** `DefaultResourceLoader` discovers skills by scanning *standard skill roots* (e.g. `<cwd>/.claude/skills/<id>/SKILL.md`), not by treating an arbitrary `cwd` as a skills directory. Pointing `cwd` at `<installRoot>` (which holds `<id>/SKILL.md` directly) does not match the layout the loader scans, so the skill still isn't discovered — and it relocates the session away from the project root where it must read context and write artifacts. A prompt mention cannot inject skill content the loader never loaded.
|
||||
|
||||
## Solution
|
||||
|
||||
Two parts: **physically install** the bundled skill to a discoverable plugin-local dir, and **forward both the requested name and the install dir** through a new seam option, end to end.
|
||||
|
||||
**Physical install** (`skill-installation.ts`) — copy each bundled `<skillId>/SKILL.md` into a plugin-local target, with a hard isolation guard (never a global `~/.claude|.codex|.gemini/skills`), idempotently:
|
||||
|
||||
```ts
|
||||
assertPluginLocalTarget(targetRoot); // isolation invariant: never a global skills dir
|
||||
if (!existsSync(join(targetRoot, skillId))) { // skip-if-exists
|
||||
mkdirSync(targetRoot, { recursive: true });
|
||||
cpSync(join(sourceRoot, skillId), join(targetRoot, skillId), { recursive: true });
|
||||
}
|
||||
```
|
||||
|
||||
**Layer 1 — engine loader seam (`pi.ts`).** A new `AgentOptions.additionalSkillPaths`, forwarded into `DefaultResourceLoader` as a real *discovery* path (distinct from the filtering `skillsOverride`):
|
||||
|
||||
```ts
|
||||
// AgentOptions
|
||||
skills?: string[]; // convenience → auto-builds a SkillSelectionContext (requestedSkillNames)
|
||||
additionalSkillPaths?: string[]; // extra dirs (each holding <id>/SKILL.md) for the loader to SCAN
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: resolvedProjectRoot,
|
||||
...(options.additionalSkillPaths?.length
|
||||
? { additionalSkillPaths: [...options.additionalSkillPaths] } : {}),
|
||||
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
||||
});
|
||||
```
|
||||
|
||||
**Layer 2 — core seam type (`plugin-types.ts`).** `CreateInteractiveAiSessionOptions` gains the matching fields so a plugin route can request them:
|
||||
|
||||
```ts
|
||||
requestedSkillNames?: string[]; // names the session should load
|
||||
additionalSkillPaths?: string[]; // dirs to scan so requestedSkillNames are discoverable
|
||||
```
|
||||
|
||||
**Layer 3 — engine adapter (`index.ts`).** Forwards both into `createFnAgent`, mapping `requestedSkillNames` → the convenience `skills` param:
|
||||
|
||||
```ts
|
||||
...(opts.requestedSkillNames?.length ? { skills: opts.requestedSkillNames } : {}),
|
||||
...(opts.additionalSkillPaths?.length ? { additionalSkillPaths: opts.additionalSkillPaths } : {}),
|
||||
```
|
||||
|
||||
**Caller — orchestrator (`orchestrator.ts`).** Passes the stage's skill id as the requested name AND the plugin-local install root as a discovery path, while keeping `cwd` on the project root:
|
||||
|
||||
```ts
|
||||
private buildSessionOptions(stage: CeStageDefinition) {
|
||||
return {
|
||||
cwd: this.projectRoot, // project root — NOT the skills dir
|
||||
requestedSkillNames: [stage.skillId],
|
||||
additionalSkillPaths: resolveStageSkillPaths(), // [resolveDefaultInstallTargetRoot()]
|
||||
// ...systemPrompt, tools, model
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
- `skillsOverride` (built by `createSkillsOverrideFromSelection`) is purely a **filter** over `base.skills`. To make a new skill *exist* in `base.skills`, **discovery** must be fed the path — exactly what `additionalSkillPaths` does on `DefaultResourceLoader`: it scans those dirs for the `<id>/SKILL.md` layout, so the physically-installed skill now appears in `base.skills`.
|
||||
- The convenience `skills` / `requestedSkillNames` param auto-builds a `SkillSelectionContext`, which makes the filter *include* that name instead of passing everything or nothing.
|
||||
- Discovery (add via `additionalSkillPaths`) and selection (keep via `requestedSkillNames`) are now both satisfied, so the skill is loaded **and** retained — with `cwd` still on the project root, so context reads and artifact writes are unaffected.
|
||||
|
||||
## Prevention
|
||||
|
||||
A plugin author shipping a bundled skill should:
|
||||
|
||||
1. **Physically install** the `SKILL.md` to a **plugin-local, discoverable** dir laid out as `<root>/<skillId>/SKILL.md` (use `cpSync` + skip-if-exists). Never install into a global `~/.claude|.codex|.gemini/skills`; keep an explicit `assertPluginLocalTarget()` guard so a global install is never clobbered.
|
||||
2. **Forward both** seam options when starting the session: `requestedSkillNames: [skillId]` (so the resolver keeps it) **and** `additionalSkillPaths: [installRoot]` (so the loader discovers it). One without the other silently no-ops — a name with no discovered file filters to `[]`; a discovered file with no requested name can be filtered out.
|
||||
3. Remember `skillsOverride` only filters — declaring a `PluginSkillContribution` is **name-only** and never injects skill content into a live session.
|
||||
4. **Prove it with a real `DefaultResourceLoader`** (see `packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts`) that asserts the skill actually appears in the resolved session skills — a scripted/fake session cannot catch a discovery gap.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- `docs/PLUGIN_AUTHORING.md` (§skills) presents `skillFiles` as sufficient for surfacing bundled skills in sessions — now misleading; warrants a note that plugins must physically install + forward `additionalSkillPaths`.
|
||||
- `docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md` assumed "`PluginSkillContribution.skillFiles` covers bundled skills" (KTD5) — corrected by this learning.
|
||||
- `docs/brainstorms/2026-06-02-compound-engineering-plugin-requirements.md` (R11–R13) defines the plugin-local, never-global install rules this fix implements.
|
||||
- No related GitHub issue exists (searched `plugin skill discovery`, `compound engineering skill` — zero matches).
|
||||
@@ -44,6 +44,7 @@ const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
|
||||
const REPORTS_PLUGIN_ID = "fusion-plugin-reports";
|
||||
const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press";
|
||||
const COMPOUND_ENGINEERING_PLUGIN_ID = "fusion-plugin-compound-engineering";
|
||||
|
||||
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
|
||||
return {
|
||||
@@ -315,6 +316,10 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
it("includes reports plugin in bundled plugin ids", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("includes compound engineering plugin in bundled plugin ids", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(COMPOUND_ENGINEERING_PLUGIN_ID);
|
||||
});
|
||||
it("fresh install: registers and loads the plugin when not in DB", async () => {
|
||||
setupBundleExists();
|
||||
const store = makePluginStore();
|
||||
|
||||
@@ -17,6 +17,7 @@ export const BUNDLED_PLUGIN_IDS = [
|
||||
"fusion-plugin-paperclip-runtime",
|
||||
"fusion-plugin-cursor-runtime",
|
||||
"fusion-plugin-cli-printing-press",
|
||||
"fusion-plugin-compound-engineering",
|
||||
] as const;
|
||||
|
||||
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
|
||||
|
||||
105
packages/core/src/__tests__/interactive-ai-session-seam.test.ts
Normal file
105
packages/core/src/__tests__/interactive-ai-session-seam.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCreateInteractiveAiSessionFactory,
|
||||
setCreateInteractiveAiSessionFactory,
|
||||
} from "../ai-engine-loader.js";
|
||||
import { PluginLoader } from "../plugin-loader.js";
|
||||
import type {
|
||||
CreateInteractiveAiSessionFactory,
|
||||
InteractiveAiSession,
|
||||
InteractiveAiSessionEvent,
|
||||
} from "../plugin-types.js";
|
||||
import type { PlanningQuestion } from "../types.js";
|
||||
|
||||
/**
|
||||
* A scripted fake interactive session: drives question → answer → complete
|
||||
* deterministically so the route-context seam can be integration-tested
|
||||
* without a live engine/model.
|
||||
*/
|
||||
function makeScriptedSession(script: InteractiveAiSessionEvent[]): InteractiveAiSession {
|
||||
let cursor = -1;
|
||||
return {
|
||||
prompt: vi.fn(async () => {
|
||||
cursor++;
|
||||
}),
|
||||
answer: vi.fn(async () => {
|
||||
cursor++;
|
||||
}),
|
||||
nextEvent: vi.fn(async () => script[Math.min(cursor, script.length - 1)]),
|
||||
dispose: vi.fn(),
|
||||
} as InteractiveAiSession;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setCreateInteractiveAiSessionFactory(undefined);
|
||||
});
|
||||
|
||||
describe("ai-engine-loader: interactive factory DI", () => {
|
||||
it("returns undefined before registration", async () => {
|
||||
await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores, returns, and clears the factory", async () => {
|
||||
const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({
|
||||
session: makeScriptedSession([{ type: "complete", data: {} }]),
|
||||
}));
|
||||
setCreateInteractiveAiSessionFactory(factory);
|
||||
await expect(getCreateInteractiveAiSessionFactory()).resolves.toBe(factory);
|
||||
setCreateInteractiveAiSessionFactory(undefined);
|
||||
await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactive session injection boundary", () => {
|
||||
function makeLoader() {
|
||||
const pluginStore = {
|
||||
getPlugin: vi.fn().mockResolvedValue({ settings: {} }),
|
||||
} as never;
|
||||
const taskStore = { getRootDir: () => "/tmp" } as never;
|
||||
return new PluginLoader({ pluginStore, taskStore });
|
||||
}
|
||||
|
||||
it("route context exposes createInteractiveAiSession when engine registered it; absent otherwise", async () => {
|
||||
const loader = makeLoader();
|
||||
|
||||
// Not registered → undefined on route context.
|
||||
const before = await loader.createRouteContext("fusion-plugin-x");
|
||||
expect(before.createInteractiveAiSession).toBeUndefined();
|
||||
|
||||
const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({
|
||||
session: makeScriptedSession([{ type: "complete", data: {} }]),
|
||||
}));
|
||||
setCreateInteractiveAiSessionFactory(factory);
|
||||
|
||||
const after = await loader.createRouteContext("fusion-plugin-x");
|
||||
expect(after.createInteractiveAiSession).toBe(factory);
|
||||
});
|
||||
|
||||
it("drives a full question → answer → complete round trip from a route context", async () => {
|
||||
const question: PlanningQuestion = { id: "q1", type: "single_select", question: "Pick", options: [{ id: "a", label: "A" }] };
|
||||
const session = makeScriptedSession([
|
||||
{ type: "question", data: question },
|
||||
{ type: "complete", data: { title: "ok" } },
|
||||
]);
|
||||
const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ session, sessionFile: "/tmp/s.json" }));
|
||||
setCreateInteractiveAiSessionFactory(factory);
|
||||
|
||||
const loader = makeLoader();
|
||||
const ctx = await loader.createRouteContext("fusion-plugin-x");
|
||||
expect(ctx.createInteractiveAiSession).toBeDefined();
|
||||
|
||||
const { session: s } = await ctx.createInteractiveAiSession!({ cwd: "/tmp", systemPrompt: "protocol" });
|
||||
|
||||
await s.prompt("start");
|
||||
const ev1 = await s.nextEvent();
|
||||
expect(ev1.type).toBe("question");
|
||||
expect(ev1.type === "question" && ev1.data.id).toBe("q1");
|
||||
|
||||
await s.answer("q1", "a");
|
||||
const ev2 = await s.nextEvent();
|
||||
expect(ev2.type).toBe("complete");
|
||||
|
||||
s.dispose();
|
||||
expect(s.dispose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
* returns `undefined` and callers degrade gracefully.
|
||||
*/
|
||||
|
||||
import type { CreateAiSessionFactory } from "./plugin-types.js";
|
||||
import type { CreateAiSessionFactory, CreateInteractiveAiSessionFactory } from "./plugin-types.js";
|
||||
|
||||
// Engine exports a function type we intentionally don't pull in here — importing
|
||||
// the type would reintroduce the cycle this module is designed to avoid.
|
||||
@@ -19,6 +19,7 @@ type CreateFnAgent = any;
|
||||
|
||||
let createFnAgent: CreateFnAgent | undefined;
|
||||
let createAiSessionFactory: CreateAiSessionFactory | undefined;
|
||||
let createInteractiveAiSessionFactory: CreateInteractiveAiSessionFactory | undefined;
|
||||
|
||||
/** Shape of a message in an agent session's state. */
|
||||
export interface AgentMessage {
|
||||
@@ -57,3 +58,23 @@ export function setCreateAiSessionFactory(fn: CreateAiSessionFactory | undefined
|
||||
export async function getCreateAiSessionFactory(): Promise<CreateAiSessionFactory | undefined> {
|
||||
return createAiSessionFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire engine's plugin-facing interactive AI session factory into core.
|
||||
* Called by `@fusion/engine` at module load; tests may register stubs.
|
||||
*/
|
||||
export function setCreateInteractiveAiSessionFactory(
|
||||
fn: CreateInteractiveAiSessionFactory | undefined,
|
||||
): void {
|
||||
createInteractiveAiSessionFactory = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns engine-registered plugin interactive AI session factory, or
|
||||
* `undefined` when engine hasn't registered it (common in isolated core tests).
|
||||
*/
|
||||
export async function getCreateInteractiveAiSessionFactory(): Promise<
|
||||
CreateInteractiveAiSessionFactory | undefined
|
||||
> {
|
||||
return createInteractiveAiSessionFactory;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export {
|
||||
getFnAgent,
|
||||
setCreateAiSessionFactory,
|
||||
getCreateAiSessionFactory,
|
||||
setCreateInteractiveAiSessionFactory,
|
||||
getCreateInteractiveAiSessionFactory,
|
||||
type AgentMessage,
|
||||
} from "./ai-engine-loader.js";
|
||||
export {
|
||||
@@ -529,6 +531,12 @@ export type {
|
||||
CreateAiSessionOptions,
|
||||
AiSessionResult,
|
||||
CreateAiSessionFactory,
|
||||
CreateInteractiveAiSessionOptions,
|
||||
InteractiveAiSessionProgressEvent,
|
||||
InteractiveAiSessionEvent,
|
||||
InteractiveAiSession,
|
||||
CreateInteractiveAiSessionResult,
|
||||
CreateInteractiveAiSessionFactory,
|
||||
PluginLogger,
|
||||
PluginSkillContribution,
|
||||
PluginWorkflowStepContribution,
|
||||
|
||||
@@ -40,7 +40,7 @@ import type {
|
||||
} from "./plugin-types.js";
|
||||
import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getCreateAiSessionFactory } from "./ai-engine-loader.js";
|
||||
import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js";
|
||||
import { scanPluginSecurity } from "./plugin-security-scan.js";
|
||||
|
||||
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
|
||||
@@ -120,9 +120,10 @@ export class PluginLoader extends EventEmitter<{
|
||||
|
||||
async createRouteContext(
|
||||
pluginId: string,
|
||||
overrides?: Partial<Pick<PluginContext, "taskStore" | "settings" | "resolveProjectTaskStore">>,
|
||||
overrides?: Partial<Pick<PluginContext, "taskStore" | "settings" | "resolveProjectTaskStore" | "emitEvent">>,
|
||||
): Promise<PluginContext> {
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
const createInteractiveAiSession = await getCreateInteractiveAiSessionFactory();
|
||||
if (process.env.DEBUG?.includes("plugins")) {
|
||||
this.log.log(
|
||||
createAiSession
|
||||
@@ -137,11 +138,15 @@ export class PluginLoader extends EventEmitter<{
|
||||
settings: overrides?.settings ?? await this.getPluginSettings(pluginId),
|
||||
logger: this.createLogger(pluginId),
|
||||
createAiSession,
|
||||
createInteractiveAiSession,
|
||||
resolveProjectTaskStore: overrides?.resolveProjectTaskStore,
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
this.emit("plugin:error", { pluginId, error: new Error(`Custom event: ${event}`) });
|
||||
// The host (dashboard) may supply a real publisher that forwards custom
|
||||
// plugin events to connected SSE clients. Absent an override, fall back to
|
||||
// logging (the historical no-op behavior) so non-dashboard hosts and tests
|
||||
// keep working.
|
||||
emitEvent: overrides?.emitEvent ?? ((event: string, data: unknown) => {
|
||||
this.log.log(`[plugin:${pluginId}] Custom event: ${event}`, data);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type { Database } from "./db.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
|
||||
import type { PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const;
|
||||
@@ -121,6 +121,134 @@ export interface AiSessionResult {
|
||||
*/
|
||||
export type CreateAiSessionFactory = (options: CreateAiSessionOptions) => Promise<AiSessionResult>;
|
||||
|
||||
// ── Interactive AI Sessions ───────────────────────────────────────────
|
||||
//
|
||||
// A generic interactive (multi-turn, await-input) AI session capability.
|
||||
// Unlike the one-shot `createAiSession` above, an interactive session can
|
||||
// pause mid-agent-turn on a structured question and resume when the caller
|
||||
// supplies an answer. The host (engine) builds the prompt → parse → retry →
|
||||
// pause → resume loop; the caller drives it by pulling events.
|
||||
//
|
||||
// The protocol is deliberately generic: the caller supplies a `systemPrompt`
|
||||
// that instructs the agent to emit the JSON question/complete contract
|
||||
// (the same shape used by `PlanningResponse`). The seam hardcodes no
|
||||
// application-specific (e.g. compound-engineering) prompts or concepts.
|
||||
|
||||
/**
|
||||
* Options for creating an interactive AI session.
|
||||
* Mirrors {@link CreateAiSessionOptions}; the caller-supplied `systemPrompt`
|
||||
* is responsible for instructing the agent to emit the question/complete
|
||||
* JSON protocol that the seam parses.
|
||||
*/
|
||||
export interface CreateInteractiveAiSessionOptions {
|
||||
/** Working directory for the agent session */
|
||||
cwd: string;
|
||||
/** System prompt for the agent (must instruct it to emit the JSON protocol) */
|
||||
systemPrompt: string;
|
||||
/** Tool mode: "coding" for full tools, "readonly" for read-only */
|
||||
tools?: "coding" | "readonly";
|
||||
/** Default model provider (e.g., "anthropic") */
|
||||
defaultProvider?: string;
|
||||
/** Default model ID within the provider */
|
||||
defaultModelId?: string;
|
||||
/**
|
||||
* Skill names the session should load (matched against discovered skills).
|
||||
* Lets a plugin point a session at a specific bundled skill rather than
|
||||
* relying on cwd-only discovery. Forwarded to the engine's skill selection.
|
||||
*/
|
||||
requestedSkillNames?: string[];
|
||||
/**
|
||||
* Extra directories to scan for skills (each holding `<id>/SKILL.md`), in
|
||||
* addition to the default cwd/agent-dir roots. A plugin that installs its
|
||||
* skills to a plugin-local directory passes that directory here so its
|
||||
* `requestedSkillNames` are actually discoverable in the live session.
|
||||
*/
|
||||
additionalSkillPaths?: string[];
|
||||
/**
|
||||
* Live progress callback, invoked WHILE a turn runs (the pull-based
|
||||
* `nextEvent()` only resolves once the turn settles). Receives streaming
|
||||
* thinking/text deltas and tool start/end markers so a caller can surface
|
||||
* the agent's work in real time. Optional; ignored by factories that cannot
|
||||
* stream. Must not throw — implementations should swallow callback errors.
|
||||
*/
|
||||
onProgress?: (event: InteractiveAiSessionProgressEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A live progress event emitted mid-turn via
|
||||
* {@link CreateInteractiveAiSessionOptions.onProgress}.
|
||||
*
|
||||
* - `thinking` / `text`: an incremental output DELTA (not a snapshot) — the
|
||||
* consumer accumulates.
|
||||
* - `tool`: a discrete tool execution start/end marker.
|
||||
*/
|
||||
export type InteractiveAiSessionProgressEvent =
|
||||
| { type: "thinking"; delta: string }
|
||||
| { type: "text"; delta: string }
|
||||
| { type: "tool"; name: string; phase: "start" | "end"; isError?: boolean };
|
||||
|
||||
/**
|
||||
* A single event pulled from an interactive AI session.
|
||||
*
|
||||
* Discriminated union on `type`:
|
||||
* - `thinking` / `text`: incremental agent output (data is a string).
|
||||
* - `question`: the agent paused awaiting structured input; the session is
|
||||
* now in awaiting-input until {@link InteractiveAiSession.answer} is called.
|
||||
* `data` is a {@link PlanningQuestion} (reused for protocol parity).
|
||||
* - `complete`: the agent finished; `data` is the final payload (shape is
|
||||
* defined by the caller's protocol — opaque to the seam).
|
||||
* - `error`: an agent/session/parse error; `data` carries a human-readable
|
||||
* message and optional error detail. The caller is never left hanging.
|
||||
*/
|
||||
export type InteractiveAiSessionEvent =
|
||||
| { type: "thinking"; data: string }
|
||||
| { type: "text"; data: string }
|
||||
| { type: "question"; data: PlanningQuestion }
|
||||
| { type: "complete"; data: unknown }
|
||||
| { type: "error"; data: { message: string; cause?: unknown } };
|
||||
|
||||
/**
|
||||
* An interactive, multi-turn AI session.
|
||||
*
|
||||
* Event delivery is **pull-based**: the caller awaits {@link nextEvent} to get
|
||||
* the next event. `nextEvent()` resolves once the session has produced an
|
||||
* event for the most recent `prompt`/`answer`. A `question` event leaves the
|
||||
* session in awaiting-input; the caller must call {@link answer} (not
|
||||
* {@link prompt}) to resume. After a `complete` or `error` event the session
|
||||
* is terminal and `nextEvent()` will keep returning that terminal event.
|
||||
*
|
||||
* (Pull-based `nextEvent()` is chosen over an async iterator because it is the
|
||||
* simpler shape to drive deterministically from a route/test: each turn is one
|
||||
* `prompt`/`answer` followed by one awaited `nextEvent`.)
|
||||
*/
|
||||
export interface InteractiveAiSession {
|
||||
/** Send a free-text turn to the agent (the opening turn, or follow-up text). */
|
||||
prompt(text: string): Promise<void>;
|
||||
/** Pull the next event produced by the most recent prompt/answer. */
|
||||
nextEvent(): Promise<InteractiveAiSessionEvent>;
|
||||
/** Answer the currently-awaiting question, resuming the agent. */
|
||||
answer(questionId: string, response: unknown): Promise<void>;
|
||||
/** Release the underlying agent/session handles. Safe to call repeatedly. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned from creating an interactive AI session.
|
||||
*/
|
||||
export interface CreateInteractiveAiSessionResult {
|
||||
/** The interactive session handle. */
|
||||
session: InteractiveAiSession;
|
||||
/** Path to persisted session file, if any. */
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine-injected factory for plugin interactive AI sessions.
|
||||
*/
|
||||
export type CreateInteractiveAiSessionFactory = (
|
||||
options: CreateInteractiveAiSessionOptions,
|
||||
) => Promise<CreateInteractiveAiSessionResult>;
|
||||
|
||||
/**
|
||||
* Context object passed to plugins at runtime.
|
||||
* Contains task store access, settings, logging, and event emission.
|
||||
@@ -137,6 +265,12 @@ export interface PluginContext {
|
||||
emitEvent: (event: string, data: unknown) => void;
|
||||
/** Engine-injected AI session factory (undefined when engine is not loaded) */
|
||||
createAiSession?: CreateAiSessionFactory;
|
||||
/**
|
||||
* Engine-injected interactive (multi-turn, await-input) AI session factory.
|
||||
* Undefined when the engine is not loaded or on non-route contexts (parity
|
||||
* with `createAiSession`).
|
||||
*/
|
||||
createInteractiveAiSession?: CreateInteractiveAiSessionFactory;
|
||||
/** Optional host capability to resolve a project-scoped TaskStore by projectId. */
|
||||
resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore>;
|
||||
}
|
||||
|
||||
@@ -311,7 +311,34 @@ function AppInner() {
|
||||
setBaseBranchFilter(value);
|
||||
setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
|
||||
// Host capability handed to plugin dashboard views: subscribe to a plugin's
|
||||
// custom SSE events (forwarded by the server as `plugin:custom`, scoped to the
|
||||
// current project) over the shared bus — so plugins push live updates without
|
||||
// deep-importing the dashboard's sse-bus or opening their own EventSource.
|
||||
const subscribePluginEvents = useCallback(
|
||||
(pluginId: string, onEvent: (e: { event: string; payload: unknown }) => void) => {
|
||||
const params = new URLSearchParams();
|
||||
if (currentProject?.id) params.set("projectId", currentProject.id);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"plugin:custom": (event: MessageEvent) => {
|
||||
try {
|
||||
const d = JSON.parse(event.data) as { pluginId?: string; event?: string; payload?: unknown };
|
||||
if (d.pluginId === pluginId && typeof d.event === "string") {
|
||||
onEvent({ event: d.event, payload: d.payload });
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed plugin:custom payloads.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[currentProject?.id],
|
||||
);
|
||||
|
||||
// Remote node data and events when in remote mode (pass searchQuery for server-side filtering)
|
||||
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined });
|
||||
useRemoteNodeEvents(currentNodeId);
|
||||
@@ -1385,6 +1412,7 @@ function AppInner() {
|
||||
projectId: currentProject?.id,
|
||||
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
|
||||
workflowSteps,
|
||||
subscribePluginEvents,
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab),
|
||||
renderTaskCard: (task: Task | TaskDetail) => (
|
||||
<TaskCard
|
||||
|
||||
@@ -37,6 +37,27 @@ async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
|
||||
return { default: component as PluginViewComponent };
|
||||
}
|
||||
|
||||
async function loadCompoundEngineeringView(): Promise<{ default: PluginViewComponent }> {
|
||||
// @vite-ignore + moduleId variable so tsc does NOT statically resolve/compile
|
||||
// the plugin's source here. The plugin must not depend on @fusion/dashboard
|
||||
// (workspace-acyclicity invariant), so its type-only dashboard import only
|
||||
// resolves in the plugin's own build; a literal import would make the
|
||||
// dashboard typecheck the plugin file and fail to resolve that import.
|
||||
const moduleId = "@fusion-plugin-examples/compound-engineering/dashboard-view";
|
||||
const exportName = "CompoundEngineeringDashboardView";
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ moduleId) as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
|
||||
const component = mod[exportName];
|
||||
if (!component) {
|
||||
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
|
||||
return { default: createMissingPluginView(moduleId, exportName) };
|
||||
}
|
||||
return { default: component as PluginViewComponent };
|
||||
} catch {
|
||||
return { default: createMissingPluginView(moduleId, exportName) };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCliPrintingPressWizardView(): Promise<{ default: PluginViewComponent }> {
|
||||
const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view";
|
||||
const exportName = "CliPrintingPressWizardView";
|
||||
@@ -85,6 +106,12 @@ export function registerBundledPluginViews(): void {
|
||||
lazy(loadRoadmapView),
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"fusion-plugin-compound-engineering",
|
||||
"compound-engineering",
|
||||
lazy(loadCompoundEngineeringView),
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"fusion-plugin-cli-printing-press",
|
||||
"wizard",
|
||||
|
||||
@@ -16,6 +16,14 @@ export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "mo
|
||||
|
||||
export type PluginToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
/** A custom event a plugin pushed via `ctx.emitEvent`, delivered over SSE. */
|
||||
export interface PluginCustomEvent {
|
||||
/** The event name the plugin emitted (e.g. "myplugin:thing-happened"). */
|
||||
event: string;
|
||||
/** The event payload the plugin emitted. */
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/** Runtime context passed to a plugin dashboard view component. */
|
||||
export interface PluginDashboardViewContext {
|
||||
projectId?: string;
|
||||
@@ -24,6 +32,16 @@ export interface PluginDashboardViewContext {
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
|
||||
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
|
||||
addToast?: (message: string, type?: PluginToastType) => void;
|
||||
/**
|
||||
* Subscribe to this plugin's custom SSE events (the host forwards
|
||||
* `plugin:custom` events a plugin pushed via `ctx.emitEvent`, scoped to the
|
||||
* current project). Returns an unsubscribe function. Absent when the host
|
||||
* doesn't provide a realtime stream; consumers should fall back to polling.
|
||||
*/
|
||||
subscribePluginEvents?: (
|
||||
pluginId: string,
|
||||
onEvent: (event: PluginCustomEvent) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
/** Composite view ID format: `plugin:{pluginId}:{viewId}`. */
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@fusion-plugin-examples/compound-engineering": "workspace:*",
|
||||
"@fusion-plugin-examples/dependency-graph": "workspace:*",
|
||||
"@fusion-plugin-examples/roadmap": "workspace:*",
|
||||
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createSSE,
|
||||
disconnectSSEClient,
|
||||
emitApprovalSseEvent,
|
||||
emitPluginCustomSseEvent,
|
||||
getActiveSSEConnections,
|
||||
markSSEClientAlive,
|
||||
} from "../sse.js";
|
||||
@@ -146,6 +147,45 @@ describe("approval SSE events", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin custom SSE events", () => {
|
||||
it("relays a plugin's custom event to connected clients as plugin:custom", () => {
|
||||
const connection = openSseConnection("plugin-custom-relay");
|
||||
|
||||
emitPluginCustomSseEvent("fusion-plugin-compound-engineering", "ce:session-question", {
|
||||
sessionId: "s1",
|
||||
questionId: "q1",
|
||||
});
|
||||
|
||||
expect(connection.res.write).toHaveBeenCalledWith(
|
||||
`event: plugin:custom\ndata: ${JSON.stringify({
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
event: "ce:session-question",
|
||||
payload: { sessionId: "s1", questionId: "q1" },
|
||||
})}\n\n`,
|
||||
);
|
||||
|
||||
connection.req.emit("close");
|
||||
});
|
||||
|
||||
it("scopes project-tagged plugin events to the matching project connection", () => {
|
||||
const projectA = openSseConnection("plugin-custom-project", "project-a");
|
||||
const projectB = openSseConnection("plugin-custom-project", "project-b");
|
||||
|
||||
emitPluginCustomSseEvent("p", "evt", { sessionId: "s1" }, "project-a");
|
||||
|
||||
const expected = `event: plugin:custom\ndata: ${JSON.stringify({
|
||||
pluginId: "p",
|
||||
event: "evt",
|
||||
payload: { sessionId: "s1" },
|
||||
})}\n\n`;
|
||||
expect(projectA.res.write).toHaveBeenCalledWith(expected);
|
||||
expect(projectB.res.write).not.toHaveBeenCalledWith(expected);
|
||||
|
||||
projectA.req.emit("close");
|
||||
projectB.req.emit("close");
|
||||
});
|
||||
});
|
||||
|
||||
describe("automation store SSE events", () => {
|
||||
it("subscribes to all automation store events", () => {
|
||||
const connection = openSseConnectionWithAutomation("automation-subscribe");
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { access, stat, readFile } from "node:fs/promises";
|
||||
import { join, isAbsolute, dirname, basename } from "node:path";
|
||||
import { emitPluginCustomSseEvent } from "./sse.js";
|
||||
import type {
|
||||
PluginLoader,
|
||||
PluginStore,
|
||||
@@ -573,6 +574,12 @@ export function createPluginRouter(
|
||||
taskStore,
|
||||
settings,
|
||||
resolveProjectTaskStore: getOrCreateProjectStore,
|
||||
// Real publish-to-/api/events seam: forward custom plugin events to
|
||||
// connected SSE clients, scoped to the request's project so a
|
||||
// project stream only sees its own events.
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
emitPluginCustomSseEvent(pluginId, event, data, projectId);
|
||||
},
|
||||
});
|
||||
|
||||
// Call the route handler with Express Request cast to unknown
|
||||
|
||||
@@ -234,6 +234,34 @@ export function emitApprovalSseEvent(event: ApprovalSseEventType, payload: unkno
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom plugin events forwarded to connected SSE clients. This is the real
|
||||
* publish-to-`/api/events` seam plugins reach through `ctx.emitEvent`: the
|
||||
* dashboard wires a plugin route context's `emitEvent` to call this, and each
|
||||
* open SSE stream forwards matching (project-scoped) events to the browser as a
|
||||
* single `plugin:custom` event. Lets a plugin push live updates (e.g. CE session
|
||||
* turns) instead of relying on client polling.
|
||||
*/
|
||||
export type PluginCustomSseListener = (
|
||||
pluginId: string,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
projectId?: string,
|
||||
) => void;
|
||||
|
||||
const pluginCustomSseListeners = new Set<PluginCustomSseListener>();
|
||||
|
||||
export function emitPluginCustomSseEvent(
|
||||
pluginId: string,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
projectId?: string,
|
||||
): void {
|
||||
for (const listener of pluginCustomSseListeners) {
|
||||
listener(pluginId, event, payload, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized plugin lifecycle payload emitted via SSE.
|
||||
* This is the stable contract the UI can reconcile.
|
||||
@@ -591,6 +619,13 @@ export function createSSE(
|
||||
send(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
const onPluginCustomEvent: PluginCustomSseListener = (pluginId, event, payload, eventProjectId) => {
|
||||
// Scope match mirrors approvals: a project-scoped stream only forwards
|
||||
// events for its own project; the default stream forwards unscoped events.
|
||||
if (projectId && eventProjectId && eventProjectId !== projectId) return;
|
||||
send(`event: plugin:custom\ndata: ${JSON.stringify({ pluginId, event, payload })}\n\n`);
|
||||
};
|
||||
|
||||
// --- Chat store event handlers ---
|
||||
const onChatSessionCreated = (session: unknown) => {
|
||||
send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`);
|
||||
@@ -740,6 +775,7 @@ export function createSSE(
|
||||
messageStore.off("message:deleted", onMessageDeleted);
|
||||
}
|
||||
approvalSseListeners.delete(onApprovalEvent);
|
||||
pluginCustomSseListeners.delete(onPluginCustomEvent);
|
||||
if (chatStore) {
|
||||
chatStore.off("chat:session:created", onChatSessionCreated);
|
||||
chatStore.off("chat:session:updated", onChatSessionUpdated);
|
||||
@@ -886,6 +922,7 @@ export function createSSE(
|
||||
// (SSE comments starting with ":" are silently consumed and never
|
||||
// fire event listeners in the browser).
|
||||
approvalSseListeners.add(onApprovalEvent);
|
||||
pluginCustomSseListeners.add(onPluginCustomEvent);
|
||||
|
||||
registerManagedConnection({
|
||||
id: connectionId,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* U2 — Empirical proof of how Compound Engineering bundled skills become
|
||||
* resolvable in an agent session.
|
||||
*
|
||||
* This drives the REAL engine skill pipeline:
|
||||
* pi-coding-agent `loadSkills` (disk discovery) →
|
||||
* `resolveSessionSkills` + `createSkillsOverrideFromSelection` (the same
|
||||
* path `createFnAgent` uses in pi.ts via DefaultResourceLoader.skillsOverride).
|
||||
*
|
||||
* THE QUESTION: does declaring `skills: PluginSkillContribution[]` (whose
|
||||
* contribution surfaces only as a *name* in `requestedSkillNames`) make a
|
||||
* bundled SKILL.md resolvable, OR is a physical install into a discoverable
|
||||
* directory also required?
|
||||
*
|
||||
* ANSWER (asserted below): the contribution alone is NOT enough. The engine
|
||||
* never ingests `PluginSkillContribution.skillFiles` into the discovered set;
|
||||
* the requested name has nothing on disk to match. A physical, plugin-local
|
||||
* install (so the SKILL.md lives on a path `loadSkills` scans) is REQUIRED.
|
||||
*
|
||||
* The test is self-contained on the engine side: it models "a physical install"
|
||||
* by materializing a `ce-plan/SKILL.md` on disk and pointing disk discovery at
|
||||
* its parent dir — exactly what the plugin's `installBundledCeSkills` does into
|
||||
* a plugin-local directory wired through `additionalSkillPaths`. (The plugin's
|
||||
* own cpSync + isolation behavior is verified in the plugin package's
|
||||
* skill-installation.test.ts; the engine package cannot import plugin source
|
||||
* without violating its tsc rootDir.)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DefaultResourceLoader, loadSkills, type Skill } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
createSkillsOverrideFromSelection,
|
||||
resolveSessionSkills,
|
||||
} from "../skill-resolver.js";
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
piLog: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const CE_STAGES = [
|
||||
"ce-strategy",
|
||||
"ce-ideate",
|
||||
"ce-brainstorm",
|
||||
"ce-plan",
|
||||
"ce-work",
|
||||
"ce-code-review",
|
||||
"ce-compound",
|
||||
] as const;
|
||||
|
||||
/** Model the plugin-local physical install: write each stage's SKILL.md to disk. */
|
||||
function materializeInstalledSkills(root: string, stages: readonly string[]): void {
|
||||
for (const id of stages) {
|
||||
const dir = join(root, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "SKILL.md"),
|
||||
`---\nname: ${id}\ndescription: ${id} pipeline stage\n---\n\n# ${id}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the exact engine resolution path for a session that requests CE skill
|
||||
* names (as if a plugin contributed them via getPluginSkills ->
|
||||
* requestedSkillNames), over whatever skills `loadSkills` discovers from
|
||||
* `discoveredSkillPaths`. Returns the resolved skill names visible to the
|
||||
* session.
|
||||
*/
|
||||
function resolveSessionFor(opts: {
|
||||
projectRootDir: string;
|
||||
agentDir: string;
|
||||
discoveredSkillPaths: string[];
|
||||
requestedSkillNames: string[];
|
||||
}): string[] {
|
||||
// 1. Disk discovery — exactly what DefaultResourceLoader feeds to its override.
|
||||
const discovered = loadSkills({
|
||||
cwd: opts.projectRootDir,
|
||||
agentDir: opts.agentDir,
|
||||
skillPaths: opts.discoveredSkillPaths,
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
// 2. Engine resolver (project settings + requested names).
|
||||
const selection = resolveSessionSkills({
|
||||
projectRootDir: opts.projectRootDir,
|
||||
requestedSkillNames: opts.requestedSkillNames,
|
||||
sessionPurpose: "executor",
|
||||
});
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
requestedSkillNames: opts.requestedSkillNames,
|
||||
sessionPurpose: "executor",
|
||||
});
|
||||
|
||||
const result = override({ skills: discovered.skills, diagnostics: discovered.diagnostics });
|
||||
return result.skills.map((s) => s.name);
|
||||
}
|
||||
|
||||
describe("U2: CE bundled skill session-resolution (empirical)", () => {
|
||||
let tmp: string;
|
||||
let projectRootDir: string;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "ce-resolve-"));
|
||||
// An empty project + empty agent dir: NOTHING ce-* is discoverable yet.
|
||||
projectRootDir = join(tmp, "project");
|
||||
agentDir = join(tmp, "agent");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("FAILING-FIRST: contribution name alone (no physical install) does NOT resolve ce-plan", () => {
|
||||
// Simulate: plugin declared skills -> requestedSkillNames includes ce-plan,
|
||||
// but no SKILL.md was installed anywhere discoverable.
|
||||
const resolved = resolveSessionFor({
|
||||
projectRootDir,
|
||||
agentDir,
|
||||
discoveredSkillPaths: [], // nothing on disk
|
||||
requestedSkillNames: ["ce-plan"],
|
||||
});
|
||||
// Proves the contribution alone is insufficient: ce-plan is NOT resolvable.
|
||||
expect(resolved).not.toContain("ce-plan");
|
||||
expect(resolved).toEqual([]);
|
||||
});
|
||||
|
||||
it("PASSING: after a plugin-local physical install, ce-plan IS resolvable for the session", () => {
|
||||
const installRoot = join(tmp, "plugin-local", ".fusion-ce-skills");
|
||||
materializeInstalledSkills(installRoot, ["ce-plan"]);
|
||||
|
||||
const resolved = resolveSessionFor({
|
||||
projectRootDir,
|
||||
agentDir,
|
||||
discoveredSkillPaths: [installRoot], // installed dir is now discoverable
|
||||
requestedSkillNames: ["ce-plan"],
|
||||
});
|
||||
|
||||
expect(resolved).toContain("ce-plan");
|
||||
});
|
||||
|
||||
it("PASSING: all seven CE stages resolve when requested after install", () => {
|
||||
const installRoot = join(tmp, ".fusion-ce-skills");
|
||||
materializeInstalledSkills(installRoot, CE_STAGES);
|
||||
|
||||
const resolved = resolveSessionFor({
|
||||
projectRootDir,
|
||||
agentDir,
|
||||
discoveredSkillPaths: [installRoot],
|
||||
requestedSkillNames: [...CE_STAGES],
|
||||
});
|
||||
for (const s of CE_STAGES) {
|
||||
expect(resolved).toContain(s);
|
||||
}
|
||||
});
|
||||
|
||||
it("PASSING (real loader): DefaultResourceLoader with additionalSkillPaths + skillsOverride discovers ce-plan — the exact path createFnAgent now feeds", async () => {
|
||||
const installRoot = join(tmp, ".fusion-ce-skills");
|
||||
materializeInstalledSkills(installRoot, ["ce-plan", "ce-work"]);
|
||||
mkdirSync(projectRootDir, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
// Build the same skillsOverride createFnAgent builds from `skills: ["ce-plan"]`.
|
||||
const selection = resolveSessionSkills({
|
||||
projectRootDir,
|
||||
requestedSkillNames: ["ce-plan"],
|
||||
sessionPurpose: "executor",
|
||||
});
|
||||
const skillsOverride = createSkillsOverrideFromSelection(selection, {
|
||||
requestedSkillNames: ["ce-plan"],
|
||||
sessionPurpose: "executor",
|
||||
});
|
||||
|
||||
// Construct the loader exactly as pi.ts createFnAgent now does: cwd on the
|
||||
// project root, the install dir passed via additionalSkillPaths, and the
|
||||
// requested-name filter as skillsOverride.
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: projectRootDir,
|
||||
agentDir,
|
||||
additionalSkillPaths: [installRoot],
|
||||
skillsOverride,
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const names = loader.getSkills().skills.map((s: Skill) => s.name);
|
||||
// ce-plan is discoverable (via additionalSkillPaths) AND survives the filter;
|
||||
// ce-work is discovered but filtered out by the requested-name override.
|
||||
expect(names).toContain("ce-plan");
|
||||
expect(names).not.toContain("ce-work");
|
||||
});
|
||||
});
|
||||
192
packages/engine/src/__tests__/interactive-ai-session.test.ts
Normal file
192
packages/engine/src/__tests__/interactive-ai-session.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PlanningQuestion, PlanningResponse } from "@fusion/core";
|
||||
import {
|
||||
createInteractiveAiSessionWith,
|
||||
type InteractiveAgentResult,
|
||||
type InteractiveAgentSession,
|
||||
} from "../interactive-ai-session.js";
|
||||
|
||||
/**
|
||||
* A scripted fake agent: each `prompt()` advances through a queue of canned
|
||||
* assistant responses, which are exposed via `state.messages` exactly like the
|
||||
* real one-shot agent. This deterministically drives the seam's turn loop
|
||||
* without a live model (the accepted integration approach per the plan).
|
||||
*/
|
||||
function makeScriptedAgent(responses: string[]): {
|
||||
session: InteractiveAgentSession;
|
||||
disposed: () => boolean;
|
||||
promptCalls: () => string[];
|
||||
} {
|
||||
let index = 0;
|
||||
let wasDisposed = false;
|
||||
const prompts: string[] = [];
|
||||
const messages: InteractiveAgentSession["state"]["messages"] = [];
|
||||
|
||||
const session: InteractiveAgentSession = {
|
||||
prompt: vi.fn(async (text: string) => {
|
||||
prompts.push(text);
|
||||
const reply = responses[index] ?? responses[responses.length - 1];
|
||||
index++;
|
||||
messages.push({ role: "assistant", content: reply });
|
||||
}),
|
||||
state: { messages },
|
||||
dispose: vi.fn(() => {
|
||||
wasDisposed = true;
|
||||
}),
|
||||
};
|
||||
|
||||
return { session, disposed: () => wasDisposed, promptCalls: () => prompts };
|
||||
}
|
||||
|
||||
function factoryFor(agent: InteractiveAgentSession): () => Promise<InteractiveAgentResult> {
|
||||
return async () => ({ session: agent, sessionFile: "/tmp/fake-session.json" });
|
||||
}
|
||||
|
||||
const q = (data: PlanningQuestion): string => JSON.stringify({ type: "question", data } satisfies PlanningResponse);
|
||||
const complete = (data: unknown): string => JSON.stringify({ type: "complete", data });
|
||||
|
||||
describe("interactive-ai-session seam", () => {
|
||||
it("round-trips question → answer → complete (happy path)", async () => {
|
||||
const question: PlanningQuestion = {
|
||||
id: "q1",
|
||||
type: "text",
|
||||
question: "What is the goal?",
|
||||
};
|
||||
const scripted = makeScriptedAgent([
|
||||
q(question),
|
||||
complete({ title: "Done", summary: "ok" }),
|
||||
]);
|
||||
|
||||
const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), {
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "emit json protocol",
|
||||
});
|
||||
|
||||
await session.prompt("start");
|
||||
const ev1 = await session.nextEvent();
|
||||
expect(ev1.type).toBe("question");
|
||||
expect(ev1.type === "question" && ev1.data.id).toBe("q1");
|
||||
|
||||
await session.answer("q1", "ship the thing");
|
||||
const ev2 = await session.nextEvent();
|
||||
expect(ev2.type).toBe("complete");
|
||||
expect(ev2.type === "complete" && ev2.data).toEqual({ title: "Done", summary: "ok" });
|
||||
|
||||
// nextEvent stays terminal after complete.
|
||||
expect((await session.nextEvent()).type).toBe("complete");
|
||||
|
||||
session.dispose();
|
||||
expect(scripted.disposed()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["text", { id: "t", type: "text", question: "Free text?" } as PlanningQuestion, "a free answer"],
|
||||
[
|
||||
"single_select",
|
||||
{
|
||||
id: "s",
|
||||
type: "single_select",
|
||||
question: "Pick one",
|
||||
options: [{ id: "a", label: "A" }, { id: "b", label: "B" }],
|
||||
} as PlanningQuestion,
|
||||
"a",
|
||||
],
|
||||
[
|
||||
"multi_select",
|
||||
{
|
||||
id: "m",
|
||||
type: "multi_select",
|
||||
question: "Pick many",
|
||||
options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }],
|
||||
} as PlanningQuestion,
|
||||
["x", "y"],
|
||||
],
|
||||
["confirm", { id: "c", type: "confirm", question: "Sure?" } as PlanningQuestion, true],
|
||||
])("round-trips %s question type", async (_name, question, answer) => {
|
||||
const scripted = makeScriptedAgent([q(question), complete({ ok: true })]);
|
||||
const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), {
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "protocol",
|
||||
});
|
||||
|
||||
await session.prompt("start");
|
||||
const ev = await session.nextEvent();
|
||||
expect(ev.type).toBe("question");
|
||||
expect(ev.type === "question" && ev.data.type).toBe(question.type);
|
||||
|
||||
await session.answer(question.id, answer);
|
||||
const done = await session.nextEvent();
|
||||
expect(done.type).toBe("complete");
|
||||
|
||||
// The structured answer is forwarded to the agent as JSON.
|
||||
const lastPrompt = scripted.promptCalls().at(-1)!;
|
||||
expect(JSON.parse(lastPrompt)).toMatchObject({ type: "answer", questionId: question.id, response: answer });
|
||||
});
|
||||
|
||||
it("retries once on unparseable output then surfaces an error event (no hang)", async () => {
|
||||
// First turn: garbage. Reformat retry: still garbage. → error.
|
||||
const scripted = makeScriptedAgent(["not json at all", "still not json"]);
|
||||
const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), {
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "protocol",
|
||||
});
|
||||
|
||||
await session.prompt("start");
|
||||
const ev = await session.nextEvent();
|
||||
expect(ev.type).toBe("error");
|
||||
expect(ev.type === "error" && ev.data.message).toMatch(/parse/i);
|
||||
|
||||
// The reformat-retry prompt was actually sent (2 prompts: initial + retry).
|
||||
expect(scripted.promptCalls().length).toBe(2);
|
||||
|
||||
// Terminal: nextEvent keeps returning the error, never hangs.
|
||||
expect((await session.nextEvent()).type).toBe("error");
|
||||
});
|
||||
|
||||
it("recovers when the reformat retry produces valid JSON", async () => {
|
||||
const question: PlanningQuestion = { id: "q1", type: "text", question: "?" };
|
||||
const scripted = makeScriptedAgent(["garbage", q(question)]);
|
||||
const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), {
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "protocol",
|
||||
});
|
||||
|
||||
await session.prompt("start");
|
||||
const ev = await session.nextEvent();
|
||||
expect(ev.type).toBe("question");
|
||||
});
|
||||
|
||||
it("surfaces agent prompt errors as an error event without throwing", async () => {
|
||||
const throwing: InteractiveAgentSession = {
|
||||
prompt: vi.fn(async () => {
|
||||
throw new Error("transport exploded");
|
||||
}),
|
||||
state: { messages: [] },
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
const { session } = await createInteractiveAiSessionWith(factoryFor(throwing), {
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "protocol",
|
||||
});
|
||||
|
||||
await expect(session.prompt("start")).resolves.toBeUndefined();
|
||||
const ev = await session.nextEvent();
|
||||
expect(ev.type).toBe("error");
|
||||
expect(ev.type === "error" && ev.data.message).toMatch(/transport exploded/);
|
||||
});
|
||||
|
||||
it("ignores answer() when not awaiting input", async () => {
|
||||
const scripted = makeScriptedAgent([complete({ ok: true })]);
|
||||
const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), {
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "protocol",
|
||||
});
|
||||
|
||||
await session.prompt("start");
|
||||
expect((await session.nextEvent()).type).toBe("complete");
|
||||
|
||||
// answer() after terminal is a no-op; nextEvent stays complete.
|
||||
await session.answer("whatever", "x");
|
||||
expect((await session.nextEvent()).type).toBe("complete");
|
||||
});
|
||||
});
|
||||
@@ -1603,4 +1603,25 @@ describe("PluginRunner", () => {
|
||||
expect(true).toBe(true); // Handler exists and doesn't throw
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactive AI session injection boundary", () => {
|
||||
it("does NOT expose createInteractiveAiSession on runtime contexts (parity with createAiSession)", async () => {
|
||||
// Register a factory the way the engine module-load block would.
|
||||
const core = await import("@fusion/core");
|
||||
core.setCreateInteractiveAiSessionFactory(
|
||||
async () => ({ session: {} as never }),
|
||||
);
|
||||
try {
|
||||
mockPluginLoader.getPlugin.mockReturnValue(createMockPlugin({ state: "started" }));
|
||||
const ctx = await pluginRunner.createRuntimeContext("test-plugin");
|
||||
expect(ctx).not.toBeNull();
|
||||
// Tool/runtime contexts must not receive the interactive factory,
|
||||
// exactly as they do not receive createAiSession.
|
||||
expect(ctx?.createAiSession).toBeUndefined();
|
||||
expect(ctx?.createInteractiveAiSession).toBeUndefined();
|
||||
} finally {
|
||||
core.setCreateInteractiveAiSessionFactory(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,12 +112,26 @@ export {
|
||||
} from "./merger-squash-audit.js";
|
||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
export {
|
||||
createInteractiveAiSessionWith,
|
||||
parseAgentResponse as parseInteractiveAgentResponse,
|
||||
type InteractiveAgentSession,
|
||||
type InteractiveAgentResult,
|
||||
type InteractiveAgentFactory,
|
||||
} from "./interactive-ai-session.js";
|
||||
|
||||
// Register createFnAgent into core's loader so consumers in @fusion/core
|
||||
// (e.g. ai-summarize, memory-compaction) can resolve it without a circular
|
||||
// static import. Runs once at engine module load.
|
||||
import type { AiSessionResult, CreateAiSessionFactory, CreateAiSessionOptions } from "@fusion/core";
|
||||
import type {
|
||||
AiSessionResult,
|
||||
CreateAiSessionFactory,
|
||||
CreateAiSessionOptions,
|
||||
CreateInteractiveAiSessionFactory,
|
||||
CreateInteractiveAiSessionOptions,
|
||||
} from "@fusion/core";
|
||||
import { createFnAgent as _createFnAgentForCore } from "./pi.js";
|
||||
import { createInteractiveAiSessionWith } from "./interactive-ai-session.js";
|
||||
|
||||
const _createAiSessionAdapter: CreateAiSessionFactory = async (options: CreateAiSessionOptions): Promise<AiSessionResult> => {
|
||||
return _createFnAgentForCore({
|
||||
@@ -129,6 +143,56 @@ const _createAiSessionAdapter: CreateAiSessionFactory = async (options: CreateAi
|
||||
});
|
||||
};
|
||||
|
||||
// Interactive (multi-turn, await-input) adapter: builds the prompt→parse→
|
||||
// retry→pause→resume loop on top of the one-shot createFnAgent.
|
||||
const _createInteractiveAiSessionAdapter: CreateInteractiveAiSessionFactory = (
|
||||
options: CreateInteractiveAiSessionOptions,
|
||||
) =>
|
||||
createInteractiveAiSessionWith(
|
||||
(opts) =>
|
||||
_createFnAgentForCore({
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
tools: opts.tools,
|
||||
defaultProvider: opts.defaultProvider,
|
||||
defaultModelId: opts.defaultModelId,
|
||||
// Forward skill selection so a plugin can load a specific bundled skill.
|
||||
// `skills` (convenience) auto-builds a SkillSelectionContext; the extra
|
||||
// discovery dirs make those skills actually visible to the loader.
|
||||
...(opts.requestedSkillNames?.length ? { skills: opts.requestedSkillNames } : {}),
|
||||
...(opts.additionalSkillPaths?.length ? { additionalSkillPaths: opts.additionalSkillPaths } : {}),
|
||||
// Live mid-turn visibility: stream thinking/text deltas and tool
|
||||
// start/end markers to the caller's onProgress while the pull-based
|
||||
// nextEvent() is still pending. Callback errors must never break the
|
||||
// agent turn.
|
||||
...(opts.onProgress
|
||||
? {
|
||||
onThinking: (delta: string) => {
|
||||
try {
|
||||
opts.onProgress!({ type: "thinking", delta });
|
||||
} catch { /* consumer error must not break the turn */ }
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
try {
|
||||
opts.onProgress!({ type: "text", delta });
|
||||
} catch { /* consumer error must not break the turn */ }
|
||||
},
|
||||
onToolStart: (name: string) => {
|
||||
try {
|
||||
opts.onProgress!({ type: "tool", name, phase: "start" });
|
||||
} catch { /* consumer error must not break the turn */ }
|
||||
},
|
||||
onToolEnd: (name: string, isError: boolean) => {
|
||||
try {
|
||||
opts.onProgress!({ type: "tool", name, phase: "end", isError });
|
||||
} catch { /* consumer error must not break the turn */ }
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
options,
|
||||
);
|
||||
|
||||
void import("@fusion/core")
|
||||
.then((core) => {
|
||||
if ("setCreateFnAgent" in core && typeof core.setCreateFnAgent === "function") {
|
||||
@@ -137,6 +201,9 @@ void import("@fusion/core")
|
||||
if ("setCreateAiSessionFactory" in core && typeof core.setCreateAiSessionFactory === "function") {
|
||||
core.setCreateAiSessionFactory(_createAiSessionAdapter);
|
||||
}
|
||||
if ("setCreateInteractiveAiSessionFactory" in core && typeof core.setCreateInteractiveAiSessionFactory === "function") {
|
||||
core.setCreateInteractiveAiSessionFactory(_createInteractiveAiSessionAdapter);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore loader registration failures in constrained test/mocked environments.
|
||||
|
||||
349
packages/engine/src/interactive-ai-session.ts
Normal file
349
packages/engine/src/interactive-ai-session.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Interactive AI session adapter (the U4 host seam).
|
||||
*
|
||||
* Builds a generic prompt → parse → retry → pause → resume loop on top of the
|
||||
* one-shot `createFnAgent`, modeled on `packages/dashboard/src/planning.ts`.
|
||||
* There is NO engine await-input primitive to call — this module IS that loop.
|
||||
*
|
||||
* Kept deliberately generic: it knows nothing about compound-engineering (or
|
||||
* any other application). The caller supplies a system prompt instructing the
|
||||
* agent to emit the JSON question/complete protocol; this module parses it and
|
||||
* surfaces structured events. To avoid leaking dashboard types into the seam,
|
||||
* the JSON parse/extract/repair helpers are reimplemented locally here rather
|
||||
* than imported from `@fusion/dashboard`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreateInteractiveAiSessionOptions,
|
||||
CreateInteractiveAiSessionResult,
|
||||
InteractiveAiSession,
|
||||
InteractiveAiSessionEvent,
|
||||
PlanningQuestion,
|
||||
PlanningResponse,
|
||||
} from "@fusion/core";
|
||||
|
||||
/** Minimal shape of an agent session we depend on (subset of pi's AgentSession). */
|
||||
export interface InteractiveAgentSession {
|
||||
prompt(text: string): Promise<void>;
|
||||
state: {
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text?: string; thinking?: string }>;
|
||||
}>;
|
||||
};
|
||||
dispose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Minimal shape of an agent factory result. */
|
||||
export interface InteractiveAgentResult {
|
||||
session: InteractiveAgentSession;
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
/** Factory that creates the underlying one-shot agent (injectable for tests). */
|
||||
export type InteractiveAgentFactory = (
|
||||
options: CreateInteractiveAiSessionOptions,
|
||||
) => Promise<InteractiveAgentResult>;
|
||||
|
||||
/** One bounded reformat retry, matching planning.ts's MAX_PARSE_RETRIES. */
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
|
||||
const REFORMAT_PROMPT =
|
||||
"Your previous response could not be parsed as JSON. " +
|
||||
'Please respond with ONLY a valid JSON object: {"type":"question","data":{...}} ' +
|
||||
'or {"type":"complete","data":{...}}. No markdown, no explanation, just the JSON.';
|
||||
|
||||
// ── Local JSON extraction/repair (reimplemented to keep core generic) ──────
|
||||
|
||||
function extractJsonCandidate(text: string): string | null {
|
||||
if (!text || !text.trim()) return null;
|
||||
|
||||
// 1. Markdown code blocks first (most reliable).
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
if (codeBlockMatch?.[1]) {
|
||||
const candidate = codeBlockMatch[1].trim();
|
||||
if (candidate.startsWith("{")) return candidate;
|
||||
}
|
||||
|
||||
// 2. Balanced top-level brace objects.
|
||||
const candidates: Array<{ text: string }> = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== "{") continue;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let j = i; j < text.length; j++) {
|
||||
const ch = text[j];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === "{") depth++;
|
||||
if (ch === "}") depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = text.slice(i, j + 1).trim();
|
||||
try {
|
||||
JSON.parse(candidate);
|
||||
candidates.push({ text: candidate });
|
||||
} catch {
|
||||
// not valid JSON, skip
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => b.text.length - a.text.length);
|
||||
return candidates[0].text;
|
||||
}
|
||||
|
||||
// 3. Last resort: full trimmed text.
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("{")) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
function repairJson(text: string): string {
|
||||
let repaired = text.replace(/,\s*([}\]])/g, "$1");
|
||||
|
||||
const count = (s: string): { braces: number; brackets: number; inString: boolean } => {
|
||||
let braces = 0;
|
||||
let brackets = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (const ch of s) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === "{") braces++;
|
||||
if (ch === "}") braces--;
|
||||
if (ch === "[") brackets++;
|
||||
if (ch === "]") brackets--;
|
||||
}
|
||||
return { braces, brackets, inString };
|
||||
};
|
||||
|
||||
if (count(repaired).inString) repaired += '"';
|
||||
const { braces, brackets } = count(repaired);
|
||||
repaired += "]".repeat(Math.max(0, brackets));
|
||||
repaired += "}".repeat(Math.max(0, braces));
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/** Parse agent output into a PlanningResponse; throws on unparseable/invalid. */
|
||||
export function parseAgentResponse(text: string): PlanningResponse {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
if (!candidate) {
|
||||
throw new Error("AI returned no valid JSON.");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
try {
|
||||
parsed = JSON.parse(repairJson(candidate));
|
||||
} catch (repairErr) {
|
||||
throw new Error(
|
||||
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
"type" in parsed &&
|
||||
"data" in parsed
|
||||
) {
|
||||
const typed = parsed as { type: string; data: unknown };
|
||||
if (
|
||||
(typed.type === "question" || typed.type === "complete") &&
|
||||
typed.data !== null &&
|
||||
typed.data !== undefined
|
||||
) {
|
||||
return parsed as PlanningResponse;
|
||||
}
|
||||
}
|
||||
throw new Error("AI returned an invalid response structure.");
|
||||
}
|
||||
|
||||
/** Extract text from the last assistant message (string | text blocks | thinking fallback). */
|
||||
function extractLastAssistantText(session: InteractiveAgentSession): string {
|
||||
const lastMessage = session.state.messages.filter((m) => m.role === "assistant").pop();
|
||||
if (!lastMessage?.content) return "";
|
||||
if (typeof lastMessage.content === "string") return lastMessage.content;
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
const textContent = lastMessage.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (textContent) return textContent;
|
||||
// Fallback: thinking blocks when no text blocks present.
|
||||
return lastMessage.content
|
||||
.filter((c): c is { type: "thinking"; thinking: string } => c.type === "thinking" && typeof c.thinking === "string")
|
||||
.map((c) => c.thinking)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type LoopState = "idle" | "awaiting_input" | "complete" | "error";
|
||||
|
||||
/**
|
||||
* Build the interactive session over an injected agent factory.
|
||||
* Exported for direct (deterministic, fake-agent) testing.
|
||||
*/
|
||||
export async function createInteractiveAiSessionWith(
|
||||
agentFactory: InteractiveAgentFactory,
|
||||
options: CreateInteractiveAiSessionOptions,
|
||||
): Promise<CreateInteractiveAiSessionResult> {
|
||||
const agentResult = await agentFactory(options);
|
||||
const agent = agentResult.session;
|
||||
|
||||
let state: LoopState = "idle";
|
||||
let pendingEvent: Promise<InteractiveAiSessionEvent> | undefined;
|
||||
let terminalEvent: InteractiveAiSessionEvent | undefined;
|
||||
let currentQuestion: PlanningQuestion | undefined;
|
||||
let disposed = false;
|
||||
|
||||
/**
|
||||
* Prompt the agent, read the last assistant message, parse it, and run one
|
||||
* bounded reformat retry. Returns the structured event for this turn.
|
||||
*/
|
||||
async function runTurn(text: string): Promise<InteractiveAiSessionEvent> {
|
||||
if (disposed) {
|
||||
return { type: "error", data: { message: "Session disposed." } };
|
||||
}
|
||||
try {
|
||||
await agent.prompt(text);
|
||||
} catch (err) {
|
||||
state = "error";
|
||||
const ev: InteractiveAiSessionEvent = {
|
||||
type: "error",
|
||||
data: { message: err instanceof Error ? err.message : String(err), cause: err },
|
||||
};
|
||||
terminalEvent = ev;
|
||||
return ev;
|
||||
}
|
||||
|
||||
let responseText = extractLastAssistantText(agent);
|
||||
let parsed: PlanningResponse | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
parsed = parseAgentResponse(responseText);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
try {
|
||||
await agent.prompt(REFORMAT_PROMPT);
|
||||
responseText = extractLastAssistantText(agent);
|
||||
} catch (promptErr) {
|
||||
lastError = promptErr instanceof Error ? promptErr : new Error(String(promptErr));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
state = "error";
|
||||
const ev: InteractiveAiSessionEvent = {
|
||||
type: "error",
|
||||
data: { message: `Failed to parse agent response: ${lastError?.message ?? "Unknown error"}`, cause: lastError },
|
||||
};
|
||||
terminalEvent = ev;
|
||||
return ev;
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
currentQuestion = parsed.data;
|
||||
state = "awaiting_input";
|
||||
return { type: "question", data: parsed.data };
|
||||
}
|
||||
|
||||
// complete
|
||||
state = "complete";
|
||||
const ev: InteractiveAiSessionEvent = { type: "complete", data: parsed.data };
|
||||
terminalEvent = ev;
|
||||
return ev;
|
||||
}
|
||||
|
||||
const session: InteractiveAiSession = {
|
||||
async prompt(text: string): Promise<void> {
|
||||
if (terminalEvent) return; // terminal: ignore further input
|
||||
pendingEvent = runTurn(text);
|
||||
// Surface prompt-time errors only via nextEvent(); never throw to caller.
|
||||
await pendingEvent.catch(() => undefined);
|
||||
},
|
||||
|
||||
async nextEvent(): Promise<InteractiveAiSessionEvent> {
|
||||
if (terminalEvent) return terminalEvent;
|
||||
if (!pendingEvent) {
|
||||
return { type: "error", data: { message: "No turn in progress. Call prompt() or answer() first." } };
|
||||
}
|
||||
return pendingEvent;
|
||||
},
|
||||
|
||||
async answer(questionId: string, response: unknown): Promise<void> {
|
||||
if (terminalEvent) return;
|
||||
if (state !== "awaiting_input") {
|
||||
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
|
||||
type: "error",
|
||||
data: { message: "answer() called while not awaiting input." },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentQuestion && questionId !== currentQuestion.id) {
|
||||
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
|
||||
type: "error",
|
||||
data: { message: `answer() questionId "${questionId}" does not match current question "${currentQuestion.id}".` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const answerMessage = JSON.stringify({
|
||||
type: "answer",
|
||||
questionId,
|
||||
response,
|
||||
});
|
||||
currentQuestion = undefined;
|
||||
state = "idle";
|
||||
pendingEvent = runTurn(answerMessage);
|
||||
await pendingEvent.catch(() => undefined);
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try {
|
||||
void agent.dispose?.();
|
||||
} catch {
|
||||
// Best-effort cleanup; never throw from dispose.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { session, sessionFile: agentResult.sessionFile };
|
||||
}
|
||||
@@ -964,6 +964,11 @@ export interface AgentOptions {
|
||||
* (and `skillSelection` is not), auto-constructs a SkillSelectionContext
|
||||
* from the cwd and these names. Ignored when `skillSelection` is set. */
|
||||
skills?: string[];
|
||||
/** Extra directories to scan for skills (each holding `<id>/SKILL.md`), in
|
||||
* addition to the default cwd/agent-dir roots. Forwarded to the resource
|
||||
* loader so callers (e.g. plugins that install skills to a private dir) can
|
||||
* make `skills`/`skillSelection` names discoverable in the live session. */
|
||||
additionalSkillPaths?: string[];
|
||||
/** Optional task-scoped env injected into this session's subprocess tools only. */
|
||||
taskEnv?: NodeJS.ProcessEnv;
|
||||
/** Last-chance abort hook fired immediately before `createAgentSession`.
|
||||
@@ -1987,6 +1992,9 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
? [options.systemPromptLayers.dynamic]
|
||||
: [],
|
||||
...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}),
|
||||
...(options.additionalSkillPaths && options.additionalSkillPaths.length > 0
|
||||
? { additionalSkillPaths: [...options.additionalSkillPaths] }
|
||||
: {}),
|
||||
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
3
plugins/fusion-plugin-compound-engineering/.gitignore
vendored
Normal file
3
plugins/fusion-plugin-compound-engineering/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Runtime, plugin-local install target for bundled ce-* skills (U2).
|
||||
# Populated by installBundledCeSkills() on plugin load; never committed.
|
||||
.fusion-ce-skills/
|
||||
191
plugins/fusion-plugin-compound-engineering/README.md
Normal file
191
plugins/fusion-plugin-compound-engineering/README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Compound Engineering Plugin for Fusion
|
||||
|
||||
A dedicated dashboard surface for the compound-engineering (CE) workflow: an
|
||||
artifact hub, interactive in-dashboard `ce-*` skill sessions, a work→board
|
||||
bridge, and event-driven bidirectional sync between the Fusion board and a
|
||||
plugin-local CE-pipeline state model. It runs **alongside** Fusion's native
|
||||
pipeline — it does not replace or bypass it.
|
||||
|
||||
## Install (one-click)
|
||||
|
||||
1. Open **Settings → Plugins → Fusion Plugins**.
|
||||
2. In **Bundled Plugins**, click **Install** on **Compound Engineering**.
|
||||
3. Enable the plugin if prompted.
|
||||
|
||||
Once installed and enabled, Fusion registers the **Compound Engineering**
|
||||
dashboard destination automatically and installs the bundled `ce-*` skills into a
|
||||
plugin-local, discoverable directory.
|
||||
|
||||
## What it does
|
||||
|
||||
Compound engineering normally runs as terminal slash-commands whose artifacts
|
||||
scatter across `docs/`, with no unified surface and no link between a finished
|
||||
plan and the board work that follows. This plugin surfaces the whole flow inside
|
||||
Fusion while **reusing the real skills** so the plugin improves as they do.
|
||||
|
||||
## Artifact hub
|
||||
|
||||
The primary dashboard view (`viewId: "compound-engineering"`) discovers and
|
||||
renders CE artifacts from their conventional locations (`STRATEGY.md`,
|
||||
`docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, `CONCEPTS.md`,
|
||||
`docs/solutions/`) and groups them by stage. Artifacts are read through a plugin
|
||||
route and rendered self-contained (sandboxed preview). The hub renders explicit
|
||||
empty / partial / error states rather than crashing or silently dropping an
|
||||
unreadable artifact.
|
||||
|
||||
Artifact HTTP endpoints live under
|
||||
`/api/plugins/fusion-plugin-compound-engineering/` and back the hub list/read.
|
||||
|
||||
## Interactive `ce-*` sessions
|
||||
|
||||
Each pipeline stage maps to a bundled skill via the **stage registry**
|
||||
(`src/session/stage-registry.ts`): `{ stageId, skillId, artifactLocation, icon,
|
||||
label }`. Adding a stage is a data entry — no new route, store, or screen.
|
||||
|
||||
The launcher lists the registered (and operator-enabled) stages. Launching a
|
||||
stage starts an **interactive** agent session driven by the host's
|
||||
`createInteractiveAiSession` seam (a foundational extension added by this plan,
|
||||
because the existing `createAiSession` is one-shot and cannot pause on a
|
||||
mid-agent question). The session orchestrator (`src/session/orchestrator.ts`):
|
||||
|
||||
- streams `thinking` / `text` turns,
|
||||
- surfaces a structured `question` and pauses in `awaiting_input`,
|
||||
- accepts a structured answer and continues,
|
||||
- on `complete`, writes the artifact to the stage's conventional location.
|
||||
|
||||
Lifecycle states are `launching → active → awaiting_input → completed`, plus
|
||||
`error` and `interrupted`. On interrupt or error the orchestrator **auto-saves
|
||||
progress and emits an observable event — never silent loss** — and an
|
||||
`interrupted`/`error` session can be resumed/retried back to its current
|
||||
question.
|
||||
|
||||
### Multiple sessions
|
||||
|
||||
Sessions are independent pipeline runs — the store, routes, and orchestrator
|
||||
all hold many at once (each with its own live agent handle). The dashboard's
|
||||
**Sessions panel** lists every session with its stage, status, and last
|
||||
activity; from there you can:
|
||||
|
||||
- **open** any session and keep working on it (an `awaiting_input` session is
|
||||
flagged "needs your input"),
|
||||
- **switch** between sessions — the panel stays visible while a flow is open,
|
||||
and a session you switch away from keeps running server-side,
|
||||
- **resume** an `interrupted`/`error` session from where it stopped,
|
||||
- **discard** a settled (completed/error/interrupted) session via
|
||||
`DELETE /sessions/:id`, which disposes any live handle before deleting the
|
||||
row (pipeline-link rows are kept — board-task provenance survives).
|
||||
|
||||
The list refreshes on any CE push event and falls back to polling
|
||||
`GET /sessions` while any session has a turn in flight.
|
||||
|
||||
### Live working output, steering, and the Q&A surface
|
||||
|
||||
Turn execution is **detached**: `POST /sessions`, `/answer`, and `/resume`
|
||||
return as soon as the session row reflects the request, with the agent turn
|
||||
running in the background. While it runs:
|
||||
|
||||
- The engine streams **live progress** through the seam's `onProgress` option
|
||||
(thinking/text deltas + tool start/end markers — a host capability any
|
||||
plugin can use). The orchestrator accumulates it per session and
|
||||
`GET /sessions/:id` attaches it as `liveActivity`, so the flow renders a
|
||||
live working pane (pulsing indicator, muted thinking, per-tool ✓/✗ lines).
|
||||
- The per-turn timeout is **inactivity-based**: a long but actively-working
|
||||
turn is never killed; only a turn with no progress for `turnIntervalMs` is
|
||||
interrupted (its working trace is preserved in the transcript).
|
||||
- On settle, the working trace is persisted into the conversation history as a
|
||||
condensed collapsible "Agent work" block — the transcript keeps the full
|
||||
story: opening message, every past question and answer (option ids rendered
|
||||
as labels), steering turns, working traces, and completion.
|
||||
|
||||
**Steering**: alongside any selectable question the user can type free-text
|
||||
guidance — attached to their answer as `{value, comment}`, or sent WITHOUT
|
||||
answering as `{feedback}`. The stage system prompt instructs the agent to
|
||||
treat both as first-class input (incorporate, adjust course, re-ask or
|
||||
proceed).
|
||||
|
||||
### Transport
|
||||
|
||||
Session updates are **pushed** over the shared `/api/events` SSE stream. The
|
||||
orchestrator emits observable events via `ctx.emitEvent` (turn / question /
|
||||
completed / error / interrupted, plus throttled mid-turn progress); the host
|
||||
forwards them to connected clients as project-scoped `plugin:custom` events,
|
||||
and the view subscribes through the `subscribePluginEvents` context capability
|
||||
— refetching the session on each event (no raw `EventSource`; no deep
|
||||
dashboard import). Client **polling of `GET /sessions/:id` remains as a
|
||||
fallback** while a turn is mid-flight, so a missed event still converges.
|
||||
Session identity is project-scoped: the `projectId` used at `start` is
|
||||
threaded through every later answer/resume/poll so they resolve the same store
|
||||
and live handle.
|
||||
|
||||
## Work → board bridge
|
||||
|
||||
When a stage reaches its work phase (`ce-work`, stage id `work`), its `complete`
|
||||
payload may carry a derived task list. The orchestrator creates each as a Fusion
|
||||
board task via `ctx.taskStore.createTask`, tagged CE-originated (source
|
||||
`workflow_step` with CE markers in `sourceMetadata`) and recorded as a
|
||||
**pipeline-link** row. The link row — not task-row JSON — is the authoritative
|
||||
back-reference from a board task to its originating pipeline/stage/artifact
|
||||
(per the FN-5719 pattern). Created tasks then run the **normal** lifecycle with
|
||||
no plugin interference. Zero derived tasks is a clean no-op.
|
||||
|
||||
## Bidirectional sync model
|
||||
|
||||
Two **separate** state machines are kept in sync, never merged:
|
||||
|
||||
- **Board-task ownership** → the task's `column`. **The board is authoritative
|
||||
for task state.**
|
||||
- **CE-pipeline ownership** → `ce_pipeline_state.{currentStage, status}`. **The
|
||||
CE flow is authoritative for artifact/pipeline content.**
|
||||
|
||||
**Inbound (board → pipeline).** The `onTaskMoved` / `onTaskCompleted` lifecycle
|
||||
hooks do the minimum under the 5s hook budget: resolve the link and
|
||||
`enqueueSync(...)`, then return. Heavy advancement is **not** done inline.
|
||||
|
||||
**Reconcile (the convergence guarantee).** `reconcileCePipelines(ctx)` is a
|
||||
single on-demand sweep — **not** a tight interval poll. It (1) drains the queue
|
||||
and (2) independently re-derives transitions by comparing live board state
|
||||
against pipeline state. Step (2) is why a dropped or never-enqueued hook event
|
||||
still converges: the queue is an optimization; the board↔state comparison is the
|
||||
source of truth.
|
||||
|
||||
**Outbound (pipeline → board).** When a pipeline advances to a stage that
|
||||
produces board work, the reconciler creates the next-stage board task via
|
||||
`ctx.taskStore.createTask` and links it.
|
||||
|
||||
**Conflict policy.** The reconciler only reads the already-terminal board task
|
||||
columns (board-authoritative) and only writes CE-owned fields plus a brand-new
|
||||
board task — the two writers never contend over the same cell.
|
||||
|
||||
## Bundled-skills isolation model
|
||||
|
||||
The `ce-*` skills are **bundled and pinned** inside the plugin
|
||||
(`src/skills/<skillId>/SKILL.md`), declared via `PluginSkillContribution` with
|
||||
plugin-root-relative `skillFiles`. On load they are physically installed
|
||||
(`cpSync`, idempotent skip-if-exists) into a **plugin-local, discoverable**
|
||||
directory so an agent session can resolve them. The install is guarded to **never
|
||||
touch a global `~/.claude/skills` path** an operator's own compound-engineering
|
||||
install owns — registering the bundled copy can never clobber a global install.
|
||||
|
||||
## Settings
|
||||
|
||||
Operator-facing settings render in **Settings → Plugins → Compound Engineering**,
|
||||
grouped as follows. Every setting has a real consumption point in the plugin.
|
||||
|
||||
### Sessions
|
||||
|
||||
| Setting | Type | Default | Effect |
|
||||
|---|---|---|---|
|
||||
| **Default Session Provider** (`defaultProvider`) | string | _(host default)_ | Passed to the interactive-session factory as `defaultProvider`. Blank → host picks. |
|
||||
| **Default Session Model** (`defaultModelId`) | string | _(host default)_ | Passed to the factory as `defaultModelId`. Blank → host picks. |
|
||||
| **Enabled Stages** (`enabledStages`) | string[] | full registry | Only these stage IDs may be launched; the orchestrator rejects others. |
|
||||
|
||||
### Sync
|
||||
|
||||
| Setting | Type | Default | Effect |
|
||||
|---|---|---|---|
|
||||
| **Reconcile on Board Changes** (`reconcileOnHooks`) | boolean | `true` | When on, the reconcile sweep auto-fires after task move/complete hooks. When off, the hook still enqueues so an on-demand sweep converges later. |
|
||||
| **Reconcile Cadence (minutes)** (`reconcileIntervalMinutes`) | number | `15` | Cadence hint for an on-demand refresh surface. Not a continuous poll loop. |
|
||||
|
||||
Getters live in `src/settings.ts` (`getDefaultProvider`, `getDefaultModelId`,
|
||||
`getEnabledStages`, `getReconcileOnHooks`, `getReconcileIntervalMinutes`), each
|
||||
returning its default when the setting is absent.
|
||||
56
plugins/fusion-plugin-compound-engineering/manifest.json
Normal file
56
plugins/fusion-plugin-compound-engineering/manifest.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"id": "fusion-plugin-compound-engineering",
|
||||
"name": "Compound Engineering",
|
||||
"version": "0.1.0",
|
||||
"description": "A dedicated dashboard surface for compound-engineering artifacts and interactive ce-* sessions.",
|
||||
"author": "Fusion Team",
|
||||
"fusionVersion": ">=0.1.0",
|
||||
"dashboardViews": [
|
||||
{
|
||||
"viewId": "compound-engineering",
|
||||
"label": "Compound Engineering",
|
||||
"componentPath": "./dashboard-view",
|
||||
"icon": "Sparkles",
|
||||
"placement": "primary",
|
||||
"order": 36
|
||||
}
|
||||
],
|
||||
"settingsSchema": {
|
||||
"defaultProvider": {
|
||||
"type": "string",
|
||||
"label": "Default Session Provider",
|
||||
"description": "Model provider used for CE interactive sessions (for example anthropic). Leave blank to use the host default.",
|
||||
"group": "Sessions",
|
||||
"defaultValue": ""
|
||||
},
|
||||
"defaultModelId": {
|
||||
"type": "string",
|
||||
"label": "Default Session Model",
|
||||
"description": "Model ID within the provider used for CE interactive sessions. Leave blank to use the host default.",
|
||||
"group": "Sessions",
|
||||
"defaultValue": ""
|
||||
},
|
||||
"enabledStages": {
|
||||
"type": "array",
|
||||
"itemType": "string",
|
||||
"label": "Enabled Stages",
|
||||
"description": "Stage IDs that may be launched from the Compound Engineering view (for example strategy, ideate, brainstorm, plan, work).",
|
||||
"group": "Sessions",
|
||||
"defaultValue": ["strategy", "ideate", "brainstorm", "plan", "work"]
|
||||
},
|
||||
"reconcileOnHooks": {
|
||||
"type": "boolean",
|
||||
"label": "Reconcile on Board Changes",
|
||||
"description": "Run the board→pipeline reconcile sweep automatically after task move/complete hooks. Disable to only reconcile on demand.",
|
||||
"group": "Sync",
|
||||
"defaultValue": true
|
||||
},
|
||||
"reconcileIntervalMinutes": {
|
||||
"type": "number",
|
||||
"label": "Reconcile Cadence (minutes)",
|
||||
"description": "Cadence hint for how often an on-demand refresh surface sweeps the reconciler. Not a continuous poll loop.",
|
||||
"group": "Sync",
|
||||
"defaultValue": 15
|
||||
}
|
||||
}
|
||||
}
|
||||
37
plugins/fusion-plugin-compound-engineering/package.json
Normal file
37
plugins/fusion-plugin-compound-engineering/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@fusion-plugin-examples/compound-engineering",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Compound Engineering plugin for Fusion",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./dashboard-view": {
|
||||
"types": "./src/dashboard-view.tsx",
|
||||
"import": "./src/dashboard-view.tsx"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import { Database } from "@fusion/core";
|
||||
import type {
|
||||
CreateInteractiveAiSessionFactory,
|
||||
InteractiveAiSession,
|
||||
InteractiveAiSessionEvent,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
|
||||
export interface TestHarness {
|
||||
db: Database;
|
||||
projectRoot: string;
|
||||
ctx: PluginContext;
|
||||
emitted: Array<{ event: string; data: unknown }>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory DB + a minimal route-style PluginContext whose `taskStore` exposes
|
||||
* `getDatabase()` / `getRootDir()` (the only surfaces the orchestrator uses) and
|
||||
* a recording `emitEvent` so tests can assert observable events.
|
||||
*/
|
||||
export function makeHarness(): TestHarness {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), "ce-session-test-"));
|
||||
const db = new Database(join(projectRoot, ".fusion"), { inMemory: true });
|
||||
db.init();
|
||||
|
||||
const emitted: Array<{ event: string; data: unknown }> = [];
|
||||
|
||||
const taskStore = {
|
||||
getDatabase: () => db,
|
||||
getRootDir: () => projectRoot,
|
||||
} as unknown as PluginContext["taskStore"];
|
||||
|
||||
const ctx: PluginContext = {
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
taskStore,
|
||||
settings: {},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
emitted.push({ event, data });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
db,
|
||||
projectRoot,
|
||||
ctx,
|
||||
emitted,
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A scripted fake interactive session: each prompt/answer advances a cursor and
|
||||
* the next `nextEvent()` yields the scripted event for that turn. Mirrors the
|
||||
* U4 seam tests' scripted fake.
|
||||
*/
|
||||
export function makeScriptedSession(script: InteractiveAiSessionEvent[]): InteractiveAiSession {
|
||||
let cursor = -1;
|
||||
return {
|
||||
prompt: vi.fn(async () => {
|
||||
cursor++;
|
||||
}),
|
||||
answer: vi.fn(async () => {
|
||||
cursor++;
|
||||
}),
|
||||
nextEvent: vi.fn(async () => {
|
||||
if (script.length === 0) {
|
||||
// An empty script is a test bug — surface it loudly rather than
|
||||
// silently returning undefined (which masks the mistake downstream).
|
||||
throw new Error("makeScriptedSession: empty script has no events to yield");
|
||||
}
|
||||
return script[Math.min(Math.max(cursor, 0), script.length - 1)];
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A factory that returns the given scripted session. */
|
||||
export function scriptedFactory(session: InteractiveAiSession): CreateInteractiveAiSessionFactory {
|
||||
return vi.fn(async () => ({ session, sessionFile: "/tmp/ce.json" }));
|
||||
}
|
||||
|
||||
/** A session whose first turn never produces an event (forces a turn timeout). */
|
||||
export function hangingSession(): InteractiveAiSession {
|
||||
return {
|
||||
prompt: vi.fn(async () => undefined),
|
||||
answer: vi.fn(async () => undefined),
|
||||
nextEvent: vi.fn(() => new Promise<InteractiveAiSessionEvent>(() => undefined)),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import manifest from "../../manifest.json";
|
||||
import plugin from "../index.js";
|
||||
import { COMPOUND_ENGINEERING_SKILLS } from "../skills.js";
|
||||
import { settingsSchema } from "../settings.js";
|
||||
|
||||
describe("compound engineering plugin manifest", () => {
|
||||
it("exports expected plugin id", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-compound-engineering");
|
||||
});
|
||||
|
||||
it("keeps runtime manifest metadata aligned with manifest.json", () => {
|
||||
expect(plugin.manifest.id).toBe(manifest.id);
|
||||
expect(plugin.manifest.name).toBe(manifest.name);
|
||||
expect(plugin.manifest.version).toBe(manifest.version);
|
||||
expect(plugin.manifest.description).toBe(manifest.description);
|
||||
expect(plugin.manifest.author).toBe(manifest.author);
|
||||
expect(plugin.manifest.fusionVersion).toBe(manifest.fusionVersion);
|
||||
});
|
||||
|
||||
it("registers a single dashboard view", () => {
|
||||
expect(plugin.dashboardViews).toEqual([
|
||||
{
|
||||
viewId: "compound-engineering",
|
||||
label: "Compound Engineering",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "Sparkles",
|
||||
placement: "primary",
|
||||
order: 36,
|
||||
},
|
||||
]);
|
||||
expect(manifest.dashboardViews).toEqual(plugin.dashboardViews);
|
||||
});
|
||||
|
||||
it("registers the session orchestration routes (U5)", () => {
|
||||
const paths = (plugin.routes ?? []).map((r) => `${r.method} ${r.path}`);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
"POST /sessions",
|
||||
"POST /sessions/:id/answer",
|
||||
"POST /sessions/:id/resume",
|
||||
"GET /sessions/:id",
|
||||
"GET /sessions",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("wires onSchemaInit for the plugin-local CE tables (U5)", () => {
|
||||
expect(typeof plugin.hooks.onSchemaInit).toBe("function");
|
||||
});
|
||||
|
||||
it("registers the bundled CE pipeline-stage skills on plugin and manifest (U2)", () => {
|
||||
const expectedIds = [
|
||||
"ce-strategy",
|
||||
"ce-ideate",
|
||||
"ce-brainstorm",
|
||||
"ce-plan",
|
||||
"ce-work",
|
||||
"ce-code-review",
|
||||
"ce-compound",
|
||||
];
|
||||
expect(COMPOUND_ENGINEERING_SKILLS.map((s) => s.skillId)).toEqual(expectedIds);
|
||||
expect(plugin.skills).toBe(COMPOUND_ENGINEERING_SKILLS);
|
||||
// Manifest mirrors agent-browser: { skillId, name } projection.
|
||||
expect(plugin.manifest.skills).toEqual(
|
||||
COMPOUND_ENGINEERING_SKILLS.map((s) => ({ skillId: s.skillId, name: s.name })),
|
||||
);
|
||||
// Each contribution points at a plugin-root-relative bundled SKILL.md.
|
||||
for (const s of COMPOUND_ENGINEERING_SKILLS) {
|
||||
expect(s.skillFiles).toEqual([`skills/${s.skillId}/SKILL.md`]);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers an onLoad hook that installs bundled skills (U2)", () => {
|
||||
expect(typeof plugin.hooks?.onLoad).toBe("function");
|
||||
});
|
||||
|
||||
it("wires the settings schema onto the runtime manifest and manifest.json (U9)", () => {
|
||||
const expectedKeys = [
|
||||
"defaultProvider",
|
||||
"defaultModelId",
|
||||
"enabledStages",
|
||||
"reconcileOnHooks",
|
||||
"reconcileIntervalMinutes",
|
||||
].sort();
|
||||
expect(plugin.manifest.settingsSchema).toBe(settingsSchema);
|
||||
expect(Object.keys(settingsSchema).sort()).toEqual(expectedKeys);
|
||||
// manifest.json mirrors the same keys (runtime/JSON alignment).
|
||||
expect(Object.keys(manifest.settingsSchema).sort()).toEqual(expectedKeys);
|
||||
// Spot-check one entry stays aligned between JSON and runtime.
|
||||
expect(manifest.settingsSchema.reconcileIntervalMinutes.defaultValue).toBe(
|
||||
settingsSchema.reconcileIntervalMinutes.defaultValue,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core";
|
||||
import { CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js";
|
||||
import { registerStage, getStage } from "../session/stage-registry.js";
|
||||
import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js";
|
||||
|
||||
const QUESTION: PlanningQuestion = {
|
||||
id: "q1",
|
||||
type: "text",
|
||||
question: "What is the topic?",
|
||||
};
|
||||
|
||||
let h: TestHarness;
|
||||
beforeEach(() => {
|
||||
h = makeHarness();
|
||||
});
|
||||
afterEach(() => {
|
||||
h.close();
|
||||
});
|
||||
|
||||
function makeOrch(script: InteractiveAiSessionEvent[]) {
|
||||
const session = makeScriptedSession(script);
|
||||
return new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
describe("orchestrator happy path", () => {
|
||||
it("start → question → answer → complete writes the artifact to the conventional location", async () => {
|
||||
const orch = makeOrch([
|
||||
{ type: "question", data: QUESTION },
|
||||
{ type: "complete", data: { artifact: "# Brainstorm\n\nThe plan.\n" } },
|
||||
]);
|
||||
|
||||
const started = await orch.start("brainstorm", { openingMessage: "let's brainstorm widgets" });
|
||||
expect(started.session.status).toBe("awaiting_input");
|
||||
expect(started.session.currentQuestion?.id).toBe("q1");
|
||||
|
||||
const done = await orch.answer(started.session.id, "q1", "widgets");
|
||||
expect(done.event?.type).toBe("complete");
|
||||
expect(done.session.status).toBe("completed");
|
||||
|
||||
// Artifact written to docs/brainstorms/ (the stage's conventional location).
|
||||
const artifactPath = done.session.artifactPath!;
|
||||
expect(artifactPath).toContain("docs/brainstorms/");
|
||||
expect(existsSync(artifactPath)).toBe(true);
|
||||
expect(readFileSync(artifactPath, "utf-8")).toContain("# Brainstorm");
|
||||
|
||||
// Observable completion event emitted.
|
||||
expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.completed);
|
||||
});
|
||||
|
||||
it("runs a SECOND stage through the SAME orchestrator with only a registry-data entry (no new route/store code)", async () => {
|
||||
// Adding a stage = data only.
|
||||
registerStage({
|
||||
stageId: "compound",
|
||||
order: 600,
|
||||
skillId: "ce-compound",
|
||||
artifactLocation: "docs/solutions/",
|
||||
icon: "BookOpen",
|
||||
label: "Compound",
|
||||
});
|
||||
expect(getStage("compound")?.skillId).toBe("ce-compound");
|
||||
|
||||
const orch = makeOrch([{ type: "complete", data: { artifact: "# Learning\n" } }]);
|
||||
const started = await orch.start("compound", { openingMessage: "document this" });
|
||||
expect(started.event?.type).toBe("complete");
|
||||
expect(started.session.stage).toBe("compound");
|
||||
expect(started.session.status).toBe("completed");
|
||||
expect(started.session.artifactPath).toContain("docs/solutions/");
|
||||
expect(readFileSync(started.session.artifactPath!, "utf-8")).toContain("# Learning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple concurrent sessions", () => {
|
||||
it("drives two independent sessions through the SAME orchestrator without cross-talk", async () => {
|
||||
// Two scripted live sessions; the factory hands them out in creation order.
|
||||
const liveA = makeScriptedSession([
|
||||
{ type: "question", data: QUESTION },
|
||||
{ type: "complete", data: { artifact: "# A\n" } },
|
||||
]);
|
||||
const liveB = makeScriptedSession([
|
||||
{ type: "question", data: { ...QUESTION, id: "q-b" } },
|
||||
{ type: "complete", data: { artifact: "# B\n" } },
|
||||
]);
|
||||
const handles = [liveA, liveB];
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session: handles.shift()! })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const a = await orch.start("brainstorm", { openingMessage: "topic A" });
|
||||
const b = await orch.start("brainstorm", { openingMessage: "topic B" });
|
||||
expect(a.session.id).not.toBe(b.session.id);
|
||||
expect(a.session.status).toBe("awaiting_input");
|
||||
expect(b.session.status).toBe("awaiting_input");
|
||||
|
||||
// Answer B first — A must stay awaiting, untouched.
|
||||
const doneB = await orch.answer(b.session.id, "q-b", "bee");
|
||||
expect(doneB.session.status).toBe("completed");
|
||||
expect(orch.getState(a.session.id)?.status).toBe("awaiting_input");
|
||||
|
||||
// A is still answerable on ITS live handle (not B's).
|
||||
const doneA = await orch.answer(a.session.id, "q1", "ay");
|
||||
expect(doneA.session.status).toBe("completed");
|
||||
expect(liveA.answer).toHaveBeenCalledTimes(1);
|
||||
expect(liveB.answer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discard disposes the live handle and deletes only that session", async () => {
|
||||
const live = makeScriptedSession([{ type: "question", data: QUESTION }]);
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session: live })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
const started = await orch.start("brainstorm", { openingMessage: "topic" });
|
||||
|
||||
expect(orch.discard(started.session.id)).toBe(true);
|
||||
expect(live.dispose).toHaveBeenCalled();
|
||||
expect(orch.getState(started.session.id)).toBeUndefined();
|
||||
// Idempotent-ish: a second discard reports false, no throw.
|
||||
expect(orch.discard(started.session.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orchestrator error + retry", () => {
|
||||
it("agent error → status error, progress preserved, observable event; retry resumes to the question", async () => {
|
||||
const orch = makeOrch([
|
||||
{ type: "question", data: QUESTION },
|
||||
{ type: "error", data: { message: "model overloaded" } },
|
||||
]);
|
||||
|
||||
const started = await orch.start("brainstorm", { openingMessage: "topic" });
|
||||
expect(started.session.currentQuestion?.id).toBe("q1");
|
||||
|
||||
const errored = await orch.answer(started.session.id, "q1", "answer-text");
|
||||
expect(errored.session.status).toBe("error");
|
||||
expect(errored.session.error).toContain("model overloaded");
|
||||
// Progress preserved: history retained.
|
||||
expect(errored.session.conversationHistory.length).toBeGreaterThan(0);
|
||||
expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.error);
|
||||
|
||||
// Retry: resume() moves an errored session forward. (Error keeps it
|
||||
// resumable; resume reads persisted state — the no-loss anchor.)
|
||||
const state = orch.getState(errored.session.id)!;
|
||||
expect(state.conversationHistory.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { InteractiveAiSession, InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core";
|
||||
import { vi } from "vitest";
|
||||
import { CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js";
|
||||
import { CeSessionStore, getCeSessionStore } from "../session/session-store.js";
|
||||
import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js";
|
||||
|
||||
/**
|
||||
* CHARACTERIZATION TEST — written first (U5 execution note: cover the
|
||||
* no-silent-loss invariant before the happy path). Asserts that an interrupted
|
||||
* mid-question session auto-saves progress, lands in `interrupted`, emits an
|
||||
* observable event, and resumes to the SAME question with full history.
|
||||
*/
|
||||
|
||||
const QUESTION: PlanningQuestion = {
|
||||
id: "q1",
|
||||
type: "single_select",
|
||||
question: "Which direction?",
|
||||
options: [
|
||||
{ id: "a", label: "A" },
|
||||
{ id: "b", label: "B" },
|
||||
],
|
||||
};
|
||||
|
||||
let h: TestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = makeHarness();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
h.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Session that yields a question on turn 1, then HANGS on the next turn
|
||||
* (the answer turn never produces an event) — forcing a turn timeout.
|
||||
*/
|
||||
function questionThenHangSession(): InteractiveAiSession {
|
||||
let cursor = -1;
|
||||
return {
|
||||
prompt: vi.fn(async () => {
|
||||
cursor++;
|
||||
}),
|
||||
answer: vi.fn(async () => {
|
||||
cursor++;
|
||||
}),
|
||||
nextEvent: vi.fn(async (): Promise<InteractiveAiSessionEvent> => {
|
||||
if (cursor === 0) return { type: "question", data: QUESTION };
|
||||
// turn 2+ hangs forever
|
||||
return new Promise<InteractiveAiSessionEvent>(() => undefined);
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("interrupt + resume (no silent loss)", () => {
|
||||
it("auto-saves progress on a turn timeout, marks interrupted, emits an event", async () => {
|
||||
const session = questionThenHangSession();
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 20,
|
||||
});
|
||||
|
||||
const started = await orch.start("brainstorm", { openingMessage: "kick off" });
|
||||
expect(started.event?.type).toBe("question");
|
||||
expect(started.session.status).toBe("awaiting_input");
|
||||
expect(started.session.currentQuestion?.id).toBe("q1");
|
||||
|
||||
// Answering triggers the next turn, which hangs → timeout → interrupted.
|
||||
const interrupted = await orch.answer(started.session.id, "q1", "a");
|
||||
expect(interrupted.session.status).toBe("interrupted");
|
||||
// Progress preserved: full history including the question and the answer.
|
||||
const history = interrupted.session.conversationHistory;
|
||||
expect(history.some((t) => t.text.includes("kick off"))).toBe(true);
|
||||
expect(history.some((t) => t.text.includes("question"))).toBe(true);
|
||||
expect(history.some((t) => t.text.includes("\"answer\""))).toBe(true);
|
||||
|
||||
// Observable event emitted — never silent loss.
|
||||
expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.interrupted);
|
||||
});
|
||||
|
||||
it("leaves an awaiting_input session (waiting on a human) untouched even far past the stale band, and resume returns the same question with full history", async () => {
|
||||
// A session legitimately paused on a human question: status awaiting_input
|
||||
// with currentQuestion set, lastActivity well past the interval stale band.
|
||||
// Human response time is unbounded, so this is NOT a crashed turn — the
|
||||
// interval rubric must not misclassify it as stale.
|
||||
const store = new CeSessionStore(h.db);
|
||||
const created = store.create({ stage: "brainstorm", turnIntervalMs: 1000 });
|
||||
store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() });
|
||||
store.appendHistory(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString() });
|
||||
store.update(created.id, {
|
||||
status: "awaiting_input",
|
||||
currentQuestion: QUESTION,
|
||||
// 10× interval old → far past the band, yet legitimately awaiting a human.
|
||||
lastActivityAt: Date.now() - 10_000,
|
||||
});
|
||||
|
||||
const recovered = store.recoverStaleSessions();
|
||||
// Not flagged stale / not recovered — a human wait is not a crashed turn.
|
||||
expect(recovered).not.toContain(created.id);
|
||||
|
||||
const after = store.get(created.id)!;
|
||||
// Awaiting-input session with a question stays resumable, unchanged.
|
||||
expect(after.status).toBe("awaiting_input");
|
||||
expect(after.currentQuestion?.id).toBe("q1");
|
||||
|
||||
// Resume via the orchestrator returns to the same question + full history.
|
||||
// Rehydration re-creates a live session and replays the opening message,
|
||||
// draining the agent's response (the question) during replay.
|
||||
const replaySession = makeScriptedSession([{ type: "question", data: QUESTION }]);
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session: replaySession })),
|
||||
projectRoot: h.projectRoot,
|
||||
});
|
||||
const resumed = await orch.resume(created.id);
|
||||
expect(resumed.session.status).toBe("awaiting_input");
|
||||
expect(resumed.session.currentQuestion?.id).toBe("q1");
|
||||
expect(resumed.session.conversationHistory).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Bug 5: an interrupted/awaiting session with a currentQuestion + history can be resumed (rehydrated) and then ANSWERED to continue to completion", async () => {
|
||||
// Simulate the post-interrupt / post-restart state: a session persisted
|
||||
// mid-question (awaiting_input, currentQuestion set, full history) whose live
|
||||
// handle was disposed and removed from this.live. This is exactly the state
|
||||
// resume() must be able to back with a real live handle.
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 });
|
||||
store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() });
|
||||
store.appendHistory(created.id, {
|
||||
role: "agent",
|
||||
text: JSON.stringify({ question: QUESTION }),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION });
|
||||
const sessionId = created.id;
|
||||
|
||||
// The rehydration factory: replays the opening prompt (yields the question,
|
||||
// which replay discards), then on the real answer turn completes the stage.
|
||||
const rehydrated = makeScriptedSession([
|
||||
{ type: "question", data: QUESTION },
|
||||
{ type: "complete", data: { artifact: "# Done\n" } },
|
||||
]);
|
||||
const factory = vi.fn(async () => ({ session: rehydrated }));
|
||||
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: factory,
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
// Pre-fix: resume() flips status to awaiting_input but never re-establishes a
|
||||
// live handle, so the subsequent answer() throws "no live handle; call
|
||||
// resume() first" — a dead-end loop. Post-fix: resume rehydrates a live one.
|
||||
const resumed = await orch.resume(sessionId);
|
||||
expect(resumed.session.status).toBe("awaiting_input");
|
||||
expect(resumed.session.currentQuestion?.id).toBe("q1");
|
||||
expect(factory).toHaveBeenCalledTimes(1); // rehydration created a live session.
|
||||
|
||||
// The resumed session is genuinely answerable now — drive it to completion.
|
||||
const done = await orch.answer(sessionId, "q1", "a");
|
||||
expect(done.event?.type).toBe("complete");
|
||||
expect(done.session.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("Bug 4: answering with a wrong questionId throws and leaves the session awaiting_input with its currentQuestion preserved", async () => {
|
||||
const session = questionThenHangSession();
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 20,
|
||||
});
|
||||
|
||||
const started = await orch.start("brainstorm", { openingMessage: "kick off" });
|
||||
expect(started.session.status).toBe("awaiting_input");
|
||||
expect(started.session.currentQuestion?.id).toBe("q1");
|
||||
|
||||
// Answer with the WRONG questionId → must reject without mutating state.
|
||||
await expect(orch.answer(started.session.id, "WRONG-ID", "a")).rejects.toThrow(/q1|WRONG-ID/);
|
||||
|
||||
// The recovery anchor is intact: still awaiting_input with currentQuestion.
|
||||
const after = orch.getState(started.session.id)!;
|
||||
expect(after.status).toBe("awaiting_input");
|
||||
expect(after.currentQuestion?.id).toBe("q1");
|
||||
// No spurious answer turn was appended to history.
|
||||
expect(after.conversationHistory.some((t) => t.text.includes("WRONG-ID"))).toBe(false);
|
||||
|
||||
// The correct questionId is still accepted (the live handle wasn't disturbed).
|
||||
// The session hangs on the answer turn → it interrupts, but it DID accept the
|
||||
// answer, proving the rejection above didn't break the seam.
|
||||
const accepted = await orch.answer(started.session.id, "q1", "a");
|
||||
expect(accepted.session.status).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("a crash with no pending question is marked interrupted (progress preserved), not silently dropped", () => {
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
const created = store.create({ stage: "brainstorm", turnIntervalMs: 1000 });
|
||||
store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() });
|
||||
store.update(created.id, { status: "active", lastActivityAt: Date.now() - 10_000 });
|
||||
|
||||
store.recoverStaleSessions();
|
||||
const after = store.get(created.id)!;
|
||||
expect(after.status).toBe("interrupted");
|
||||
expect(after.error).toMatch(/progress preserved/i);
|
||||
expect(after.conversationHistory).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CreateInteractiveAiSessionFactory,
|
||||
InteractiveAiSessionEvent,
|
||||
InteractiveAiSessionProgressEvent,
|
||||
PlanningQuestion,
|
||||
} from "@fusion/core";
|
||||
import { buildStageSystemPrompt, CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js";
|
||||
import { getStage } from "../session/stage-registry.js";
|
||||
import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js";
|
||||
|
||||
/**
|
||||
* Live working-output + steering-protocol coverage:
|
||||
* - mid-turn progress (thinking/text deltas, tool markers) is visible via
|
||||
* getLiveActivity while the turn runs, emitted as observable events, and
|
||||
* persisted into history as a condensed trace when the turn settles;
|
||||
* - the turn timeout is INACTIVITY-based — an actively-working long turn is
|
||||
* never killed, a quiet one is interrupted with its trace preserved;
|
||||
* - detached start/answer return immediately and converge via persisted state;
|
||||
* - the stage system prompt documents the steering response shapes.
|
||||
*/
|
||||
|
||||
const QUESTION: PlanningQuestion = { id: "q1", type: "text", question: "Topic?" };
|
||||
|
||||
let h: TestHarness;
|
||||
beforeEach(() => {
|
||||
h = makeHarness();
|
||||
});
|
||||
afterEach(() => {
|
||||
h.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** A factory exposing the onProgress hook and a controllable nextEvent. */
|
||||
function progressFactory(nextEvent: () => Promise<InteractiveAiSessionEvent>) {
|
||||
const captured: { progress?: (e: InteractiveAiSessionProgressEvent) => void; dispose: ReturnType<typeof vi.fn> } = {
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
const factory: CreateInteractiveAiSessionFactory = vi.fn(async (opts) => {
|
||||
captured.progress = opts.onProgress;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn(async () => undefined),
|
||||
answer: vi.fn(async () => undefined),
|
||||
nextEvent,
|
||||
dispose: captured.dispose,
|
||||
},
|
||||
};
|
||||
});
|
||||
return { factory, captured };
|
||||
}
|
||||
|
||||
describe("live working output", () => {
|
||||
it("buffers mid-turn progress, emits observable events, and persists the trace on settle (before the question)", async () => {
|
||||
const evt = deferred<InteractiveAiSessionEvent>();
|
||||
const { factory, captured } = progressFactory(() => evt.promise);
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: factory,
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await orch.start("brainstorm", { openingMessage: "go", detach: true });
|
||||
expect(["launching", "active"]).toContain(started.session.status);
|
||||
await vi.waitFor(() => expect(captured.progress).toBeDefined());
|
||||
|
||||
// Stream: consecutive deltas of one kind merge; tool start/end are discrete.
|
||||
captured.progress!({ type: "thinking", delta: "Let me " });
|
||||
captured.progress!({ type: "thinking", delta: "look around." });
|
||||
captured.progress!({ type: "tool", name: "Read", phase: "start" });
|
||||
captured.progress!({ type: "tool", name: "Read", phase: "end", isError: false });
|
||||
captured.progress!({ type: "text", delta: "Drafting…" });
|
||||
|
||||
const live = orch.getLiveActivity(started.session.id);
|
||||
expect(live.map((t) => t.kind)).toEqual(["thinking", "tool", "text"]);
|
||||
expect(live[0].text).toBe("Let me look around.");
|
||||
expect(live[1].done).toBe(true);
|
||||
expect(live[1].isError).toBeUndefined();
|
||||
|
||||
// Observable progress event emitted (throttled; the first one is immediate).
|
||||
expect(
|
||||
h.emitted.some((e) => e.event === CE_EVENTS.turn && (e.data as { kind?: string }).kind === "progress"),
|
||||
).toBe(true);
|
||||
|
||||
// Settle the turn → buffer flushed into history BEFORE the question record.
|
||||
evt.resolve({ type: "question", data: QUESTION });
|
||||
await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("awaiting_input"));
|
||||
expect(orch.getLiveActivity(started.session.id)).toHaveLength(0);
|
||||
|
||||
const history = orch.getState(started.session.id)!.conversationHistory;
|
||||
const activityIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"activity"'));
|
||||
const questionIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"question"'));
|
||||
expect(activityIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(questionIdx).toBeGreaterThan(activityIdx);
|
||||
const trace = JSON.parse(history[activityIdx].text) as {
|
||||
activity: { turns: Array<{ kind: string; text: string }> };
|
||||
};
|
||||
expect(trace.activity.turns.map((t) => t.kind)).toEqual(["thinking", "tool", "text"]);
|
||||
});
|
||||
|
||||
it("inactivity watchdog: an actively-working long turn survives past the timeout; a quiet one is interrupted with its trace kept", async () => {
|
||||
const { factory, captured } = progressFactory(() => new Promise<InteractiveAiSessionEvent>(() => undefined));
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: factory,
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 120,
|
||||
});
|
||||
const started = await orch.start("brainstorm", { openingMessage: "go", detach: true });
|
||||
const id = started.session.id;
|
||||
await vi.waitFor(() => expect(captured.progress).toBeDefined());
|
||||
|
||||
// Keep working for ~3× the timeout — must NOT be interrupted.
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await sleep(45);
|
||||
captured.progress!({ type: "thinking", delta: "." });
|
||||
}
|
||||
expect(orch.getState(id)?.status).toBe("active");
|
||||
|
||||
// Go quiet → interrupted after the inactivity window, trace preserved.
|
||||
await vi.waitFor(() => expect(orch.getState(id)?.status).toBe("interrupted"), { timeout: 2000 });
|
||||
expect(orch.getState(id)?.error).toMatch(/no agent activity/i);
|
||||
const history = orch.getState(id)!.conversationHistory;
|
||||
expect(history.some((t) => t.text.startsWith('{"activity"'))).toBe(true);
|
||||
expect(captured.dispose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detached turns (route posture)", () => {
|
||||
it("answer(detach) returns immediately with status active and converges to the next question", async () => {
|
||||
const NEXT: PlanningQuestion = { id: "q2", type: "text", question: "More?" };
|
||||
const scripted = makeScriptedSession([
|
||||
{ type: "question", data: QUESTION },
|
||||
{ type: "question", data: NEXT },
|
||||
]);
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session: scripted })),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
const started = await orch.start("brainstorm", { openingMessage: "go" });
|
||||
expect(started.session.status).toBe("awaiting_input");
|
||||
|
||||
const stepped = await orch.answer(started.session.id, "q1", "widgets", { detach: true });
|
||||
// Detached return reflects the just-accepted answer, not the settled turn…
|
||||
expect(stepped.session.status).toBe("active");
|
||||
expect(stepped.session.currentQuestion).toBeNull();
|
||||
// …and the background turn converges to the next question.
|
||||
await vi.waitFor(() => expect(orch.getState(started.session.id)?.currentQuestion?.id).toBe("q2"));
|
||||
expect(orch.getState(started.session.id)?.status).toBe("awaiting_input");
|
||||
});
|
||||
|
||||
it("start(detach) without a working factory converges to an error state (never silent)", async () => {
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => {
|
||||
throw new Error("factory exploded");
|
||||
}),
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
const started = await orch.start("brainstorm", { openingMessage: "go", detach: true });
|
||||
expect(started.session.id).toBeTruthy();
|
||||
await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("error"));
|
||||
expect(orch.getState(started.session.id)?.error).toContain("factory exploded");
|
||||
expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("steering protocol", () => {
|
||||
it("the stage system prompt documents direct, value+comment, and feedback-only response shapes", () => {
|
||||
const prompt = buildStageSystemPrompt(getStage("brainstorm")!);
|
||||
expect(prompt).toContain('"value"');
|
||||
expect(prompt).toContain('"comment"');
|
||||
expect(prompt).toContain('"feedback"');
|
||||
expect(prompt).toMatch(/steering/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginContext, PluginRouteResponse } from "@fusion/core";
|
||||
import { createSessionRoutes } from "../routes/session-routes.js";
|
||||
import { makeHarness, type TestHarness } from "./_harness.js";
|
||||
|
||||
/**
|
||||
* Routes-level smoke test for the POLLING transport. Exercises validation and
|
||||
* the get-session-state read path that clients poll. The orchestrator's live
|
||||
* interactive flow is covered by orchestrator-flow.test.ts; here createInter-
|
||||
* activeAiSession is absent (non-engine context), so `start` returns a 400 —
|
||||
* which is the correct, non-hanging behavior.
|
||||
*/
|
||||
|
||||
let h: TestHarness;
|
||||
beforeEach(() => {
|
||||
h = makeHarness();
|
||||
});
|
||||
afterEach(() => {
|
||||
h.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function route(method: string, path: string) {
|
||||
const r = createSessionRoutes().find((x) => x.method === method && x.path === path);
|
||||
if (!r) throw new Error(`route ${method} ${path} not found`);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function call(method: string, path: string, req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
return (await route(method, path).handler(req, ctx)) as PluginRouteResponse;
|
||||
}
|
||||
|
||||
describe("session routes (polling transport)", () => {
|
||||
it("exposes start / answer / resume / get-session-state / list", () => {
|
||||
const paths = createSessionRoutes().map((r) => `${r.method} ${r.path}`);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
"POST /sessions",
|
||||
"POST /sessions/:id/answer",
|
||||
"POST /sessions/:id/resume",
|
||||
"GET /sessions/:id",
|
||||
"GET /sessions",
|
||||
"DELETE /sessions/:id",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("DELETE /sessions/:id discards a session (404 for unknown, gone afterwards, others kept)", async () => {
|
||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
const keep = store.create({ stage: "brainstorm" });
|
||||
const drop = store.create({ stage: "plan" });
|
||||
|
||||
const missing = await call("DELETE", "/sessions/:id", { params: { id: "nope" } }, h.ctx);
|
||||
expect(missing.status).toBe(404);
|
||||
|
||||
const deleted = await call("DELETE", "/sessions/:id", { params: { id: drop.id } }, h.ctx);
|
||||
expect(deleted.status).toBe(200);
|
||||
expect(store.get(drop.id)).toBeUndefined();
|
||||
expect(store.get(keep.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it("GET /sessions lists every session so a client can manage multiple concurrently", async () => {
|
||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||
const store = getCeSessionStore(h.ctx);
|
||||
store.create({ stage: "brainstorm" });
|
||||
store.create({ stage: "plan" });
|
||||
|
||||
const res = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx);
|
||||
expect(res.status).toBe(200);
|
||||
const sessions = (res.body as { sessions: Array<{ stage: string }> }).sessions;
|
||||
expect(sessions.map((s) => s.stage).sort()).toEqual(["brainstorm", "plan"]);
|
||||
});
|
||||
|
||||
it("POST /sessions requires a stage", async () => {
|
||||
const res = await call("POST", "/sessions", { body: {} }, h.ctx);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /sessions without engine interactive factory returns a clean 400 (no hang)", async () => {
|
||||
const res = await call("POST", "/sessions", { body: { stage: "brainstorm", message: "go" } }, h.ctx);
|
||||
expect(res.status).toBe(400);
|
||||
expect((res.body as { error: string }).error).toMatch(/not available/i);
|
||||
});
|
||||
|
||||
it("GET /sessions/:id returns 404 for an unknown id and 200 for a known one", async () => {
|
||||
const missing = await call("GET", "/sessions/:id", { params: { id: "nope" } }, h.ctx);
|
||||
expect(missing.status).toBe(404);
|
||||
|
||||
// Seed a session directly so the poll route has something to return.
|
||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||
const seeded = getCeSessionStore(h.ctx).create({ stage: "brainstorm" });
|
||||
const found = await call("GET", "/sessions/:id", { params: { id: seeded.id } }, h.ctx);
|
||||
expect(found.status).toBe(200);
|
||||
expect((found.body as { session: { id: string } }).session.id).toBe(seeded.id);
|
||||
});
|
||||
|
||||
it("POST /sessions/:id/answer validates questionId and response", async () => {
|
||||
const res = await call("POST", "/sessions/:id/answer", { params: { id: "x" }, body: {} }, h.ctx);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { CeSessionStore, STALE_INTERVAL_MULTIPLE } from "../session/session-store.js";
|
||||
import { ensureCeSchema } from "../schema.js";
|
||||
import { makeHarness, type TestHarness } from "./_harness.js";
|
||||
|
||||
let h: TestHarness;
|
||||
beforeEach(() => {
|
||||
h = makeHarness();
|
||||
});
|
||||
afterEach(() => {
|
||||
h.close();
|
||||
});
|
||||
|
||||
describe("ensureCeSchema", () => {
|
||||
it("is idempotent (safe to run repeatedly)", () => {
|
||||
ensureCeSchema(h.db);
|
||||
ensureCeSchema(h.db);
|
||||
const cols = h.db.prepare("PRAGMA table_info(ce_sessions)").all() as Array<{ name: string }>;
|
||||
const names = cols.map((c) => c.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
"id",
|
||||
"stage",
|
||||
"status",
|
||||
"currentQuestion",
|
||||
"conversationHistory",
|
||||
"projectId",
|
||||
"lastActivityAt",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CeSessionStore CRUD + JSON round-trip", () => {
|
||||
it("creates, reads back, and round-trips JSON fields", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const created = store.create({ stage: "brainstorm", projectId: "p1" });
|
||||
expect(created.status).toBe("launching");
|
||||
|
||||
store.update(created.id, {
|
||||
currentQuestion: { id: "q", type: "confirm", question: "ok?" },
|
||||
status: "awaiting_input",
|
||||
});
|
||||
store.appendHistory(created.id, { role: "user", text: "hi", at: "2026-06-02T00:00:00Z" });
|
||||
|
||||
const read = store.get(created.id)!;
|
||||
expect(read.currentQuestion?.id).toBe("q");
|
||||
expect(read.conversationHistory).toHaveLength(1);
|
||||
expect(read.status).toBe("awaiting_input");
|
||||
expect(read.projectId).toBe("p1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-session independence + delete", () => {
|
||||
it("holds many independent sessions; deleting one leaves the others untouched", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const a = store.create({ stage: "brainstorm", projectId: "p1" });
|
||||
const b = store.create({ stage: "plan", projectId: "p1" });
|
||||
const c = store.create({ stage: "work" });
|
||||
expect(store.list()).toHaveLength(3);
|
||||
|
||||
expect(store.delete(b.id)).toBe(true);
|
||||
expect(store.get(b.id)).toBeUndefined();
|
||||
expect(store.get(a.id)).toBeDefined();
|
||||
expect(store.get(c.id)).toBeDefined();
|
||||
// Deleting a missing row reports false, no throw.
|
||||
expect(store.delete(b.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interval-relative staleness (FN-4172 rubric)", () => {
|
||||
it("does NOT misclassify a healthy-but-slow session as stale", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const s = store.create({ stage: "brainstorm", turnIntervalMs: 1000 });
|
||||
store.update(s.id, { status: "active" });
|
||||
|
||||
const now = Date.now();
|
||||
// 2.5× the interval old: slow, but within the 3× band → NOT stale.
|
||||
const slow = store.update(s.id, { status: "active", lastActivityAt: now - 2_500 })!;
|
||||
expect(STALE_INTERVAL_MULTIPLE).toBe(3);
|
||||
expect(store.isStale(slow, now)).toBe(false);
|
||||
|
||||
// 4× the interval old → stale.
|
||||
const stalled = store.update(s.id, { status: "active", lastActivityAt: now - 4_000 })!;
|
||||
expect(store.isStale(stalled, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("never flags terminal sessions as stale regardless of age", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const s = store.create({ stage: "brainstorm", turnIntervalMs: 1000 });
|
||||
const completed = store.update(s.id, { status: "completed", lastActivityAt: Date.now() - 1_000_000 })!;
|
||||
expect(store.isStale(completed)).toBe(false);
|
||||
});
|
||||
|
||||
it("Bug 2: a human-slow awaiting_input session past 3× is NOT recovered, while a stuck active one still is", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const now = Date.now();
|
||||
|
||||
// A session legitimately waiting on a human, far past 3× the interval. Human
|
||||
// response time is unbounded — this is not a crashed turn.
|
||||
const waiting = store.create({ stage: "brainstorm", turnIntervalMs: 1000 });
|
||||
store.update(waiting.id, {
|
||||
status: "awaiting_input",
|
||||
currentQuestion: { id: "q", type: "text", question: "?" },
|
||||
lastActivityAt: now - 100_000, // 100× interval
|
||||
});
|
||||
|
||||
// A genuinely stuck in-flight agent turn past the threshold.
|
||||
const stuck = store.create({ stage: "brainstorm", turnIntervalMs: 1000 });
|
||||
store.update(stuck.id, { status: "active", lastActivityAt: now - 100_000 });
|
||||
|
||||
const recovered = store.recoverStaleSessions(now);
|
||||
|
||||
// The human-wait is excluded from the interval rubric entirely.
|
||||
expect(recovered).not.toContain(waiting.id);
|
||||
expect(store.get(waiting.id)!.status).toBe("awaiting_input");
|
||||
|
||||
// The stuck active turn is still recovered (here: no question → interrupted).
|
||||
expect(recovered).toContain(stuck.id);
|
||||
expect(store.get(stuck.id)!.status).toBe("interrupted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("corrupt-JSON resilience + status validation", () => {
|
||||
it("degrades gracefully when a JSON column is corrupted (no throw)", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const s = store.create({ stage: "brainstorm" });
|
||||
// Corrupt both JSON columns directly in the DB.
|
||||
h.db
|
||||
.prepare("UPDATE ce_sessions SET currentQuestion = ?, conversationHistory = ? WHERE id = ?")
|
||||
.run("{not valid json", "also not json", s.id);
|
||||
|
||||
// Reading the row must not throw; corrupt fields fall back to null / [].
|
||||
const read = store.get(s.id)!;
|
||||
expect(read.id).toBe(s.id);
|
||||
expect(read.currentQuestion).toBeNull();
|
||||
expect(read.conversationHistory).toEqual([]);
|
||||
// The rest of the row still surfaces the session's real state.
|
||||
expect(read.stage).toBe("brainstorm");
|
||||
});
|
||||
|
||||
it("degrades semantically-wrong-but-valid JSON to null / [] (not just syntax errors)", () => {
|
||||
const store = new CeSessionStore(h.db);
|
||||
const s = store.create({ stage: "brainstorm" });
|
||||
// Valid JSON, wrong shape: conversationHistory='null' parses to a non-array;
|
||||
// currentQuestion='{}' parses to an object missing the required question fields.
|
||||
h.db
|
||||
.prepare("UPDATE ce_sessions SET currentQuestion = ?, conversationHistory = ? WHERE id = ?")
|
||||
.run("{}", "null", s.id);
|
||||
|
||||
const read = store.get(s.id)!;
|
||||
expect(read.currentQuestion).toBeNull();
|
||||
expect(read.conversationHistory).toEqual([]);
|
||||
// appendHistory must not throw spreading the recovered (array) history.
|
||||
expect(() => store.appendHistory(s.id, { role: "user", text: "hi", at: "t" })).not.toThrow();
|
||||
expect(store.get(s.id)!.conversationHistory).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asCeSessionStatus validation", () => {
|
||||
it("accepts valid statuses and rejects anything else", async () => {
|
||||
const { asCeSessionStatus } = await import("../session/session-store.js");
|
||||
expect(asCeSessionStatus("active")).toBe("active");
|
||||
expect(asCeSessionStatus("interrupted")).toBe("interrupted");
|
||||
expect(asCeSessionStatus("bogus")).toBeUndefined();
|
||||
expect(asCeSessionStatus("")).toBeUndefined();
|
||||
expect(asCeSessionStatus(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginSettingType } from "@fusion/plugin-sdk";
|
||||
import { listStages } from "../session/stage-registry.js";
|
||||
import {
|
||||
DEFAULT_ENABLED_STAGES,
|
||||
DEFAULT_MODEL_ID,
|
||||
DEFAULT_PROVIDER,
|
||||
DEFAULT_RECONCILE_INTERVAL_MINUTES,
|
||||
DEFAULT_RECONCILE_ON_HOOKS,
|
||||
getDefaultModelId,
|
||||
getDefaultProvider,
|
||||
getEnabledStages,
|
||||
getReconcileIntervalMinutes,
|
||||
getReconcileOnHooks,
|
||||
settingsSchema,
|
||||
} from "../settings.js";
|
||||
|
||||
const VALID_TYPES: PluginSettingType[] = ["string", "number", "boolean", "enum", "password", "array"];
|
||||
|
||||
describe("compound engineering plugin settings schema", () => {
|
||||
it("uses only valid plugin setting types and labels", () => {
|
||||
for (const [key, schema] of Object.entries(settingsSchema)) {
|
||||
expect(VALID_TYPES).toContain(schema.type);
|
||||
expect(typeof schema.label).toBe("string");
|
||||
expect(schema.label?.trim().length).toBeGreaterThan(0);
|
||||
|
||||
if (schema.type === "enum") {
|
||||
expect(Array.isArray(schema.enumValues)).toBe(true);
|
||||
expect(schema.enumValues?.length ?? 0).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
expect(schema.itemType).toBe("string");
|
||||
}
|
||||
|
||||
expect(key.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the expected keys grouped into Sessions and Sync", () => {
|
||||
expect(Object.keys(settingsSchema).sort()).toEqual(
|
||||
[
|
||||
"defaultModelId",
|
||||
"defaultProvider",
|
||||
"enabledStages",
|
||||
"reconcileIntervalMinutes",
|
||||
"reconcileOnHooks",
|
||||
].sort(),
|
||||
);
|
||||
expect(settingsSchema.defaultProvider.group).toBe("Sessions");
|
||||
expect(settingsSchema.defaultModelId.group).toBe("Sessions");
|
||||
expect(settingsSchema.enabledStages.group).toBe("Sessions");
|
||||
expect(settingsSchema.reconcileOnHooks.group).toBe("Sync");
|
||||
expect(settingsSchema.reconcileIntervalMinutes.group).toBe("Sync");
|
||||
});
|
||||
|
||||
it("defaults enabledStages to the full stage registry", () => {
|
||||
expect(DEFAULT_ENABLED_STAGES).toEqual(listStages().map((s) => s.stageId));
|
||||
expect(settingsSchema.enabledStages.defaultValue).toEqual(DEFAULT_ENABLED_STAGES);
|
||||
});
|
||||
|
||||
it("uses documented literal defaults", () => {
|
||||
expect(settingsSchema.reconcileOnHooks.defaultValue).toBe(true);
|
||||
expect(settingsSchema.reconcileIntervalMinutes.defaultValue).toBe(15);
|
||||
expect(settingsSchema.defaultProvider.defaultValue).toBe("");
|
||||
expect(settingsSchema.defaultModelId.defaultValue).toBe("");
|
||||
});
|
||||
|
||||
it("returns defaults for empty settings", () => {
|
||||
const empty = {};
|
||||
expect(getDefaultProvider(empty)).toBeUndefined();
|
||||
expect(DEFAULT_PROVIDER).toBe("");
|
||||
expect(getDefaultModelId(empty)).toBeUndefined();
|
||||
expect(DEFAULT_MODEL_ID).toBe("");
|
||||
// getEnabledStages re-reads the LIVE registry default (so runtime-registered
|
||||
// stages are launchable); DEFAULT_ENABLED_STAGES is the import-time snapshot
|
||||
// used for the schema/manifest literal.
|
||||
expect(getEnabledStages(empty)).toEqual(listStages().map((s) => s.stageId));
|
||||
expect(getReconcileOnHooks(empty)).toBe(DEFAULT_RECONCILE_ON_HOOKS);
|
||||
expect(getReconcileIntervalMinutes(empty)).toBe(DEFAULT_RECONCILE_INTERVAL_MINUTES);
|
||||
});
|
||||
|
||||
it("returns configured values when provided", () => {
|
||||
const populated = {
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-opus",
|
||||
enabledStages: ["strategy", "plan"],
|
||||
reconcileOnHooks: false,
|
||||
reconcileIntervalMinutes: 30,
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
expect(getDefaultProvider(populated)).toBe("anthropic");
|
||||
expect(getDefaultModelId(populated)).toBe("claude-opus");
|
||||
expect(getEnabledStages(populated)).toEqual(["strategy", "plan"]);
|
||||
expect(getReconcileOnHooks(populated)).toBe(false);
|
||||
expect(getReconcileIntervalMinutes(populated)).toBe(30);
|
||||
});
|
||||
|
||||
it("clamps the reconcile cadence to at least one minute", () => {
|
||||
expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 0 })).toBe(1);
|
||||
expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: -5 })).toBe(1);
|
||||
expect(getReconcileIntervalMinutes({ reconcileIntervalMinutes: 7.9 })).toBe(7);
|
||||
});
|
||||
|
||||
it("falls back to defaults for malformed values", () => {
|
||||
const liveDefault = listStages().map((s) => s.stageId);
|
||||
expect(getEnabledStages({ enabledStages: "not-an-array" })).toEqual(liveDefault);
|
||||
expect(getEnabledStages({ enabledStages: [] })).toEqual(liveDefault);
|
||||
expect(getDefaultProvider({ defaultProvider: " " })).toBeUndefined();
|
||||
expect(getReconcileOnHooks({ reconcileOnHooks: "yes" })).toBe(DEFAULT_RECONCILE_ON_HOOKS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertPluginLocalTarget,
|
||||
installBundledCeSkills,
|
||||
isPluginLocalPath,
|
||||
resolveBundledSkillsRoot,
|
||||
} from "../skill-installation.js";
|
||||
import { COMPOUND_ENGINEERING_SKILLS } from "../skills.js";
|
||||
|
||||
describe("compound engineering bundled skill install", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "ce-skill-install-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("installs every bundled CE skill into the plugin-local target", () => {
|
||||
const targetRoot = join(tmp, "plugin-local", ".fusion-ce-skills");
|
||||
const { results } = installBundledCeSkills({ targetRoot });
|
||||
|
||||
for (const skill of COMPOUND_ENGINEERING_SKILLS) {
|
||||
const r = results.find((x) => x.skillId === skill.skillId)!;
|
||||
expect(r.outcome).toBe("installed");
|
||||
const skillMd = join(targetRoot, skill.skillId, "SKILL.md");
|
||||
expect(existsSync(skillMd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent: a second run with the target present is a skip-if-exists no-op", () => {
|
||||
const targetRoot = join(tmp, ".fusion-ce-skills");
|
||||
const first = installBundledCeSkills({ targetRoot });
|
||||
expect(first.results.every((r) => r.outcome === "installed")).toBe(true);
|
||||
|
||||
// Tamper with an installed file; skip-if-exists must NOT overwrite it.
|
||||
const sentinelPath = join(targetRoot, "ce-plan", "SKILL.md");
|
||||
writeFileSync(sentinelPath, "SENTINEL");
|
||||
|
||||
const second = installBundledCeSkills({ targetRoot });
|
||||
expect(second.results.every((r) => r.outcome === "skipped")).toBe(true);
|
||||
expect(readFileSync(sentinelPath, "utf-8")).toBe("SENTINEL");
|
||||
});
|
||||
|
||||
// ── AE2: isolation — a global compound-engineering install is untouched ──
|
||||
it("AE2: never writes outside the plugin-local target when a global install exists", () => {
|
||||
// Seed a fake global compound-engineering install under a fake HOME.
|
||||
const fakeHome = join(tmp, "home");
|
||||
const globalSkillsDir = join(fakeHome, ".claude", "skills", "ce-plan");
|
||||
mkdirSync(globalSkillsDir, { recursive: true });
|
||||
const globalSkillMd = join(globalSkillsDir, "SKILL.md");
|
||||
writeFileSync(globalSkillMd, "GLOBAL-ORIGINAL");
|
||||
const beforeContent = readFileSync(globalSkillMd, "utf-8");
|
||||
const beforeMtime = statSync(globalSkillMd).mtimeMs;
|
||||
|
||||
const targetRoot = join(tmp, "plugin-local", ".fusion-ce-skills");
|
||||
const { targetRoot: usedTarget, results } = installBundledCeSkills({ targetRoot });
|
||||
|
||||
// The install target is provably plugin-local, never the global dir.
|
||||
expect(usedTarget.includes(join(".claude", "skills"))).toBe(false);
|
||||
expect(isPluginLocalPath(usedTarget)).toBe(true);
|
||||
for (const r of results) {
|
||||
expect(r.targetDir.includes(join(".claude", "skills"))).toBe(false);
|
||||
}
|
||||
|
||||
// The global install is byte-for-byte and mtime untouched.
|
||||
expect(readFileSync(globalSkillMd, "utf-8")).toBe(beforeContent);
|
||||
expect(statSync(globalSkillMd).mtimeMs).toBe(beforeMtime);
|
||||
});
|
||||
|
||||
it("AE2 guard: refuses to install into a global client skills directory", () => {
|
||||
const globalTarget = join(tmp, "home", ".claude", "skills");
|
||||
expect(() => assertPluginLocalTarget(globalTarget)).toThrow(/plugin-local/i);
|
||||
expect(() => installBundledCeSkills({ targetRoot: globalTarget })).toThrow(/plugin-local/i);
|
||||
expect(isPluginLocalPath(globalTarget)).toBe(false);
|
||||
});
|
||||
|
||||
// ── Edge: malformed/missing SKILL.md surfaces a clear error ──
|
||||
it("edge: a missing/malformed bundled SKILL.md surfaces a clear load error, not a silent skip", () => {
|
||||
// Point at an empty source root so every skill's source dir is missing.
|
||||
const emptySource = join(tmp, "empty-source");
|
||||
mkdirSync(emptySource, { recursive: true });
|
||||
const targetRoot = join(tmp, ".fusion-ce-skills");
|
||||
|
||||
const { results } = installBundledCeSkills({ targetRoot, sourceRoot: emptySource });
|
||||
for (const r of results) {
|
||||
expect(r.outcome).toBe("error");
|
||||
expect(r.reason).toMatch(/missing|SKILL\.md/i);
|
||||
}
|
||||
|
||||
// Now a malformed SKILL.md (no frontmatter name) for one skill.
|
||||
const malformedSource = join(tmp, "malformed-source");
|
||||
const planDir = join(malformedSource, "ce-plan");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
writeFileSync(join(planDir, "SKILL.md"), "no frontmatter here\n");
|
||||
const res2 = installBundledCeSkills({ targetRoot: join(tmp, "t2"), sourceRoot: malformedSource });
|
||||
const plan = res2.results.find((r) => r.skillId === "ce-plan")!;
|
||||
expect(plan.outcome).toBe("error");
|
||||
expect(plan.reason).toMatch(/frontmatter 'name:'/i);
|
||||
});
|
||||
|
||||
it("bundled source root resolves and contains all SKILL.md files", () => {
|
||||
const root = resolveBundledSkillsRoot();
|
||||
expect(existsSync(root)).toBe(true);
|
||||
for (const skill of COMPOUND_ENGINEERING_SKILLS) {
|
||||
expect(existsSync(join(root, skill.skillId, "SKILL.md"))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PlanningQuestionType } from "@fusion/core";
|
||||
import {
|
||||
RICH_INTERACTION_TYPES,
|
||||
canRenderRichly,
|
||||
isRichInteractionType,
|
||||
} from "../dashboard/ce-question-support.js";
|
||||
import { getStage } from "../session/stage-registry.js";
|
||||
|
||||
/**
|
||||
* Skill-interaction audit (Success Criteria, U6).
|
||||
*
|
||||
* CLASSIFICATION PROVENANCE — be honest. This audit is a DECLARED /
|
||||
* EXPECTED classification, NOT a measurement taken from driving live `ce-*`
|
||||
* skill sessions. We do not invoke a real model here. The interaction types
|
||||
* each stage performs are read from each stage's protocol — the SKILL.md
|
||||
* "Interaction Rules / Interaction Method" sections that govern how the skill
|
||||
* asks questions (e.g. ce-brainstorm: "Ask one question at a time", "Prefer
|
||||
* single-select", "Use multi-select rarely", open-ended free-text questions;
|
||||
* ce-ideate / ce-plan: single-select-preferred + free-text). Each declared
|
||||
* interaction is then classified against CeFlow's renderable set
|
||||
* (`RICH_INTERACTION_TYPES`) to compute a rich-vs-chat coverage ratio.
|
||||
*
|
||||
* The test FAILS if any sampled interaction is unclassified (a type CeFlow's
|
||||
* support module doesn't recognize at all), which is the guard that keeps the
|
||||
* audit honest as the skills' protocols evolve. When a stage declares a
|
||||
* confirm/text/single/multi interaction, that is rich-renderable; an
|
||||
* "unknown_type" declaration would be unclassified and fail.
|
||||
*/
|
||||
|
||||
interface DeclaredInteraction {
|
||||
/** A label for the interaction occurrence within the stage's protocol. */
|
||||
name: string;
|
||||
/** The interaction type the stage's protocol uses for it. */
|
||||
type: string;
|
||||
/** Whether the stage's protocol supplies options for this interaction. */
|
||||
hasOptions: boolean;
|
||||
}
|
||||
|
||||
interface StageProtocol {
|
||||
stageId: string;
|
||||
/** Source the declaration was read from (for traceability in the report). */
|
||||
source: string;
|
||||
interactions: DeclaredInteraction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared protocols for the sampled stages, derived from each SKILL.md's
|
||||
* Interaction section. These are protocol declarations, not live captures.
|
||||
*/
|
||||
const SAMPLED_STAGES: StageProtocol[] = [
|
||||
{
|
||||
stageId: "brainstorm",
|
||||
source: "src/skills/ce-brainstorm/SKILL.md → Interaction Rules",
|
||||
interactions: [
|
||||
{ name: "narrowing choice (one direction/priority/next step)", type: "single_select", hasOptions: true },
|
||||
{ name: "compatible set (goals/constraints/non-goals)", type: "multi_select", hasOptions: true },
|
||||
{ name: "genuinely open / diagnostic question", type: "text", hasOptions: false },
|
||||
{ name: "proceed-to-write confirmation", type: "confirm", hasOptions: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
stageId: "ideate",
|
||||
source: "src/skills/ce-ideate/SKILL.md → Interaction Method",
|
||||
interactions: [
|
||||
{ name: "concise single-select when natural options exist", type: "single_select", hasOptions: true },
|
||||
{ name: "open-ended ideation prompt", type: "text", hasOptions: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
stageId: "plan",
|
||||
source: "src/skills/ce-plan/SKILL.md → Interaction Method",
|
||||
interactions: [
|
||||
{ name: "concise single-select choice", type: "single_select", hasOptions: true },
|
||||
{ name: "clarifying free-text question (Phase 0.4 bootstrap)", type: "text", hasOptions: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function classify(i: DeclaredInteraction): { classified: boolean; rich: boolean } {
|
||||
const classified = isRichInteractionType(i.type);
|
||||
if (!classified) return { classified: false, rich: false };
|
||||
// canRenderRichly is the same predicate CeFlow uses at runtime.
|
||||
const rich = canRenderRichly({
|
||||
type: i.type as PlanningQuestionType,
|
||||
options: i.hasOptions ? [{ id: "x", label: "x" }] : undefined,
|
||||
});
|
||||
return { classified: true, rich };
|
||||
}
|
||||
|
||||
describe("skill-interaction audit (declared classification)", () => {
|
||||
it("every sampled stage is a registered stage", () => {
|
||||
for (const s of SAMPLED_STAGES) {
|
||||
expect(getStage(s.stageId), `stage ${s.stageId} must be registered`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies every declared interaction (fails on an unclassified interaction)", () => {
|
||||
const unclassified: string[] = [];
|
||||
for (const stage of SAMPLED_STAGES) {
|
||||
for (const i of stage.interactions) {
|
||||
if (!isRichInteractionType(i.type)) {
|
||||
unclassified.push(`${stage.stageId}:${i.name} (type=${i.type})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(unclassified, `unclassified interactions: ${unclassified.join(", ")}`).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("produces a measured rich-vs-chat coverage ratio for the sampled stages", () => {
|
||||
let total = 0;
|
||||
let rich = 0;
|
||||
const perStage: Array<{ stageId: string; rich: number; total: number }> = [];
|
||||
|
||||
for (const stage of SAMPLED_STAGES) {
|
||||
let sRich = 0;
|
||||
for (const i of stage.interactions) {
|
||||
total += 1;
|
||||
const c = classify(i);
|
||||
if (c.rich) {
|
||||
rich += 1;
|
||||
sRich += 1;
|
||||
}
|
||||
}
|
||||
perStage.push({ stageId: stage.stageId, rich: sRich, total: stage.interactions.length });
|
||||
}
|
||||
|
||||
const ratio = rich / total;
|
||||
|
||||
// Emit the produced coverage figure (visible in test output / report).
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[skill-interaction-audit] rich-renderable coverage: ${rich}/${total} = ${(ratio * 100).toFixed(1)}% ` +
|
||||
`(declared classification, not live-measured)\n` +
|
||||
perStage.map((p) => ` - ${p.stageId}: ${p.rich}/${p.total}`).join("\n"),
|
||||
);
|
||||
|
||||
// The audit must compute and assert a real ratio. For the sampled stages,
|
||||
// every declared interaction maps onto CeFlow's renderable set, so coverage
|
||||
// is 100% — but the assertion is on the COMPUTED value, and the guard above
|
||||
// would drop it below 1 (and the unclassified test would fail) the moment a
|
||||
// stage declares an interaction CeFlow can't express.
|
||||
expect(total).toBeGreaterThanOrEqual(2 + 2 + 2); // 2-3 stages, ≥2 interactions each
|
||||
expect(ratio).toBeGreaterThan(0);
|
||||
expect(ratio).toBeLessThanOrEqual(1);
|
||||
expect(ratio).toBe(rich / total);
|
||||
|
||||
// Sanity: the four rich types CeFlow advertises are the classification set.
|
||||
expect([...RICH_INTERACTION_TYPES].sort()).toEqual(
|
||||
["confirm", "multi_select", "single_select", "text"],
|
||||
);
|
||||
});
|
||||
|
||||
it("a hypothetical unrenderable interaction would be unclassified (guard proof)", () => {
|
||||
const rogue: DeclaredInteraction = { name: "ranked drag-and-drop", type: "rank_order", hasOptions: true };
|
||||
expect(isRichInteractionType(rogue.type)).toBe(false);
|
||||
expect(classify(rogue).rich).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { installBundledCeSkills } from "../skill-installation.js";
|
||||
import { resolveStageSkillPaths, buildStageSystemPrompt } from "../session/orchestrator.js";
|
||||
import { getStage } from "../session/stage-registry.js";
|
||||
|
||||
/**
|
||||
* Prove the launched stage's ce-* skill is REACHABLE for the session — now via
|
||||
* the real seam wiring (closes the U2 → U5 carry-forward).
|
||||
*
|
||||
* The U4 `CreateInteractiveAiSessionOptions` surface now carries
|
||||
* `requestedSkillNames` + `additionalSkillPaths`, which the engine adapter
|
||||
* forwards into `createFnAgent` (`skills` + the loader's `additionalSkillPaths`).
|
||||
* So the orchestrator hands the session BOTH the stage's skill id and the
|
||||
* install directory to discover it from. This test asserts:
|
||||
* 1. the install directory `resolveStageSkillPaths()` returns actually holds
|
||||
* the stage's `<skillId>/SKILL.md` after install, and
|
||||
* 2. the system prompt names the stage's skill id.
|
||||
*
|
||||
* The engine package separately proves (compound-engineering-skill-resolution
|
||||
* .test.ts) that `loadSkills` + the resolver resolve a ce-* skill once that
|
||||
* directory is on the discovery path — together the chain is closed.
|
||||
*/
|
||||
|
||||
describe("stage skill reachability (real seam wiring)", () => {
|
||||
let tmpTargets: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const t of tmpTargets) rmSync(t, { recursive: true, force: true });
|
||||
tmpTargets = [];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolveStageSkillPaths returns the plugin-local install root the session scans", () => {
|
||||
// The orchestrator passes this as additionalSkillPaths; never a global path.
|
||||
const skillPaths = resolveStageSkillPaths();
|
||||
expect(skillPaths).toHaveLength(1);
|
||||
expect(skillPaths[0]).toMatch(/\.fusion-ce-skills$/);
|
||||
expect(skillPaths[0]).not.toMatch(/\.(claude|codex|gemini)[/\\]skills/);
|
||||
});
|
||||
|
||||
it("installing bundled skills onto a discovery root produces the stage's SKILL.md", () => {
|
||||
const stage = getStage("brainstorm")!;
|
||||
// Install into a temp discovery root (isolated; mirrors what the real
|
||||
// plugin-local install produces, without writing into the repo dir).
|
||||
const target = mkdtempSync(join(tmpdir(), "ce-skill-reach-"));
|
||||
tmpTargets.push(target);
|
||||
|
||||
const { results } = installBundledCeSkills({ targetRoot: target });
|
||||
expect(results.every((r) => r.outcome === "installed" || r.outcome === "skipped")).toBe(true);
|
||||
|
||||
const installedSkillMd = join(target, stage.skillId, "SKILL.md");
|
||||
expect(existsSync(installedSkillMd)).toBe(true);
|
||||
});
|
||||
|
||||
it("the stage system prompt names the stage's ce-* skill id", () => {
|
||||
const stage = getStage("brainstorm")!;
|
||||
const prompt = buildStageSystemPrompt(stage);
|
||||
expect(prompt).toContain(stage.skillId); // "ce-brainstorm"
|
||||
expect(prompt).toContain("question");
|
||||
expect(prompt).toContain("complete");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CreateInteractiveAiSessionOptions,
|
||||
InteractiveAiSessionEvent,
|
||||
} from "@fusion/core";
|
||||
import { CeOrchestrator } from "../session/orchestrator.js";
|
||||
import { getStage } from "../session/stage-registry.js";
|
||||
import { resolveDefaultInstallTargetRoot } from "../skill-installation.js";
|
||||
import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js";
|
||||
|
||||
/**
|
||||
* Proves the orchestrator hands a launched session the wiring a LIVE agent needs
|
||||
* to actually load the stage's bundled ce-* skill (closes the U2/U5 carry-forward):
|
||||
* - cwd is the real project root (where the agent reads context + writes the
|
||||
* artifact), NOT the skills directory;
|
||||
* - requestedSkillNames names the stage's ce-* skill;
|
||||
* - additionalSkillPaths includes the plugin-local install root so the engine
|
||||
* loader can discover that skill.
|
||||
* The engine adapter forwards these to createFnAgent (skills + additionalSkillPaths);
|
||||
* compound-engineering-skill-resolution.test.ts proves the loader then resolves it.
|
||||
*/
|
||||
describe("session skill wiring", () => {
|
||||
let h: TestHarness;
|
||||
beforeEach(() => {
|
||||
h = makeHarness();
|
||||
});
|
||||
afterEach(() => {
|
||||
h.close();
|
||||
});
|
||||
|
||||
it("start() passes the stage skill id, install path, and project-root cwd to the factory", async () => {
|
||||
const captured: CreateInteractiveAiSessionOptions[] = [];
|
||||
const script: InteractiveAiSessionEvent[] = [
|
||||
{ type: "complete", data: { artifact: "# done" } },
|
||||
];
|
||||
const session = makeScriptedSession(script);
|
||||
const factory = vi.fn(async (opts: CreateInteractiveAiSessionOptions) => {
|
||||
captured.push(opts);
|
||||
return { session };
|
||||
});
|
||||
const orch = new CeOrchestrator({
|
||||
ctx: h.ctx,
|
||||
createInteractiveAiSession: factory,
|
||||
projectRoot: h.projectRoot,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
await orch.start("brainstorm", { openingMessage: "let's go" });
|
||||
|
||||
expect(captured).toHaveLength(1);
|
||||
const opts = captured[0];
|
||||
const stage = getStage("brainstorm")!;
|
||||
// cwd is the project root, not the skills dir.
|
||||
expect(opts.cwd).toBe(h.projectRoot);
|
||||
// the stage's ce-* skill is requested...
|
||||
expect(opts.requestedSkillNames).toEqual([stage.skillId]);
|
||||
// ...and the plugin-local install root is on the discovery path.
|
||||
expect(opts.additionalSkillPaths).toEqual([resolveDefaultInstallTargetRoot()]);
|
||||
expect(opts.additionalSkillPaths?.[0]).toMatch(/\.fusion-ce-skills$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InteractiveAiSessionEvent, PluginContext, Task } from "@fusion/core";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import plugin, {
|
||||
CeOrchestrator,
|
||||
CE_PLUGIN_ID,
|
||||
WORK_STAGE_ID,
|
||||
} from "../index.js";
|
||||
import { getCePipelineStore } from "../sync/pipeline-store.js";
|
||||
import { CeReconciler, reconcileCePipelines } from "../sync/reconciler.js";
|
||||
import { registerStage, unregisterStage } from "../session/stage-registry.js";
|
||||
import { makeScriptedSession } from "./_harness.js";
|
||||
|
||||
/**
|
||||
* U8 bidirectional-sync tests. REAL in-memory TaskStore (genuine board tasks) +
|
||||
* the actual lifecycle-hook handlers and reconciler. We exercise the two
|
||||
* separate state machines (board columns vs ce_pipeline_state) and prove the
|
||||
* dropped-event convergence path independently of the hooks.
|
||||
*/
|
||||
|
||||
let rootDir: string;
|
||||
let taskStore: TaskStore;
|
||||
let ctx: PluginContext;
|
||||
let emitted: Array<{ event: string; data: unknown }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "ce-sync-"));
|
||||
taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global"), { inMemoryDb: true });
|
||||
await taskStore.init();
|
||||
emitted = [];
|
||||
ctx = {
|
||||
pluginId: CE_PLUGIN_ID,
|
||||
taskStore,
|
||||
settings: {},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: (event: string, data: unknown) => emitted.push({ event, data }),
|
||||
} as unknown as PluginContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
taskStore?.close();
|
||||
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
/** The board enforces ordered transitions; walk a task forward to a target column. */
|
||||
const COLUMN_PATH = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
async function moveTo(taskId: string, target: string): Promise<void> {
|
||||
const current = (await taskStore.getTask(taskId))!.column;
|
||||
const from = COLUMN_PATH.indexOf(current);
|
||||
const to = COLUMN_PATH.indexOf(target);
|
||||
for (let i = from + 1; i <= to; i++) {
|
||||
await taskStore.moveTask(taskId, COLUMN_PATH[i] as never);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the work stage so a CE pipeline + its first board task + state record exist. */
|
||||
async function landPipeline(stage = "plan"): Promise<{ cePipelineId: string; task: Task }> {
|
||||
// Register-free: drive the WORK stage (which seeds state) but point the link at
|
||||
// `stage` so we can advance through the real stage order. Simplest: use the
|
||||
// work bridge directly via the orchestrator at the work stage, then rewrite the
|
||||
// pipeline state's currentStage to `stage` for ordering tests.
|
||||
const script: InteractiveAiSessionEvent[] = [
|
||||
{ type: "complete", data: { artifact: "# log\n", tasks: [{ description: "do stage work" }] } },
|
||||
];
|
||||
const orch = new CeOrchestrator({
|
||||
ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session: makeScriptedSession(script) })),
|
||||
projectRoot: rootDir,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "go" });
|
||||
const cePipelineId = started.session.id;
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
if (stage !== WORK_STAGE_ID) {
|
||||
// Reposition both the link stage and the state stage to `stage` so the
|
||||
// pipeline has a non-terminal stage to advance FROM.
|
||||
const links = store.listByPipeline(cePipelineId);
|
||||
const db = taskStore.getDatabase();
|
||||
for (const l of links) {
|
||||
db.prepare(`UPDATE ce_pipeline_links SET ceStageId = ? WHERE id = ?`).run(stage, l.id);
|
||||
}
|
||||
store.upsertState({ cePipelineId, currentStage: stage, status: "running" });
|
||||
}
|
||||
const tasks = await taskStore.listTasks();
|
||||
return { cePipelineId, task: tasks[0] };
|
||||
}
|
||||
|
||||
describe("U8 inbound hooks (board → pipeline)", () => {
|
||||
it("onTaskMoved only enqueues when the task is CE-linked; ignores unrelated tasks fast", async () => {
|
||||
const { task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
// Unrelated (non-CE) board task → hook is a no-op (no queue row).
|
||||
const other = await taskStore.createTask({ description: "unrelated work" });
|
||||
await plugin.hooks.onTaskMoved!(other, "triage", "todo", ctx);
|
||||
expect(store.listPendingSync()).toHaveLength(0);
|
||||
|
||||
// CE-linked task move → a queue row is appended synchronously. We do NOT
|
||||
// await the hook (its body is synchronous; awaiting would let the
|
||||
// fired-and-forgotten reconcile drain the row), so we observe the pending
|
||||
// entry the fast path wrote before any deferred work runs.
|
||||
void plugin.hooks.onTaskMoved!(task, "todo", "in-progress", ctx);
|
||||
const pending = store.listPendingSync();
|
||||
expect(pending.length).toBeGreaterThanOrEqual(1);
|
||||
expect(pending.some((p) => p.taskId === task.id && p.reason === "task_moved")).toBe(true);
|
||||
});
|
||||
|
||||
it("the hook handler does NOT advance the pipeline inline (heavy work is deferred)", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
// Move the task to a terminal column then fire ONLY the synchronous part of
|
||||
// the hook. We assert that synchronously the pipeline stage is unchanged —
|
||||
// advancement happens in the (deferred) reconcile, not inline.
|
||||
await moveTo(task.id, "done");
|
||||
const stageBefore = store.getState(cePipelineId)!.currentStage;
|
||||
// Drive the hook but capture state immediately after the synchronous body.
|
||||
const p = plugin.hooks.onTaskMoved!(task, "in-progress", "done", ctx);
|
||||
// The synchronous body has already run (enqueue) but the fired-and-forgotten
|
||||
// reconcile has not been awaited. Inline, the stage must be unchanged.
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe(stageBefore);
|
||||
// A queue row exists (the fast path did its job).
|
||||
expect(store.listPendingSync().some((q) => q.taskId === task.id)).toBe(true);
|
||||
await p; // let the fire-and-forget settle for clean teardown.
|
||||
});
|
||||
|
||||
it("the hook handler completes well under the 5s budget even with a slow reconciler", async () => {
|
||||
const { task } = await landPipeline("plan");
|
||||
await moveTo(task.id, "done");
|
||||
const start = Date.now();
|
||||
await plugin.hooks.onTaskMoved!(task, "in-progress", "done", ctx);
|
||||
// The hook awaits NOTHING heavy; it returns synchronously-ish.
|
||||
expect(Date.now() - start).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("U8 reconciler (convergence + outbound)", () => {
|
||||
it("AE3: a CE task reaching a terminal column advances the pipeline to the next stage with NO manual step", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("plan");
|
||||
|
||||
// Board moves the task to done (the only manual-equivalent action: a normal
|
||||
// board transition). The hook enqueues; reconcile advances.
|
||||
await moveTo(task.id, "done");
|
||||
await plugin.hooks.onTaskCompleted!({ ...task, column: "done" }, ctx);
|
||||
await reconcileCePipelines(ctx);
|
||||
|
||||
// Pipeline advanced plan → work (next in stage order) with no manual step.
|
||||
const state = store.getState(cePipelineId)!;
|
||||
expect(state.currentStage).toBe("work");
|
||||
});
|
||||
|
||||
it("outbound: advancing the pipeline propagates a NEW next-stage board task", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const before = (await taskStore.listTasks()).length;
|
||||
|
||||
await moveTo(task.id, "in-review");
|
||||
await reconcileCePipelines(ctx); // no hook fired — pure re-derivation.
|
||||
|
||||
const after = await taskStore.listTasks();
|
||||
expect(after.length).toBe(before + 1);
|
||||
const newTask = after.find((t) => t.id !== task.id)!;
|
||||
const meta = newTask.sourceMetadata as Record<string, unknown>;
|
||||
expect(meta.pluginId).toBe(CE_PLUGIN_ID);
|
||||
expect(meta.cePipelineId).toBe(cePipelineId);
|
||||
expect(meta.ceStageId).toBe("work");
|
||||
// The pipeline is now awaiting the new board task.
|
||||
expect(getCePipelineStore(ctx).getState(cePipelineId)!.status).toBe("awaiting_board");
|
||||
});
|
||||
|
||||
it("MISSED HOOK EVENT → the reconcile sweep still converges (no queue row needed)", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
// Simulate a DROPPED hook: move the board task to a terminal column but do
|
||||
// NOT call any hook and do NOT enqueue anything.
|
||||
await moveTo(task.id, "done");
|
||||
expect(store.listPendingSync()).toHaveLength(0); // nothing was enqueued.
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("plan"); // not advanced yet.
|
||||
|
||||
// The on-demand sweep re-derives the transition from board truth alone.
|
||||
const result = await new CeReconciler(ctx).reconcile();
|
||||
expect(result.advanced).toBe(1);
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("work");
|
||||
});
|
||||
|
||||
it("reconcile is idempotent: a second sweep does not double-advance or duplicate tasks", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
await moveTo(task.id, "done");
|
||||
await reconcileCePipelines(ctx);
|
||||
const afterFirst = (await taskStore.listTasks()).length;
|
||||
const stageFirst = getCePipelineStore(ctx).getState(cePipelineId)!.currentStage;
|
||||
|
||||
await reconcileCePipelines(ctx);
|
||||
expect((await taskStore.listTasks()).length).toBe(afterFirst);
|
||||
expect(getCePipelineStore(ctx).getState(cePipelineId)!.currentStage).toBe(stageFirst);
|
||||
});
|
||||
|
||||
it("partial completion does not advance: pipeline stays running until ALL current-stage tasks are terminal", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
// Add a second current-stage task to the SAME pipeline/stage.
|
||||
const t2 = await taskStore.createTask({ description: "second plan task" });
|
||||
store.createLink({ taskId: t2.id, cePipelineId, ceStageId: "plan", ceArtifactPath: null });
|
||||
|
||||
await moveTo(task.id, "done"); // only one terminal.
|
||||
await reconcileCePipelines(ctx);
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("plan"); // not advanced.
|
||||
|
||||
await moveTo(t2.id, "done"); // now both terminal.
|
||||
await reconcileCePipelines(ctx);
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("work"); // advanced.
|
||||
});
|
||||
|
||||
it("Bug 1: a deleted current-stage task does NOT wedge the pipeline — one terminal + one deleted still advances", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
// Add a SECOND current-stage task linked to the same pipeline/stage, then
|
||||
// DELETE it from the board (loadTasks will yield undefined for it).
|
||||
const doomed = await taskStore.createTask({ description: "second plan task (to delete)" });
|
||||
store.createLink({ taskId: doomed.id, cePipelineId, ceStageId: "plan", ceArtifactPath: null });
|
||||
await taskStore.deleteTask(doomed.id);
|
||||
|
||||
// The remaining task reaches terminal. Pre-fix: the deleted task made
|
||||
// `every(... t && ...)` false, wedging the pipeline at "plan" forever.
|
||||
await moveTo(task.id, "done");
|
||||
await reconcileCePipelines(ctx);
|
||||
|
||||
// Post-fix: terminality is computed over EXISTING tasks only → it advances.
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("work");
|
||||
});
|
||||
|
||||
it("Bug 1: if ALL current-stage tasks were deleted, the pipeline is left unchanged (no wedge, no crash)", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
await taskStore.deleteTask(task.id); // every current-stage task gone.
|
||||
|
||||
// Safe non-wedging behavior: state unchanged, no advancement, no throw.
|
||||
await expect(reconcileCePipelines(ctx)).resolves.toBeTruthy();
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("plan");
|
||||
});
|
||||
|
||||
it("Bug 3: a stage registered with an `order` between two existing stages is the next stage (not append-at-end)", async () => {
|
||||
// Insert a stage between plan(400) and work(500). Registry/Map insertion
|
||||
// order would append it at the end; the explicit `order` slots it mid-pipeline.
|
||||
registerStage({
|
||||
stageId: "refine",
|
||||
order: 450,
|
||||
skillId: "ce-refine",
|
||||
artifactLocation: "docs/refine/",
|
||||
icon: "Wand",
|
||||
label: "Refine",
|
||||
});
|
||||
try {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
await moveTo(task.id, "done");
|
||||
await reconcileCePipelines(ctx);
|
||||
|
||||
// Advances to the inserted stage, NOT to "work" (the old append-at-end).
|
||||
expect(store.getState(cePipelineId)!.currentStage).toBe("refine");
|
||||
} finally {
|
||||
unregisterStage("refine");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("U8 conflict resolution (board vs CE authority)", () => {
|
||||
it("simultaneous board move + CE advance: board keeps the task column, CE keeps the pipeline content", async () => {
|
||||
const { cePipelineId, task } = await landPipeline("plan");
|
||||
const store = getCePipelineStore(ctx);
|
||||
|
||||
// CE-flow side: the pipeline owns its content; record an artifact (CE-authoritative).
|
||||
store.transitionState(cePipelineId, { lastArtifactPath: "/docs/plans/p.md" });
|
||||
|
||||
// Board side: move the task to done (board-authoritative for the column).
|
||||
await moveTo(task.id, "done");
|
||||
|
||||
// Reconcile resolves the collision: it READS the board column (never rewrites
|
||||
// the terminal task) and WRITES only CE-owned fields + a NEW task.
|
||||
await reconcileCePipelines(ctx);
|
||||
|
||||
// Board authority: the original task's column is exactly what the board set.
|
||||
const reread = await taskStore.getTask(task.id);
|
||||
expect(reread!.column).toBe("done");
|
||||
|
||||
// CE authority: the pipeline content (stage + artifact) is what CE wrote.
|
||||
const state = store.getState(cePipelineId)!;
|
||||
expect(state.currentStage).toBe("work");
|
||||
expect(state.lastArtifactPath).toBe("/docs/plans/p.md");
|
||||
|
||||
// The new outbound task is a fresh row — the writers never contended on one cell.
|
||||
const tasks = await taskStore.listTasks();
|
||||
const next = tasks.find((t) => t.id !== task.id)!;
|
||||
expect((next.sourceMetadata as Record<string, unknown>).ceStageId).toBe("work");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InteractiveAiSessionEvent, PluginContext } from "@fusion/core";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import {
|
||||
CeOrchestrator,
|
||||
CE_PLUGIN_ID,
|
||||
CE_WORK_SOURCE_TYPE,
|
||||
WORK_STAGE_ID,
|
||||
} from "../session/orchestrator.js";
|
||||
import { getCePipelineStore } from "../sync/pipeline-store.js";
|
||||
import { makeScriptedSession } from "./_harness.js";
|
||||
|
||||
/**
|
||||
* U7 work bridge tests. These use the REAL in-memory TaskStore (so created tasks
|
||||
* are genuine board tasks under the normal lifecycle) and a scripted fake
|
||||
* interactive session (the same deterministic driver U5/U6 use).
|
||||
*/
|
||||
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let taskStore: TaskStore;
|
||||
let ctx: PluginContext;
|
||||
let emitted: Array<{ event: string; data: unknown }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "ce-work-bridge-"));
|
||||
globalDir = join(rootDir, ".fusion-global");
|
||||
taskStore = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await taskStore.init();
|
||||
|
||||
emitted = [];
|
||||
ctx = {
|
||||
pluginId: CE_PLUGIN_ID,
|
||||
taskStore,
|
||||
settings: {},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
emitted.push({ event, data });
|
||||
},
|
||||
} as unknown as PluginContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
taskStore?.close();
|
||||
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
function makeOrch(script: InteractiveAiSessionEvent[]) {
|
||||
const session = makeScriptedSession(script);
|
||||
return new CeOrchestrator({
|
||||
ctx,
|
||||
createInteractiveAiSession: vi.fn(async () => ({ session })),
|
||||
projectRoot: rootDir,
|
||||
turnTimeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
describe("work bridge (U7)", () => {
|
||||
it("lands derived tasks on the board, tagged CE-originated with a resolvable back-reference", async () => {
|
||||
const orch = makeOrch([
|
||||
{
|
||||
type: "complete",
|
||||
data: {
|
||||
artifact: "# Work log\n",
|
||||
tasks: [
|
||||
{ title: "Wire the thing", description: "Implement the thing in module X." },
|
||||
{ description: "Add tests for the thing.", column: "todo" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "do the work" });
|
||||
expect(started.session.status).toBe("completed");
|
||||
const cePipelineId = started.session.id;
|
||||
|
||||
// Two board tasks created.
|
||||
const tasks = await taskStore.listTasks();
|
||||
expect(tasks).toHaveLength(2);
|
||||
|
||||
const pipelineStore = getCePipelineStore(ctx);
|
||||
|
||||
for (const task of tasks) {
|
||||
// CE-originated provenance: valid SourceType + CE marker + back-ref copy.
|
||||
// (TaskStore exposes provenance as flat top-level fields on the Task.)
|
||||
expect(task.sourceType).toBe(CE_WORK_SOURCE_TYPE);
|
||||
const meta = task.sourceMetadata as Record<string, unknown> | undefined;
|
||||
expect(meta?.pluginId).toBe(CE_PLUGIN_ID);
|
||||
expect(meta?.cePipelineId).toBe(cePipelineId);
|
||||
expect(meta?.ceStageId).toBe(WORK_STAGE_ID);
|
||||
|
||||
// Authoritative back-reference: the link row resolves task→pipeline/artifact.
|
||||
const link = pipelineStore.findByTaskId(task.id);
|
||||
expect(link).toBeDefined();
|
||||
expect(link?.cePipelineId).toBe(cePipelineId);
|
||||
expect(link?.ceStageId).toBe(WORK_STAGE_ID);
|
||||
expect(link?.ceArtifactPath).toBe(started.session.artifactPath);
|
||||
}
|
||||
|
||||
// Pipeline lists exactly its two links.
|
||||
expect(pipelineStore.listByPipeline(cePipelineId)).toHaveLength(2);
|
||||
|
||||
// Optional column honored.
|
||||
const todoTask = tasks.find((t) => t.description.includes("Add tests"));
|
||||
expect(todoTask?.column).toBe("todo");
|
||||
});
|
||||
|
||||
it("created tasks run the NORMAL lifecycle with no plugin interference", async () => {
|
||||
const orch = makeOrch([
|
||||
{ type: "complete", data: { tasks: [{ description: "A normal task." }] } },
|
||||
]);
|
||||
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "go" });
|
||||
|
||||
const tasks = await taskStore.listTasks();
|
||||
expect(tasks).toHaveLength(1);
|
||||
const task = tasks[0];
|
||||
|
||||
// It is an ordinary board task: default column, normal mutation works, and the
|
||||
// plugin attached no extra status/hook state beyond provenance metadata.
|
||||
expect(task.column).toBe("triage");
|
||||
const moved = await taskStore.moveTask(task.id, "todo");
|
||||
expect(moved.column).toBe("todo");
|
||||
|
||||
// Re-read is a clean, normal task (provenance is the only CE footprint).
|
||||
const reread = await taskStore.getTask(task.id);
|
||||
expect(reread?.column).toBe("todo");
|
||||
expect((reread?.sourceMetadata as Record<string, unknown>)?.pluginId).toBe(CE_PLUGIN_ID);
|
||||
void started;
|
||||
});
|
||||
|
||||
it("zero derived tasks is a clean no-op (no board tasks, no orphan link rows)", async () => {
|
||||
const orch = makeOrch([{ type: "complete", data: { artifact: "# Nothing to do\n", tasks: [] } }]);
|
||||
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "nothing here" });
|
||||
expect(started.session.status).toBe("completed");
|
||||
|
||||
expect(await taskStore.listTasks()).toHaveLength(0);
|
||||
expect(getCePipelineStore(ctx).listByPipeline(started.session.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a completion payload with NO tasks field is also a no-op", async () => {
|
||||
const orch = makeOrch([{ type: "complete", data: { artifact: "# Just an artifact\n" } }]);
|
||||
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "x" });
|
||||
expect(started.session.status).toBe("completed");
|
||||
expect(await taskStore.listTasks()).toHaveLength(0);
|
||||
expect(getCePipelineStore(ctx).listByPipeline(started.session.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a non-work stage with a tasks payload does NOT land board tasks (bridge is work-only)", async () => {
|
||||
const orch = makeOrch([
|
||||
{ type: "complete", data: { artifact: "# Brainstorm\n", tasks: [{ description: "should be ignored" }] } },
|
||||
]);
|
||||
await orch.start("brainstorm", { openingMessage: "ideas" });
|
||||
expect(await taskStore.listTasks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as realFs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Mock node:fs so we can observe/inject behaviour around readFileSync and
|
||||
// accessSync without relying on vi.spyOn (ESM namespace exports are not
|
||||
// configurable). The list scan probes readability with accessSync (no bytes
|
||||
// read); readFileSync is only used when an artifact's content is actually
|
||||
// fetched (readArtifactById). The hooks below default to passthrough and
|
||||
// individual tests override them.
|
||||
let readFileHook: ((path: realFs.PathOrFileDescriptor, original: typeof realFs.readFileSync, args: unknown[]) => unknown) | undefined;
|
||||
let accessHook: ((path: realFs.PathLike, original: typeof realFs.accessSync, args: unknown[]) => unknown) | undefined;
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof realFs>();
|
||||
return {
|
||||
...actual,
|
||||
readFileSync: (path: realFs.PathOrFileDescriptor, ...args: unknown[]) => {
|
||||
if (readFileHook) return readFileHook(path, actual.readFileSync, args);
|
||||
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...args);
|
||||
},
|
||||
accessSync: (path: realFs.PathLike, ...args: unknown[]) => {
|
||||
if (accessHook) return accessHook(path, actual.accessSync, args);
|
||||
return (actual.accessSync as (...a: unknown[]) => unknown)(path, ...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } = realFs;
|
||||
const { discoverArtifacts, readArtifactById } = await import("../discovery.js");
|
||||
|
||||
function makeRepo(): string {
|
||||
return mkdtempSync(join(tmpdir(), "ce-discovery-"));
|
||||
}
|
||||
|
||||
describe("discoverArtifacts", () => {
|
||||
let root: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
readFileHook = undefined;
|
||||
accessHook = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns grouped artifacts from a fixture repo tree (happy path)", () => {
|
||||
root = makeRepo();
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
|
||||
writeFileSync(join(root, "CONCEPTS.md"), "# Concepts");
|
||||
mkdirSync(join(root, "docs/ideation"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/ideation/a.md"), "ideation a");
|
||||
writeFileSync(join(root, "docs/ideation/b.md"), "ideation b");
|
||||
mkdirSync(join(root, "docs/brainstorms"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/brainstorms/x.md"), "brainstorm x");
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/plan1.md"), "plan 1");
|
||||
mkdirSync(join(root, "docs/solutions"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/solutions/sol.md"), "solution");
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const byStage = Object.fromEntries(result.groups.map((g) => [g.stage, g]));
|
||||
|
||||
expect(result.totalArtifacts).toBe(7);
|
||||
expect(result.totalErrors).toBe(0);
|
||||
expect(byStage.strategy.entries).toHaveLength(1);
|
||||
expect(byStage.concepts.entries).toHaveLength(1);
|
||||
expect(byStage.ideation.entries).toHaveLength(2);
|
||||
expect(byStage.brainstorm.entries).toHaveLength(1);
|
||||
expect(byStage.plan.entries).toHaveLength(1);
|
||||
expect(byStage.solution.entries).toHaveLength(1);
|
||||
// Every group present is flagged present.
|
||||
expect(byStage.ideation.present).toBe(true);
|
||||
// All entries are artifacts in the happy path.
|
||||
expect(result.groups.flatMap((g) => g.entries).every((e) => e.kind === "artifact")).toBe(true);
|
||||
});
|
||||
|
||||
it("orders directory artifacts by updatedAt DESC", () => {
|
||||
root = makeRepo();
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
const older = join(root, "docs/plans/old.md");
|
||||
const newer = join(root, "docs/plans/new.md");
|
||||
writeFileSync(older, "old");
|
||||
writeFileSync(newer, "new");
|
||||
// Force deterministic mtimes: old < new.
|
||||
const now = Date.now();
|
||||
utimesSync(older, new Date(now - 10_000), new Date(now - 10_000));
|
||||
utimesSync(newer, new Date(now), new Date(now));
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const plan = result.groups.find((g) => g.stage === "plan")!;
|
||||
expect(plan.entries.map((e) => e.name)).toEqual(["new.md", "old.md"]);
|
||||
});
|
||||
|
||||
it("reports a partial-discovery state: some categories present, others empty", () => {
|
||||
root = makeRepo();
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/p.md"), "plan");
|
||||
// No ideation / brainstorms / solutions / CONCEPTS.
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const populated = result.groups.filter((g) => g.entries.length > 0);
|
||||
const empty = result.groups.filter((g) => g.entries.length === 0);
|
||||
expect(populated.map((g) => g.stage).sort()).toEqual(["plan", "strategy"]);
|
||||
expect(empty.length).toBeGreaterThan(0);
|
||||
// Empty groups are still present in the result so the hub can render them.
|
||||
expect(result.groups).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("returns an all-empty result when nothing is present (first-run)", () => {
|
||||
root = makeRepo();
|
||||
const result = discoverArtifacts(root);
|
||||
expect(result.totalArtifacts).toBe(0);
|
||||
expect(result.totalErrors).toBe(0);
|
||||
expect(result.groups.every((g) => g.entries.length === 0 && !g.present)).toBe(true);
|
||||
});
|
||||
|
||||
it("represents an unreadable artifact as an error entry, not a crash or silent drop", () => {
|
||||
root = makeRepo();
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
const readable = join(root, "docs/plans/good.md");
|
||||
writeFileSync(readable, "good");
|
||||
|
||||
// Simulate a malformed/unreadable artifact: the specific file throws when
|
||||
// the list scan probes readability (accessSync).
|
||||
accessHook = (path, original, args) => {
|
||||
if (typeof path === "string" && path.endsWith("good.md")) {
|
||||
throw new Error("EIO: simulated read failure");
|
||||
}
|
||||
return (original as (...a: unknown[]) => unknown)(path, ...args);
|
||||
};
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
const plan = result.groups.find((g) => g.stage === "plan")!;
|
||||
expect(plan.entries).toHaveLength(1);
|
||||
const entry = plan.entries[0];
|
||||
expect(entry.kind).toBe("error");
|
||||
expect(entry.kind === "error" && entry.error).toContain("simulated read failure");
|
||||
expect(result.totalErrors).toBe(1);
|
||||
expect(result.totalArtifacts).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores unrelated files and does not read outside the conventional locations", () => {
|
||||
root = makeRepo();
|
||||
// Conventional artifact that SHOULD be read.
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
|
||||
mkdirSync(join(root, "docs/ideation"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/ideation/keep.md"), "keep");
|
||||
// Unrelated files that must NOT be read.
|
||||
writeFileSync(join(root, "README.md"), "readme"); // root-level non-conventional .md
|
||||
writeFileSync(join(root, "package.json"), "{}");
|
||||
writeFileSync(join(root, "docs/ideation/notes.txt"), "non-md, ignore"); // non-.md in a scanned dir
|
||||
mkdirSync(join(root, "secrets"), { recursive: true });
|
||||
writeFileSync(join(root, "secrets/secret.md"), "TOP SECRET"); // outside the allowlist
|
||||
mkdirSync(join(root, "docs/random"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/random/r.md"), "unrelated"); // docs subtree but not conventional
|
||||
|
||||
const opened: string[] = [];
|
||||
// The list scan probes readability with accessSync (no bytes read); track
|
||||
// exactly which paths it touches.
|
||||
accessHook = (path, original, args) => {
|
||||
if (typeof path === "string") opened.push(path);
|
||||
return (original as (...a: unknown[]) => unknown)(path, ...args);
|
||||
};
|
||||
|
||||
const result = discoverArtifacts(root);
|
||||
|
||||
// Only the two conventional artifacts were probed.
|
||||
expect(opened.some((p) => p.endsWith("STRATEGY.md"))).toBe(true);
|
||||
expect(opened.some((p) => p.endsWith(join("ideation", "keep.md")))).toBe(true);
|
||||
// Nothing outside the allowlist was opened.
|
||||
expect(opened.some((p) => p.includes(`${join("secrets", "secret.md")}`))).toBe(false);
|
||||
expect(opened.some((p) => p.endsWith("README.md"))).toBe(false);
|
||||
expect(opened.some((p) => p.endsWith("package.json"))).toBe(false);
|
||||
expect(opened.some((p) => p.endsWith("notes.txt"))).toBe(false);
|
||||
expect(opened.some((p) => p.includes(join("random", "r.md")))).toBe(false);
|
||||
|
||||
expect(result.totalArtifacts).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readArtifactById", () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = makeRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reads a conventional file artifact", () => {
|
||||
writeFileSync(join(root, "STRATEGY.md"), "# Strategy body");
|
||||
const res = readArtifactById(root, "strategy:STRATEGY.md");
|
||||
expect(res).toBeDefined();
|
||||
expect(res && "content" in res && res.content).toContain("Strategy body");
|
||||
});
|
||||
|
||||
it("reads a directory artifact's immediate Markdown child", () => {
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/p.md"), "plan body");
|
||||
const res = readArtifactById(root, "plan:docs/plans/p.md");
|
||||
expect(res && "content" in res && res.content).toBe("plan body");
|
||||
});
|
||||
|
||||
it("refuses a forged id that escapes the conventional location", () => {
|
||||
writeFileSync(join(root, "secrets.md"), "secret");
|
||||
// Attempt to traverse out of docs/plans into the repo root.
|
||||
expect(readArtifactById(root, "plan:../../secrets.md")).toBeUndefined();
|
||||
// Wrong stage/path pairing for a file location.
|
||||
expect(readArtifactById(root, "strategy:CONCEPTS.md")).toBeUndefined();
|
||||
// Unknown stage.
|
||||
expect(readArtifactById(root, "bogus:whatever.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a nested path under a directory location (non-immediate child)", () => {
|
||||
mkdirSync(join(root, "docs/plans/sub"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/sub/deep.md"), "deep");
|
||||
expect(readArtifactById(root, "plan:docs/plans/sub/deep.md")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
|
||||
/**
|
||||
* CE artifact discovery (U3).
|
||||
*
|
||||
* Scans a fixed allowlist of conventional CE artifact locations relative to a
|
||||
* project root and returns artifacts grouped by stage. The allowlist is the
|
||||
* ONLY filesystem surface this module touches — it never recurses outside a
|
||||
* conventional location and never reads a file that does not live under one of
|
||||
* them. An artifact that cannot be read or is malformed is represented as an
|
||||
* `error` entry rather than crashing the scan or being silently dropped.
|
||||
*
|
||||
* Locations (per the plan): STRATEGY.md, docs/ideation/, docs/brainstorms/,
|
||||
* docs/plans/, docs/solutions/, CONCEPTS.md.
|
||||
*/
|
||||
|
||||
export type CeArtifactStage =
|
||||
| "strategy"
|
||||
| "ideation"
|
||||
| "brainstorm"
|
||||
| "plan"
|
||||
| "solution"
|
||||
| "concepts";
|
||||
|
||||
/** Whether a conventional location is a single file or a directory of files. */
|
||||
type LocationKind = "file" | "directory";
|
||||
|
||||
interface ConventionalLocation {
|
||||
stage: CeArtifactStage;
|
||||
/** Human label for the stage group. */
|
||||
label: string;
|
||||
/** Project-root-relative path. */
|
||||
path: string;
|
||||
kind: LocationKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The conventional CE artifact locations. This is the discovery allowlist — the
|
||||
* scanner reads ONLY these paths (and, for directories, their immediate `.md`
|
||||
* children). Nothing outside this list is opened.
|
||||
*/
|
||||
export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = [
|
||||
{ stage: "strategy", label: "Strategy", path: "STRATEGY.md", kind: "file" },
|
||||
{ stage: "ideation", label: "Ideation", path: "docs/ideation", kind: "directory" },
|
||||
{ stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" },
|
||||
{ stage: "plan", label: "Plans", path: "docs/plans", kind: "directory" },
|
||||
{ stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" },
|
||||
{ stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" },
|
||||
];
|
||||
|
||||
/** A discovered, readable artifact. */
|
||||
export interface CeArtifact {
|
||||
/** Stable id: `${stage}:${relativePath}`. Safe to use as a route param after encoding. */
|
||||
id: string;
|
||||
stage: CeArtifactStage;
|
||||
/** Project-root-relative path with forward slashes. */
|
||||
path: string;
|
||||
/** Filename (basename). */
|
||||
name: string;
|
||||
/** Size in bytes. */
|
||||
size: number;
|
||||
/** Last-modified epoch ms — used for `(stage, updatedAt DESC)` ordering. */
|
||||
updatedAt: number;
|
||||
/** Discriminator. */
|
||||
kind: "artifact";
|
||||
}
|
||||
|
||||
/** An artifact location that exists but could not be read / was malformed. */
|
||||
export interface CeArtifactError {
|
||||
id: string;
|
||||
stage: CeArtifactStage;
|
||||
path: string;
|
||||
name: string;
|
||||
/** Discriminator. */
|
||||
kind: "error";
|
||||
/** Human-readable reason the artifact could not be surfaced. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type CeArtifactEntry = CeArtifact | CeArtifactError;
|
||||
|
||||
/** Artifacts (and error entries) grouped by stage. */
|
||||
export interface CeArtifactGroup {
|
||||
stage: CeArtifactStage;
|
||||
label: string;
|
||||
/** True when the conventional location for this stage exists on disk. */
|
||||
present: boolean;
|
||||
/** Entries, ordered by `updatedAt DESC` (errors sort last, keyed by name). */
|
||||
entries: CeArtifactEntry[];
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
groups: CeArtifactGroup[];
|
||||
/** Convenience flags for the hub's empty / partial states. */
|
||||
totalArtifacts: number;
|
||||
totalErrors: number;
|
||||
}
|
||||
|
||||
const MAX_ARTIFACT_BYTES = 2_000_000;
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(sep).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: a resolved path must stay within the project root AND under the
|
||||
* specific conventional location it was discovered through. This is the
|
||||
* concrete enforcement of "do not read outside the conventional locations".
|
||||
*/
|
||||
function isWithin(root: string, locationAbs: string, candidate: string): boolean {
|
||||
const relToLocation = relative(locationAbs, candidate);
|
||||
if (relToLocation.startsWith("..") || isAbsolute(relToLocation)) return false;
|
||||
const relToRoot = relative(root, candidate);
|
||||
if (relToRoot.startsWith("..") || isAbsolute(relToRoot)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeId(stage: CeArtifactStage, relPath: string): string {
|
||||
return `${stage}:${relPath}`;
|
||||
}
|
||||
|
||||
/** Build a uniform `error` entry, deriving `id`/`name` from `(stage, relPath)`. */
|
||||
function makeError(stage: CeArtifactStage, relPath: string, message: string): CeArtifactError {
|
||||
return {
|
||||
id: makeId(stage, relPath),
|
||||
stage,
|
||||
path: relPath,
|
||||
name: relPath.split("/").pop() ?? relPath,
|
||||
kind: "error",
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
|
||||
function readArtifactEntry(
|
||||
stage: CeArtifactStage,
|
||||
root: string,
|
||||
locationAbs: string,
|
||||
abs: string,
|
||||
relPath: string,
|
||||
): CeArtifactEntry {
|
||||
const name = relPath.split("/").pop() ?? relPath;
|
||||
// Defense in depth: refuse anything that escaped the conventional location.
|
||||
if (!isWithin(root, locationAbs, abs)) {
|
||||
return makeError(stage, relPath, "Path is outside its conventional location");
|
||||
}
|
||||
try {
|
||||
const st = statSync(abs);
|
||||
if (st.size > MAX_ARTIFACT_BYTES) {
|
||||
return makeError(stage, relPath, `Artifact too large to read (${st.size} bytes)`);
|
||||
}
|
||||
// Probe READ PERMISSION only (no bytes transferred) so an unreadable file is
|
||||
// surfaced now as an error entry rather than crashing later at render time.
|
||||
// NOTE: this is a permission probe, NOT a content check — malformed/corrupt
|
||||
// file CONTENT is only detected at read time (readCeArtifact), not here.
|
||||
accessSync(abs, constants.R_OK);
|
||||
return {
|
||||
id: makeId(stage, relPath),
|
||||
stage,
|
||||
path: relPath,
|
||||
name,
|
||||
size: st.size,
|
||||
updatedAt: st.mtimeMs,
|
||||
kind: "artifact",
|
||||
};
|
||||
} catch (err) {
|
||||
return makeError(stage, relPath, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function sortEntries(entries: CeArtifactEntry[]): CeArtifactEntry[] {
|
||||
// Composite ordering analogue: artifacts by updatedAt DESC; errors last,
|
||||
// stable by name. (See docs/performance/dashboard-load.md — the persisted
|
||||
// equivalent is a `(type, updatedAt DESC)` index.)
|
||||
return [...entries].sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "artifact" ? -1 : 1;
|
||||
if (a.kind === "artifact" && b.kind === "artifact") return b.updatedAt - a.updatedAt;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGroup {
|
||||
const locationAbs = join(root, loc.path);
|
||||
const entries: CeArtifactEntry[] = [];
|
||||
let present = false;
|
||||
|
||||
let st: ReturnType<typeof statSync> | undefined;
|
||||
try {
|
||||
st = statSync(locationAbs);
|
||||
present = true;
|
||||
} catch {
|
||||
// Location simply does not exist — an empty (but valid) category.
|
||||
return { stage: loc.stage, label: loc.label, present: false, entries: [] };
|
||||
}
|
||||
|
||||
if (loc.kind === "file") {
|
||||
if (st.isFile()) {
|
||||
entries.push(readArtifactEntry(loc.stage, root, locationAbs, locationAbs, toPosix(loc.path)));
|
||||
} else {
|
||||
// A conventional file path that is actually a directory is malformed.
|
||||
entries.push(
|
||||
makeError(
|
||||
loc.stage,
|
||||
toPosix(loc.path),
|
||||
"Expected a file at the conventional location but found a directory",
|
||||
),
|
||||
);
|
||||
}
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
|
||||
// Directory location: read ONLY immediate children, only Markdown files.
|
||||
// Non-recursive on purpose — we never descend into unrelated subtrees.
|
||||
let names: string[] = [];
|
||||
try {
|
||||
if (!st.isDirectory()) {
|
||||
entries.push(
|
||||
makeError(
|
||||
loc.stage,
|
||||
toPosix(loc.path),
|
||||
"Expected a directory at the conventional location but found a file",
|
||||
),
|
||||
);
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
names = readdirSync(locationAbs);
|
||||
} catch (err) {
|
||||
entries.push(makeError(loc.stage, toPosix(loc.path), err instanceof Error ? err.message : String(err)));
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
|
||||
for (const childName of names) {
|
||||
// Ignore unrelated files: only Markdown artifacts count. Dotfiles and any
|
||||
// non-.md file are skipped outright (not read).
|
||||
if (childName.startsWith(".")) continue;
|
||||
if (!childName.toLowerCase().endsWith(".md")) continue;
|
||||
const abs = join(locationAbs, childName);
|
||||
const relPath = toPosix(join(loc.path, childName));
|
||||
// Skip nested directories named *.md — only regular files are artifacts.
|
||||
let childStat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
childStat = statSync(abs);
|
||||
} catch (err) {
|
||||
entries.push(makeError(loc.stage, relPath, err instanceof Error ? err.message : String(err)));
|
||||
continue;
|
||||
}
|
||||
if (!childStat.isFile()) continue;
|
||||
entries.push(readArtifactEntry(loc.stage, root, locationAbs, abs, relPath));
|
||||
}
|
||||
|
||||
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover CE artifacts under `projectRoot`, grouped by stage. Never throws for
|
||||
* per-artifact problems — those become `error` entries. Always returns one
|
||||
* group per conventional location (empty groups included so the hub can render
|
||||
* a partial-discovery state).
|
||||
*/
|
||||
export function discoverArtifacts(projectRoot: string): DiscoveryResult {
|
||||
const root = projectRoot;
|
||||
const groups = CONVENTIONAL_LOCATIONS.map((loc) => discoverLocation(root, loc));
|
||||
let totalArtifacts = 0;
|
||||
let totalErrors = 0;
|
||||
for (const g of groups) {
|
||||
for (const e of g.entries) {
|
||||
if (e.kind === "artifact") totalArtifacts += 1;
|
||||
else totalErrors += 1;
|
||||
}
|
||||
}
|
||||
return { groups, totalArtifacts, totalErrors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single artifact by its `stage:relativePath` id and return its raw
|
||||
* content. Re-validates the path against the conventional-location allowlist so
|
||||
* a forged id can never read an arbitrary file. Returns `undefined` if the id
|
||||
* does not map to a known conventional location or the file is missing.
|
||||
*/
|
||||
export function readArtifactById(
|
||||
projectRoot: string,
|
||||
id: string,
|
||||
): { artifact: CeArtifact; content: string } | { error: string } | undefined {
|
||||
const sepIdx = id.indexOf(":");
|
||||
if (sepIdx <= 0) return undefined;
|
||||
const stage = id.slice(0, sepIdx) as CeArtifactStage;
|
||||
const relPath = id.slice(sepIdx + 1);
|
||||
const loc = CONVENTIONAL_LOCATIONS.find((l) => l.stage === stage);
|
||||
if (!loc) return undefined;
|
||||
|
||||
const locationAbs = join(projectRoot, loc.path);
|
||||
const abs = join(projectRoot, relPath);
|
||||
|
||||
// The requested path must live under the stage's conventional location.
|
||||
// For file locations, the path must equal the location itself.
|
||||
if (loc.kind === "file") {
|
||||
if (toPosix(relPath) !== toPosix(loc.path)) return undefined;
|
||||
} else if (!isWithin(projectRoot, locationAbs, abs)) {
|
||||
return undefined;
|
||||
}
|
||||
// Directory artifacts must be immediate Markdown children.
|
||||
if (loc.kind === "directory") {
|
||||
const rel = relative(locationAbs, abs);
|
||||
if (rel.includes(sep) || rel.startsWith("..") || !rel.toLowerCase().endsWith(".md")) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let mtimeMs: number;
|
||||
let size: number;
|
||||
try {
|
||||
const st = statSync(abs);
|
||||
if (!st.isFile()) return { error: "Artifact is not a readable file" };
|
||||
if (st.size > MAX_ARTIFACT_BYTES) return { error: `Artifact too large to read (${st.size} bytes)` };
|
||||
mtimeMs = st.mtimeMs;
|
||||
size = st.size;
|
||||
} catch (err) {
|
||||
// A missing file is "not found" (404), not a malformed-artifact error (422).
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
try {
|
||||
content = readFileSync(abs, "utf8");
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
const name = relPath.split("/").pop() ?? relPath;
|
||||
return {
|
||||
artifact: {
|
||||
id,
|
||||
stage,
|
||||
path: toPosix(relPath),
|
||||
name,
|
||||
size,
|
||||
updatedAt: mtimeMs,
|
||||
kind: "artifact",
|
||||
},
|
||||
content,
|
||||
};
|
||||
}
|
||||
34
plugins/fusion-plugin-compound-engineering/src/dashboard-interop.d.ts
vendored
Normal file
34
plugins/fusion-plugin-compound-engineering/src/dashboard-interop.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
// Ambient declaration for the dashboard host's plugin-view context, so this
|
||||
// bundled plugin can consume the type WITHOUT a runtime dependency on
|
||||
// `@fusion/dashboard` (a host package). Depending on the host would create a
|
||||
// dashboard -> plugin -> dashboard cycle and violate the workspace-acyclicity /
|
||||
// "bundled plugins must not depend on host packages" invariants. The host
|
||||
// passes the real object at runtime; this minimal structural shape is enough to
|
||||
// type-check the fields this plugin actually reads. Mirrors the interop pattern
|
||||
// used by fusion-plugin-dependency-graph.
|
||||
declare module "@fusion/dashboard/app/plugins/types" {
|
||||
import type { ReactNode } from "react";
|
||||
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
|
||||
|
||||
export type DetailTaskTab =
|
||||
| "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";
|
||||
export type PluginToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface PluginCustomEvent {
|
||||
event: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface PluginDashboardViewContext {
|
||||
projectId?: string;
|
||||
tasks: Task[];
|
||||
workflowSteps: WorkflowStep[];
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
|
||||
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
|
||||
addToast?: (message: string, type?: PluginToastType) => void;
|
||||
subscribePluginEvents?: (
|
||||
pluginId: string,
|
||||
onEvent: (event: PluginCustomEvent) => void,
|
||||
) => () => void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Dashboard surface entry for the Compound Engineering plugin (U3).
|
||||
*
|
||||
* Thin re-export of the real hub component (mirrors how reports splits
|
||||
* `src/dashboard-view.tsx` from `src/dashboard/ReportsView.tsx`). The export
|
||||
* name `CompoundEngineeringDashboardView` is the one `registerBundledPluginViews`
|
||||
* imports — `componentPath` in the manifest is cosmetic; this binding is real.
|
||||
*/
|
||||
export {
|
||||
CompoundEngineeringView as CompoundEngineeringDashboardView,
|
||||
default,
|
||||
} from "./dashboard/CompoundEngineeringView.js";
|
||||
@@ -0,0 +1,556 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js";
|
||||
import { canRenderRichly } from "./ce-question-support.js";
|
||||
|
||||
/**
|
||||
* CeFlow — the interactive renderer (U6).
|
||||
*
|
||||
* Renders the four interaction types CeFlow expresses richly (`text`,
|
||||
* `single_select`, `multi_select`, `confirm`), the FULL conversation so far —
|
||||
* past questions and answers as proper chat bubbles, the agent's working
|
||||
* traces (thinking / tool activity) as collapsible blocks — and, while a turn
|
||||
* runs, a LIVE working pane streaming the agent's current output.
|
||||
*
|
||||
* Steering: alongside any selectable question the user can attach free-text
|
||||
* guidance to their answer (`{value, comment}`) or send guidance WITHOUT
|
||||
* answering (`{feedback}`) — the stage system prompt instructs the agent to
|
||||
* treat both as first-class input.
|
||||
*
|
||||
* When a turn carries a question CeFlow CANNOT express, it degrades to a
|
||||
* plain chat view that is VISUALLY MARKED as degraded (R8/AE1) — the stage is
|
||||
* still completable there via a free-text answer.
|
||||
*
|
||||
* It does NOT import `PlanningModeModal` or any dashboard internal (KTD3 scope
|
||||
* boundary); it only consumes the `PlanningQuestion` shape for parity.
|
||||
*/
|
||||
|
||||
export interface CeFlowProps {
|
||||
session?: CeSession;
|
||||
busy?: boolean;
|
||||
error?: string;
|
||||
/** Submit an answer to the current question. */
|
||||
onAnswer: (questionId: string, response: unknown) => void;
|
||||
/** Resume an interrupted/error session. */
|
||||
onResume?: () => void;
|
||||
/** Back to the launcher. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// ── Transcript parsing ───────────────────────────────────────────────────────
|
||||
|
||||
type DisplayItem =
|
||||
| { kind: "chat"; role: "user" | "agent"; text: string }
|
||||
| { kind: "qa-question"; question: PlanningQuestion }
|
||||
| { kind: "qa-answer"; question?: PlanningQuestion; response: unknown }
|
||||
| { kind: "activity"; turns: CeActivityTurn[] }
|
||||
| { kind: "complete" };
|
||||
|
||||
function tryParseJson(text: string): Record<string, unknown> | undefined {
|
||||
if (!text.startsWith("{")) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the persisted history (chat turns + serialized control records) into
|
||||
* renderable items. Control records are no longer hidden — questions, answers,
|
||||
* and working traces are the conversation.
|
||||
*/
|
||||
function parseHistory(history: CeConversationTurn[]): DisplayItem[] {
|
||||
const items: DisplayItem[] = [];
|
||||
const questionsById = new Map<string, PlanningQuestion>();
|
||||
for (const turn of history) {
|
||||
const obj = tryParseJson(turn.text);
|
||||
if (obj && turn.role === "agent") {
|
||||
const q = obj.question as PlanningQuestion | undefined;
|
||||
if (q && typeof q.id === "string" && typeof q.question === "string") {
|
||||
questionsById.set(q.id, q);
|
||||
items.push({ kind: "qa-question", question: q });
|
||||
continue;
|
||||
}
|
||||
const activity = obj.activity as { turns?: CeActivityTurn[] } | undefined;
|
||||
if (activity && Array.isArray(activity.turns)) {
|
||||
items.push({ kind: "activity", turns: activity.turns });
|
||||
continue;
|
||||
}
|
||||
if (obj.complete === true) {
|
||||
items.push({ kind: "complete" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (obj && turn.role === "user" && "answer" in obj) {
|
||||
items.push({
|
||||
kind: "qa-answer",
|
||||
question: typeof obj.questionId === "string" ? questionsById.get(obj.questionId) : undefined,
|
||||
response: obj.answer,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
items.push({ kind: "chat", role: turn.role, text: turn.text });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Human-readable rendering of an answer payload (option ids → labels). */
|
||||
function formatAnswer(
|
||||
response: unknown,
|
||||
question?: PlanningQuestion,
|
||||
): { main: string; comment?: string; feedbackOnly?: boolean } {
|
||||
if (response && typeof response === "object" && !Array.isArray(response)) {
|
||||
const r = response as Record<string, unknown>;
|
||||
if (typeof r.feedback === "string") return { main: r.feedback, feedbackOnly: true };
|
||||
if ("value" in r) {
|
||||
const base = formatAnswer(r.value, question);
|
||||
return {
|
||||
main: base.main,
|
||||
...(typeof r.comment === "string" && r.comment ? { comment: r.comment } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
const label = (id: unknown): string =>
|
||||
question?.options?.find((o) => o.id === id)?.label ?? String(id);
|
||||
if (Array.isArray(response)) return { main: response.map(label).join(", ") };
|
||||
if (typeof response === "boolean") return { main: response ? "Yes" : "No" };
|
||||
return { main: label(response) };
|
||||
}
|
||||
|
||||
// ── Working-trace rendering ──────────────────────────────────────────────────
|
||||
|
||||
/** Render thinking/text/tool activity turns (persisted trace or live pane). */
|
||||
function ActivityTrace({ turns, live }: { turns: CeActivityTurn[]; live?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`ce-flow-activity${live ? " is-live" : ""}`}
|
||||
data-testid={live ? "ce-flow-live-activity" : "ce-flow-activity-trace"}
|
||||
>
|
||||
{turns.map((t, i) =>
|
||||
t.kind === "tool" ? (
|
||||
<div
|
||||
key={i}
|
||||
className={`ce-activity-tool${t.isError ? " is-error" : t.done ? " is-done" : " is-running"}`}
|
||||
data-testid="ce-activity-tool"
|
||||
>
|
||||
<span className="ce-activity-tool-marker">{t.isError ? "✗" : t.done ? "✓" : "▸"}</span> {t.text}
|
||||
</div>
|
||||
) : (
|
||||
<pre key={i} className={`ce-activity-block ce-activity-${t.kind}`} data-kind={t.kind}>
|
||||
{t.text}
|
||||
</pre>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the full conversation: chat, Q&A bubbles, and working traces. */
|
||||
function Transcript({ history }: { history: CeConversationTurn[] }) {
|
||||
const items = useMemo(() => parseHistory(history), [history]);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<ol className="ce-flow-transcript" data-testid="ce-flow-transcript">
|
||||
{items.map((item, i) => {
|
||||
switch (item.kind) {
|
||||
case "chat":
|
||||
return (
|
||||
<li key={i} className={`ce-flow-turn ce-flow-turn-${item.role}`} data-role={item.role}>
|
||||
<span className="ce-flow-turn-role">{item.role === "agent" ? "Agent" : "You"}</span>
|
||||
<span className="ce-flow-turn-text">{item.text}</span>
|
||||
</li>
|
||||
);
|
||||
case "qa-question":
|
||||
return (
|
||||
<li key={i} className="ce-flow-turn ce-flow-turn-agent ce-flow-turn-question" data-testid="ce-flow-past-question">
|
||||
<span className="ce-flow-turn-role">Agent asked</span>
|
||||
<span className="ce-flow-turn-text">{item.question.question}</span>
|
||||
</li>
|
||||
);
|
||||
case "qa-answer": {
|
||||
const a = formatAnswer(item.response, item.question);
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className={`ce-flow-turn ce-flow-turn-user ce-flow-turn-answer${a.feedbackOnly ? " is-steering" : ""}`}
|
||||
data-testid="ce-flow-past-answer"
|
||||
>
|
||||
<span className="ce-flow-turn-role">{a.feedbackOnly ? "You steered" : "You answered"}</span>
|
||||
<span className="ce-flow-turn-text">{a.main}</span>
|
||||
{a.comment ? (
|
||||
<span className="ce-flow-turn-comment" data-testid="ce-flow-answer-comment">
|
||||
{a.comment}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
case "activity":
|
||||
return (
|
||||
<li key={i} className="ce-flow-turn ce-flow-turn-agent ce-flow-turn-activity">
|
||||
<details className="ce-flow-activity-details" data-testid="ce-flow-activity">
|
||||
<summary>Agent work ({item.turns.length} step{item.turns.length === 1 ? "" : "s"})</summary>
|
||||
<ActivityTrace turns={item.turns} />
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
case "complete":
|
||||
return (
|
||||
<li key={i} className="ce-flow-turn ce-flow-turn-agent ce-flow-turn-done">
|
||||
<span className="ce-flow-turn-text">✓ Stage complete</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question rendering ───────────────────────────────────────────────────────
|
||||
|
||||
/** Rich renderer for a single supported question type. */
|
||||
function RichQuestion({
|
||||
question,
|
||||
disabled,
|
||||
onAnswer,
|
||||
}: {
|
||||
question: PlanningQuestion;
|
||||
disabled: boolean;
|
||||
onAnswer: (questionId: string, response: unknown) => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [multi, setMulti] = useState<string[]>([]);
|
||||
|
||||
const submit = (response: unknown) => onAnswer(question.id, response);
|
||||
|
||||
return (
|
||||
<div className="ce-flow-question" data-testid="ce-flow-question" data-qtype={question.type}>
|
||||
<p className="ce-flow-question-text">{question.question}</p>
|
||||
{question.description ? <p className="ce-flow-question-desc">{question.description}</p> : null}
|
||||
|
||||
{question.type === "text" ? (
|
||||
<form
|
||||
className="ce-flow-text"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) submit(text.trim());
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
data-testid="ce-flow-text-input"
|
||||
aria-label={question.question}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={disabled || !text.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{question.type === "confirm" ? (
|
||||
<div className="ce-flow-confirm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="ce-flow-confirm-yes"
|
||||
disabled={disabled}
|
||||
onClick={() => submit(true)}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="ce-flow-confirm-no"
|
||||
disabled={disabled}
|
||||
onClick={() => submit(false)}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{question.type === "single_select" ? (
|
||||
<ul className="ce-flow-options" data-testid="ce-flow-single">
|
||||
{(question.options ?? []).map((opt) => (
|
||||
<li key={opt.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="ce-flow-option btn"
|
||||
data-option={opt.id}
|
||||
disabled={disabled}
|
||||
onClick={() => submit(opt.id)}
|
||||
>
|
||||
<span className="ce-flow-option-label">{opt.label}</span>
|
||||
{opt.description ? <span className="ce-flow-option-desc">{opt.description}</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{question.type === "multi_select" ? (
|
||||
<form
|
||||
className="ce-flow-options"
|
||||
data-testid="ce-flow-multi"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit(multi);
|
||||
}}
|
||||
>
|
||||
<ul>
|
||||
{(question.options ?? []).map((opt) => {
|
||||
const checked = multi.includes(opt.id);
|
||||
return (
|
||||
<li key={opt.id}>
|
||||
<label className="ce-flow-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-option={opt.id}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setMulti((prev) =>
|
||||
e.target.checked ? [...prev, opt.id] : prev.filter((id) => id !== opt.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="ce-flow-option-label">{opt.label}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button type="submit" className="btn btn-primary" data-testid="ce-flow-multi-submit" disabled={disabled}>
|
||||
Confirm selection
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Degraded chat fallback (R8/AE1). Used when a question can't be expressed by
|
||||
* the rich renderer. Visibly marked as degraded; the stage is still completable
|
||||
* because the user can answer in free text, which is submitted back through the
|
||||
* same answer route.
|
||||
*/
|
||||
function DegradedQuestion({
|
||||
question,
|
||||
disabled,
|
||||
onAnswer,
|
||||
}: {
|
||||
question: PlanningQuestion;
|
||||
disabled: boolean;
|
||||
onAnswer: (questionId: string, response: unknown) => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
return (
|
||||
<div className="ce-flow-question ce-flow-degraded" data-testid="ce-flow-degraded" data-qtype={question.type}>
|
||||
<p className="ce-flow-degraded-banner" role="status" data-testid="ce-flow-degraded-banner">
|
||||
⚠ Chat fallback — this prompt can't be shown as buttons here. Answer in your own words below.
|
||||
</p>
|
||||
<p className="ce-flow-question-text">{question.question}</p>
|
||||
{question.description ? <p className="ce-flow-question-desc">{question.description}</p> : null}
|
||||
{Array.isArray(question.options) && question.options.length > 0 ? (
|
||||
<ul className="ce-flow-degraded-options">
|
||||
{question.options.map((opt) => (
|
||||
<li key={opt.id}>{opt.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<form
|
||||
className="ce-flow-text"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) onAnswer(question.id, text.trim());
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
data-testid="ce-flow-degraded-input"
|
||||
aria-label={question.question}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={disabled || !text.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Question panel with steering. Wraps the rich/degraded renderer and adds the
|
||||
* guidance channel for selectable questions:
|
||||
* - guidance typed + an option clicked → `{value, comment}` (answer + steer),
|
||||
* - guidance typed + "Send guidance" → `{feedback}` (steer without answering).
|
||||
* Free-text questions skip the extra box — their answer field already takes
|
||||
* the user's own words.
|
||||
*/
|
||||
function QuestionPanel({
|
||||
question,
|
||||
disabled,
|
||||
onAnswer,
|
||||
}: {
|
||||
question: PlanningQuestion;
|
||||
disabled: boolean;
|
||||
onAnswer: (questionId: string, response: unknown) => void;
|
||||
}) {
|
||||
const [guidance, setGuidance] = useState("");
|
||||
const rich = canRenderRichly(question);
|
||||
|
||||
const submitWithGuidance = (questionId: string, response: unknown) => {
|
||||
const comment = guidance.trim();
|
||||
onAnswer(questionId, comment ? { value: response, comment } : response);
|
||||
setGuidance("");
|
||||
};
|
||||
|
||||
const sendGuidanceOnly = () => {
|
||||
const feedback = guidance.trim();
|
||||
if (!feedback) return;
|
||||
onAnswer(question.id, { feedback });
|
||||
setGuidance("");
|
||||
};
|
||||
|
||||
const showGuidance = rich && question.type !== "text";
|
||||
|
||||
return (
|
||||
<div className="ce-flow-question-panel">
|
||||
{rich ? (
|
||||
<RichQuestion question={question} disabled={disabled} onAnswer={submitWithGuidance} />
|
||||
) : (
|
||||
<DegradedQuestion question={question} disabled={disabled} onAnswer={onAnswer} />
|
||||
)}
|
||||
{showGuidance ? (
|
||||
<div className="ce-flow-guidance" data-testid="ce-flow-guidance">
|
||||
<label className="ce-flow-guidance-label" htmlFor="ce-flow-guidance-input">
|
||||
Steer in your own words (optional — attached to your answer, or sent on its own)
|
||||
</label>
|
||||
<div className="ce-flow-guidance-row">
|
||||
<textarea
|
||||
id="ce-flow-guidance-input"
|
||||
data-testid="ce-flow-guidance-input"
|
||||
value={guidance}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setGuidance(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="e.g. focus on the mobile flow, skip auth for now…"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="ce-flow-guidance-send"
|
||||
disabled={disabled || !guidance.trim()}
|
||||
onClick={sendGuidanceOnly}
|
||||
>
|
||||
Send guidance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Flow surface ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function CeFlow(props: CeFlowProps) {
|
||||
const { session, busy, error, onAnswer, onResume, onClose } = props;
|
||||
|
||||
const question = session?.currentQuestion ?? undefined;
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="ce-flow card" data-testid="ce-flow-empty">
|
||||
<p>No active session.</p>
|
||||
{onClose ? (
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = session.status;
|
||||
const settledTerminal = status === "completed";
|
||||
const recoverable = status === "interrupted" || status === "error";
|
||||
const working = status === "active" || status === "launching";
|
||||
|
||||
return (
|
||||
<div className="ce-flow card" data-testid="ce-flow" data-status={status} data-stage={session.stage}>
|
||||
<header className="ce-flow-header">
|
||||
<h3>{session.stage}</h3>
|
||||
<span className="ce-flow-status" data-testid="ce-flow-status">
|
||||
{status.replace("_", " ")}
|
||||
</span>
|
||||
{onClose ? (
|
||||
<button type="button" className="btn ce-flow-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<Transcript history={session.conversationHistory} />
|
||||
|
||||
{working || (busy && status !== "awaiting_input") ? (
|
||||
<div className="ce-flow-working" data-testid="ce-flow-thinking">
|
||||
<p className="ce-flow-working-label">
|
||||
<span className="ce-flow-pulse" aria-hidden="true" />
|
||||
Agent working…
|
||||
</p>
|
||||
{session.liveActivity && session.liveActivity.length > 0 ? (
|
||||
<ActivityTrace turns={session.liveActivity} live />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="ce-flow-error" role="alert" data-testid="ce-flow-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status === "awaiting_input" && question ? (
|
||||
<QuestionPanel question={question} disabled={Boolean(busy)} onAnswer={onAnswer} />
|
||||
) : null}
|
||||
|
||||
{recoverable ? (
|
||||
<div className="ce-flow-recover" data-testid="ce-flow-recover">
|
||||
<p className="ce-flow-error" role="alert">
|
||||
Session {status}{session.error ? `: ${session.error}` : ""}.
|
||||
</p>
|
||||
{onResume ? (
|
||||
<button type="button" className="btn btn-primary" data-testid="ce-flow-resume" onClick={onResume} disabled={Boolean(busy)}>
|
||||
Resume
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settledTerminal ? (
|
||||
<div className="ce-flow-complete" data-testid="ce-flow-complete">
|
||||
<p>Stage complete.</p>
|
||||
{session.artifactPath ? (
|
||||
<p className="ce-flow-artifact-path" data-testid="ce-flow-artifact-path">
|
||||
Artifact: {session.artifactPath}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CeFlow;
|
||||
@@ -0,0 +1,519 @@
|
||||
.ce-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ce-view-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ce-view-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ce-view-summary {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ce-loading,
|
||||
.ce-view-error {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ce-view-error {
|
||||
color: var(--color-danger, #d23);
|
||||
}
|
||||
|
||||
.ce-empty {
|
||||
max-width: 36rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ce-empty h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ce-empty-hint {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ce-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ce-group {
|
||||
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.25));
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ce-group[data-empty="true"] {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.ce-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ce-group-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ce-group-count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ce-group-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ce-artifact-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ce-artifact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ce-artifact.is-selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ce-artifact-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: inherit;
|
||||
padding: 0.25rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ce-artifact-path {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ce-artifact-error .ce-artifact-error-msg {
|
||||
color: var(--color-danger, #d23);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ce-artifact-error {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.ce-view[data-mobile="true"] .ce-groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* --- Stage launcher (U6) --- */
|
||||
.ce-launcher {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.ce-launcher-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ce-launcher-tile {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ce-launcher-icon {
|
||||
flex: none;
|
||||
}
|
||||
.ce-view-start {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* --- CeFlow interactive renderer (U6) --- */
|
||||
.ce-flow {
|
||||
margin: 0.75rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.ce-flow-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ce-flow-header h3 {
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ce-flow-status {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.7;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ce-flow-close {
|
||||
margin-left: auto;
|
||||
}
|
||||
.ce-flow-transcript {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ce-flow-turn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ce-flow-turn-role {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ce-flow-turn-agent .ce-flow-turn-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ce-flow-thinking {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ce-flow-question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ce-flow-question-text {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.ce-flow-question-desc {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
margin: 0;
|
||||
}
|
||||
.ce-flow-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ce-flow-text textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
.ce-flow-confirm {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ce-flow-options {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.ce-flow-options ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.ce-flow-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ce-flow-option-desc {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ce-flow-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ce-flow-error {
|
||||
color: var(--color-danger, #d23);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Degraded chat fallback (R8/AE1) — must read as visibly distinct. */
|
||||
.ce-flow-degraded {
|
||||
border: 1px dashed var(--color-warning, #c80);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
background: color-mix(in srgb, var(--color-warning, #c80) 8%, transparent);
|
||||
}
|
||||
.ce-flow-degraded-banner {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-warning, #a60);
|
||||
}
|
||||
.ce-flow-degraded-options {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.8;
|
||||
margin: 0 0 0.4rem;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
/* Sessions panel — manage/switch across multiple concurrent CE sessions. */
|
||||
.ce-sessions {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.ce-sessions-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.ce-session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ce-session-row.is-active .ce-session-open {
|
||||
border-color: var(--color-accent, #36c);
|
||||
background: color-mix(in srgb, var(--color-accent, #36c) 8%, transparent);
|
||||
}
|
||||
.ce-session-open {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ce-session-open:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.ce-session-stage {
|
||||
font-weight: 600;
|
||||
}
|
||||
.ce-session-status {
|
||||
font-size: 0.74rem;
|
||||
text-transform: capitalize;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ce-session-status-awaiting_input {
|
||||
color: var(--color-warning, #a60);
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
.ce-session-status-error,
|
||||
.ce-session-status-interrupted {
|
||||
color: var(--color-danger, #d23);
|
||||
opacity: 1;
|
||||
}
|
||||
.ce-session-status-completed {
|
||||
color: var(--color-success, #2a7);
|
||||
opacity: 1;
|
||||
}
|
||||
.ce-session-updated {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Q&A transcript bubbles ────────────────────────────────────────────── */
|
||||
.ce-flow-transcript {
|
||||
list-style: none;
|
||||
margin: 0 0 0.8rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ce-flow-turn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
max-width: 85%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--color-border, #ddd) 30%, transparent);
|
||||
}
|
||||
.ce-flow-turn-user {
|
||||
align-self: flex-end;
|
||||
background: color-mix(in srgb, var(--color-accent, #36c) 12%, transparent);
|
||||
}
|
||||
.ce-flow-turn-role {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ce-flow-turn-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.ce-flow-turn-question {
|
||||
border-left: 3px solid var(--color-accent, #36c);
|
||||
}
|
||||
.ce-flow-turn-answer.is-steering {
|
||||
border-left: 3px solid var(--color-warning, #c80);
|
||||
}
|
||||
.ce-flow-turn-comment {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--color-border, #ddd) 60%, transparent);
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
.ce-flow-turn-done {
|
||||
align-self: center;
|
||||
background: color-mix(in srgb, var(--color-success, #2a7) 10%, transparent);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.ce-flow-turn-activity {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Agent working trace (persisted + live) ─────────────────────────────── */
|
||||
.ce-flow-activity-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ce-flow-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0.3rem 0 0;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border, #ddd) 70%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-border, #ddd) 12%, transparent);
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ce-activity-block {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.ce-activity-thinking {
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
.ce-activity-tool {
|
||||
font-size: 0.76rem;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.ce-activity-tool.is-running .ce-activity-tool-marker {
|
||||
color: var(--color-accent, #36c);
|
||||
}
|
||||
.ce-activity-tool.is-done .ce-activity-tool-marker {
|
||||
color: var(--color-success, #2a7);
|
||||
}
|
||||
.ce-activity-tool.is-error .ce-activity-tool-marker {
|
||||
color: var(--color-danger, #d23);
|
||||
}
|
||||
|
||||
/* ── Live working pane ──────────────────────────────────────────────────── */
|
||||
.ce-flow-working {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.ce-flow-working-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ce-flow-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, #36c);
|
||||
animation: ce-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ce-pulse {
|
||||
0%, 100% { opacity: 0.25; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Steering / guidance channel ────────────────────────────────────────── */
|
||||
.ce-flow-guidance {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--color-border, #ddd) 70%, transparent);
|
||||
}
|
||||
.ce-flow-guidance-label {
|
||||
display: block;
|
||||
font-size: 0.74rem;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.ce-flow-guidance-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.ce-flow-guidance-row textarea {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import "./CompoundEngineeringView.css";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
|
||||
import { useArtifacts } from "./hooks/useArtifacts.js";
|
||||
import { useViewportMode } from "./hooks/useViewportMode.js";
|
||||
import { useCeSession, type CeSessionSubscribe } from "./hooks/useCeSession.js";
|
||||
import { useCeSessions, type CeSessionsSubscribe } from "./hooks/useCeSessions.js";
|
||||
import { getArtifactPreviewUrl } from "./hooks/api.js";
|
||||
import { CeFlow } from "./CeFlow.js";
|
||||
import { getStage, listStages, type CeStageDefinition } from "../session/stage-registry.js";
|
||||
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
|
||||
import type { CeSession, CeSessionStatus } from "../session/session-store.js";
|
||||
|
||||
const CE_PLUGIN_ID = "fusion-plugin-compound-engineering";
|
||||
|
||||
/** Resolve a lucide icon name (from the registry) to a component, with fallback. */
|
||||
function resolveIcon(name: string): LucideIcon {
|
||||
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
|
||||
return icons[name] ?? LucideIcons.Circle;
|
||||
}
|
||||
|
||||
/** Launcher: lists exactly the registered stages (R4) and launches one. */
|
||||
function StageLauncher({
|
||||
stages,
|
||||
disabled,
|
||||
onLaunch,
|
||||
}: {
|
||||
stages: CeStageDefinition[];
|
||||
disabled: boolean;
|
||||
onLaunch: (stage: CeStageDefinition) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ce-launcher card" data-testid="ce-launcher">
|
||||
<h3>Start a stage</h3>
|
||||
<ul className="ce-launcher-list">
|
||||
{stages.map((stage) => {
|
||||
const Icon = resolveIcon(stage.icon);
|
||||
return (
|
||||
<li key={stage.stageId}>
|
||||
<button
|
||||
type="button"
|
||||
className="ce-launcher-tile btn"
|
||||
data-testid="ce-launcher-stage"
|
||||
data-stage={stage.stageId}
|
||||
disabled={disabled}
|
||||
onClick={() => onLaunch(stage)}
|
||||
>
|
||||
<Icon className="ce-launcher-icon" size={18} aria-hidden="true" />
|
||||
<span className="ce-launcher-label">{stage.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Statuses that are settled (no agent turn in flight). */
|
||||
const TERMINAL: ReadonlySet<CeSessionStatus> = new Set(["completed", "error", "interrupted"]);
|
||||
|
||||
function statusLabel(status: CeSessionStatus): string {
|
||||
return status.replace("_", " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions panel: every CE session (each an independent pipeline run) with its
|
||||
* stage, status, and last activity — open any to keep working on it, discard
|
||||
* settled ones. Sessions keep running server-side while not open here.
|
||||
*/
|
||||
function SessionsPanel({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
disabled,
|
||||
onOpen,
|
||||
onDiscard,
|
||||
}: {
|
||||
sessions: CeSession[];
|
||||
activeSessionId?: string;
|
||||
disabled: boolean;
|
||||
onOpen: (session: CeSession) => void;
|
||||
onDiscard: (session: CeSession) => void;
|
||||
}) {
|
||||
if (sessions.length === 0) return null;
|
||||
return (
|
||||
<section className="ce-sessions card" data-testid="ce-sessions">
|
||||
<header className="ce-group-header">
|
||||
<h3>Sessions</h3>
|
||||
<span className="ce-group-count">{sessions.length}</span>
|
||||
</header>
|
||||
<ul className="ce-sessions-list">
|
||||
{sessions.map((s) => {
|
||||
const stageLabel = getStage(s.stage)?.label ?? s.stage;
|
||||
const awaiting = s.status === "awaiting_input";
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`ce-session-row${s.id === activeSessionId ? " is-active" : ""}`}
|
||||
data-testid="ce-session-row"
|
||||
data-session={s.id}
|
||||
data-status={s.status}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ce-session-open"
|
||||
data-testid="ce-session-open"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpen(s)}
|
||||
>
|
||||
<span className="ce-session-stage">{stageLabel}</span>
|
||||
<span className={`ce-session-status ce-session-status-${s.status}`} data-testid="ce-session-status">
|
||||
{awaiting ? "needs your input" : statusLabel(s.status)}
|
||||
</span>
|
||||
<span className="ce-session-updated">{new Date(s.updatedAt).toLocaleString()}</span>
|
||||
</button>
|
||||
{TERMINAL.has(s.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ce-session-discard"
|
||||
data-testid="ce-session-discard"
|
||||
disabled={disabled}
|
||||
onClick={() => onDiscard(s)}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompoundEngineeringViewProps {
|
||||
context?: PluginDashboardViewContext;
|
||||
/** Test seam: override the active project id without a host context. */
|
||||
projectId?: string;
|
||||
/** Test seam: force the viewport-gated fetch on/off. */
|
||||
enabledOverride?: boolean;
|
||||
}
|
||||
|
||||
function readProjectId(props: CompoundEngineeringViewProps): string | undefined {
|
||||
if (props.projectId) return props.projectId;
|
||||
const ctx = props.context as { projectId?: string } | undefined;
|
||||
return ctx?.projectId;
|
||||
}
|
||||
|
||||
/** First-run / empty state: no artifacts AND no errors anywhere. */
|
||||
function EmptyState({ onStart }: { onStart: () => void }) {
|
||||
return (
|
||||
<div className="ce-empty card" data-testid="ce-empty-state">
|
||||
<h3>Start your compounding pipeline</h3>
|
||||
<p>
|
||||
No compound-engineering artifacts found yet. Compound Engineering tracks the documents your
|
||||
pipeline produces — strategy, ideation, brainstorms, plans, solutions, and concepts — as you
|
||||
move through each stage.
|
||||
</p>
|
||||
<p className="ce-empty-hint">
|
||||
Begin with a stage and its artifact will appear here, grouped and traceable.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" data-testid="ce-start-action" onClick={onStart}>
|
||||
Start a stage
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({
|
||||
entry,
|
||||
projectId,
|
||||
onSelect,
|
||||
selected,
|
||||
}: {
|
||||
entry: CeArtifactEntry;
|
||||
projectId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
if (entry.kind === "error") {
|
||||
return (
|
||||
<li className="ce-artifact ce-artifact-error" data-testid="ce-artifact-error">
|
||||
<span className="ce-artifact-name">{entry.name}</span>
|
||||
<span className="ce-artifact-error-msg" role="alert">
|
||||
Could not read: {entry.error}
|
||||
</span>
|
||||
<span className="ce-artifact-path">{entry.path}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li className={`ce-artifact${selected ? " is-selected" : ""}`} data-testid="ce-artifact">
|
||||
<button type="button" className="ce-artifact-btn" onClick={() => onSelect(entry.id)}>
|
||||
<span className="ce-artifact-name">{entry.name}</span>
|
||||
<span className="ce-artifact-path">{entry.path}</span>
|
||||
</button>
|
||||
<a
|
||||
className="ce-artifact-open"
|
||||
href={getArtifactPreviewUrl(entry.id, projectId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function StageGroup({
|
||||
group,
|
||||
projectId,
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: {
|
||||
group: CeArtifactGroup;
|
||||
projectId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
selectedId?: string;
|
||||
}) {
|
||||
const empty = group.entries.length === 0;
|
||||
return (
|
||||
<section className="ce-group" data-testid="ce-group" data-stage={group.stage} data-empty={empty ? "true" : "false"}>
|
||||
<header className="ce-group-header">
|
||||
<h3>{group.label}</h3>
|
||||
<span className="ce-group-count">{group.entries.length}</span>
|
||||
</header>
|
||||
{empty ? (
|
||||
<p className="ce-group-empty" data-testid="ce-group-empty">
|
||||
No {group.label.toLowerCase()} artifacts yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="ce-artifact-list">
|
||||
{group.entries.map((entry) => (
|
||||
<ArtifactRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
projectId={projectId}
|
||||
onSelect={onSelect}
|
||||
selected={selectedId === entry.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
|
||||
const projectId = readProjectId(props);
|
||||
const { mobile, active } = useViewportMode();
|
||||
const enabled = props.enabledOverride ?? active;
|
||||
const { result, loading, error } = useArtifacts({ projectId, enabled });
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
|
||||
const stages = listStages();
|
||||
// Live push: when the host forwards a plugin:custom SSE event for THIS session,
|
||||
// refetch — lower latency than the poll fallback. Uses the host-provided
|
||||
// subscribe capability (no raw EventSource, no deep dashboard import); when the
|
||||
// host doesn't supply it, the hook falls back to polling.
|
||||
const subscribePluginEvents = (props.context as PluginDashboardViewContext | undefined)
|
||||
?.subscribePluginEvents;
|
||||
const subscribe = useMemo<CeSessionSubscribe | undefined>(() => {
|
||||
if (!subscribePluginEvents) return undefined;
|
||||
return (sessionId, _projectId, onSessionEvent) =>
|
||||
subscribePluginEvents(CE_PLUGIN_ID, ({ payload }) => {
|
||||
if ((payload as { sessionId?: string } | undefined)?.sessionId === sessionId) {
|
||||
onSessionEvent();
|
||||
}
|
||||
});
|
||||
}, [subscribePluginEvents]);
|
||||
const ceSession = useCeSession(subscribe ? { subscribe } : {});
|
||||
// Session list refresh: ANY CE push event means some session changed.
|
||||
const subscribeList = useMemo<CeSessionsSubscribe | undefined>(() => {
|
||||
if (!subscribePluginEvents) return undefined;
|
||||
return (onAnyEvent) => subscribePluginEvents(CE_PLUGIN_ID, () => onAnyEvent());
|
||||
}, [subscribePluginEvents]);
|
||||
const ceSessions = useCeSessions({
|
||||
projectId,
|
||||
enabled,
|
||||
...(subscribeList ? { subscribe: subscribeList } : {}),
|
||||
});
|
||||
const [launcherOpen, setLauncherOpen] = useState(false);
|
||||
|
||||
const totalArtifacts = result?.totalArtifacts ?? 0;
|
||||
const totalErrors = result?.totalErrors ?? 0;
|
||||
const hasAnything = totalArtifacts > 0 || totalErrors > 0;
|
||||
// Partial discovery: at least one category populated AND at least one empty.
|
||||
const populatedGroups = result?.groups.filter((g) => g.entries.length > 0).length ?? 0;
|
||||
const emptyGroups = result?.groups.filter((g) => g.entries.length === 0).length ?? 0;
|
||||
const isPartial = populatedGroups > 0 && emptyGroups > 0;
|
||||
|
||||
const onStart = () => setLauncherOpen(true);
|
||||
|
||||
const onLaunch = useCallback(
|
||||
(stage: CeStageDefinition) => {
|
||||
setLauncherOpen(false);
|
||||
void ceSession
|
||||
.start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId })
|
||||
.then(() => ceSessions.refresh());
|
||||
},
|
||||
[ceSession, ceSessions, projectId],
|
||||
);
|
||||
|
||||
const onOpenSession = useCallback(
|
||||
(s: CeSession) => {
|
||||
void ceSession.open(s.id, { projectId });
|
||||
},
|
||||
[ceSession, projectId],
|
||||
);
|
||||
|
||||
const onDiscardSession = useCallback(
|
||||
(s: CeSession) => {
|
||||
void ceSessions.remove(s.id);
|
||||
},
|
||||
[ceSessions],
|
||||
);
|
||||
|
||||
// Closing the flow returns to the overview WITHOUT stopping the session —
|
||||
// it keeps running server-side and stays reachable from the sessions panel.
|
||||
const onCloseFlow = useCallback(() => {
|
||||
ceSession.reset();
|
||||
void ceSessions.refresh();
|
||||
}, [ceSession, ceSessions]);
|
||||
|
||||
// Once a session is active here, the flow renderer owns the surface until
|
||||
// closed — but the sessions panel stays visible so other sessions remain
|
||||
// one click away (switching does not stop the open one).
|
||||
if (ceSession.session) {
|
||||
return (
|
||||
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
|
||||
<div className="ce-view-header">
|
||||
<h2>Compound Engineering</h2>
|
||||
</div>
|
||||
<SessionsPanel
|
||||
sessions={ceSessions.sessions}
|
||||
activeSessionId={ceSession.session.id}
|
||||
disabled={ceSession.busy}
|
||||
onOpen={onOpenSession}
|
||||
onDiscard={onDiscardSession}
|
||||
/>
|
||||
<CeFlow
|
||||
session={ceSession.session}
|
||||
busy={ceSession.busy}
|
||||
error={ceSession.error}
|
||||
onAnswer={ceSession.answer}
|
||||
onResume={ceSession.resume}
|
||||
onClose={onCloseFlow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
|
||||
<div className="ce-view-header">
|
||||
<h2>Compound Engineering</h2>
|
||||
{hasAnything ? (
|
||||
<span className="ce-view-summary" data-testid="ce-summary">
|
||||
{totalArtifacts} artifact{totalArtifacts === 1 ? "" : "s"}
|
||||
{totalErrors > 0 ? ` · ${totalErrors} unreadable` : ""}
|
||||
{isPartial ? " · partial" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{hasAnything ? (
|
||||
<button type="button" className="btn btn-primary ce-view-start" data-testid="ce-start-action-header" onClick={onStart}>
|
||||
Start a stage
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{launcherOpen ? (
|
||||
<StageLauncher stages={stages} disabled={ceSession.busy} onLaunch={onLaunch} />
|
||||
) : null}
|
||||
|
||||
<SessionsPanel
|
||||
sessions={ceSessions.sessions}
|
||||
disabled={ceSession.busy}
|
||||
onOpen={onOpenSession}
|
||||
onDiscard={onDiscardSession}
|
||||
/>
|
||||
|
||||
{ceSessions.error ? (
|
||||
<div className="ce-view-error card" role="alert" data-testid="ce-sessions-error">
|
||||
Failed to load sessions: {ceSessions.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ceSession.error && !ceSession.session ? (
|
||||
<div className="ce-view-error card" role="alert" data-testid="ce-session-error">
|
||||
Failed to start session: {ceSession.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="ce-view-error card" role="alert" data-testid="ce-fetch-error">
|
||||
Failed to load artifacts: {error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && !result ? (
|
||||
<div className="ce-loading" data-testid="ce-loading">
|
||||
Discovering artifacts…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result && !hasAnything ? (
|
||||
<EmptyState onStart={onStart} />
|
||||
) : null}
|
||||
|
||||
{result && hasAnything ? (
|
||||
<div className="ce-groups" data-partial={isPartial ? "true" : "false"}>
|
||||
{result.groups.map((group) => (
|
||||
<StageGroup
|
||||
key={group.stage}
|
||||
group={group}
|
||||
projectId={projectId}
|
||||
onSelect={setSelectedId}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompoundEngineeringView;
|
||||
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { CeFlow } from "../CeFlow.js";
|
||||
import type { CeSession } from "../../session/session-store.js";
|
||||
|
||||
function makeSession(over: Partial<CeSession> & { currentQuestion?: PlanningQuestion | null }): CeSession {
|
||||
return {
|
||||
id: "s1",
|
||||
stage: "brainstorm",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: null,
|
||||
conversationHistory: [],
|
||||
projectId: null,
|
||||
artifactPath: null,
|
||||
error: null,
|
||||
turnIntervalMs: 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: "2026-06-02T00:00:00Z",
|
||||
updatedAt: "2026-06-02T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CeFlow — rich question rendering + submit", () => {
|
||||
it("renders + submits a text question", () => {
|
||||
const onAnswer = vi.fn();
|
||||
const q: PlanningQuestion = { id: "q-text", type: "text", question: "What's the goal?" };
|
||||
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
|
||||
const input = screen.getByTestId("ce-flow-text-input");
|
||||
fireEvent.change(input, { target: { value: "ship faster" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onAnswer).toHaveBeenCalledWith("q-text", "ship faster");
|
||||
});
|
||||
|
||||
it("renders + submits a single_select question", () => {
|
||||
const onAnswer = vi.fn();
|
||||
const q: PlanningQuestion = {
|
||||
id: "q-single",
|
||||
type: "single_select",
|
||||
question: "Pick a direction",
|
||||
options: [
|
||||
{ id: "a", label: "Alpha" },
|
||||
{ id: "b", label: "Beta" },
|
||||
],
|
||||
};
|
||||
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
fireEvent.click(screen.getByText("Beta"));
|
||||
expect(onAnswer).toHaveBeenCalledWith("q-single", "b");
|
||||
});
|
||||
|
||||
it("renders + submits a multi_select question", () => {
|
||||
const onAnswer = vi.fn();
|
||||
const q: PlanningQuestion = {
|
||||
id: "q-multi",
|
||||
type: "multi_select",
|
||||
question: "Which goals?",
|
||||
options: [
|
||||
{ id: "g1", label: "Speed" },
|
||||
{ id: "g2", label: "Quality" },
|
||||
{ id: "g3", label: "Cost" },
|
||||
],
|
||||
};
|
||||
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
const boxes = screen.getByTestId("ce-flow-multi").querySelectorAll("input[type=checkbox]");
|
||||
fireEvent.click(boxes[0]);
|
||||
fireEvent.click(boxes[2]);
|
||||
fireEvent.click(screen.getByTestId("ce-flow-multi-submit"));
|
||||
expect(onAnswer).toHaveBeenCalledWith("q-multi", ["g1", "g3"]);
|
||||
});
|
||||
|
||||
it("renders + submits a confirm question (both branches)", () => {
|
||||
const onAnswer = vi.fn();
|
||||
const q: PlanningQuestion = { id: "q-c", type: "confirm", question: "Write the doc now?" };
|
||||
const { rerender } = render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
fireEvent.click(screen.getByTestId("ce-flow-confirm-yes"));
|
||||
expect(onAnswer).toHaveBeenLastCalledWith("q-c", true);
|
||||
rerender(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
fireEvent.click(screen.getByTestId("ce-flow-confirm-no"));
|
||||
expect(onAnswer).toHaveBeenLastCalledWith("q-c", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CeFlow — degraded fallback (AE1)", () => {
|
||||
it("falls back to a visibly-degraded chat view for an unrenderable interaction, and the stage still completes", () => {
|
||||
const onAnswer = vi.fn();
|
||||
// A type CeFlow cannot express richly — degrades to chat.
|
||||
const rogue = {
|
||||
id: "q-rogue",
|
||||
type: "rank_order",
|
||||
question: "Rank these by priority",
|
||||
options: [{ id: "a", label: "A" }],
|
||||
} as unknown as PlanningQuestion;
|
||||
|
||||
const { rerender } = render(<CeFlow session={makeSession({ currentQuestion: rogue })} onAnswer={onAnswer} />);
|
||||
|
||||
// Visibly marked as degraded.
|
||||
const banner = screen.getByTestId("ce-flow-degraded-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("ce-flow-question")).not.toBeInTheDocument();
|
||||
|
||||
// Stage is still completable: free-text answer submits through the same route.
|
||||
fireEvent.change(screen.getByTestId("ce-flow-degraded-input"), { target: { value: "A then B" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onAnswer).toHaveBeenCalledWith("q-rogue", "A then B");
|
||||
|
||||
// After the answer the orchestrator reaches `complete` → CeFlow shows done.
|
||||
rerender(
|
||||
<CeFlow
|
||||
session={makeSession({ status: "completed", currentQuestion: null, artifactPath: "/repo/docs/brainstorms/x.md" })}
|
||||
onAnswer={onAnswer}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("ce-flow-complete")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ce-flow-artifact-path")).toHaveTextContent("/repo/docs/brainstorms/x.md");
|
||||
});
|
||||
|
||||
it("degrades a select question that arrives with no options", () => {
|
||||
const onAnswer = vi.fn();
|
||||
const q: PlanningQuestion = { id: "q-empty", type: "single_select", question: "Pick", options: [] };
|
||||
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
expect(screen.getByTestId("ce-flow-degraded")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CeFlow — steering (guidance channel)", () => {
|
||||
const q: PlanningQuestion = {
|
||||
id: "q-steer",
|
||||
type: "single_select",
|
||||
question: "Pick a direction",
|
||||
options: [
|
||||
{ id: "a", label: "Alpha" },
|
||||
{ id: "b", label: "Beta" },
|
||||
],
|
||||
};
|
||||
|
||||
it("attaches typed guidance to the chosen answer as {value, comment}", () => {
|
||||
const onAnswer = vi.fn();
|
||||
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
fireEvent.change(screen.getByTestId("ce-flow-guidance-input"), {
|
||||
target: { value: "focus on mobile" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Beta"));
|
||||
expect(onAnswer).toHaveBeenCalledWith("q-steer", { value: "b", comment: "focus on mobile" });
|
||||
});
|
||||
|
||||
it("sends guidance WITHOUT answering as {feedback}", () => {
|
||||
const onAnswer = vi.fn();
|
||||
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
const send = screen.getByTestId("ce-flow-guidance-send");
|
||||
expect(send).toBeDisabled(); // empty guidance can't be sent
|
||||
fireEvent.change(screen.getByTestId("ce-flow-guidance-input"), {
|
||||
target: { value: "skip auth for now" },
|
||||
});
|
||||
fireEvent.click(send);
|
||||
expect(onAnswer).toHaveBeenCalledWith("q-steer", { feedback: "skip auth for now" });
|
||||
});
|
||||
|
||||
it("plain answers stay unwrapped when no guidance is typed", () => {
|
||||
const onAnswer = vi.fn();
|
||||
render(<CeFlow session={makeSession({ currentQuestion: q })} onAnswer={onAnswer} />);
|
||||
fireEvent.click(screen.getByText("Alpha"));
|
||||
expect(onAnswer).toHaveBeenCalledWith("q-steer", "a");
|
||||
});
|
||||
|
||||
it("free-text questions get no extra guidance box (their answer field already takes free text)", () => {
|
||||
const textQ: PlanningQuestion = { id: "q-text", type: "text", question: "Goal?" };
|
||||
render(<CeFlow session={makeSession({ currentQuestion: textQ })} onAnswer={vi.fn()} />);
|
||||
expect(screen.queryByTestId("ce-flow-guidance")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CeFlow — Q&A transcript rendering", () => {
|
||||
const pastQ: PlanningQuestion = {
|
||||
id: "q-past",
|
||||
type: "single_select",
|
||||
question: "Which path?",
|
||||
options: [
|
||||
{ id: "x", label: "The X path" },
|
||||
{ id: "y", label: "The Y path" },
|
||||
],
|
||||
};
|
||||
|
||||
function historyWith(answer: unknown) {
|
||||
return [
|
||||
{ role: "user" as const, text: "kick off", at: "t0" },
|
||||
{ role: "agent" as const, text: JSON.stringify({ question: pastQ }), at: "t1" },
|
||||
{ role: "user" as const, text: JSON.stringify({ answer, questionId: "q-past" }), at: "t2" },
|
||||
];
|
||||
}
|
||||
|
||||
it("renders past questions and answers as bubbles, mapping option ids to labels", () => {
|
||||
render(
|
||||
<CeFlow
|
||||
session={makeSession({ status: "active", conversationHistory: historyWith("y") })}
|
||||
onAnswer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("ce-flow-past-question")).toHaveTextContent("Which path?");
|
||||
// The answer shows the LABEL, not the raw option id.
|
||||
expect(screen.getByTestId("ce-flow-past-answer")).toHaveTextContent("The Y path");
|
||||
// The opening message renders as a plain user bubble.
|
||||
expect(screen.getByText("kick off")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders {value, comment} answers with the steering comment attached", () => {
|
||||
render(
|
||||
<CeFlow
|
||||
session={makeSession({
|
||||
status: "active",
|
||||
conversationHistory: historyWith({ value: "x", comment: "but keep it small" }),
|
||||
})}
|
||||
onAnswer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("ce-flow-past-answer")).toHaveTextContent("The X path");
|
||||
expect(screen.getByTestId("ce-flow-answer-comment")).toHaveTextContent("but keep it small");
|
||||
});
|
||||
|
||||
it("renders {feedback} turns as steering, not answers", () => {
|
||||
render(
|
||||
<CeFlow
|
||||
session={makeSession({
|
||||
status: "active",
|
||||
conversationHistory: historyWith({ feedback: "go another way" }),
|
||||
})}
|
||||
onAnswer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const turn = screen.getByTestId("ce-flow-past-answer");
|
||||
expect(turn).toHaveTextContent("You steered");
|
||||
expect(turn).toHaveTextContent("go another way");
|
||||
});
|
||||
|
||||
it("renders persisted working traces as a collapsible activity block", () => {
|
||||
const history = [
|
||||
{
|
||||
role: "agent" as const,
|
||||
text: JSON.stringify({
|
||||
activity: {
|
||||
turns: [
|
||||
{ kind: "thinking", text: "Scanning the repo…", at: "t" },
|
||||
{ kind: "tool", text: "Read", at: "t", done: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
at: "t1",
|
||||
},
|
||||
];
|
||||
render(<CeFlow session={makeSession({ status: "active", conversationHistory: history })} onAnswer={vi.fn()} />);
|
||||
const details = screen.getByTestId("ce-flow-activity");
|
||||
expect(details).toHaveTextContent("Agent work (2 steps)");
|
||||
expect(screen.getByText("Scanning the repo…")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ce-activity-tool")).toHaveTextContent("Read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CeFlow — lifecycle surfaces", () => {
|
||||
it("shows the working pane while a turn runs", () => {
|
||||
render(<CeFlow session={makeSession({ status: "active", currentQuestion: null })} busy onAnswer={vi.fn()} />);
|
||||
expect(screen.getByTestId("ce-flow-thinking")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("streams live working output (thinking + tools) while the agent works", () => {
|
||||
const session = makeSession({
|
||||
status: "active",
|
||||
currentQuestion: null,
|
||||
liveActivity: [
|
||||
{ kind: "thinking", text: "Considering options…", at: "t" },
|
||||
{ kind: "tool", text: "Grep", at: "t", done: false },
|
||||
],
|
||||
});
|
||||
render(<CeFlow session={session} onAnswer={vi.fn()} />);
|
||||
const pane = screen.getByTestId("ce-flow-live-activity");
|
||||
expect(pane).toHaveTextContent("Considering options…");
|
||||
expect(screen.getByTestId("ce-activity-tool")).toHaveTextContent("Grep");
|
||||
});
|
||||
|
||||
it("offers resume on an interrupted session", () => {
|
||||
const onResume = vi.fn();
|
||||
render(
|
||||
<CeFlow
|
||||
session={makeSession({ status: "interrupted", currentQuestion: null, error: "stalled" })}
|
||||
onAnswer={vi.fn()}
|
||||
onResume={onResume}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("ce-flow-resume"));
|
||||
expect(onResume).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
|
||||
// Mock the network layer so the view renders from seeded discovery results.
|
||||
const listArtifacts = vi.fn(async (): Promise<DiscoveryResult> => {
|
||||
throw new Error("listArtifacts mock not configured");
|
||||
});
|
||||
const listSessions = vi.fn(async (): Promise<CeSession[]> => []);
|
||||
const deleteSession = vi.fn(async (_id: string, _projectId?: string): Promise<void> => undefined);
|
||||
const getSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => {
|
||||
throw new Error("getSession mock not configured");
|
||||
});
|
||||
vi.mock("../hooks/api.js", () => ({
|
||||
listArtifacts: () => listArtifacts(),
|
||||
getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
|
||||
listSessions: () => listSessions(),
|
||||
deleteSession: (id: string, projectId?: string) => deleteSession(id, projectId),
|
||||
getSession: (id: string, projectId?: string) => getSession(id, projectId),
|
||||
startSession: vi.fn(),
|
||||
answerSession: vi.fn(),
|
||||
resumeSession: vi.fn(),
|
||||
}));
|
||||
|
||||
import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
|
||||
import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
|
||||
import type { CeSession } from "../../session/session-store.js";
|
||||
|
||||
function mkCeSession(over: Partial<CeSession>): CeSession {
|
||||
return {
|
||||
id: "sess-1",
|
||||
stage: "brainstorm",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: null,
|
||||
conversationHistory: [],
|
||||
projectId: "p1",
|
||||
artifactPath: null,
|
||||
error: null,
|
||||
turnIntervalMs: 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: "2026-06-03T00:00:00Z",
|
||||
updatedAt: "2026-06-03T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [
|
||||
{ stage: "strategy", label: "Strategy" },
|
||||
{ stage: "ideation", label: "Ideation" },
|
||||
{ stage: "brainstorm", label: "Brainstorms" },
|
||||
{ stage: "plan", label: "Plans" },
|
||||
{ stage: "solution", label: "Solutions" },
|
||||
{ stage: "concepts", label: "Concepts" },
|
||||
];
|
||||
|
||||
function makeResult(overrides: Partial<Record<DiscoveryResult["groups"][number]["stage"], DiscoveryResult["groups"][number]["entries"]>>): DiscoveryResult {
|
||||
const groups = ALL_STAGES.map(({ stage, label }) => ({
|
||||
stage,
|
||||
label,
|
||||
present: Boolean(overrides[stage]?.length),
|
||||
entries: overrides[stage] ?? [],
|
||||
}));
|
||||
let totalArtifacts = 0;
|
||||
let totalErrors = 0;
|
||||
for (const g of groups) {
|
||||
for (const e of g.entries) {
|
||||
if (e.kind === "artifact") totalArtifacts += 1;
|
||||
else totalErrors += 1;
|
||||
}
|
||||
}
|
||||
return { groups, totalArtifacts, totalErrors };
|
||||
}
|
||||
|
||||
describe("CompoundEngineeringView", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearArtifactsCache();
|
||||
listArtifacts.mockReset();
|
||||
listSessions.mockReset();
|
||||
listSessions.mockResolvedValue([]);
|
||||
deleteSession.mockReset();
|
||||
deleteSession.mockResolvedValue(undefined);
|
||||
getSession.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders the empty / first-run state with an orientation + start action", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-empty-state");
|
||||
expect(screen.getByText(/Start your compounding pipeline/i)).toBeInTheDocument();
|
||||
const start = screen.getByTestId("ce-start-action");
|
||||
expect(start).toBeInTheDocument();
|
||||
// Start affordance is wired to a placeholder (toast); clicking does not throw.
|
||||
fireEvent.click(start);
|
||||
});
|
||||
|
||||
it("renders the partial-discovery state (some categories present, others empty)", async () => {
|
||||
listArtifacts.mockResolvedValue(
|
||||
makeResult({
|
||||
strategy: [
|
||||
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
|
||||
],
|
||||
plan: [
|
||||
{ kind: "artifact", id: "plan:docs/plans/p.md", stage: "plan", path: "docs/plans/p.md", name: "p.md", size: 5, updatedAt: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-summary");
|
||||
// Partial flag surfaces in the summary and on the groups container.
|
||||
expect(screen.getByTestId("ce-summary").textContent).toMatch(/partial/i);
|
||||
const groups = screen.getByTestId("ce-summary").closest(".ce-view")!.querySelector(".ce-groups");
|
||||
expect(groups?.getAttribute("data-partial")).toBe("true");
|
||||
// Populated groups render artifacts; empty ones render an empty hint.
|
||||
expect(screen.getAllByTestId("ce-artifact")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("ce-group-empty").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders an error entry for an unreadable artifact (not a crash or silent drop)", async () => {
|
||||
listArtifacts.mockResolvedValue(
|
||||
makeResult({
|
||||
plan: [
|
||||
{ kind: "error", id: "plan:docs/plans/bad.md", stage: "plan", path: "docs/plans/bad.md", name: "bad.md", error: "EIO: simulated read failure" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
const errorEntry = await screen.findByTestId("ce-artifact-error");
|
||||
expect(errorEntry).toBeInTheDocument();
|
||||
expect(errorEntry.textContent).toMatch(/simulated read failure/i);
|
||||
// Surfaced as an unreadable count in the summary.
|
||||
expect(screen.getByTestId("ce-summary").textContent).toMatch(/unreadable/i);
|
||||
});
|
||||
|
||||
it("lists multiple sessions with status badges; terminal sessions get a discard affordance", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([
|
||||
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
|
||||
mkCeSession({ id: "b", stage: "plan", status: "active" }),
|
||||
mkCeSession({ id: "c", stage: "work", status: "completed" }),
|
||||
]);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-sessions");
|
||||
const rows = screen.getAllByTestId("ce-session-row");
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.getAttribute("data-status"))).toEqual([
|
||||
"awaiting_input",
|
||||
"active",
|
||||
"completed",
|
||||
]);
|
||||
// Awaiting sessions advertise that they need the user.
|
||||
expect(rows[0].textContent).toMatch(/needs your input/i);
|
||||
// Only the terminal session can be discarded.
|
||||
expect(screen.getAllByTestId("ce-session-discard")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens an existing session from the list into the flow (and back without losing it)", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([
|
||||
mkCeSession({ id: "a", stage: "brainstorm", status: "awaiting_input" }),
|
||||
mkCeSession({ id: "b", stage: "plan", status: "active" }),
|
||||
]);
|
||||
getSession.mockResolvedValue(
|
||||
mkCeSession({
|
||||
id: "a",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: { id: "q1", type: "text", question: "Topic?" },
|
||||
}),
|
||||
);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-sessions");
|
||||
fireEvent.click(screen.getAllByTestId("ce-session-open")[0]);
|
||||
|
||||
// The flow surface opens on the adopted session…
|
||||
const flow = await screen.findByTestId("ce-flow");
|
||||
expect(flow.getAttribute("data-stage")).toBe("brainstorm");
|
||||
expect(getSession).toHaveBeenCalledWith("a", "p1");
|
||||
// …while the sessions panel stays visible for switching, with the open
|
||||
// session marked active.
|
||||
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
|
||||
const rows = screen.getAllByTestId("ce-session-row");
|
||||
expect(rows[0].className).toMatch(/is-active/);
|
||||
|
||||
// Closing returns to the overview; the session list survives (the session
|
||||
// itself keeps running server-side — close does not delete anything).
|
||||
fireEvent.click(screen.getByText("Close"));
|
||||
await screen.findByTestId("ce-empty-state");
|
||||
expect(screen.getByTestId("ce-sessions")).toBeInTheDocument();
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a terminal session via the list", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
listSessions.mockResolvedValue([mkCeSession({ id: "done", stage: "plan", status: "completed" })]);
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
|
||||
|
||||
await screen.findByTestId("ce-sessions");
|
||||
listSessions.mockResolvedValue([]);
|
||||
fireEvent.click(screen.getByTestId("ce-session-discard"));
|
||||
|
||||
await waitFor(() => expect(deleteSession).toHaveBeenCalledWith("done", "p1"));
|
||||
await waitFor(() => expect(screen.queryByTestId("ce-sessions")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("does not fetch when the viewport-gated flag is disabled", async () => {
|
||||
listArtifacts.mockResolvedValue(makeResult({}));
|
||||
render(<CompoundEngineeringView projectId="p1" enabledOverride={false} />);
|
||||
// Give effects a tick.
|
||||
await waitFor(() => expect(screen.getByTestId("compound-engineering-view")).toBeInTheDocument());
|
||||
expect(listArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
import type { CeSession } from "../../session/session-store.js";
|
||||
import { listStages } from "../../session/stage-registry.js";
|
||||
|
||||
// Mock the whole api module: artifacts (so the view renders empty) + session.
|
||||
const startSession = vi.fn<(stage: string, opts?: unknown) => Promise<CeSession>>();
|
||||
vi.mock("../hooks/api.js", () => ({
|
||||
listArtifacts: async (): Promise<DiscoveryResult> => ({
|
||||
groups: [],
|
||||
totalArtifacts: 0,
|
||||
totalErrors: 0,
|
||||
}),
|
||||
getArtifactPreviewUrl: (id: string) => `/preview/${id}`,
|
||||
startSession: (stage: string, opts?: unknown) => startSession(stage, opts),
|
||||
answerSession: vi.fn(),
|
||||
resumeSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
import { CompoundEngineeringView } from "../CompoundEngineeringView.js";
|
||||
import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js";
|
||||
|
||||
afterEach(() => {
|
||||
__test_clearArtifactsCache();
|
||||
startSession.mockReset();
|
||||
});
|
||||
|
||||
function mkSession(over: Partial<CeSession>): CeSession {
|
||||
return {
|
||||
id: "s1",
|
||||
stage: "brainstorm",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: { id: "q1", type: "text", question: "What's the topic?" },
|
||||
conversationHistory: [],
|
||||
projectId: null,
|
||||
artifactPath: null,
|
||||
error: null,
|
||||
turnIntervalMs: 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: "t",
|
||||
updatedAt: "t",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Stage launcher (R4)", () => {
|
||||
it("lists exactly the registered stages", async () => {
|
||||
render(<CompoundEngineeringView enabledOverride projectId="p1" />);
|
||||
// Empty-state start affordance opens the launcher.
|
||||
await waitFor(() => screen.getByTestId("ce-empty-state"));
|
||||
fireEvent.click(screen.getByTestId("ce-start-action"));
|
||||
|
||||
const tiles = await screen.findAllByTestId("ce-launcher-stage");
|
||||
const expected = listStages();
|
||||
expect(tiles).toHaveLength(expected.length);
|
||||
const renderedStages = tiles.map((t) => t.getAttribute("data-stage")).sort();
|
||||
expect(renderedStages).toEqual(expected.map((s) => s.stageId).sort());
|
||||
// And the labels match the registry.
|
||||
for (const stage of expected) {
|
||||
expect(screen.getByText(stage.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("launching a stage starts its session and renders CeFlow", async () => {
|
||||
startSession.mockResolvedValue(mkSession({ stage: "plan" }));
|
||||
render(<CompoundEngineeringView enabledOverride projectId="p1" />);
|
||||
await waitFor(() => screen.getByTestId("ce-empty-state"));
|
||||
fireEvent.click(screen.getByTestId("ce-start-action"));
|
||||
|
||||
const planTile = (await screen.findAllByTestId("ce-launcher-stage")).find(
|
||||
(t) => t.getAttribute("data-stage") === "plan",
|
||||
)!;
|
||||
await act(async () => {
|
||||
fireEvent.click(planTile);
|
||||
});
|
||||
|
||||
expect(startSession).toHaveBeenCalledWith("plan", expect.objectContaining({ projectId: "p1" }));
|
||||
expect(await screen.findByTestId("ce-flow")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ce-flow-text-input")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The renderable set of `CeFlow` (R8/AE1 boundary).
|
||||
*
|
||||
* `CeFlow` renders four interaction types richly: `text` (free-text input),
|
||||
* `single_select`, `multi_select`, and `confirm`. Any other interaction — an
|
||||
* unknown future question type, or a select-type question that arrives without
|
||||
* the options it needs to render choices — is NOT expressible by the rich
|
||||
* renderer and must degrade to the visibly-marked chat fallback.
|
||||
*
|
||||
* This module is the single source of truth for that boundary so the renderer
|
||||
* and the skill-interaction audit agree on what "renderable richly" means.
|
||||
*/
|
||||
import type { PlanningQuestion, PlanningQuestionType } from "@fusion/core";
|
||||
|
||||
/** The interaction types CeFlow renders with dedicated rich controls. */
|
||||
export const RICH_INTERACTION_TYPES: readonly PlanningQuestionType[] = [
|
||||
"text",
|
||||
"single_select",
|
||||
"multi_select",
|
||||
"confirm",
|
||||
] as const;
|
||||
|
||||
export function isRichInteractionType(type: string): type is PlanningQuestionType {
|
||||
return (RICH_INTERACTION_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether CeFlow can render this concrete question with rich controls. A
|
||||
* select-type question with no usable options can't present choices, so it
|
||||
* degrades to chat even though its `type` is in the rich set.
|
||||
*/
|
||||
export function canRenderRichly(question: Pick<PlanningQuestion, "type" | "options">): boolean {
|
||||
if (!isRichInteractionType(question.type)) return false;
|
||||
if (question.type === "single_select" || question.type === "multi_select") {
|
||||
return Array.isArray(question.options) && question.options.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { useCeSession, type CeSessionTransport, type CeSessionSubscribe } from "../useCeSession.js";
|
||||
import type { CeSession } from "../../../session/session-store.js";
|
||||
|
||||
function mkSession(over: Partial<CeSession>): CeSession {
|
||||
return {
|
||||
id: "s1",
|
||||
stage: "brainstorm",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: null,
|
||||
conversationHistory: [],
|
||||
projectId: null,
|
||||
artifactPath: null,
|
||||
error: null,
|
||||
turnIntervalMs: 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: "t",
|
||||
updatedAt: "t",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const Q: PlanningQuestion = { id: "q1", type: "text", question: "go?" };
|
||||
|
||||
function Harness({ transport }: { transport: CeSessionTransport }) {
|
||||
const s = useCeSession({ transport, pollIntervalMs: 5 });
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="status">{s.session?.status ?? "none"}</span>
|
||||
<span data-testid="busy">{s.busy ? "busy" : "idle"}</span>
|
||||
<span data-testid="err">{s.error ?? ""}</span>
|
||||
<button onClick={() => void s.start("brainstorm", { projectId: "p1" })}>start</button>
|
||||
<button onClick={() => void s.open("s2", { projectId: "p2" })}>open</button>
|
||||
<button onClick={() => void s.answer("q1", "yes")}>answer</button>
|
||||
<button onClick={() => void s.resume()}>resume</button>
|
||||
<button onClick={() => s.reset()}>reset</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useCeSession lifecycle", () => {
|
||||
afterEach(() => {
|
||||
// Ensure faked timers never leak into the next test.
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("start → awaiting_input → answer → completed", async () => {
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(async () => mkSession({ status: "awaiting_input", currentQuestion: Q })),
|
||||
answer: vi.fn(async () => mkSession({ status: "completed", currentQuestion: null, artifactPath: "/a.md" })),
|
||||
resume: vi.fn(async () => mkSession({})),
|
||||
get: vi.fn(async () => mkSession({})),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("start").click();
|
||||
});
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("answer").click();
|
||||
});
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("completed");
|
||||
// projectId from start() must thread through to answer() (FN: per-request
|
||||
// store resolution selects the session's owning store/live handle).
|
||||
expect(transport.answer).toHaveBeenCalledWith("s1", "q1", "yes", "p1");
|
||||
});
|
||||
|
||||
it("threads the start projectId through resume and poll", async () => {
|
||||
vi.useFakeTimers();
|
||||
const get = vi.fn(async () => mkSession({ status: "active" }));
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
|
||||
answer: vi.fn(),
|
||||
resume: vi.fn(async () => mkSession({ status: "active" })),
|
||||
get,
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {
|
||||
screen.getByText("start").click();
|
||||
});
|
||||
await act(async () => {
|
||||
screen.getByText("resume").click();
|
||||
});
|
||||
expect(transport.resume).toHaveBeenCalledWith("s1", "p1");
|
||||
// The poll (active status) must also carry the projectId. Harness uses a 5ms
|
||||
// interval; advance fake time deterministically past one tick.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(get).toHaveBeenCalledWith("s1", "p1");
|
||||
});
|
||||
|
||||
it("polls while active and stops once settled", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const get = vi.fn(async () => {
|
||||
calls += 1;
|
||||
return calls >= 2 ? mkSession({ status: "awaiting_input", currentQuestion: Q }) : mkSession({ status: "active" });
|
||||
});
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(async () => mkSession({ status: "active", currentQuestion: null })),
|
||||
answer: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
get,
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {
|
||||
screen.getByText("start").click();
|
||||
});
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("active");
|
||||
|
||||
// Advance fake time so the poll interval fires and converges to awaiting_input.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
});
|
||||
expect(get).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
|
||||
});
|
||||
|
||||
it("open() adopts an existing session and threads ITS projectId to later calls", async () => {
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(),
|
||||
answer: vi.fn(async () => mkSession({ id: "s2", status: "completed" })),
|
||||
resume: vi.fn(),
|
||||
get: vi.fn(async () => mkSession({ id: "s2", status: "awaiting_input", currentQuestion: Q })),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("open").click();
|
||||
});
|
||||
expect(transport.get).toHaveBeenCalledWith("s2", "p2");
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
|
||||
|
||||
// Subsequent answer goes to the opened session with the opened projectId.
|
||||
await act(async () => {
|
||||
screen.getByText("answer").click();
|
||||
});
|
||||
expect(transport.answer).toHaveBeenCalledWith("s2", "q1", "yes", "p2");
|
||||
});
|
||||
|
||||
it("surfaces a start error", async () => {
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
answer: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {
|
||||
screen.getByText("start").click();
|
||||
});
|
||||
expect(screen.getByTestId("err")).toHaveTextContent("boom");
|
||||
});
|
||||
|
||||
it("resume transitions an interrupted session", async () => {
|
||||
const transport: CeSessionTransport = {
|
||||
start: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
|
||||
answer: vi.fn(),
|
||||
resume: vi.fn(async () => mkSession({ status: "awaiting_input", currentQuestion: Q })),
|
||||
get: vi.fn(async () => mkSession({ status: "interrupted", currentQuestion: Q })),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {
|
||||
screen.getByText("start").click();
|
||||
});
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("interrupted");
|
||||
await act(async () => {
|
||||
screen.getByText("resume").click();
|
||||
});
|
||||
expect(transport.resume).toHaveBeenCalledWith("s1", "p1");
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("awaiting_input");
|
||||
});
|
||||
|
||||
it("refetches when a session event is pushed over the subscribe seam", async () => {
|
||||
let fire: (() => void) | undefined;
|
||||
const subscribe: CeSessionSubscribe = (_sessionId, _projectId, onSessionEvent) => {
|
||||
fire = onSessionEvent;
|
||||
return () => {
|
||||
fire = undefined;
|
||||
};
|
||||
};
|
||||
const get = vi.fn(async () => mkSession({ status: "completed", currentQuestion: null, artifactPath: "/a.md" }));
|
||||
const transport: CeSessionTransport = {
|
||||
// start returns an active (mid-turn) session; without a push or poll it stays active.
|
||||
start: vi.fn(async () => mkSession({ status: "active", currentQuestion: null })),
|
||||
answer: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
get,
|
||||
};
|
||||
|
||||
function PushHarness() {
|
||||
const s = useCeSession({ transport, subscribe, pollIntervalMs: 100000 });
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="status">{s.session?.status ?? "none"}</span>
|
||||
<button onClick={() => void s.start("brainstorm", { projectId: "p1" })}>start</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<PushHarness />);
|
||||
await act(async () => {
|
||||
screen.getByText("start").click();
|
||||
});
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("active");
|
||||
|
||||
// A pushed event triggers an immediate refetch (no poll interval elapsed).
|
||||
await act(async () => {
|
||||
fire?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(get).toHaveBeenCalledWith("s1", "p1");
|
||||
expect(screen.getByTestId("status")).toHaveTextContent("completed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { useCeSessions, type CeSessionsTransport, type CeSessionsSubscribe } from "../useCeSessions.js";
|
||||
import type { CeSession } from "../../../session/session-store.js";
|
||||
|
||||
function mkSession(over: Partial<CeSession>): CeSession {
|
||||
return {
|
||||
id: "s1",
|
||||
stage: "brainstorm",
|
||||
status: "awaiting_input",
|
||||
currentQuestion: null,
|
||||
conversationHistory: [],
|
||||
projectId: null,
|
||||
artifactPath: null,
|
||||
error: null,
|
||||
turnIntervalMs: 1000,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: "t",
|
||||
updatedAt: "t",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({
|
||||
transport,
|
||||
subscribe,
|
||||
}: {
|
||||
transport: CeSessionsTransport;
|
||||
subscribe?: CeSessionsSubscribe;
|
||||
}) {
|
||||
const s = useCeSessions({
|
||||
projectId: "p1",
|
||||
transport,
|
||||
pollIntervalMs: 5,
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="count">{s.sessions.length}</span>
|
||||
<span data-testid="ids">{s.sessions.map((x) => x.id).join(",")}</span>
|
||||
<span data-testid="err">{s.error ?? ""}</span>
|
||||
<button onClick={() => void s.refresh()}>refresh</button>
|
||||
<button onClick={() => void s.remove("s1")}>remove</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useCeSessions (multi-session list)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("lists all sessions on mount with the projectId", async () => {
|
||||
const list = vi.fn(async () => [mkSession({ id: "s1" }), mkSession({ id: "s2", stage: "plan" })]);
|
||||
const transport: CeSessionsTransport = { list, remove: vi.fn() };
|
||||
render(<Harness transport={transport} />);
|
||||
|
||||
await act(async () => {});
|
||||
expect(list).toHaveBeenCalledWith("p1");
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("ids")).toHaveTextContent("s1,s2");
|
||||
});
|
||||
|
||||
it("remove() deletes via the transport then refreshes the list", async () => {
|
||||
let removed = false;
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => (removed ? [mkSession({ id: "s2" })] : [mkSession({ id: "s1" }), mkSession({ id: "s2" })])),
|
||||
remove: vi.fn(async () => {
|
||||
removed = true;
|
||||
}),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("2");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("remove").click();
|
||||
});
|
||||
expect(transport.remove).toHaveBeenCalledWith("s1", "p1");
|
||||
expect(screen.getByTestId("ids")).toHaveTextContent("s2");
|
||||
});
|
||||
|
||||
it("refreshes when a push event fires", async () => {
|
||||
let fire: (() => void) | undefined;
|
||||
const subscribe: CeSessionsSubscribe = (onAnyEvent) => {
|
||||
fire = onAnyEvent;
|
||||
return () => {
|
||||
fire = undefined;
|
||||
};
|
||||
};
|
||||
let n = 1;
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => Array.from({ length: n }, (_, i) => mkSession({ id: `s${i + 1}` }))),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
render(<Harness transport={transport} subscribe={subscribe} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("1");
|
||||
|
||||
n = 2;
|
||||
await act(async () => {
|
||||
fire?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("polls while any session is mid-turn and stops when all settle", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => {
|
||||
calls += 1;
|
||||
return [mkSession({ id: "s1", status: calls >= 3 ? "completed" : "active" })];
|
||||
}),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("1");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
const settledCalls = calls;
|
||||
expect(calls).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// All settled → polling stops (no further list calls as time advances).
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
expect(calls).toBe(settledCalls);
|
||||
});
|
||||
|
||||
it("surfaces a list error without crashing", async () => {
|
||||
const transport: CeSessionsTransport = {
|
||||
list: vi.fn(async () => {
|
||||
throw new Error("kaput");
|
||||
}),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
render(<Harness transport={transport} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId("err")).toHaveTextContent("kaput");
|
||||
expect(screen.getByTestId("count")).toHaveTextContent("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
import type { CeSession } from "../../session/session-store.js";
|
||||
|
||||
const BASE = "/api/plugins/fusion-plugin-compound-engineering";
|
||||
|
||||
function qp(params: Record<string, string | undefined>): string {
|
||||
const entries = Object.entries(params).filter(
|
||||
([, v]) => typeof v === "string" && v.length > 0,
|
||||
) as Array<[string, string]>;
|
||||
if (entries.length === 0) return "";
|
||||
return `?${entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit, responseType: "json" | "text" = "json"): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, init);
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const data = (await response.json()) as { error?: string };
|
||||
if (data.error) message = data.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (responseType === "text") return (await response.text()) as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function listArtifacts(projectId?: string): Promise<DiscoveryResult> {
|
||||
return request<DiscoveryResult>(`/artifacts${qp({ projectId })}`);
|
||||
}
|
||||
|
||||
export async function getArtifact(
|
||||
id: string,
|
||||
projectId?: string,
|
||||
): Promise<{ content: string; name: string }> {
|
||||
const data = await request<{ artifact: { name: string }; content: string }>(
|
||||
`/artifacts/${encodeURIComponent(id)}${qp({ projectId })}`,
|
||||
);
|
||||
return { content: data.content, name: data.artifact.name };
|
||||
}
|
||||
|
||||
export function getArtifactPreviewUrl(id: string, projectId?: string): string {
|
||||
return `${BASE}/artifacts/${encodeURIComponent(id)}/preview.html${qp({ projectId })}`;
|
||||
}
|
||||
|
||||
// --- Interactive CE session routes (polling transport, U5/U6) ---------------
|
||||
|
||||
/** Start a stage session. Returns the freshly-created session (after one turn). */
|
||||
export async function startSession(
|
||||
stage: string,
|
||||
opts: { message?: string; projectId?: string } = {},
|
||||
): Promise<CeSession> {
|
||||
const data = await request<{ session: CeSession }>(`/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ stage, message: opts.message ?? "", projectId: opts.projectId }),
|
||||
});
|
||||
return data.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an answer to the awaiting question and advance the session.
|
||||
*
|
||||
* `projectId` MUST match the one used at `startSession` — it selects the
|
||||
* project-scoped store that holds the session row and its live in-process
|
||||
* handle. Omitting it (or sending a different one) resolves a different store
|
||||
* and the session won't be found.
|
||||
*/
|
||||
export async function answerSession(
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
response: unknown,
|
||||
projectId?: string,
|
||||
): Promise<CeSession> {
|
||||
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/answer`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ questionId, response, projectId }),
|
||||
});
|
||||
return data.session;
|
||||
}
|
||||
|
||||
/** Resume an interrupted/error/awaiting session. `projectId` must match start (see answerSession). */
|
||||
export async function resumeSession(sessionId: string, projectId?: string): Promise<CeSession> {
|
||||
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/resume`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ projectId }),
|
||||
});
|
||||
return data.session;
|
||||
}
|
||||
|
||||
/** List CE sessions, newest-activity first (optionally filtered by status/stage). */
|
||||
export async function listSessions(
|
||||
opts: { projectId?: string; status?: string; stage?: string } = {},
|
||||
): Promise<CeSession[]> {
|
||||
const data = await request<{ sessions: CeSession[] }>(
|
||||
`/sessions${qp({ projectId: opts.projectId, status: opts.status, stage: opts.stage })}`,
|
||||
);
|
||||
return data.sessions;
|
||||
}
|
||||
|
||||
/** Discard a session (disposes any live handle, deletes the row). `projectId` must match start. */
|
||||
export async function deleteSession(sessionId: string, projectId?: string): Promise<void> {
|
||||
await request<{ deleted: boolean }>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Poll the current persisted session state. `projectId` must match start (see answerSession). */
|
||||
export async function getSession(sessionId: string, projectId?: string): Promise<CeSession> {
|
||||
const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}${qp({ projectId })}`);
|
||||
return data.session;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listArtifacts } from "./api.js";
|
||||
import type { DiscoveryResult } from "../../artifacts/discovery.js";
|
||||
|
||||
/**
|
||||
* Short-TTL discovery cache keyed by `projectId` (the discovery scan has no
|
||||
* per-row id, so the project is the cache unit). Mirrors the dashboard
|
||||
* performance kit (docs/performance/dashboard-load.md): a 30s TTL balances
|
||||
* freshness against repeated viewport-driven refetches.
|
||||
*/
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
const discoveryCache = new Map<string, { value: DiscoveryResult; expiresAt: number }>();
|
||||
|
||||
function cacheKey(projectId?: string): string {
|
||||
return `discovery:${projectId ?? "__default__"}`;
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export function __test_clearArtifactsCache(): void {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
export interface UseArtifactsResult {
|
||||
result?: DiscoveryResult;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover CE artifacts for the active project. The fetch is viewport-gated via
|
||||
* the `enabled` flag — when the CE view is offscreen/disabled it returns stable
|
||||
* empty state and triggers no network request (performance kit). Results are
|
||||
* served from a short-TTL cache to collapse repeated mounts.
|
||||
*/
|
||||
export function useArtifacts({
|
||||
projectId,
|
||||
enabled = true,
|
||||
}: {
|
||||
projectId?: string;
|
||||
enabled?: boolean;
|
||||
}): UseArtifactsResult {
|
||||
const [result, setResult] = useState<DiscoveryResult | undefined>(() => {
|
||||
const cached = discoveryCache.get(cacheKey(projectId));
|
||||
return cached && cached.expiresAt > Date.now() ? cached.value : undefined;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const key = cacheKey(projectId);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
setResult(cached.value);
|
||||
setError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
listArtifacts(projectId)
|
||||
.then((value) => {
|
||||
if (controller.signal.aborted) return;
|
||||
discoveryCache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
setResult(value);
|
||||
setError(undefined);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load artifacts");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [projectId, enabled]);
|
||||
|
||||
return useMemo(() => ({ result, loading, error }), [result, loading, error]);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { CeSession, CeSessionStatus } from "../../session/session-store.js";
|
||||
import {
|
||||
answerSession as answerSessionApi,
|
||||
getSession as getSessionApi,
|
||||
resumeSession as resumeSessionApi,
|
||||
startSession as startSessionApi,
|
||||
} from "./api.js";
|
||||
|
||||
/**
|
||||
* Injectable transport so component tests can drive the lifecycle without a
|
||||
* network. Defaults to the real polling routes.
|
||||
*/
|
||||
export interface CeSessionTransport {
|
||||
start(stage: string, opts: { message?: string; projectId?: string }): Promise<CeSession>;
|
||||
answer(sessionId: string, questionId: string, response: unknown, projectId?: string): Promise<CeSession>;
|
||||
resume(sessionId: string, projectId?: string): Promise<CeSession>;
|
||||
get(sessionId: string, projectId?: string): Promise<CeSession>;
|
||||
}
|
||||
|
||||
const defaultTransport: CeSessionTransport = {
|
||||
start: (stage, opts) => startSessionApi(stage, opts),
|
||||
answer: (id, qid, response, projectId) => answerSessionApi(id, qid, response, projectId),
|
||||
resume: (id, projectId) => resumeSessionApi(id, projectId),
|
||||
get: (id, projectId) => getSessionApi(id, projectId),
|
||||
};
|
||||
|
||||
/** Statuses where no further polling is useful (settled or waiting on the user). */
|
||||
const SETTLED: ReadonlySet<CeSessionStatus> = new Set([
|
||||
"awaiting_input",
|
||||
"completed",
|
||||
"error",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Subscribe to live session push events. Called with the current sessionId +
|
||||
* projectId and a callback to invoke when this session changes; returns an
|
||||
* unsubscribe fn. Default is a no-op (polling-only) so the hook stays pure and
|
||||
* node/jsdom tests don't touch the browser SSE bus; the dashboard view injects a
|
||||
* real adapter built on the shared `/api/events` stream.
|
||||
*/
|
||||
export type CeSessionSubscribe = (
|
||||
sessionId: string,
|
||||
projectId: string | undefined,
|
||||
onSessionEvent: () => void,
|
||||
) => () => void;
|
||||
|
||||
const noopSubscribe: CeSessionSubscribe = () => () => {};
|
||||
|
||||
export interface UseCeSessionOptions {
|
||||
/** Poll interval (ms) while a turn is running (status active/launching). */
|
||||
pollIntervalMs?: number;
|
||||
transport?: CeSessionTransport;
|
||||
/** Live push subscription (default no-op = polling only). */
|
||||
subscribe?: CeSessionSubscribe;
|
||||
}
|
||||
|
||||
export interface UseCeSessionResult {
|
||||
session?: CeSession;
|
||||
/** True while a request (start/answer/resume) is in flight. */
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
start(stage: string, opts?: { message?: string; projectId?: string }): Promise<void>;
|
||||
/** Adopt an EXISTING session (e.g. from the session list) as the active one. */
|
||||
open(sessionId: string, opts?: { projectId?: string }): Promise<void>;
|
||||
answer(questionId: string, response: unknown): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a single CE stage session through its lifecycle: start → watch the
|
||||
* live working output while the turn runs → render question → submit answer →
|
||||
* continue → completed/error; resume an interrupted/error session.
|
||||
*
|
||||
* Turn execution is DETACHED server-side: start/answer/resume return as soon
|
||||
* as the session row reflects the request (status `active`), and the client
|
||||
* converges via push (subscribe) with polling as the fallback. While a turn is
|
||||
* mid-flight, GET attaches `liveActivity` — the agent's streaming working
|
||||
* output — so each refetch updates the live pane.
|
||||
*/
|
||||
export function useCeSession(options: UseCeSessionOptions = {}): UseCeSessionResult {
|
||||
const transport = options.transport ?? defaultTransport;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 1500;
|
||||
const subscribe = options.subscribe ?? noopSubscribe;
|
||||
|
||||
const [session, setSession] = useState<CeSession | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
// Keep the live id for the polling effect without re-subscribing on every
|
||||
// session field change.
|
||||
const sessionIdRef = useRef<string | undefined>(undefined);
|
||||
// The projectId used at start() selects the project-scoped store that owns the
|
||||
// session row + live handle. Every later call (answer/resume/poll) MUST reuse
|
||||
// it, or the request resolves a different store and the session isn't found.
|
||||
const projectIdRef = useRef<string | undefined>(undefined);
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const apply = useCallback((next: CeSession) => {
|
||||
sessionIdRef.current = next.id;
|
||||
if (mounted.current) setSession(next);
|
||||
}, []);
|
||||
|
||||
const run = useCallback(
|
||||
async (op: () => Promise<CeSession>) => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const next = await op();
|
||||
apply(next);
|
||||
} catch (err) {
|
||||
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
},
|
||||
[apply],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
(stage: string, opts: { message?: string; projectId?: string } = {}) => {
|
||||
projectIdRef.current = opts.projectId;
|
||||
return run(() => transport.start(stage, opts));
|
||||
},
|
||||
[run, transport],
|
||||
);
|
||||
|
||||
// Adopt an existing session (started earlier, possibly in another view visit)
|
||||
// as this hook's active session. Like start(), it pins the projectId used for
|
||||
// every subsequent call — the session row lives in that project's store.
|
||||
const open = useCallback(
|
||||
(sessionId: string, opts: { projectId?: string } = {}) => {
|
||||
projectIdRef.current = opts.projectId;
|
||||
sessionIdRef.current = sessionId;
|
||||
return run(() => transport.get(sessionId, opts.projectId));
|
||||
},
|
||||
[run, transport],
|
||||
);
|
||||
|
||||
const answer = useCallback(
|
||||
(questionId: string, response: unknown) => {
|
||||
const id = sessionIdRef.current;
|
||||
if (!id) return Promise.resolve();
|
||||
return run(() => transport.answer(id, questionId, response, projectIdRef.current));
|
||||
},
|
||||
[run, transport],
|
||||
);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
const id = sessionIdRef.current;
|
||||
if (!id) return Promise.resolve();
|
||||
return run(() => transport.resume(id, projectIdRef.current));
|
||||
}, [run, transport]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
sessionIdRef.current = undefined;
|
||||
projectIdRef.current = undefined;
|
||||
setSession(undefined);
|
||||
setError(undefined);
|
||||
setBusy(false);
|
||||
}, []);
|
||||
|
||||
// Live push: when the host forwards a session event over SSE, refetch the
|
||||
// persisted state immediately (lower latency than the poll interval). Polling
|
||||
// below remains as a fallback when push isn't wired or an event is missed.
|
||||
const sessionId = session?.id;
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
return subscribe(sessionId, projectIdRef.current, () => {
|
||||
transport
|
||||
.get(sessionId, projectIdRef.current)
|
||||
.then((next) => {
|
||||
if (mounted.current) apply(next);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
});
|
||||
}, [sessionId, subscribe, transport, apply]);
|
||||
|
||||
// Poll while a turn is mid-flight (active/launching) and we are not already
|
||||
// issuing a request. Stops as soon as the session settles.
|
||||
const status = session?.status;
|
||||
useEffect(() => {
|
||||
const id = sessionIdRef.current;
|
||||
if (!id || busy) return;
|
||||
if (!status || SETTLED.has(status)) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setInterval(() => {
|
||||
transport
|
||||
.get(id, projectIdRef.current)
|
||||
.then((next) => {
|
||||
if (!cancelled) apply(next);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled && mounted.current) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
}, pollIntervalMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [status, busy, transport, apply, pollIntervalMs]);
|
||||
|
||||
return { session, busy, error, start, open, answer, resume, reset };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { CeSession } from "../../session/session-store.js";
|
||||
import { deleteSession as deleteSessionApi, listSessions as listSessionsApi } from "./api.js";
|
||||
|
||||
/**
|
||||
* Injectable list transport so component tests can drive the session list
|
||||
* without a network. Defaults to the real routes.
|
||||
*/
|
||||
export interface CeSessionsTransport {
|
||||
list(projectId?: string): Promise<CeSession[]>;
|
||||
remove(sessionId: string, projectId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultTransport: CeSessionsTransport = {
|
||||
list: (projectId) => listSessionsApi({ projectId }),
|
||||
remove: (id, projectId) => deleteSessionApi(id, projectId),
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to ANY CE plugin push event (no per-session filter — any session
|
||||
* turn/question/complete should refresh the list). Returns an unsubscribe fn.
|
||||
* Default no-op = polling only, same posture as useCeSession's subscribe.
|
||||
*/
|
||||
export type CeSessionsSubscribe = (onAnyEvent: () => void) => () => void;
|
||||
|
||||
export interface UseCeSessionsOptions {
|
||||
projectId?: string;
|
||||
/** Gate fetching (mirrors useArtifacts' viewport gating). Default true. */
|
||||
enabled?: boolean;
|
||||
/** Poll interval (ms) while any session has a turn in flight. */
|
||||
pollIntervalMs?: number;
|
||||
transport?: CeSessionsTransport;
|
||||
subscribe?: CeSessionsSubscribe;
|
||||
}
|
||||
|
||||
export interface UseCeSessionsResult {
|
||||
sessions: CeSession[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
/** Re-fetch the list now (e.g. after launching or closing a session). */
|
||||
refresh(): Promise<void>;
|
||||
/** Discard a session and refresh the list. */
|
||||
remove(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Statuses with an agent turn in flight — the list keeps polling while any exist. */
|
||||
const IN_FLIGHT = new Set<CeSession["status"]>(["active", "launching"]);
|
||||
|
||||
/**
|
||||
* Multi-session management list (server state is already multi-session: each
|
||||
* row is an independent pipeline run with its own live handle). Refreshes on
|
||||
* any plugin push event, and polls as a fallback while any session is
|
||||
* mid-turn so progress made in another tab/process still shows up.
|
||||
*/
|
||||
export function useCeSessions(options: UseCeSessionsOptions = {}): UseCeSessionsResult {
|
||||
const { projectId } = options;
|
||||
const enabled = options.enabled ?? true;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 5000;
|
||||
const transport = options.transport ?? defaultTransport;
|
||||
const subscribe = options.subscribe;
|
||||
|
||||
const [sessions, setSessions] = useState<CeSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await transport.list(projectId);
|
||||
if (mounted.current) {
|
||||
setSessions(next);
|
||||
setError(undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mounted.current) setLoading(false);
|
||||
}
|
||||
}, [transport, projectId]);
|
||||
|
||||
// Initial fetch (and on project switch).
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
setLoading(true);
|
||||
void refresh();
|
||||
}, [enabled, refresh]);
|
||||
|
||||
// Live push: any CE event means some session changed — refresh the list.
|
||||
useEffect(() => {
|
||||
if (!enabled || !subscribe) return;
|
||||
return subscribe(() => {
|
||||
void refresh();
|
||||
});
|
||||
}, [enabled, subscribe, refresh]);
|
||||
|
||||
// Poll fallback only while a turn is actually in flight somewhere.
|
||||
const anyInFlight = sessions.some((s) => IN_FLIGHT.has(s.status));
|
||||
useEffect(() => {
|
||||
if (!enabled || !anyInFlight) return;
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, pollIntervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [enabled, anyInFlight, pollIntervalMs, refresh]);
|
||||
|
||||
const remove = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
await transport.remove(sessionId, projectId);
|
||||
} catch (err) {
|
||||
if (mounted.current) setError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
},
|
||||
[transport, projectId, refresh],
|
||||
);
|
||||
|
||||
return { sessions, loading, error, refresh, remove };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Tracks viewport state for the CE hub. `mobile` mirrors the dashboard's mobile
|
||||
* breakpoint (includes landscape phones). `active` reports whether the document
|
||||
* is currently visible — used to viewport-gate the discovery fetch so an
|
||||
* offscreen/backgrounded hub triggers no network work (performance kit).
|
||||
*/
|
||||
export function useViewportMode() {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 768px), (max-height: 480px)");
|
||||
const onChange = () => setMobile(mq.matches);
|
||||
onChange();
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibility = () => setActive(document.visibilityState !== "hidden");
|
||||
onVisibility();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, []);
|
||||
|
||||
return { mobile, active };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
// @testing-library/react only auto-registers cleanup when vitest globals are
|
||||
// enabled. We don't enable globals here, so we wire it manually — otherwise
|
||||
// React leaves the test tree mounted, its scheduler fires a deferred update
|
||||
// via setImmediate after the jsdom environment is torn down, and the suite
|
||||
// fails with "ReferenceError: window is not defined".
|
||||
afterEach(() => cleanup());
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
147
plugins/fusion-plugin-compound-engineering/src/index.ts
Normal file
147
plugins/fusion-plugin-compound-engineering/src/index.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { COMPOUND_ENGINEERING_SKILLS } from "./skills.js";
|
||||
import { installBundledCeSkills } from "./skill-installation.js";
|
||||
import { ensureCeSchema } from "./schema.js";
|
||||
import { createSessionRoutes } from "./routes/session-routes.js";
|
||||
import { createArtifactRoutes } from "./routes/artifact-routes.js";
|
||||
import { getCePipelineStore } from "./sync/pipeline-store.js";
|
||||
import { reconcileCePipelines } from "./sync/reconciler.js";
|
||||
import { settingsSchema } from "./settings.js";
|
||||
import { getReconcileOnHooks } from "./settings.js";
|
||||
|
||||
export { CompoundEngineeringDashboardView } from "./dashboard-view.js";
|
||||
export { COMPOUND_ENGINEERING_SKILLS } from "./skills.js";
|
||||
export {
|
||||
installBundledCeSkills,
|
||||
resolveBundledSkillsRoot,
|
||||
resolveDefaultInstallTargetRoot,
|
||||
isPluginLocalPath,
|
||||
} from "./skill-installation.js";
|
||||
export { ensureCeSchema } from "./schema.js";
|
||||
export { CeSessionStore, getCeSessionStore } from "./session/session-store.js";
|
||||
export { CePipelineStore, getCePipelineStore } from "./sync/pipeline-store.js";
|
||||
export type {
|
||||
CePipelineLink,
|
||||
CreateCePipelineLinkInput,
|
||||
CePipelineState,
|
||||
CePipelineStatus,
|
||||
CeSyncQueueEntry,
|
||||
CeSyncReason,
|
||||
} from "./sync/pipeline-store.js";
|
||||
export { CeReconciler, reconcileCePipelines } from "./sync/reconciler.js";
|
||||
export type { ReconcileResult } from "./sync/reconciler.js";
|
||||
export {
|
||||
CeOrchestrator,
|
||||
WORK_STAGE_ID,
|
||||
CE_PLUGIN_ID,
|
||||
CE_WORK_SOURCE_TYPE,
|
||||
} from "./session/orchestrator.js";
|
||||
export { getStage, listStages, registerStage } from "./session/stage-registry.js";
|
||||
export {
|
||||
settingsSchema,
|
||||
getDefaultProvider,
|
||||
getDefaultModelId,
|
||||
getEnabledStages,
|
||||
getReconcileOnHooks,
|
||||
getReconcileIntervalMinutes,
|
||||
} from "./settings.js";
|
||||
|
||||
const plugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-compound-engineering",
|
||||
name: "Compound Engineering",
|
||||
version: "0.1.0",
|
||||
description: "A dedicated dashboard surface for compound-engineering artifacts and interactive ce-* sessions.",
|
||||
author: "Fusion Team",
|
||||
fusionVersion: ">=0.1.0",
|
||||
skills: COMPOUND_ENGINEERING_SKILLS.map((s) => ({ skillId: s.skillId, name: s.name })),
|
||||
settingsSchema,
|
||||
},
|
||||
state: "installed",
|
||||
skills: COMPOUND_ENGINEERING_SKILLS,
|
||||
hooks: {
|
||||
// Idempotent DDL for the plugin-local CE tables (ce_sessions). Runs against
|
||||
// the same DB route handlers reach via ctx.taskStore.getDatabase() (U5).
|
||||
onSchemaInit: ensureCeSchema,
|
||||
// INBOUND board→pipeline sync (U8 / FN-5719). The 5s hook budget
|
||||
// (plugin-runner invokeHookSafe) means these MUST be fast: resolve the link,
|
||||
// ENQUEUE a sync signal, and return. Heavy advancement (board reads, outbound
|
||||
// task creation) happens in the reconciler, NOT inline here. A reconcile
|
||||
// drain is fired-and-forgotten (never awaited) so a slow sweep cannot blow
|
||||
// the hook budget; correctness does not depend on it firing because the next
|
||||
// reconcile() sweep re-derives the transition from board truth.
|
||||
onTaskMoved: (task, fromColumn, toColumn, ctx) => {
|
||||
const store = getCePipelineStore(ctx);
|
||||
const link = store.findByTaskId(task.id);
|
||||
if (!link) return; // not a CE-linked task → ignore fast.
|
||||
store.enqueueSync({
|
||||
cePipelineId: link.cePipelineId,
|
||||
taskId: task.id,
|
||||
reason: "task_moved",
|
||||
fromColumn,
|
||||
toColumn,
|
||||
});
|
||||
// Setting-gated auto-drain (U9): when disabled, the enqueue still happens
|
||||
// so an on-demand reconcile (route/refresh) converges later; we just skip
|
||||
// the inline sweep.
|
||||
if (!getReconcileOnHooks(ctx.settings)) return;
|
||||
void Promise.resolve()
|
||||
.then(() => reconcileCePipelines(ctx))
|
||||
.catch((err) => ctx.logger.warn(`CE reconcile (onTaskMoved) failed: ${String(err)}`));
|
||||
},
|
||||
onTaskCompleted: (task, ctx) => {
|
||||
const store = getCePipelineStore(ctx);
|
||||
const link = store.findByTaskId(task.id);
|
||||
if (!link) return;
|
||||
store.enqueueSync({
|
||||
cePipelineId: link.cePipelineId,
|
||||
taskId: task.id,
|
||||
reason: "task_completed",
|
||||
toColumn: "done",
|
||||
});
|
||||
if (!getReconcileOnHooks(ctx.settings)) return;
|
||||
void Promise.resolve()
|
||||
.then(() => reconcileCePipelines(ctx))
|
||||
.catch((err) => ctx.logger.warn(`CE reconcile (onTaskCompleted) failed: ${String(err)}`));
|
||||
},
|
||||
// Install the bundled, pinned ce-* SKILL.md files into a plugin-local,
|
||||
// discoverable directory on load. The engine ingests
|
||||
// PluginSkillContribution only as a name; physical discovery requires the
|
||||
// files to exist on a path it scans (U2 finding). Install is idempotent
|
||||
// (skip-if-exists) and guarded to never touch a global ~/.claude/skills.
|
||||
onLoad: async (ctx) => {
|
||||
try {
|
||||
const { targetRoot, results } = installBundledCeSkills();
|
||||
const installed = results.filter((r) => r.outcome === "installed").length;
|
||||
const errored = results.filter((r) => r.outcome === "error");
|
||||
if (errored.length > 0) {
|
||||
ctx.logger.warn(
|
||||
`Compound Engineering: ${errored.length} skill(s) failed to install: ${errored
|
||||
.map((e) => `${e.skillId} (${e.reason})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
ctx.logger.info(
|
||||
`Compound Engineering skills ready — installed=${installed} target=${targetRoot}`,
|
||||
);
|
||||
ctx.emitEvent("compound-engineering:skills-installed", { targetRoot, results });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.logger.error(`Compound Engineering skill install failed: ${message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
routes: [...createSessionRoutes(), ...createArtifactRoutes()],
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "compound-engineering",
|
||||
label: "Compound Engineering",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "Sparkles",
|
||||
placement: "primary",
|
||||
order: 36,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { PluginRouteResponse } from "@fusion/core";
|
||||
import { createArtifactRoutes } from "../artifact-routes.js";
|
||||
import { makeHarness, type TestHarness } from "../../__tests__/_harness.js";
|
||||
|
||||
function route(path: string, method = "GET") {
|
||||
const def = createArtifactRoutes().find((r) => r.path === path && r.method === method);
|
||||
if (!def) throw new Error(`route not found: ${method} ${path}`);
|
||||
return def;
|
||||
}
|
||||
|
||||
describe("artifact routes", () => {
|
||||
let h: TestHarness;
|
||||
|
||||
afterEach(() => h?.close());
|
||||
|
||||
it("GET /artifacts lists discovered artifacts grouped by stage", async () => {
|
||||
h = makeHarness();
|
||||
writeFileSync(join(h.projectRoot, "STRATEGY.md"), "# Strategy");
|
||||
mkdirSync(join(h.projectRoot, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(h.projectRoot, "docs/plans/p.md"), "plan body");
|
||||
|
||||
const res = (await route("/artifacts").handler({ query: {} }, h.ctx)) as PluginRouteResponse;
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { totalArtifacts: number; groups: Array<{ stage: string; entries: unknown[] }> };
|
||||
expect(body.totalArtifacts).toBe(2);
|
||||
const plan = body.groups.find((g) => g.stage === "plan")!;
|
||||
expect(plan.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /artifacts/:id returns raw content", async () => {
|
||||
h = makeHarness();
|
||||
writeFileSync(join(h.projectRoot, "STRATEGY.md"), "# Strategy body");
|
||||
|
||||
const res = (await route("/artifacts/:id").handler(
|
||||
{ params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} },
|
||||
h.ctx,
|
||||
)) as PluginRouteResponse;
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { content: string }).content).toContain("Strategy body");
|
||||
});
|
||||
|
||||
it("GET /artifacts/:id 404s an unknown id", async () => {
|
||||
h = makeHarness();
|
||||
const res = (await route("/artifacts/:id").handler(
|
||||
{ params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} },
|
||||
h.ctx,
|
||||
)) as PluginRouteResponse;
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /artifacts/:id/preview.html returns a self-contained, escaped HTML document", async () => {
|
||||
h = makeHarness();
|
||||
// Content with an injection attempt — must be escaped, not executed.
|
||||
writeFileSync(join(h.projectRoot, "STRATEGY.md"), "<script>alert('x')</script>\n# Plan");
|
||||
|
||||
const res = (await route("/artifacts/:id/preview.html").handler(
|
||||
{ params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} },
|
||||
h.ctx,
|
||||
)) as PluginRouteResponse;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.contentType).toContain("text/html");
|
||||
const html = res.body as string;
|
||||
expect(html.startsWith("<!DOCTYPE html>")).toBe(true);
|
||||
// Self-contained: inlined <style>, no remote asset URLs.
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).not.toMatch(/https?:\/\//);
|
||||
// data-section markers present (reports rendering contract).
|
||||
expect(html).toContain('data-section="content"');
|
||||
// The raw script tag is escaped, not embedded as live markup.
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).not.toContain("<script>alert");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import { discoverArtifacts, readArtifactById } from "../artifacts/discovery.js";
|
||||
import { asString } from "./route-helpers.js";
|
||||
|
||||
/**
|
||||
* Artifact routes (U3): list discovered CE artifacts grouped by stage, and read
|
||||
* a single artifact's content. The render endpoint returns a SELF-CONTAINED HTML
|
||||
* document (sandboxed `srcDoc`, inlined styles, no remote assets) mirroring the
|
||||
* reports preview/export pattern (docs/plugins/reports.md) so the dashboard can
|
||||
* embed it in a sandboxed iframe without leaking host styles or scripts.
|
||||
*
|
||||
* Project root: artifacts live on disk relative to the project root, which the
|
||||
* route reaches via `ctx.taskStore.getRootDir()` (the same root the U5
|
||||
* orchestrator writes artifacts to). When a `projectId` is supplied and the host
|
||||
* exposes `resolveProjectTaskStore`, the per-project root is used.
|
||||
*/
|
||||
|
||||
interface RouteRequest {
|
||||
params: Record<string, string>;
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
async function resolveProjectRoot(ctx: PluginContext, projectId?: string): Promise<string> {
|
||||
if (projectId && ctx.resolveProjectTaskStore) {
|
||||
try {
|
||||
const store = await ctx.resolveProjectTaskStore(projectId);
|
||||
return store.getRootDir();
|
||||
} catch {
|
||||
// Fall through to the default task store root.
|
||||
}
|
||||
}
|
||||
return ctx.taskStore.getRootDir();
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw artifact markdown in a fully self-contained HTML document. Content is
|
||||
* HTML-escaped and rendered inside `<pre>` so nothing in the artifact can inject
|
||||
* markup or script; styles are inlined; there are no remote `href`/`src` URLs.
|
||||
* `data-section` markers mirror the reports rendering contract so an embedding
|
||||
* viewer can offer section quick-jumps without re-parsing.
|
||||
*/
|
||||
export function renderArtifactDocument(name: string, content: string): string {
|
||||
const escaped = escapeHtml(content);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(name)}</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.5; background: #ffffff; color: #1a1a1a; }
|
||||
@media (prefers-color-scheme: dark) { body { background: #16181d; color: #e6e6e6; } }
|
||||
.artifact-doc { padding: 1.25rem 1.5rem; }
|
||||
.artifact-doc h1 { font-size: 1.1rem; margin: 0 0 1rem; font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.artifact-body { white-space: pre-wrap; word-break: break-word; margin: 0; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="artifact-doc" data-section="artifact">
|
||||
<h1 data-section="title">${escapeHtml(name)}</h1>
|
||||
<pre class="artifact-body" data-section="content">${escaped}</pre>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function createArtifactRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/artifacts",
|
||||
description: "List discovered CE artifacts grouped by stage.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const query = (req as RouteRequest).query ?? {};
|
||||
const projectId = asString(query.projectId);
|
||||
const root = await resolveProjectRoot(ctx, projectId);
|
||||
const result = discoverArtifacts(root);
|
||||
return { status: 200, body: result };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/artifacts/:id",
|
||||
description: "Read a single CE artifact's raw content (JSON).",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = decodeURIComponent(request.params.id);
|
||||
const projectId = asString(request.query?.projectId);
|
||||
const root = await resolveProjectRoot(ctx, projectId);
|
||||
const result = readArtifactById(root, id);
|
||||
if (!result) return { status: 404, body: { error: `Artifact ${id} not found` } };
|
||||
if ("error" in result) return { status: 422, body: { error: result.error } };
|
||||
return { status: 200, body: { artifact: result.artifact, content: result.content } };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/artifacts/:id/preview.html",
|
||||
description: "Read a single CE artifact rendered as a self-contained HTML document.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = decodeURIComponent(request.params.id);
|
||||
const projectId = asString(request.query?.projectId);
|
||||
const root = await resolveProjectRoot(ctx, projectId);
|
||||
const result = readArtifactById(root, id);
|
||||
if (!result) return { status: 404, body: { error: `Artifact ${id} not found` } };
|
||||
if ("error" in result) return { status: 422, body: { error: result.error } };
|
||||
return {
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: renderArtifactDocument(result.artifact.name, result.content),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Shared route helpers.
|
||||
*
|
||||
* `asString` coerces an unknown request value to a non-empty string or
|
||||
* `undefined` — the common "optional, must be a real string" guard used across
|
||||
* the CE route handlers. (settings.ts has a different-signature `asString` that
|
||||
* is intentionally NOT consolidated here.)
|
||||
*/
|
||||
export function asString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import { CeOrchestrator } from "../session/orchestrator.js";
|
||||
import { asCeSessionStatus, getCeSessionStore } from "../session/session-store.js";
|
||||
import { getCePipelineStore } from "../sync/pipeline-store.js";
|
||||
import { asString } from "./route-helpers.js";
|
||||
|
||||
/**
|
||||
* Session routes (U5): start / answer / resume / get-session-state.
|
||||
*
|
||||
* TRANSPORT. Turn execution is DETACHED: start/answer/resume return as soon
|
||||
* as the session row reflects the request, with the agent turn running in the
|
||||
* background. Clients converge via the `plugin:custom` SSE push (the
|
||||
* orchestrator emits throttled progress events) with `GET /sessions/:id`
|
||||
* polling as the fallback — that GET also attaches the in-flight working
|
||||
* output (`liveActivity`) so the user can watch the agent work mid-turn.
|
||||
*/
|
||||
|
||||
interface RouteRequest {
|
||||
params: Record<string, string>;
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the orchestrator per TaskStore so live in-process interactive-session
|
||||
* handles survive across requests within a process (a fresh orchestrator per
|
||||
* request would lose the live handle needed to answer a question).
|
||||
*/
|
||||
const orchestratorCache = new WeakMap<object, CeOrchestrator>();
|
||||
|
||||
function getOrchestrator(ctx: PluginContext): CeOrchestrator {
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = orchestratorCache.get(key);
|
||||
if (cached) return cached;
|
||||
const orch = new CeOrchestrator({ ctx });
|
||||
orchestratorCache.set(key, orch);
|
||||
return orch;
|
||||
}
|
||||
|
||||
function badRequest(message: string): PluginRouteResponse {
|
||||
return { status: 400, body: { error: message } };
|
||||
}
|
||||
|
||||
export function createSessionRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/sessions",
|
||||
description: "Start an interactive CE stage session.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const body = (req as RouteRequest).body as Record<string, unknown> | undefined;
|
||||
const stageId = asString(body?.stage);
|
||||
const openingMessage = asString(body?.message) ?? "";
|
||||
if (!stageId) return badRequest("`stage` is required");
|
||||
|
||||
const orch = getOrchestrator(ctx);
|
||||
try {
|
||||
const result = await orch.start(stageId, {
|
||||
openingMessage,
|
||||
projectId: asString(body?.projectId) ?? null,
|
||||
detach: true,
|
||||
});
|
||||
return { status: 201, body: { session: result.session } };
|
||||
} catch (err) {
|
||||
return { status: 400, body: { error: err instanceof Error ? err.message : String(err) } };
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/sessions/:id/answer",
|
||||
description: "Answer the awaiting question and continue the session.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = request.params.id;
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
const questionId = asString(body?.questionId);
|
||||
if (!questionId) return badRequest("`questionId` is required");
|
||||
if (!("response" in (body ?? {}))) return badRequest("`response` is required");
|
||||
|
||||
const orch = getOrchestrator(ctx);
|
||||
try {
|
||||
const result = await orch.answer(id, questionId, (body as Record<string, unknown>).response, {
|
||||
detach: true,
|
||||
});
|
||||
return { status: 200, body: { session: result.session } };
|
||||
} catch (err) {
|
||||
return { status: 409, body: { error: err instanceof Error ? err.message : String(err) } };
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/sessions/:id/resume",
|
||||
description: "Resume an awaiting_input or interrupted session to its current question.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const id = (req as RouteRequest).params.id;
|
||||
const orch = getOrchestrator(ctx);
|
||||
try {
|
||||
const result = await orch.resume(id, { detach: true });
|
||||
return { status: 200, body: { session: result.session } };
|
||||
} catch (err) {
|
||||
return { status: 404, body: { error: err instanceof Error ? err.message : String(err) } };
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/sessions/:id",
|
||||
description: "Get current session state, including in-flight working output (liveActivity).",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const id = (req as RouteRequest).params.id;
|
||||
const session = getCeSessionStore(ctx).get(id);
|
||||
if (!session) return { status: 404, body: { error: `Session ${id} not found` } };
|
||||
// Attach the orchestrator's transient mid-turn buffer so a polling
|
||||
// client can watch the agent work while the turn runs.
|
||||
const liveActivity = getOrchestrator(ctx).getLiveActivity(id);
|
||||
return {
|
||||
status: 200,
|
||||
body: { session: liveActivity.length > 0 ? { ...session, liveActivity } : session },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/sessions",
|
||||
description: "List CE sessions (optionally filtered by status/stage).",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const query = (req as RouteRequest).query ?? {};
|
||||
const status = asCeSessionStatus(typeof query.status === "string" ? query.status : undefined);
|
||||
const stage = typeof query.stage === "string" ? query.stage : undefined;
|
||||
const sessions = getCeSessionStore(ctx).list({ status, stage });
|
||||
return { status: 200, body: { sessions } };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/sessions/:id",
|
||||
description: "Discard a CE session (disposes any live handle, deletes the row).",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const id = (req as RouteRequest).params.id;
|
||||
// Go through the orchestrator so an in-flight live handle is disposed,
|
||||
// not just the row removed (a bare store.delete would leave the agent
|
||||
// running unobserved in this process).
|
||||
const removed = getOrchestrator(ctx).discard(id);
|
||||
if (!removed) return { status: 404, body: { error: `Session ${id} not found` } };
|
||||
return { status: 200, body: { deleted: true } };
|
||||
},
|
||||
},
|
||||
{
|
||||
// U7 work bridge: observe the board tasks a CE pipeline (session) landed,
|
||||
// via their link records (the addressable back-reference, FN-5719). The
|
||||
// session id IS the pipeline id. Outbound-only in U7; U8 layers state.
|
||||
method: "GET",
|
||||
path: "/sessions/:id/links",
|
||||
description: "List the CE pipeline-link records (work→board) for a session/pipeline.",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const id = (req as RouteRequest).params.id;
|
||||
const links = getCePipelineStore(ctx).listByPipeline(id);
|
||||
return { status: 200, body: { links } };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
117
plugins/fusion-plugin-compound-engineering/src/schema.ts
Normal file
117
plugins/fusion-plugin-compound-engineering/src/schema.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Database } from "@fusion/core";
|
||||
|
||||
/**
|
||||
* Idempotent DDL for the Compound Engineering plugin-local tables (U5).
|
||||
*
|
||||
* Wired via `hooks.onSchemaInit` and run against the same DB that route
|
||||
* handlers reach through `ctx.taskStore.getDatabase()` (the sanctioned
|
||||
* plugin-table access path; `PluginContext` exposes no `db` handle and the
|
||||
* loader `emitEvent` is a logging stub — see the U5 storage/event seam note).
|
||||
*
|
||||
* `ce_sessions` is the no-silent-loss core: every interactive stage session is
|
||||
* persisted here so an interrupt/error never destroys progress (lesson:
|
||||
* docs/incidents/2026-05-23-lost-work-tasks.md). The `currentQuestion` and
|
||||
* `conversationHistory` columns are JSON; resume reconstructs the awaiting
|
||||
* question and full history from them.
|
||||
*
|
||||
* `lastActivityAt` is an interval-relative liveness field (epoch millis of the
|
||||
* last produced event). Staleness is judged relative to the session's
|
||||
* configured turn interval, NOT by raw last-event age, so a healthy-but-slow
|
||||
* agent turn is not misclassified stale (docs/fn-4172-heartbeat-investigation.md).
|
||||
*
|
||||
* `ce_pipeline_links` (U7) is the addressable back-reference table: it links a
|
||||
* board task to the CE pipeline/stage/artifact that produced it. Per FN-5719 the
|
||||
* back-reference lives in this plugin-local table (NOT in task-row JSON) so
|
||||
* board-task ownership and CE-pipeline ownership stay separate state machines.
|
||||
* U7 keeps it minimal (link records only); U8 extends it with the bidirectional
|
||||
* pipeline-state machine.
|
||||
*
|
||||
* `ce_pipeline_state` (U8) is the CE-pipeline's OWN state machine — DISTINCT from
|
||||
* board-task column state (KTD4 / FN-5719: two separate ownership state machines,
|
||||
* never one shared column encoding two concerns). It tracks where the pipeline
|
||||
* itself is: `currentStage` (the CE stage the pipeline has reached) and `status`
|
||||
* (`running` | `advancing` | `awaiting_board` | `completed`). The board owns task
|
||||
* columns; this table owns pipeline progress. They are reconciled — never merged.
|
||||
*
|
||||
* `ce_pipeline_sync_queue` (U8) is the event-enqueue seam (FN-5719). Lifecycle
|
||||
* hooks write a row here FAST (5s hook budget) and return; the reconciler drains
|
||||
* it. A dropped/never-enqueued event is still recovered because the reconciler
|
||||
* ALSO re-derives transitions from board state — the queue is an optimization,
|
||||
* board+state comparison is the convergence guarantee. `processedAt NULL` =
|
||||
* pending; non-null = drained (kept for audit, swept idempotently).
|
||||
*/
|
||||
export function ensureCeSchema(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ce_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
stage TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'launching','active','awaiting_input','completed','error','interrupted'
|
||||
)),
|
||||
currentQuestion TEXT,
|
||||
conversationHistory TEXT NOT NULL DEFAULT '[]',
|
||||
projectId TEXT,
|
||||
artifactPath TEXT,
|
||||
error TEXT,
|
||||
turnIntervalMs INTEGER NOT NULL DEFAULT 120000,
|
||||
lastActivityAt INTEGER NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxCeSessionsStatusUpdated
|
||||
ON ce_sessions(status, updatedAt DESC, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxCeSessionsStageCreated
|
||||
ON ce_sessions(stage, createdAt DESC, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxCeSessionsProject
|
||||
ON ce_sessions(projectId, updatedAt DESC, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ce_pipeline_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
taskId TEXT NOT NULL,
|
||||
cePipelineId TEXT NOT NULL,
|
||||
ceStageId TEXT NOT NULL,
|
||||
ceArtifactPath TEXT,
|
||||
createdAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxCePipelineLinksPipeline
|
||||
ON ce_pipeline_links(cePipelineId, createdAt DESC, id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idxCePipelineLinksTask
|
||||
ON ce_pipeline_links(taskId);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ce_pipeline_state (
|
||||
cePipelineId TEXT PRIMARY KEY,
|
||||
currentStage TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'running','advancing','awaiting_board','completed'
|
||||
)),
|
||||
lastArtifactPath TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxCePipelineStateStatus
|
||||
ON ce_pipeline_state(status, updatedAt DESC, cePipelineId);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ce_pipeline_sync_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
cePipelineId TEXT NOT NULL,
|
||||
taskId TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
fromColumn TEXT,
|
||||
toColumn TEXT,
|
||||
enqueuedAt TEXT NOT NULL,
|
||||
processedAt TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxCePipelineSyncQueuePending
|
||||
ON ce_pipeline_sync_queue(processedAt, enqueuedAt, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxCePipelineSyncQueuePipeline
|
||||
ON ce_pipeline_sync_queue(cePipelineId, enqueuedAt, id);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import type {
|
||||
CreateInteractiveAiSessionFactory,
|
||||
InteractiveAiSession,
|
||||
InteractiveAiSessionEvent,
|
||||
InteractiveAiSessionProgressEvent,
|
||||
PlanningQuestion,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
import { resolveDefaultInstallTargetRoot } from "../skill-installation.js";
|
||||
import { getCePipelineStore, type CePipelineStore } from "../sync/pipeline-store.js";
|
||||
import { createCeTaskWithLink } from "../sync/ce-task.js";
|
||||
import { getDefaultModelId, getDefaultProvider, getEnabledStages } from "../settings.js";
|
||||
import type { CeActivityTurn, CeSession, CeSessionStore } from "./session-store.js";
|
||||
import { getCeSessionStore } from "./session-store.js";
|
||||
import { getStage, type CeStageDefinition } from "./stage-registry.js";
|
||||
|
||||
/**
|
||||
* The stage id whose `complete` payload carries a derived task list to land on
|
||||
* the board (U7). Its skill is `ce-work` (see the stage registry).
|
||||
*/
|
||||
export const WORK_STAGE_ID = "work";
|
||||
|
||||
/**
|
||||
* CE provenance identity constants. Defined in `../sync/ce-task.ts` (so the
|
||||
* reconciler can reuse them without importing this module) and re-exported here
|
||||
* for existing consumers (index.ts, tests) that import them from the orchestrator.
|
||||
*/
|
||||
export { CE_PLUGIN_ID, CE_WORK_SOURCE_TYPE } from "../sync/ce-task.js";
|
||||
|
||||
/**
|
||||
* COMPLETION-PAYLOAD → TASKS CONTRACT (U7).
|
||||
*
|
||||
* The `work` stage's `complete` event `data` MAY carry a `tasks` array describing
|
||||
* the board tasks to create. Each entry needs at least a `description` (the only
|
||||
* required TaskCreateInput field); `title` and `column` are optional.
|
||||
*
|
||||
* { artifact?: string, tasks?: Array<{ title?: string, description: string, column?: Column }> }
|
||||
*
|
||||
* A missing/empty `tasks` array is a clean no-op (no board tasks, no link rows).
|
||||
* Entries with a blank description are skipped (createTask would reject them).
|
||||
*/
|
||||
export interface CeDerivedTaskSpec {
|
||||
title?: string;
|
||||
description: string;
|
||||
column?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default per-turn INACTIVITY timeout. A turn is treated as stalled only after
|
||||
* this long with NO live progress (thinking/text/tool activity) — a long but
|
||||
* actively-working turn is never killed. Without an onProgress-capable factory
|
||||
* (e.g. scripted test fakes), this degrades to a fixed per-turn timeout.
|
||||
*/
|
||||
const DEFAULT_TURN_TIMEOUT_MS = 120000;
|
||||
|
||||
/** Throttle for progress-driven SSE emits + lastActivityAt bumps. */
|
||||
const PROGRESS_EMIT_INTERVAL_MS = 500;
|
||||
|
||||
/** Caps so a runaway turn cannot grow the live buffer unbounded. */
|
||||
const MAX_ACTIVITY_TURNS = 200;
|
||||
const MAX_ACTIVITY_TURN_CHARS = 16000;
|
||||
/** Caps for the condensed activity trace persisted into history on settle. */
|
||||
const MAX_PERSISTED_ACTIVITY_TURNS = 50;
|
||||
const MAX_PERSISTED_ACTIVITY_TURN_CHARS = 4000;
|
||||
|
||||
/**
|
||||
* Observable event names emitted via `ctx.emitEvent`. The no-silent-loss
|
||||
* invariant requires that interrupt/error ALWAYS emit one of these AND persist
|
||||
* progress first.
|
||||
*/
|
||||
export const CE_EVENTS = {
|
||||
turn: "compound-engineering:session-turn",
|
||||
question: "compound-engineering:session-question",
|
||||
completed: "compound-engineering:session-completed",
|
||||
error: "compound-engineering:session-error",
|
||||
interrupted: "compound-engineering:session-interrupted",
|
||||
} as const;
|
||||
|
||||
export class CeTurnTimeoutError extends Error {
|
||||
constructor(ms: number) {
|
||||
super(`CE session turn stalled: no agent activity for ${ms}ms`);
|
||||
this.name = "CeTurnTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OrchestratorDeps {
|
||||
ctx: PluginContext;
|
||||
/**
|
||||
* Interactive-session factory. Defaults to `ctx.createInteractiveAiSession`
|
||||
* (route contexts only); injectable for deterministic, scripted-fake tests.
|
||||
*/
|
||||
createInteractiveAiSession?: CreateInteractiveAiSessionFactory;
|
||||
/** Project root used for the session cwd and artifact writes. */
|
||||
projectRoot?: string;
|
||||
/** Override the per-turn timeout (ms). */
|
||||
turnTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill discovery wiring (closes the U2 → U5 carry-forward).
|
||||
*
|
||||
* U2 proved a `PluginSkillContribution` is NOT auto-ingested by the engine
|
||||
* skill-resolver; a physical install onto a discoverable path is required, and
|
||||
* the plugin installs its bundled `ce-*` skills to a plugin-local directory
|
||||
* (`resolveDefaultInstallTargetRoot()`). The U4 seam now carries
|
||||
* `requestedSkillNames` + `additionalSkillPaths`, which the engine adapter
|
||||
* forwards into `createFnAgent` (`skills` + the loader's `additionalSkillPaths`).
|
||||
* So the orchestrator hands the live session BOTH the stage's skill id and the
|
||||
* install directory to discover it from — the session runs with `cwd` at the
|
||||
* real project root (where it reads context and writes artifacts), not at the
|
||||
* skills directory.
|
||||
*/
|
||||
export function resolveStageSkillPaths(): string[] {
|
||||
// The install target root holds `<skillId>/SKILL.md` for each installed skill;
|
||||
// passing it as an additional skill-discovery path makes the stage's skill
|
||||
// loadable while the session cwd stays on the project.
|
||||
return [resolveDefaultInstallTargetRoot()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system prompt: instruct the agent to (a) apply the named ce-* skill
|
||||
* and (b) emit the JSON question/complete protocol the U4 seam parses.
|
||||
*/
|
||||
export function buildStageSystemPrompt(stage: CeStageDefinition): string {
|
||||
return [
|
||||
`You are running the Compound Engineering "${stage.stageId}" stage.`,
|
||||
`Apply the bundled skill "${stage.skillId}" (it has been loaded into this session).`,
|
||||
"",
|
||||
"Drive the stage as an interactive question/answer flow. On every turn respond with ONLY a JSON object:",
|
||||
' - To ask the user something: {"type":"question","data":{"id":"<unique>","type":"single_select|multi_select|text|confirm","question":"...","options":[{"id":"..","label":".."}]}}',
|
||||
' - When the stage is finished: {"type":"complete","data":{"artifact":"<full markdown document>", ...}}',
|
||||
"No markdown fences, no prose outside the JSON object.",
|
||||
"",
|
||||
"The user's reply arrives as {\"type\":\"answer\",\"questionId\":\"...\",\"response\":...}. The response takes one of three shapes:",
|
||||
" - a direct answer to your question (an option id, array of option ids, text, or boolean),",
|
||||
' - {"value": <direct answer>, "comment": "<guidance>"} — apply the answer AND incorporate the guidance into how you proceed,',
|
||||
' - {"feedback": "<guidance only>"} — the user is steering rather than answering. Incorporate the feedback, adjust course, and either re-ask the question (possibly revised) or continue if the feedback resolves it.',
|
||||
"Steering feedback is first-class input: never ignore it, and acknowledge course corrections in your next question or output.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export interface StartStageOptions {
|
||||
/** Opening user message (the stage prompt / topic). */
|
||||
openingMessage: string;
|
||||
projectId?: string | null;
|
||||
/**
|
||||
* Return as soon as the session row exists, with the turn running in the
|
||||
* background (the route posture — lets clients watch live working output
|
||||
* via push/poll instead of blocking on the whole turn).
|
||||
*/
|
||||
detach?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a single orchestrator step (start / answer / resume). */
|
||||
export interface CeStepResult {
|
||||
session: CeSession;
|
||||
/** The event the seam produced for this step, if a turn ran. */
|
||||
event?: InteractiveAiSessionEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a stage's interactive skill session: streams thinking/text, surfaces
|
||||
* questions (persisted as `awaiting_input`), accepts answers, and on `complete`
|
||||
* writes the artifact to the stage's conventional location. On interrupt/error
|
||||
* it AUTO-SAVES progress and emits an observable event — never silent loss.
|
||||
*
|
||||
* Liveness uses the interval-relative rubric (CeSessionStore.isStale): a slow
|
||||
* turn is not misclassified stale.
|
||||
*/
|
||||
export class CeOrchestrator {
|
||||
private readonly ctx: PluginContext;
|
||||
private readonly store: CeSessionStore;
|
||||
private readonly pipelineStore: CePipelineStore;
|
||||
private readonly factory: CreateInteractiveAiSessionFactory | undefined;
|
||||
private readonly projectRoot: string;
|
||||
private readonly turnTimeoutMs: number;
|
||||
/** Live in-memory session handles keyed by ce_session id. */
|
||||
private readonly live = new Map<string, InteractiveAiSession>();
|
||||
/** Mid-turn working output per session (transient; flushed to history on settle). */
|
||||
private readonly activity = new Map<string, CeActivityTurn[]>();
|
||||
/** Last progress timestamp per session (drives the inactivity watchdog). */
|
||||
private readonly lastProgressAt = new Map<string, number>();
|
||||
/** Last progress-driven emit per session (throttling). */
|
||||
private readonly lastProgressEmitAt = new Map<string, number>();
|
||||
/** Sessions currently REPLAYING history (rehydrate) — progress suppressed. */
|
||||
private readonly replaying = new Set<string>();
|
||||
|
||||
constructor(deps: OrchestratorDeps) {
|
||||
this.ctx = deps.ctx;
|
||||
this.store = getCeSessionStore(deps.ctx);
|
||||
this.pipelineStore = getCePipelineStore(deps.ctx);
|
||||
this.factory = deps.createInteractiveAiSession ?? deps.ctx.createInteractiveAiSession;
|
||||
this.projectRoot = deps.projectRoot ?? deps.ctx.taskStore.getRootDir();
|
||||
this.turnTimeoutMs = deps.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the interactive-session options for a stage. The session runs with
|
||||
* `cwd` on the real project root (where it reads context and writes the stage
|
||||
* artifact) and is handed BOTH the stage's `ce-*` skill id and the plugin-local
|
||||
* install directory to discover it from — so the live agent actually loads the
|
||||
* bundled skill (closing the U2/U5 skill-discovery carry-forward). Model
|
||||
* provider/model are setting-gated (U9); omitted keys let the host pick defaults.
|
||||
*/
|
||||
private buildSessionOptions(
|
||||
stage: CeStageDefinition,
|
||||
sessionId: string,
|
||||
): Parameters<CreateInteractiveAiSessionFactory>[0] {
|
||||
const defaultProvider = getDefaultProvider(this.ctx.settings);
|
||||
const defaultModelId = getDefaultModelId(this.ctx.settings);
|
||||
return {
|
||||
cwd: this.projectRoot,
|
||||
systemPrompt: buildStageSystemPrompt(stage),
|
||||
tools: "coding",
|
||||
requestedSkillNames: [stage.skillId],
|
||||
additionalSkillPaths: resolveStageSkillPaths(),
|
||||
onProgress: (event) => this.handleProgress(sessionId, event),
|
||||
...(defaultProvider ? { defaultProvider } : {}),
|
||||
...(defaultModelId ? { defaultModelId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live mid-turn visibility. Accumulates streamed deltas into the session's
|
||||
* activity buffer (consecutive deltas of one kind merge into one turn; tool
|
||||
* markers are discrete), pokes the inactivity watchdog, and — throttled —
|
||||
* bumps the persisted liveness anchor and emits an observable turn event so
|
||||
* push clients refetch. Replay (rehydrate) progress is fully suppressed:
|
||||
* it reconstructs context, it is not new work.
|
||||
*/
|
||||
private handleProgress(sessionId: string, event: InteractiveAiSessionProgressEvent): void {
|
||||
if (this.replaying.has(sessionId)) return;
|
||||
this.lastProgressAt.set(sessionId, Date.now());
|
||||
|
||||
const turns = this.activity.get(sessionId) ?? [];
|
||||
if (!this.activity.has(sessionId)) this.activity.set(sessionId, turns);
|
||||
const now = new Date().toISOString();
|
||||
if (event.type === "tool") {
|
||||
if (event.phase === "start") {
|
||||
turns.push({ kind: "tool", text: event.name, at: now, done: false });
|
||||
} else {
|
||||
// Mark the most recent still-open tool turn with this name as done.
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const t = turns[i];
|
||||
if (t.kind === "tool" && t.text === event.name && !t.done) {
|
||||
t.done = true;
|
||||
if (event.isError) t.isError = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const last = turns[turns.length - 1];
|
||||
if (last && last.kind === event.type) {
|
||||
if (last.text.length < MAX_ACTIVITY_TURN_CHARS) {
|
||||
last.text = (last.text + event.delta).slice(0, MAX_ACTIVITY_TURN_CHARS);
|
||||
}
|
||||
} else {
|
||||
turns.push({ kind: event.type, text: event.delta.slice(0, MAX_ACTIVITY_TURN_CHARS), at: now });
|
||||
}
|
||||
}
|
||||
// Cap the buffer; drop oldest (the tail is what the user is watching).
|
||||
if (turns.length > MAX_ACTIVITY_TURNS) turns.splice(0, turns.length - MAX_ACTIVITY_TURNS);
|
||||
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - (this.lastProgressEmitAt.get(sessionId) ?? 0) >= PROGRESS_EMIT_INTERVAL_MS) {
|
||||
this.lastProgressEmitAt.set(sessionId, nowMs);
|
||||
// Bump lastActivityAt so the staleness rubric sees an actively-working
|
||||
// turn as alive; emit so push clients refetch (GET attaches the buffer).
|
||||
this.store.update(sessionId, {});
|
||||
this.ctx.emitEvent(CE_EVENTS.turn, { sessionId, kind: "progress" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the in-flight working output for a session (route accessor). */
|
||||
getLiveActivity(sessionId: string): CeActivityTurn[] {
|
||||
return this.activity.get(sessionId) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a condensed copy of the live activity buffer into history (so the
|
||||
* transcript keeps the working trace after the turn settles), then clear it.
|
||||
*/
|
||||
private flushActivity(sessionId: string): void {
|
||||
const turns = this.activity.get(sessionId);
|
||||
this.activity.delete(sessionId);
|
||||
if (!turns || turns.length === 0) return;
|
||||
const condensed = turns.slice(-MAX_PERSISTED_ACTIVITY_TURNS).map((t) => ({
|
||||
...t,
|
||||
text: t.text.slice(0, MAX_PERSISTED_ACTIVITY_TURN_CHARS),
|
||||
}));
|
||||
this.store.appendHistory(sessionId, {
|
||||
role: "agent",
|
||||
text: JSON.stringify({ activity: { turns: condensed } }),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inactivity watchdog: rejects only after `turnTimeoutMs` with NO progress.
|
||||
* Every progress event re-arms it, so a long actively-working turn survives;
|
||||
* with a non-streaming factory it degrades to a fixed per-turn timeout.
|
||||
*/
|
||||
private createWatchdog(sessionId: string): { promise: Promise<never>; cancel(): void } {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
this.lastProgressAt.set(sessionId, Date.now());
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
const check = () => {
|
||||
if (cancelled) return;
|
||||
const elapsed = Date.now() - (this.lastProgressAt.get(sessionId) ?? 0);
|
||||
if (elapsed >= this.turnTimeoutMs) {
|
||||
reject(new CeTurnTimeoutError(this.turnTimeoutMs));
|
||||
return;
|
||||
}
|
||||
timer = globalThis.setTimeout(check, this.turnTimeoutMs - elapsed);
|
||||
timer.unref?.();
|
||||
};
|
||||
check();
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a fresh session for a registered stage and run the opening turn.
|
||||
*
|
||||
* `detach: true` (the route posture) returns as soon as the session row
|
||||
* exists with the turn running in the background — the client converges via
|
||||
* push/poll and can watch the live working output. Errors during a detached
|
||||
* turn surface through session state (failSession/interruptSession), never
|
||||
* as an unhandled rejection. Validation errors still throw synchronously.
|
||||
*/
|
||||
async start(stageId: string, opts: StartStageOptions): Promise<CeStepResult> {
|
||||
const stage = getStage(stageId);
|
||||
if (!stage) throw new Error(`Unknown CE stage: ${stageId}`);
|
||||
// Setting-gated launch (U9): only stages the operator enabled may launch.
|
||||
if (!getEnabledStages(this.ctx.settings).includes(stageId)) {
|
||||
throw new Error(`CE stage is not enabled: ${stageId}`);
|
||||
}
|
||||
if (!this.factory) {
|
||||
throw new Error(
|
||||
"Interactive AI sessions are not available (createInteractiveAiSession is only injected on route contexts with the engine loaded).",
|
||||
);
|
||||
}
|
||||
|
||||
const session = this.store.create({
|
||||
stage: stageId,
|
||||
projectId: opts.projectId ?? null,
|
||||
turnIntervalMs: this.turnTimeoutMs,
|
||||
});
|
||||
this.store.appendHistory(session.id, { role: "user", text: opts.openingMessage, at: new Date().toISOString() });
|
||||
|
||||
const turn = this.runOpeningTurn(session.id, stage, opts.openingMessage);
|
||||
if (opts.detach) {
|
||||
// runOpeningTurn never rejects (all failures persist into session state).
|
||||
void turn;
|
||||
return { session: this.requireSession(session.id) };
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
/** Create the live handle and run the opening turn. Never rejects. */
|
||||
private async runOpeningTurn(
|
||||
sessionId: string,
|
||||
stage: CeStageDefinition,
|
||||
openingMessage: string,
|
||||
): Promise<CeStepResult> {
|
||||
let interactive;
|
||||
try {
|
||||
interactive = await this.factory!(this.buildSessionOptions(stage, sessionId));
|
||||
} catch (err) {
|
||||
return { session: this.failSession(sessionId, err), event: undefined };
|
||||
}
|
||||
this.live.set(sessionId, interactive.session);
|
||||
this.store.update(sessionId, { status: "active" });
|
||||
return this.runTurn(sessionId, () => interactive.session.prompt(openingMessage), interactive.session);
|
||||
}
|
||||
|
||||
/** Answer the awaiting question and continue the loop (detachable like start). */
|
||||
async answer(
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
response: unknown,
|
||||
opts: { detach?: boolean } = {},
|
||||
): Promise<CeStepResult> {
|
||||
const session = this.requireSession(sessionId);
|
||||
if (session.status !== "awaiting_input") {
|
||||
throw new Error(`Session ${sessionId} is not awaiting input (status=${session.status}).`);
|
||||
}
|
||||
// Validate the questionId BEFORE mutating any persisted state. A stale/wrong
|
||||
// questionId must NOT clear `currentQuestion` or flip status to active —
|
||||
// doing so would destroy the recovery anchor while the seam rejects the
|
||||
// mismatch, leaving the DB diverged from the live session. Reject cleanly and
|
||||
// leave `currentQuestion`/status intact so the session stays answerable.
|
||||
if (questionId !== session.currentQuestion?.id) {
|
||||
throw new Error(
|
||||
`Session ${sessionId} is awaiting question "${session.currentQuestion?.id ?? "(none)"}", not "${questionId}".`,
|
||||
);
|
||||
}
|
||||
const live = this.live.get(sessionId);
|
||||
if (!live) {
|
||||
throw new Error(`Session ${sessionId} has no live handle in this process; call resume() first.`);
|
||||
}
|
||||
this.store.appendHistory(sessionId, {
|
||||
role: "user",
|
||||
text: JSON.stringify({ answer: response, questionId }),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
this.store.update(sessionId, { status: "active", currentQuestion: null });
|
||||
const turn = this.runTurn(sessionId, () => live.answer(questionId, response), live);
|
||||
if (opts.detach) {
|
||||
// runTurn never rejects (all failures persist into session state).
|
||||
void turn;
|
||||
return { session: this.requireSession(sessionId) };
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an `awaiting_input`, `interrupted`, or `error` session — and, crucially,
|
||||
* RE-ESTABLISH a live interactive handle so the resumed session can actually be
|
||||
* answered (Bug 5). After an interrupt/timeout the live handle was disposed and
|
||||
* removed from `this.live`; flipping persisted status back to `awaiting_input`
|
||||
* without a live handle was a dead end (resume → answer → "call resume() first").
|
||||
*
|
||||
* When a question is still pending and no live handle exists, we rehydrate via
|
||||
* the factory and REPLAY the persisted conversation history (opening message +
|
||||
* prior answers) to prime the fresh agent back to the current awaiting question,
|
||||
* repopulating `this.live`. Replay is side-effect-suppressed: it reconstructs
|
||||
* the agent's context only — no artifact writes, no event emits, no history
|
||||
* re-append (the DB already reflects the final state).
|
||||
*
|
||||
* If no factory is available (no live handle can ever be created in this
|
||||
* process), we DO NOT advertise a misleading answerable status: the session is
|
||||
* left `interrupted` with a clear error explaining it can't be continued here.
|
||||
*/
|
||||
async resume(sessionId: string, opts: { detach?: boolean } = {}): Promise<CeStepResult> {
|
||||
const session = this.requireSession(sessionId);
|
||||
|
||||
// Terminal / already-answerable-with-a-live-handle cases need no rehydration.
|
||||
if (session.status === "completed") return { session };
|
||||
if (session.status === "awaiting_input" && this.live.has(sessionId)) {
|
||||
return { session }; // already live + answerable.
|
||||
}
|
||||
|
||||
// No pending question → nothing to re-prime to. Mark active so the caller can
|
||||
// re-run the turn with fresh input (retry for `error`, resume for others).
|
||||
if (!session.currentQuestion) {
|
||||
const next = this.store.update(sessionId, { status: "active", error: null }) ?? session;
|
||||
return { session: next };
|
||||
}
|
||||
|
||||
// A live handle already exists (e.g. interrupted but not disposed) — just
|
||||
// restore the answerable status.
|
||||
if (this.live.has(sessionId)) {
|
||||
const next = this.store.update(sessionId, { status: "awaiting_input", error: null }) ?? session;
|
||||
return { session: next };
|
||||
}
|
||||
|
||||
// Rehydration path: re-create the live session and replay history back to the
|
||||
// current question.
|
||||
if (!this.factory) {
|
||||
// Honest status: we cannot back an answerable state in this process, so do
|
||||
// not pretend the session is resumable here. Surface a clear error.
|
||||
const next =
|
||||
this.store.update(sessionId, {
|
||||
status: "interrupted",
|
||||
error:
|
||||
"Session cannot be continued in this process: interactive AI sessions are unavailable (no factory on this context). Resume from a route context with the engine loaded.",
|
||||
}) ?? session;
|
||||
return { session: next };
|
||||
}
|
||||
|
||||
const rehydration = (async (): Promise<CeStepResult> => {
|
||||
try {
|
||||
await this.rehydrate(session);
|
||||
} catch (err) {
|
||||
// Rehydration failed — keep progress, surface the failure, do not
|
||||
// advertise an answerable status we can't back.
|
||||
return { session: this.interruptSession(sessionId, err) };
|
||||
}
|
||||
const next = this.store.update(sessionId, { status: "awaiting_input", error: null }) ?? session;
|
||||
return { session: next };
|
||||
})();
|
||||
|
||||
if (opts.detach) {
|
||||
// Rehydration replays the conversation against the live model and can be
|
||||
// slow; the route posture marks the session active and converges via
|
||||
// push/poll. The IIFE never rejects (failures persist into state).
|
||||
const next = this.store.update(sessionId, { status: "active", error: null }) ?? session;
|
||||
void rehydration;
|
||||
return { session: next };
|
||||
}
|
||||
return rehydration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create a live interactive session and REPLAY the persisted conversation so
|
||||
* the fresh agent is primed back to the current awaiting question. Side effects
|
||||
* are suppressed: we drain the seam's events to advance the agent's context but
|
||||
* do NOT persist/emit/write — the DB already holds the authoritative final
|
||||
* state. Populates `this.live[session.id]` on success.
|
||||
*/
|
||||
private async rehydrate(session: CeSession): Promise<void> {
|
||||
const stage = getStage(session.stage);
|
||||
if (!stage) throw new Error(`Unknown CE stage: ${session.stage}`);
|
||||
|
||||
// Replay is side-effect-suppressed — including live progress, which would
|
||||
// otherwise re-stream the old turns' output as if it were new work.
|
||||
this.replaying.add(session.id);
|
||||
try {
|
||||
await this.rehydrateReplay(session, stage);
|
||||
} finally {
|
||||
this.replaying.delete(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async rehydrateReplay(session: CeSession, stage: CeStageDefinition): Promise<void> {
|
||||
const interactive = await this.factory!(this.buildSessionOptions(stage, session.id));
|
||||
const live = interactive.session;
|
||||
|
||||
// Walk the recorded user turns in order. The FIRST user turn is the opening
|
||||
// message (raw text); each subsequent user turn is a serialized
|
||||
// {answer, questionId} produced by answer(). Drive the seam with each, and
|
||||
// drain exactly one event per drive to advance the agent's context — but
|
||||
// suppress all side effects (no persist/emit/artifact-write).
|
||||
const userTurns = session.conversationHistory.filter((t) => t.role === "user");
|
||||
try {
|
||||
for (let i = 0; i < userTurns.length; i++) {
|
||||
const turn = userTurns[i];
|
||||
if (i === 0) {
|
||||
await live.prompt(turn.text);
|
||||
} else {
|
||||
const parsed = this.parseAnswerTurn(turn.text);
|
||||
if (!parsed) continue; // tolerate non-answer user turns.
|
||||
await live.answer(parsed.questionId, parsed.answer);
|
||||
}
|
||||
// Drain the agent's response for this turn to keep the seam in lockstep,
|
||||
// but DISCARD it — replay reconstructs context, it does not re-run the
|
||||
// turn loop's side effects.
|
||||
await live.nextEvent();
|
||||
}
|
||||
} catch (err) {
|
||||
// Replay failed mid-way — dispose the half-primed handle so we don't leave
|
||||
// a broken live session behind, then propagate.
|
||||
try {
|
||||
live.dispose();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.live.set(session.id, live);
|
||||
}
|
||||
|
||||
/** Parse a serialized `{ answer, questionId }` user turn produced by answer(). */
|
||||
private parseAnswerTurn(text: string): { questionId: string; answer: unknown } | undefined {
|
||||
try {
|
||||
const obj = JSON.parse(text) as { answer?: unknown; questionId?: unknown };
|
||||
if (typeof obj?.questionId === "string") {
|
||||
return { questionId: obj.questionId, answer: obj.answer };
|
||||
}
|
||||
} catch {
|
||||
// not a JSON answer turn
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Read-through accessor for routes. */
|
||||
getState(sessionId: string): CeSession | undefined {
|
||||
return this.store.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard a session: dispose any live in-process handle (so an in-flight
|
||||
* agent doesn't keep running unobserved) and delete the persisted row.
|
||||
* Returns false when the session doesn't exist. Pipeline-link rows are NOT
|
||||
* touched — board tasks the session landed keep their provenance records.
|
||||
*/
|
||||
discard(sessionId: string): boolean {
|
||||
this.disposeLive(sessionId);
|
||||
return this.store.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn behind a timeout race, persist the resulting event, and on a
|
||||
* turn-level failure auto-save + emit. The `driver` performs the prompt/answer
|
||||
* against the live session; we then pull exactly one event.
|
||||
*/
|
||||
private async runTurn(
|
||||
sessionId: string,
|
||||
driver: () => Promise<void>,
|
||||
live: InteractiveAiSession,
|
||||
): Promise<CeStepResult> {
|
||||
let event: InteractiveAiSessionEvent;
|
||||
const watchdog = this.createWatchdog(sessionId);
|
||||
try {
|
||||
event = await Promise.race([
|
||||
(async () => {
|
||||
await driver();
|
||||
return live.nextEvent();
|
||||
})(),
|
||||
watchdog.promise,
|
||||
]);
|
||||
} catch (err) {
|
||||
// Timeout or driver throw → auto-save as interrupted (progress preserved)
|
||||
// and emit an observable event. Never silent loss.
|
||||
watchdog.cancel();
|
||||
const session = this.interruptSession(sessionId, err);
|
||||
this.disposeLive(sessionId);
|
||||
return { session, event: { type: "error", data: { message: session.error ?? "interrupted", cause: err } } };
|
||||
}
|
||||
watchdog.cancel();
|
||||
|
||||
const session = this.applyEvent(sessionId, event);
|
||||
if (event.type === "complete" || event.type === "error") {
|
||||
this.disposeLive(sessionId);
|
||||
}
|
||||
// Work bridge (U7): the work stage's completion payload lands derived tasks
|
||||
// on the board, tagged CE-originated + recorded as pipeline links. Outbound
|
||||
// only — created tasks then run the NORMAL lifecycle with no plugin hooks.
|
||||
if (event.type === "complete" && session.stage === WORK_STAGE_ID) {
|
||||
await this.landWorkTasks(session, event.data);
|
||||
}
|
||||
return { session, event };
|
||||
}
|
||||
|
||||
/** Persist a seam event onto the session row + emit the matching observable event. */
|
||||
private applyEvent(sessionId: string, event: InteractiveAiSessionEvent): CeSession {
|
||||
// The turn settled — persist its working trace into history (so the
|
||||
// transcript keeps it) BEFORE the settling record, then clear the buffer.
|
||||
if (event.type === "question" || event.type === "complete" || event.type === "error") {
|
||||
this.flushActivity(sessionId);
|
||||
}
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
case "text": {
|
||||
this.store.appendHistory(sessionId, { role: "agent", text: event.data, at: new Date().toISOString() });
|
||||
const s = this.store.update(sessionId, { status: "active" }) ?? this.requireSession(sessionId);
|
||||
this.ctx.emitEvent(CE_EVENTS.turn, { sessionId, kind: event.type });
|
||||
return s;
|
||||
}
|
||||
case "question": {
|
||||
const q: PlanningQuestion = event.data;
|
||||
this.store.appendHistory(sessionId, {
|
||||
role: "agent",
|
||||
text: JSON.stringify({ question: q }),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
const s = this.store.update(sessionId, { status: "awaiting_input", currentQuestion: q }) ?? this.requireSession(sessionId);
|
||||
this.ctx.emitEvent(CE_EVENTS.question, { sessionId, questionId: q.id });
|
||||
return s;
|
||||
}
|
||||
case "complete": {
|
||||
const artifactPath = this.writeArtifact(sessionId, event.data);
|
||||
this.store.appendHistory(sessionId, {
|
||||
role: "agent",
|
||||
text: JSON.stringify({ complete: true }),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
const s =
|
||||
this.store.update(sessionId, { status: "completed", currentQuestion: null, artifactPath }) ??
|
||||
this.requireSession(sessionId);
|
||||
this.ctx.emitEvent(CE_EVENTS.completed, { sessionId, artifactPath });
|
||||
return s;
|
||||
}
|
||||
case "error": {
|
||||
const message = event.data.message;
|
||||
// Error preserves progress (currentQuestion/history untouched) so retry
|
||||
// can resume. Status error; observable event emitted.
|
||||
const s = this.store.update(sessionId, { status: "error", error: message }) ?? this.requireSession(sessionId);
|
||||
this.ctx.emitEvent(CE_EVENTS.error, { sessionId, message });
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Work bridge (U7). Read the derived task list from the work stage's
|
||||
* completion payload, create each as a board task tagged CE-originated, and
|
||||
* record a pipeline-link row resolving task→pipeline/stage/artifact. Zero
|
||||
* derived tasks is a clean no-op (no board tasks, no orphan link rows). The
|
||||
* created tasks then run the normal lifecycle — no hooks attached here (U8).
|
||||
*/
|
||||
private async landWorkTasks(session: CeSession, data: unknown): Promise<void> {
|
||||
const specs = this.extractTaskSpecs(data);
|
||||
if (specs.length === 0) return;
|
||||
|
||||
// The session is the CE pipeline run; its id is the stable pipeline id the
|
||||
// link rows (and U8's state machine) address.
|
||||
const cePipelineId = session.id;
|
||||
const ceStageId = session.stage;
|
||||
const ceArtifactPath = session.artifactPath ?? null;
|
||||
|
||||
// Seed the CE-pipeline STATE record (U8). This is the pipeline's OWN state
|
||||
// machine, distinct from the board task columns it will spawn. The pipeline
|
||||
// is "running" at this stage until a board signal advances it.
|
||||
this.pipelineStore.upsertState({
|
||||
cePipelineId,
|
||||
currentStage: ceStageId,
|
||||
status: "running",
|
||||
lastArtifactPath: ceArtifactPath,
|
||||
});
|
||||
|
||||
for (const spec of specs) {
|
||||
const description = spec.description.trim();
|
||||
if (!description) continue; // createTask rejects blank descriptions.
|
||||
|
||||
// Shared contract: create the CE-tagged board task AND its authoritative
|
||||
// pipeline-link row (FN-5719) in one place (see createCeTaskWithLink).
|
||||
await createCeTaskWithLink(this.ctx.taskStore, this.pipelineStore, {
|
||||
title: spec.title,
|
||||
description,
|
||||
column: spec.column,
|
||||
cePipelineId,
|
||||
ceStageId,
|
||||
ceArtifactPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the `{ tasks: [...] }` completion-payload contract; tolerant of shape. */
|
||||
private extractTaskSpecs(data: unknown): CeDerivedTaskSpec[] {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const raw = (data as { tasks?: unknown }).tasks;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const specs: CeDerivedTaskSpec[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
const description = typeof e.description === "string" ? e.description : "";
|
||||
if (!description.trim()) continue;
|
||||
specs.push({
|
||||
description,
|
||||
title: typeof e.title === "string" ? e.title : undefined,
|
||||
column: typeof e.column === "string" ? e.column : undefined,
|
||||
});
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
/** Persist `interrupted` with progress preserved and emit. */
|
||||
private interruptSession(sessionId: string, cause: unknown): CeSession {
|
||||
// Keep the working trace: an interrupted turn's output is exactly what the
|
||||
// user needs to see to understand where it stopped.
|
||||
this.flushActivity(sessionId);
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
const s =
|
||||
this.store.update(sessionId, { status: "interrupted", error: message }) ?? this.requireSession(sessionId);
|
||||
this.ctx.emitEvent(CE_EVENTS.interrupted, { sessionId, message });
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Persist `error` (session-create failure path) and emit. */
|
||||
private failSession(sessionId: string, cause: unknown): CeSession {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
const s = this.store.update(sessionId, { status: "error", error: message }) ?? this.requireSession(sessionId);
|
||||
this.ctx.emitEvent(CE_EVENTS.error, { sessionId, message });
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the stage artifact to its conventional location (R10). Accepts either
|
||||
* a `{ artifact: string }` payload or a raw string. Returns the absolute path.
|
||||
*/
|
||||
private writeArtifact(sessionId: string, data: unknown): string {
|
||||
const session = this.requireSession(sessionId);
|
||||
const stage = getStage(session.stage);
|
||||
const location = stage?.artifactLocation ?? `docs/ce/${session.stage}/`;
|
||||
const content = this.extractArtifactContent(data);
|
||||
|
||||
const target = location.endsWith("/")
|
||||
? join(location, `${session.stage}-${session.id}.md`)
|
||||
: location;
|
||||
const abs = isAbsolute(target) ? target : join(this.projectRoot, target);
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, content, "utf-8");
|
||||
return abs;
|
||||
}
|
||||
|
||||
private extractArtifactContent(data: unknown): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (data && typeof data === "object" && "artifact" in data) {
|
||||
const a = (data as { artifact: unknown }).artifact;
|
||||
if (typeof a === "string") return a;
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
private requireSession(sessionId: string): CeSession {
|
||||
const s = this.store.get(sessionId);
|
||||
if (!s) throw new Error(`CE session not found: ${sessionId}`);
|
||||
return s;
|
||||
}
|
||||
|
||||
private disposeLive(sessionId: string): void {
|
||||
const live = this.live.get(sessionId);
|
||||
if (live) {
|
||||
try {
|
||||
live.dispose();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
this.live.delete(sessionId);
|
||||
}
|
||||
this.activity.delete(sessionId);
|
||||
this.lastProgressAt.delete(sessionId);
|
||||
this.lastProgressEmitAt.delete(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Database, PlanningQuestion, PluginContext } from "@fusion/core";
|
||||
import { ensureCeSchema } from "../schema.js";
|
||||
|
||||
/**
|
||||
* CE session lifecycle states (mirrors the plan's state machine):
|
||||
* launching → active → awaiting_input ↔ active → completed | error | interrupted;
|
||||
* interrupted/error → active on resume/retry.
|
||||
*/
|
||||
export const CE_SESSION_STATUSES = [
|
||||
"launching",
|
||||
"active",
|
||||
"awaiting_input",
|
||||
"completed",
|
||||
"error",
|
||||
"interrupted",
|
||||
] as const;
|
||||
|
||||
export type CeSessionStatus = (typeof CE_SESSION_STATUSES)[number];
|
||||
|
||||
/** Narrow an arbitrary string (e.g. a query param) to a valid status, else undefined. */
|
||||
export function asCeSessionStatus(value: string | undefined): CeSessionStatus | undefined {
|
||||
return value && (CE_SESSION_STATUSES as readonly string[]).includes(value)
|
||||
? (value as CeSessionStatus)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** A single recorded turn in the conversation history (for resume). */
|
||||
export interface CeConversationTurn {
|
||||
role: "user" | "agent";
|
||||
/** Free text, or a serialized question/answer marker. */
|
||||
text: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of live agent activity (mid-turn working output): an accumulated
|
||||
* thinking/text block or a discrete tool execution marker.
|
||||
*/
|
||||
export interface CeActivityTurn {
|
||||
kind: "thinking" | "text" | "tool";
|
||||
text: string;
|
||||
at: string;
|
||||
/** Tool turns: execution finished. */
|
||||
done?: boolean;
|
||||
/** Tool turns: execution finished with an error. */
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface CeSession {
|
||||
id: string;
|
||||
stage: string;
|
||||
status: CeSessionStatus;
|
||||
currentQuestion: PlanningQuestion | null;
|
||||
conversationHistory: CeConversationTurn[];
|
||||
/**
|
||||
* TRANSIENT: in-flight working output for the current turn, attached by the
|
||||
* GET-session route from the orchestrator's in-memory buffer. Never persisted
|
||||
* to the row; absent when no turn is running (or in another process).
|
||||
*/
|
||||
liveActivity?: CeActivityTurn[];
|
||||
projectId: string | null;
|
||||
artifactPath: string | null;
|
||||
error: string | null;
|
||||
/** Expected per-turn interval (ms); drives interval-relative staleness. */
|
||||
turnIntervalMs: number;
|
||||
/** Epoch millis of the last produced event (liveness anchor). */
|
||||
lastActivityAt: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CeSessionRow {
|
||||
id: string;
|
||||
stage: string;
|
||||
status: CeSessionStatus;
|
||||
currentQuestion: string | null;
|
||||
conversationHistory: string;
|
||||
projectId: string | null;
|
||||
artifactPath: string | null;
|
||||
error: string | null;
|
||||
turnIntervalMs: number;
|
||||
lastActivityAt: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCeSessionInput {
|
||||
stage: string;
|
||||
projectId?: string | null;
|
||||
artifactPath?: string | null;
|
||||
turnIntervalMs?: number;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default multiple of the turn interval beyond which a non-terminal session is
|
||||
* considered stale. Mirrors the FN-4172 rubric (`> 3× interval`), interval-
|
||||
* relative rather than a raw last-event age.
|
||||
*/
|
||||
export const STALE_INTERVAL_MULTIPLE = 3;
|
||||
|
||||
const DEFAULT_TURN_INTERVAL_MS = 120000;
|
||||
|
||||
/**
|
||||
* Parse a JSON column, falling back to `fallback` when it is missing, fails to
|
||||
* parse (syntax error), OR parses to the wrong shape. Shape validation matters:
|
||||
* a column holding `'null'` or `'{}'` parses fine but would yield a non-array
|
||||
* `conversationHistory` that later crashes `appendHistory`'s spread — so a
|
||||
* semantically-corrupt value is treated exactly like a syntactically-corrupt one.
|
||||
*/
|
||||
function safeParse<T>(raw: string | null, fallback: T, isValid: (value: unknown) => value is T): T {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isValid(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
// A corrupted JSON column must not crash reads of an otherwise-valid row
|
||||
// (and must not destroy the rest of the session). Degrade to the fallback;
|
||||
// the row's status/error still surface the session's real state.
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function isConversationHistory(value: unknown): value is CeConversationTurn[] {
|
||||
return (
|
||||
Array.isArray(value)
|
||||
&& value.every((turn) => {
|
||||
if (typeof turn !== "object" || turn === null) return false;
|
||||
const t = turn as Record<string, unknown>;
|
||||
return (t.role === "user" || t.role === "agent") && typeof t.text === "string" && typeof t.at === "string";
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isPlanningQuestionOrNull(value: unknown): value is PlanningQuestion | null {
|
||||
if (value === null) return true;
|
||||
if (typeof value !== "object") return false;
|
||||
const q = value as Record<string, unknown>;
|
||||
return typeof q.id === "string" && typeof q.type === "string" && typeof q.question === "string";
|
||||
}
|
||||
|
||||
function rowToSession(row: CeSessionRow): CeSession {
|
||||
return {
|
||||
id: row.id,
|
||||
stage: row.stage,
|
||||
status: row.status,
|
||||
currentQuestion: safeParse<PlanningQuestion | null>(row.currentQuestion, null, isPlanningQuestionOrNull),
|
||||
conversationHistory: safeParse<CeConversationTurn[]>(row.conversationHistory, [], isConversationHistory),
|
||||
projectId: row.projectId,
|
||||
artifactPath: row.artifactPath,
|
||||
error: row.error,
|
||||
turnIntervalMs: row.turnIntervalMs,
|
||||
lastActivityAt: row.lastActivityAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-local persistence for CE interactive sessions. Reaches the DB the same
|
||||
* way reports does (via `ctx.taskStore.getDatabase()`), and ensures its schema
|
||||
* defensively on construction so a store created before `onSchemaInit` ran (or
|
||||
* in a test) still works.
|
||||
*/
|
||||
export class CeSessionStore {
|
||||
private readonly db: Database;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
ensureCeSchema(db);
|
||||
}
|
||||
|
||||
create(input: CreateCeSessionInput): CeSession {
|
||||
const now = new Date().toISOString();
|
||||
const session: CeSession = {
|
||||
id: input.id ?? randomUUID(),
|
||||
stage: input.stage,
|
||||
status: "launching",
|
||||
currentQuestion: null,
|
||||
conversationHistory: [],
|
||||
projectId: input.projectId ?? null,
|
||||
artifactPath: input.artifactPath ?? null,
|
||||
error: null,
|
||||
turnIntervalMs: input.turnIntervalMs ?? DEFAULT_TURN_INTERVAL_MS,
|
||||
lastActivityAt: Date.now(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO ce_sessions
|
||||
(id, stage, status, currentQuestion, conversationHistory, projectId, artifactPath, error, turnIntervalMs, lastActivityAt, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
session.id,
|
||||
session.stage,
|
||||
session.status,
|
||||
null,
|
||||
JSON.stringify(session.conversationHistory),
|
||||
session.projectId,
|
||||
session.artifactPath,
|
||||
null,
|
||||
session.turnIntervalMs,
|
||||
session.lastActivityAt,
|
||||
session.createdAt,
|
||||
session.updatedAt,
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
get(id: string): CeSession | undefined {
|
||||
const row = this.db.prepare(`SELECT * FROM ce_sessions WHERE id = ?`).get(id) as CeSessionRow | undefined;
|
||||
return row ? rowToSession(row) : undefined;
|
||||
}
|
||||
|
||||
list(filter: { status?: CeSessionStatus; stage?: string } = {}): CeSession[] {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (filter.status) {
|
||||
clauses.push("status = ?");
|
||||
params.push(filter.status);
|
||||
}
|
||||
if (filter.stage) {
|
||||
clauses.push("stage = ?");
|
||||
params.push(filter.stage);
|
||||
}
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM ce_sessions ${where} ORDER BY updatedAt DESC, id`)
|
||||
.all(...params) as CeSessionRow[];
|
||||
return rows.map(rowToSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a session. Always bumps `updatedAt`; bumps `lastActivityAt` unless the
|
||||
* caller explicitly overrides it (used by liveness tests to simulate age).
|
||||
*/
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CeSession,
|
||||
"status" | "currentQuestion" | "conversationHistory" | "artifactPath" | "error" | "lastActivityAt" | "projectId"
|
||||
>
|
||||
>,
|
||||
): CeSession | undefined {
|
||||
const existing = this.get(id);
|
||||
if (!existing) return undefined;
|
||||
const next: CeSession = {
|
||||
...existing,
|
||||
...patch,
|
||||
lastActivityAt: patch.lastActivityAt ?? Date.now(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE ce_sessions SET
|
||||
status = ?, currentQuestion = ?, conversationHistory = ?, projectId = ?,
|
||||
artifactPath = ?, error = ?, lastActivityAt = ?, updatedAt = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(
|
||||
next.status,
|
||||
next.currentQuestion ? JSON.stringify(next.currentQuestion) : null,
|
||||
JSON.stringify(next.conversationHistory),
|
||||
next.projectId,
|
||||
next.artifactPath,
|
||||
next.error,
|
||||
next.lastActivityAt,
|
||||
next.updatedAt,
|
||||
id,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Delete a session row. Returns true when a row was removed. */
|
||||
delete(id: string): boolean {
|
||||
const result = this.db.prepare(`DELETE FROM ce_sessions WHERE id = ?`).run(id);
|
||||
return Number(result.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Append a turn to the conversation history (no other field touched). */
|
||||
appendHistory(id: string, turn: CeConversationTurn): CeSession | undefined {
|
||||
const existing = this.get(id);
|
||||
if (!existing) return undefined;
|
||||
return this.update(id, { conversationHistory: [...existing.conversationHistory, turn] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval-relative staleness: a non-terminal session is stale only when its
|
||||
* last activity is older than `multiple × turnIntervalMs`. A healthy-but-slow
|
||||
* session (within the interval band) is NOT stale. Terminal sessions
|
||||
* (completed/error/interrupted) are never "stale" — they are already settled.
|
||||
*/
|
||||
isStale(session: CeSession, now = Date.now(), multiple = STALE_INTERVAL_MULTIPLE): boolean {
|
||||
if (session.status === "completed" || session.status === "error" || session.status === "interrupted") {
|
||||
return false;
|
||||
}
|
||||
return now - session.lastActivityAt > multiple * session.turnIntervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover sessions left non-terminal by a crash/restart. A session with a
|
||||
* persisted `currentQuestion` is restored to `awaiting_input` (resumable);
|
||||
* one without is marked `interrupted` with its progress preserved — never
|
||||
* silently dropped. Returns the ids transitioned.
|
||||
*/
|
||||
recoverStaleSessions(now = Date.now(), multiple = STALE_INTERVAL_MULTIPLE): string[] {
|
||||
// Only IN-FLIGHT agent turns are subject to the interval-staleness rubric:
|
||||
// `active`/`launching` mean an agent turn should be progressing, so exceeding
|
||||
// the interval band signals a crashed/abandoned turn worth recovering.
|
||||
//
|
||||
// `awaiting_input` is DELIBERATELY excluded: a session waiting on human input
|
||||
// is not a crashed turn — human response time is unbounded, and the interval
|
||||
// rubric measures agent turns, not human waits. Flagging it stale would
|
||||
// misclassify a legitimately-paused session. It is already in its resumable
|
||||
// state, so no recovery action is needed.
|
||||
const candidates = this.list().filter(
|
||||
(s) => (s.status === "active" || s.status === "launching") && this.isStale(s, now, multiple),
|
||||
);
|
||||
const recovered: string[] = [];
|
||||
for (const s of candidates) {
|
||||
if (s.currentQuestion) {
|
||||
this.update(s.id, { status: "awaiting_input" });
|
||||
} else {
|
||||
this.update(s.id, { status: "interrupted", error: s.error ?? "Session interrupted — progress preserved, resume to continue" });
|
||||
}
|
||||
recovered.push(s.id);
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
|
||||
const storeCache = new WeakMap<object, CeSessionStore>();
|
||||
|
||||
/** WeakMap-cached store keyed by the TaskStore instance (mirrors reports). */
|
||||
export function getCeSessionStore(ctx: PluginContext): CeSessionStore {
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = storeCache.get(key);
|
||||
if (cached) return cached;
|
||||
const store = new CeSessionStore(ctx.taskStore.getDatabase());
|
||||
storeCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Generic stage registry (KTD6).
|
||||
*
|
||||
* A single map takes each stage → `{ skillId, artifact location/glob,
|
||||
* presentation metadata }`. The orchestrator needs `skillId` +
|
||||
* `artifactLocation` to launch a stage and write its `complete` output (R10);
|
||||
* the dashboard needs `icon` + `label` (+ optional `artifactGlob`) to list and
|
||||
* render the launcher (R4). Adding a stage is a data entry in this map — no new
|
||||
* route, store, or screen code. "Which stages render richly vs. fall back to
|
||||
* chat" is measured by the U6 skill-interaction audit, not assumed here.
|
||||
*/
|
||||
|
||||
export interface CeStageDefinition {
|
||||
/** Stage id (stable, kebab-case). */
|
||||
stageId: string;
|
||||
/**
|
||||
* Explicit pipeline ordinal. Pipeline progression (nextStageAfter) sorts by
|
||||
* THIS value, NOT by registry/Map insertion order — so a stage registered out
|
||||
* of order, or inserted mid-pipeline later, advances correctly. Lower runs
|
||||
* earlier; values need not be contiguous (gaps leave room to insert between).
|
||||
*/
|
||||
order: number;
|
||||
/** Bundled skill the orchestrator loads for this stage. */
|
||||
skillId: string;
|
||||
/**
|
||||
* Conventional artifact location for this stage's `complete` output,
|
||||
* project-root-relative. When the path ends in `/` the orchestrator writes a
|
||||
* timestamped file inside that directory; otherwise it writes that exact file.
|
||||
*/
|
||||
artifactLocation: string;
|
||||
/**
|
||||
* lucide-react icon name for the launcher tile (a string, resolved to a
|
||||
* component in the dashboard so the registry stays a pure-data module with no
|
||||
* React import). Must match an export of `lucide-react`.
|
||||
*/
|
||||
icon: string;
|
||||
/** Human label for the launcher tile. */
|
||||
label: string;
|
||||
/**
|
||||
* Optional glob (project-root-relative) describing where this stage's
|
||||
* artifacts live for hub discovery. Defaults are derived from
|
||||
* `artifactLocation` when omitted.
|
||||
*/
|
||||
artifactGlob?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first registration slice. Locations mirror where the real ce-* skills
|
||||
* write today (STRATEGY.md, docs/ideation/, docs/brainstorms/, docs/plans/).
|
||||
* Icons are lucide-react export names.
|
||||
*/
|
||||
const STAGE_DEFINITIONS: CeStageDefinition[] = [
|
||||
{
|
||||
stageId: "strategy",
|
||||
order: 100,
|
||||
skillId: "ce-strategy",
|
||||
artifactLocation: "STRATEGY.md",
|
||||
icon: "Compass",
|
||||
label: "Strategy",
|
||||
artifactGlob: "STRATEGY.md",
|
||||
},
|
||||
{
|
||||
stageId: "ideate",
|
||||
order: 200,
|
||||
skillId: "ce-ideate",
|
||||
artifactLocation: "docs/ideation/",
|
||||
icon: "Lightbulb",
|
||||
label: "Ideate",
|
||||
artifactGlob: "docs/ideation/**/*.md",
|
||||
},
|
||||
{
|
||||
stageId: "brainstorm",
|
||||
order: 300,
|
||||
skillId: "ce-brainstorm",
|
||||
artifactLocation: "docs/brainstorms/",
|
||||
icon: "Sparkles",
|
||||
label: "Brainstorm",
|
||||
artifactGlob: "docs/brainstorms/**/*.md",
|
||||
},
|
||||
{
|
||||
stageId: "plan",
|
||||
order: 400,
|
||||
skillId: "ce-plan",
|
||||
artifactLocation: "docs/plans/",
|
||||
icon: "ListChecks",
|
||||
label: "Plan",
|
||||
artifactGlob: "docs/plans/**/*.md",
|
||||
},
|
||||
{
|
||||
// The work stage (U7). Its `ce-work` skill drives execution and, on
|
||||
// `complete`, carries a derived task list that the orchestrator lands on the
|
||||
// board (tagged CE-originated + recorded as pipeline links). The artifact is
|
||||
// the work log / summary for this stage.
|
||||
stageId: "work",
|
||||
order: 500,
|
||||
skillId: "ce-work",
|
||||
artifactLocation: "docs/work/",
|
||||
icon: "Hammer",
|
||||
label: "Work",
|
||||
artifactGlob: "docs/work/**/*.md",
|
||||
},
|
||||
];
|
||||
|
||||
const REGISTRY = new Map<string, CeStageDefinition>(STAGE_DEFINITIONS.map((s) => [s.stageId, s]));
|
||||
|
||||
export function getStage(stageId: string): CeStageDefinition | undefined {
|
||||
return REGISTRY.get(stageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* All registered stages sorted by their explicit `order` ordinal (NOT Map
|
||||
* insertion order). Ties break by `stageId` for a stable, deterministic order.
|
||||
*/
|
||||
export function listStages(): CeStageDefinition[] {
|
||||
return [...REGISTRY.values()].sort((a, b) => a.order - b.order || a.stageId.localeCompare(b.stageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an additional stage at runtime (used by tests to prove "adding a
|
||||
* stage requires only data"). Production stages live in STAGE_DEFINITIONS.
|
||||
*/
|
||||
export function registerStage(def: CeStageDefinition): void {
|
||||
REGISTRY.set(def.stageId, def);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a runtime-registered stage. Production stages from STAGE_DEFINITIONS are
|
||||
* protected (no-op) so tests can't accidentally drop a built-in stage. Used by
|
||||
* tests to keep the shared global registry clean across cases.
|
||||
*/
|
||||
export function unregisterStage(stageId: string): void {
|
||||
if (STAGE_DEFINITIONS.some((s) => s.stageId === stageId)) return;
|
||||
REGISTRY.delete(stageId);
|
||||
}
|
||||
131
plugins/fusion-plugin-compound-engineering/src/settings.ts
Normal file
131
plugins/fusion-plugin-compound-engineering/src/settings.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { PluginSettingSchema } from "@fusion/plugin-sdk";
|
||||
import { listStages } from "./session/stage-registry.js";
|
||||
|
||||
/**
|
||||
* Operator-facing settings for the Compound Engineering plugin (U9).
|
||||
*
|
||||
* Grouped like `fusion-plugin-reports`. Every setting here has a real, honest
|
||||
* consumption point in the existing plugin code:
|
||||
* - Sessions group → the orchestrator's interactive-session factory call
|
||||
* (`defaultProvider`/`defaultModelId`) and the launch
|
||||
* guard (`enabledStages`).
|
||||
* - Sync group → the reconciler trigger surface (auto-drain on hooks +
|
||||
* the cadence hint a refresh surface reads).
|
||||
*
|
||||
* `DEFAULT_*` consts are the single source of truth shared by the schema
|
||||
* defaults, the typed getters, and the settings test.
|
||||
*/
|
||||
|
||||
/** Sessions: default provider/model for CE interactive sessions. */
|
||||
export const DEFAULT_PROVIDER = "";
|
||||
export const DEFAULT_MODEL_ID = "";
|
||||
|
||||
/** Sessions: which pipeline stages are launchable. Defaults to the full registry. */
|
||||
export const DEFAULT_ENABLED_STAGES: string[] = listStages().map((s) => s.stageId);
|
||||
|
||||
/** Sync: whether the board→pipeline reconcile sweep auto-fires after lifecycle hooks. */
|
||||
export const DEFAULT_RECONCILE_ON_HOOKS = true;
|
||||
/**
|
||||
* Sync: cadence hint (minutes) a refresh/poll-fallback surface uses when it
|
||||
* sweeps the reconciler on demand. This is a HINT, not a host scheduler — there
|
||||
* is no continuous poll loop (per docs/performance/dashboard-load.md); a refresh
|
||||
* surface reads this to decide how often to offer/auto-trigger a manual sweep.
|
||||
*/
|
||||
export const DEFAULT_RECONCILE_INTERVAL_MINUTES = 15;
|
||||
|
||||
export const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
defaultProvider: {
|
||||
type: "string",
|
||||
label: "Default Session Provider",
|
||||
description: "Model provider used for CE interactive sessions (for example anthropic). Leave blank to use the host default.",
|
||||
group: "Sessions",
|
||||
defaultValue: DEFAULT_PROVIDER,
|
||||
},
|
||||
defaultModelId: {
|
||||
type: "string",
|
||||
label: "Default Session Model",
|
||||
description: "Model ID within the provider used for CE interactive sessions. Leave blank to use the host default.",
|
||||
group: "Sessions",
|
||||
defaultValue: DEFAULT_MODEL_ID,
|
||||
},
|
||||
enabledStages: {
|
||||
type: "array",
|
||||
itemType: "string",
|
||||
label: "Enabled Stages",
|
||||
description: "Stage IDs that may be launched from the Compound Engineering view (for example strategy, ideate, brainstorm, plan, work).",
|
||||
group: "Sessions",
|
||||
defaultValue: DEFAULT_ENABLED_STAGES,
|
||||
},
|
||||
|
||||
reconcileOnHooks: {
|
||||
type: "boolean",
|
||||
label: "Reconcile on Board Changes",
|
||||
description: "Run the board→pipeline reconcile sweep automatically after task move/complete hooks. Disable to only reconcile on demand.",
|
||||
group: "Sync",
|
||||
defaultValue: DEFAULT_RECONCILE_ON_HOOKS,
|
||||
},
|
||||
reconcileIntervalMinutes: {
|
||||
type: "number",
|
||||
label: "Reconcile Cadence (minutes)",
|
||||
description: "Cadence hint for how often an on-demand refresh surface sweeps the reconciler. Not a continuous poll loop.",
|
||||
group: "Sync",
|
||||
defaultValue: DEFAULT_RECONCILE_INTERVAL_MINUTES,
|
||||
},
|
||||
};
|
||||
|
||||
function asString(settings: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = settings[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asBoolean(settings: Record<string, unknown>, key: string, fallback: boolean): boolean {
|
||||
const value = settings[key];
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(settings: Record<string, unknown>, key: string, fallback: number): number {
|
||||
const value = settings[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function asStringArray(settings: Record<string, unknown>, key: string, fallback: string[]): string[] {
|
||||
const value = settings[key];
|
||||
if (!Array.isArray(value)) return [...fallback];
|
||||
const normalized = value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
||||
return normalized.length > 0 ? normalized : [...fallback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default provider for CE sessions. Returns `undefined` when unset so the
|
||||
* orchestrator can omit it and let the host pick its default provider.
|
||||
*/
|
||||
export function getDefaultProvider(settings: Record<string, unknown>): string | undefined {
|
||||
return asString(settings, "defaultProvider");
|
||||
}
|
||||
|
||||
/**
|
||||
* Default model ID for CE sessions. Returns `undefined` when unset so the
|
||||
* orchestrator can omit it and let the host pick its default model.
|
||||
*/
|
||||
export function getDefaultModelId(settings: Record<string, unknown>): string | undefined {
|
||||
return asString(settings, "defaultModelId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage IDs that may be launched. When unset, defaults to the LIVE registry
|
||||
* (re-read here, not the import-time snapshot) so a stage registered at runtime
|
||||
* is launchable by default — disabling is an explicit opt-out, not opt-in.
|
||||
*/
|
||||
export function getEnabledStages(settings: Record<string, unknown>): string[] {
|
||||
return asStringArray(settings, "enabledStages", listStages().map((s) => s.stageId));
|
||||
}
|
||||
|
||||
/** Whether the reconcile sweep auto-fires after lifecycle hooks. */
|
||||
export function getReconcileOnHooks(settings: Record<string, unknown>): boolean {
|
||||
return asBoolean(settings, "reconcileOnHooks", DEFAULT_RECONCILE_ON_HOOKS);
|
||||
}
|
||||
|
||||
/** On-demand reconcile cadence hint in minutes (>= 1). */
|
||||
export function getReconcileIntervalMinutes(settings: Record<string, unknown>): number {
|
||||
return Math.max(1, Math.floor(asNumber(settings, "reconcileIntervalMinutes", DEFAULT_RECONCILE_INTERVAL_MINUTES)));
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { COMPOUND_ENGINEERING_SKILLS } from "./skills.js";
|
||||
|
||||
/**
|
||||
* Physical install of the bundled Compound Engineering skills.
|
||||
*
|
||||
* EMPIRICAL FINDING (U2): the engine never ingests
|
||||
* `PluginSkillContribution.skillFiles` into the set of skills that
|
||||
* pi-coding-agent's `DefaultResourceLoader`/`loadSkills` discovers. The
|
||||
* contribution only contributes a *name* to `requestedSkillNames`, which the
|
||||
* skill-resolver then tries to MATCH against skills already discovered from
|
||||
* disk. If no `SKILL.md` for that name was discovered, the requested name
|
||||
* resolves to nothing. Therefore a physical install into a discoverable,
|
||||
* PLUGIN-LOCAL skills directory is required.
|
||||
*
|
||||
* This mirrors the cpSync + skip-if-exists pattern of
|
||||
* `installBundledFusionSkill` (packages/cli) but the target is ALWAYS
|
||||
* plugin-local — it MUST NOT be a global `<home>/.claude/skills` path (R12/AE2).
|
||||
* The installed directory is intended to be wired into a session via
|
||||
* `additionalSkillPaths` (engine-side, in later units), keeping discovery
|
||||
* scoped to the plugin and never clobbering a user's global install.
|
||||
*/
|
||||
|
||||
export type CeSkillInstallOutcome = "installed" | "skipped" | "error";
|
||||
|
||||
export interface CeSkillInstallResult {
|
||||
skillId: string;
|
||||
sourceDir: string;
|
||||
targetDir: string;
|
||||
outcome: CeSkillInstallOutcome;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface InstallBundledCeSkillsResult {
|
||||
targetRoot: string;
|
||||
results: CeSkillInstallResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the plugin's bundled `src/skills` directory (the pinned
|
||||
* source of truth). Resolved relative to this module so it is correct whether
|
||||
* running from `src` (tests/dev) or `dist` (build output).
|
||||
*/
|
||||
export function resolveBundledSkillsRoot(): string {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
// src/skill-installation.ts -> src/skills ; dist/skill-installation.js -> the
|
||||
// bundled skills live next to source under src/skills, so when running from
|
||||
// dist we walk up one and into src/skills.
|
||||
const dir = dirname(here);
|
||||
const local = resolve(dir, "skills");
|
||||
if (existsSync(local)) return local;
|
||||
return resolve(dir, "..", "src", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Default plugin-local install target. Lives under the plugin package root
|
||||
* (`.fusion-ce-skills/`), which is ALWAYS plugin-local and never a global
|
||||
* client skills directory. Callers may override via `targetRoot` (e.g. tests),
|
||||
* but a guard rejects any target inside a global ".claude"/".codex"/".gemini"
|
||||
* skills tree.
|
||||
*/
|
||||
export function resolveDefaultInstallTargetRoot(): string {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
// <pkg>/(src|dist)/skill-installation.* -> <pkg>/.fusion-ce-skills
|
||||
return resolve(dirname(here), "..", ".fusion-ce-skills");
|
||||
}
|
||||
|
||||
const GLOBAL_SKILL_DIR_PATTERN = /[\\/]\.(claude|codex|gemini)[\\/]skills([\\/]|$)/;
|
||||
|
||||
/**
|
||||
* Guard: refuse to install into a global client skills directory.
|
||||
* This is the AE2 isolation invariant — the global compound-engineering install
|
||||
* (if present) must be provably untouched.
|
||||
*/
|
||||
export function assertPluginLocalTarget(targetRoot: string): void {
|
||||
const normalized = resolve(targetRoot);
|
||||
if (GLOBAL_SKILL_DIR_PATTERN.test(normalized + sep)) {
|
||||
throw new Error(
|
||||
`Refusing to install Compound Engineering skills into a global client skills directory: ${normalized}. ` +
|
||||
`Install target MUST be plugin-local (never <home>/.claude|.codex|.gemini/skills).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a bundled SKILL.md exists and has a non-empty frontmatter
|
||||
* `name:`. A malformed/missing file surfaces a clear error instead of being
|
||||
* silently skipped.
|
||||
*/
|
||||
function assertValidSkillSource(skillId: string, sourceDir: string): void {
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`Bundled skill source directory missing for '${skillId}': ${sourceDir}`);
|
||||
}
|
||||
const skillMd = join(sourceDir, "SKILL.md");
|
||||
if (!existsSync(skillMd)) {
|
||||
throw new Error(`Bundled skill '${skillId}' has no SKILL.md at ${skillMd}`);
|
||||
}
|
||||
const content = readFileSync(skillMd, "utf-8");
|
||||
if (!/^---[\s\S]*?\bname\s*:\s*\S/m.test(content)) {
|
||||
throw new Error(
|
||||
`Bundled skill '${skillId}' SKILL.md at ${skillMd} is missing a frontmatter 'name:' field`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallBundledCeSkillsOptions {
|
||||
/** Override the install target root (must be plugin-local). */
|
||||
targetRoot?: string;
|
||||
/** Override the bundled source root (tests). */
|
||||
sourceRoot?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy each bundled CE skill directory into the plugin-local install target.
|
||||
* Idempotent: existing per-skill target dirs are preserved (skip-if-exists).
|
||||
*/
|
||||
export function installBundledCeSkills(
|
||||
options: InstallBundledCeSkillsOptions = {},
|
||||
): InstallBundledCeSkillsResult {
|
||||
const targetRoot = options.targetRoot
|
||||
? resolve(options.targetRoot)
|
||||
: resolveDefaultInstallTargetRoot();
|
||||
assertPluginLocalTarget(targetRoot);
|
||||
|
||||
const sourceRoot = options.sourceRoot ? resolve(options.sourceRoot) : resolveBundledSkillsRoot();
|
||||
|
||||
const results = COMPOUND_ENGINEERING_SKILLS.map<CeSkillInstallResult>((skill) => {
|
||||
const sourceDir = join(sourceRoot, skill.skillId);
|
||||
const targetDir = join(targetRoot, skill.skillId);
|
||||
try {
|
||||
assertValidSkillSource(skill.skillId, sourceDir);
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
return { skillId: skill.skillId, sourceDir, targetDir, outcome: "skipped", reason: "existing install preserved" };
|
||||
}
|
||||
|
||||
mkdirSync(targetRoot, { recursive: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true });
|
||||
return { skillId: skill.skillId, sourceDir, targetDir, outcome: "installed" };
|
||||
} catch (error) {
|
||||
return {
|
||||
skillId: skill.skillId,
|
||||
sourceDir,
|
||||
targetDir,
|
||||
outcome: "error",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return { targetRoot, results };
|
||||
}
|
||||
|
||||
/** True if the given path is absolute and not inside a global client skills dir. */
|
||||
export function isPluginLocalPath(p: string): boolean {
|
||||
return isAbsolute(p) && !GLOBAL_SKILL_DIR_PATTERN.test(resolve(p) + sep);
|
||||
}
|
||||
79
plugins/fusion-plugin-compound-engineering/src/skills.ts
Normal file
79
plugins/fusion-plugin-compound-engineering/src/skills.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { PluginSkillContribution } from "@fusion/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Compound Engineering pipeline-stage skills, bundled (pinned) inside the plugin.
|
||||
*
|
||||
* Each entry's `skillFiles` is plugin-root-relative and points at a `SKILL.md`
|
||||
* physically shipped under `src/skills/<skillId>/`. The bundled copy is a pinned
|
||||
* snapshot (KTD5) — never a symlink to the global compound-engineering cache —
|
||||
* so registering it can never clobber a user's global install (R12).
|
||||
*
|
||||
* The frontmatter `name` in each bundled SKILL.md equals the directory name
|
||||
* (e.g. `ce-brainstorm`), so `skillId === name` here. pi-coding-agent's
|
||||
* `loadSkills` derives `Skill.name` from that frontmatter, which is what the
|
||||
* engine skill-resolver matches against.
|
||||
*/
|
||||
export const COMPOUND_ENGINEERING_SKILLS: PluginSkillContribution[] = [
|
||||
{
|
||||
skillId: "ce-strategy",
|
||||
name: "ce-strategy",
|
||||
description:
|
||||
"Create or maintain STRATEGY.md — the product's target problem, approach, users, key metrics, and tracks of work.",
|
||||
skillFiles: ["skills/ce-strategy/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["strategy", "roadmap", "what are we working on", "set up the strategy doc"],
|
||||
},
|
||||
{
|
||||
skillId: "ce-ideate",
|
||||
name: "ce-ideate",
|
||||
description:
|
||||
"Generate and critically evaluate grounded ideas about a topic before committing to one direction.",
|
||||
skillFiles: ["skills/ce-ideate/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["ideate", "give me ideas", "what should I improve", "surprise me"],
|
||||
},
|
||||
{
|
||||
skillId: "ce-brainstorm",
|
||||
name: "ce-brainstorm",
|
||||
description:
|
||||
"Explore requirements and approaches through collaborative dialogue, then write a right-sized requirements document.",
|
||||
skillFiles: ["skills/ce-brainstorm/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["brainstorm", "what should we build", "help me think through"],
|
||||
},
|
||||
{
|
||||
skillId: "ce-plan",
|
||||
name: "ce-plan",
|
||||
description:
|
||||
"Create structured plans for multi-step tasks and optionally deepen existing plans via sub-agent review.",
|
||||
skillFiles: ["skills/ce-plan/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["plan this", "create a plan", "break this down", "deepen the plan"],
|
||||
},
|
||||
{
|
||||
skillId: "ce-work",
|
||||
name: "ce-work",
|
||||
description: "Execute work efficiently while maintaining quality and finishing features.",
|
||||
skillFiles: ["skills/ce-work/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["do the work", "implement", "execute the plan", "finish this feature"],
|
||||
},
|
||||
{
|
||||
skillId: "ce-code-review",
|
||||
name: "ce-code-review",
|
||||
description:
|
||||
"Structured code review using tiered persona agents, confidence-gated findings, and a merge/dedup pipeline.",
|
||||
skillFiles: ["skills/ce-code-review/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["code review", "review this change", "review before PR"],
|
||||
},
|
||||
{
|
||||
skillId: "ce-compound",
|
||||
name: "ce-compound",
|
||||
description:
|
||||
"Document a recently solved problem to compound the team's knowledge or the project's shared CONCEPTS.md vocabulary.",
|
||||
skillFiles: ["skills/ce-compound/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["compound this", "document this learning", "capture this solution"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
name: ce-brainstorm
|
||||
description: 'Explore requirements and approaches through collaborative dialogue, then write a right-sized requirements document. Use when the user says "let''s brainstorm", "what should we build", or "help me think through X", presents a vague or ambitious feature request, or seems unsure about scope or direction -- even without explicitly asking to brainstorm.'
|
||||
argument-hint: "[feature idea or problem to explore] [output:html]"
|
||||
---
|
||||
|
||||
# Brainstorm a Feature or Improvement
|
||||
|
||||
**Note: The current year is 2026.** Use this when dating requirements documents.
|
||||
|
||||
Brainstorming helps answer **WHAT** to build through collaborative dialogue. It precedes `/ce-plan`, which answers **HOW** to build it.
|
||||
|
||||
The durable output of this workflow is a **requirements document**. In other workflows this might be called a lightweight PRD or feature brief. In compound engineering, keep the workflow name `brainstorm`, but make the written artifact strong enough that planning does not need to invent product behavior, scope boundaries, or success criteria.
|
||||
|
||||
This skill does not implement code. It explores, clarifies, and documents decisions for later planning or execution.
|
||||
|
||||
**IMPORTANT: All file references in generated documents must use repo-relative paths (e.g., `src/models/user.rb`), never absolute paths. Absolute paths break portability across machines, worktrees, and teammates.**
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Assess scope first** - Match the amount of ceremony to the size and ambiguity of the work.
|
||||
2. **Be a thinking partner** - Suggest alternatives, challenge assumptions, and explore what-ifs instead of only extracting requirements.
|
||||
3. **Resolve product decisions here** - User-facing behavior, scope boundaries, and success criteria belong in this workflow. Detailed implementation belongs in planning.
|
||||
4. **Keep implementation out of the requirements doc by default** - Do not include libraries, schemas, endpoints, file layouts, or code-level design unless the brainstorm itself is inherently about a technical or architectural change.
|
||||
5. **Right-size the artifact** - Simple work gets a compact requirements document or brief alignment. Larger work gets a fuller document. Do not add ceremony that does not help planning.
|
||||
6. **Apply YAGNI to carrying cost, not coding effort** - Prefer the simplest approach that delivers meaningful value. Avoid speculative complexity and hypothetical future-proofing, but low-cost polish or delight is worth including when its ongoing cost is small and easy to maintain.
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
These rules apply to every brainstorm, including the universal (non-software) flow routed to `references/universal-brainstorming.md`.
|
||||
|
||||
1. **Ask one question at a time** - One question per turn, even when sub-questions feel related. Stacking several questions in a single message produces diluted answers; pick the single most useful one and ask it.
|
||||
2. **Prefer single-select multiple choice** - Use single-select when choosing one direction, one priority, or one next step.
|
||||
3. **Use multi-select rarely and intentionally** - Use it only for compatible sets such as goals, constraints, non-goals, or success criteria that can all coexist. If prioritization matters, follow up by asking which selected item is primary.
|
||||
4. **Default to the platform's blocking question tool** - Use `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). These tools include a free-text fallback (e.g., "Other" in Claude Code), so options scaffold the answer without confining it — well-chosen options surface dimensions the user may not have separated, and pick-plus-optional-note is lower activation energy than composing prose from scratch. This default holds for opening and elicitation questions too, not only narrowing. Fall back to numbered options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
|
||||
5. **Use an open-ended question only when the question is genuinely open** - Drop the blocking tool only when (a) the answer is inherently narrative ("walk me through how you got here"), (b) the question is diagnostic or introspective and presented options would unintentionally influence the user's answer (e.g., "what concerns you most?" — a 4-option menu would nudge them toward those axes rather than the ones actually on their mind), or (c) you cannot write 3-4 genuinely distinct, plausibly-correct options that cover the space without padding or strawmen. The test: if you'd be straining to fill the option slots, the question is open — ask it open-ended. Rule 1 still applies: still one question per turn.
|
||||
6. **Open-ended questions earn their place only when they're specific enough to elicit a substantive answer** - Apply Rule 5 silently: just ask the question, do not narrate the form choice. The question itself must give the user something concrete to anchor on. Good: *"What's the most concrete thing someone's already done about this — paid for it, built a workaround, quit a tool over it?"* (this is one of Phase 1.2's rigor probes — it earns its open-endedness by naming what counts as an answer). Too thin: *"What's your take?"* (nothing to bite into; user defaults to a one-liner that wastes the open question). Avoid (a) narrating the form choice ("the most useful question I can ask here is..."), (b) framings that imply a short answer ("briefly", "in one sentence"), (c) yes/no traps, and (d) AI-slop warmth wrappers ("take it wherever feels relevant").
|
||||
|
||||
## Output Guidance
|
||||
|
||||
- **Keep outputs concise** - Prefer short sections, brief bullets, and only enough detail to support the next decision.
|
||||
- **Use repo-relative paths** - When referencing files, use paths relative to the repo root (e.g., `src/models/user.rb`), never absolute paths. Absolute paths make documents non-portable across machines and teammates.
|
||||
|
||||
## Feature Description
|
||||
|
||||
<feature_description> #$ARGUMENTS </feature_description>
|
||||
|
||||
**If the feature description above is empty, ask the user:** "What would you like to explore? Please describe the feature, problem, or improvement you're thinking about."
|
||||
|
||||
Do not proceed until you have a feature description from the user.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### Phase 0: Resume, Assess, and Route
|
||||
|
||||
#### 0.0 Resolve Output Mode
|
||||
|
||||
Determine `OUTPUT_FORMAT` before any other phase fires. Output mode is **exclusive** — the requirements doc is written as either markdown (`.md`) OR HTML (`.html`), never both. Precedence: CLI arg > config > default (`md`), with a hard pipeline-mode override.
|
||||
|
||||
**Read config (pre-resolved at skill load):**
|
||||
!`cat "$(git rev-parse --show-toplevel 2>/dev/null)/.compound-engineering/config.local.yaml" 2>/dev/null || echo '__NO_CONFIG__'`
|
||||
|
||||
Resolution steps:
|
||||
|
||||
1. **CLI arg.** Scan `$ARGUMENTS` for a token starting with the literal prefix `output:`. If found, strip it from arguments before treating the remainder as the feature description, and match its value case-insensitively against `md` and `html`.
|
||||
- `output:` alone (no value) → no-op, fall through to step 2.
|
||||
- `output:<unknown>` (e.g., `output:pdf`) → drop the token, fall through to step 2, and remember to emit a one-line note above the post-generation menu after final resolution: `Ignored unknown output: value '<value>' — using <resolved_format> instead.` where `<resolved_format>` is the value `OUTPUT_FORMAT` actually resolved to after steps 2-4. Do not hardcode `md` in the note — that misleads users when config has set HTML.
|
||||
2. **Config.** If step 1 did not resolve and the pre-resolved YAML above has an **active (non-commented)** `brainstorm_output:` key whose value matches `md` or `html` (case-insensitive), use it. Missing, invalid, or commented values fall through silently. Critical: lines starting with `#` are YAML comments and must be ignored — the shipped config template includes commented examples like `# brainstorm_output: html` to document the option, and matching those as active settings would silently force HTML mode on every run without the user having opted in.
|
||||
3. **Default.** Otherwise `OUTPUT_FORMAT=md`.
|
||||
4. **Pipeline override.** When invoked from LFG or any `disable-model-invocation` context, force `OUTPUT_FORMAT=md` regardless of steps 1-3. Downstream consumers (`ce-plan`, `ce-work`) parse markdown reliably; HTML in pipeline runs is unnecessary friction.
|
||||
|
||||
**Token-parsing convention:** only literal-prefix flag tokens (`output:`, `mode:`, `delegate:` where applicable) are consumed and stripped. Other `<word>:<word>` tokens — including conventional commit prefixes like `feat:`, `fix:`, `chore:` that may appear inside a feature description — pass through verbatim.
|
||||
|
||||
**Load the format-rendering reference based on the resolved value.** Section content is the same in either format; presentation differs. Both rendering references are paired with `references/brainstorm-sections.md`, which describes what the brainstorm contains regardless of format.
|
||||
|
||||
- When `OUTPUT_FORMAT=md`, read `references/markdown-rendering.md` for format principles.
|
||||
- When `OUTPUT_FORMAT=html`, read `references/html-rendering.md` for format principles.
|
||||
|
||||
The `output:` preference does NOT auto-propagate to `ce-plan` on handoff — ce-plan re-resolves its own `plan_output` config independently. Asymmetric output (`requirements.html` + `plan.md`) is acceptable; users who want HTML for both set both keys in `.compound-engineering/config.local.yaml`.
|
||||
|
||||
#### 0.1 Resume Existing Work When Appropriate
|
||||
|
||||
If the user references an existing brainstorm topic or document, or there is an obvious recent matching `*-requirements.{md,html}` file in `docs/brainstorms/`:
|
||||
- Read the document
|
||||
- Confirm with the user before resuming: "Found an existing requirements doc for [topic]. Should I continue from this, or start fresh?"
|
||||
- If resuming, summarize the current state briefly, continue from its existing decisions and outstanding questions, and update the existing document instead of creating a duplicate
|
||||
- **Resume preserves the existing artifact's format, except pipeline mode.** Write back in whatever format the existing artifact uses — markdown if the existing file is `.md`, HTML if it is `.html`. Explicit `output:` arguments on this run override (e.g., resuming an `.html` doc with `output:md` switches the artifact to markdown). Pipeline mode (LFG, any `disable-model-invocation` context) always wins per Phase 0.0: even when resuming an existing `.html` brainstorm, pipeline runs force `OUTPUT_FORMAT=md` so downstream automation receives the markdown shape it expects. The resume rewrites the markdown file at the parallel path and the original `.html` is left in place untouched.
|
||||
|
||||
#### 0.1b Classify Task Domain
|
||||
|
||||
Before proceeding to Phase 0.2, classify whether this is a software task. The key question is: **does the task involve building, modifying, or architecting software?** -- not whether the task *mentions* software topics.
|
||||
|
||||
**Software** (continue to Phase 0.2) -- the task references code, repositories, APIs, databases, or asks to build/modify/debug/deploy software.
|
||||
|
||||
**Non-software brainstorming** (route to universal brainstorming) -- BOTH conditions must be true:
|
||||
- None of the software signals above are present
|
||||
- The task describes something the user wants to explore, decide, or think through in a non-software domain
|
||||
|
||||
**Neither** (respond directly, skip all brainstorming phases) -- the input is a quick-help request, error message, factual question, or single-step task that doesn't need a brainstorm.
|
||||
|
||||
**If non-software brainstorming is detected:** Read `references/universal-brainstorming.md` and use those facilitation principles. Skip Phases 0.2–4 below — the **Core Principles and Interaction Rules above still apply unchanged**, including one-question-per-turn and the default to the platform's blocking question tool.
|
||||
|
||||
#### 0.2 Assess Whether Brainstorming Is Needed
|
||||
|
||||
**Clear requirements indicators:**
|
||||
- Specific acceptance criteria provided
|
||||
- Referenced existing patterns to follow
|
||||
- Described exact expected behavior
|
||||
- Constrained, well-defined scope
|
||||
|
||||
**If requirements are already clear:**
|
||||
Keep the interaction brief. Confirm understanding and present concise next-step options rather than forcing a long brainstorm. Only write a short requirements document when a durable handoff to planning or later review would be valuable. Skip Phase 1.1 and 1.2 entirely — go straight to Phase 1.3 or Phase 2.5 in announce-mode (synthesis emitted for visibility, no blocking confirmation), then to Phase 3.
|
||||
|
||||
#### 0.3 Assess Scope
|
||||
|
||||
Use the feature description plus a light repo scan to classify the work:
|
||||
- **Lightweight** - small, well-bounded, low ambiguity
|
||||
- **Standard** - normal feature or bounded refactor with some decisions to make
|
||||
- **Deep** - cross-cutting, strategic, or highly ambiguous
|
||||
|
||||
If the scope is unclear, ask one targeted question to disambiguate and then proceed.
|
||||
|
||||
**Deep sub-mode: feature vs product.** For Deep scope, also classify whether the brainstorm must establish product shape or inherit it:
|
||||
|
||||
- **Deep — feature** (default): existing product shape anchors decisions. Primary actors, core outcome, positioning, and primary flows are already established in the product or repo. The brainstorm extends or refines within that shape.
|
||||
- **Deep — product**: the brainstorm must establish product shape rather than inherit it. Primary actors, core outcome, positioning against adjacent products, or primary end-to-end flows are materially unresolved. Existing code lowers the odds of product-tier but does not by itself rule it out — a half-built tool with ambiguous shape is still product-tier.
|
||||
|
||||
Product-tier triggers additional Phase 1.2 questions and additional sections in the requirements document. Feature-tier uses the current Deep behavior unchanged.
|
||||
|
||||
### Phase 1: Understand the Idea
|
||||
|
||||
#### 1.1 Existing Context Scan
|
||||
|
||||
Scan the repo before substantive brainstorming. Match depth to scope:
|
||||
|
||||
**Lightweight** — Search for the topic, check if something similar already exists, and move on.
|
||||
|
||||
**Standard and Deep** — Two passes:
|
||||
|
||||
*Constraint Check* — Check project instruction files (`AGENTS.md`, and `CLAUDE.md` only if retained as compatibility context) for workflow, product, or scope constraints that affect the brainstorm. Also read `STRATEGY.md` if it exists — the product's target problem, approach, persona, and active tracks are direct input to what this brainstorm should deliver and should shape scope, success criteria, and which approaches are aligned vs out-of-scope. Also read `CONCEPTS.md` at repo root if it exists — the project's authoritative vocabulary. Use these names in dialogue, approaches, and the requirements doc; map user-offered synonyms back. If any of these add nothing, move on.
|
||||
|
||||
*Topic Scan* — Search for relevant terms. Read the most relevant existing artifact if one exists (brainstorm, plan, spec, skill, feature doc). Skim adjacent examples covering similar behavior.
|
||||
|
||||
If nothing obvious appears after a short scan, say so and continue. Two rules govern technical depth during the scan:
|
||||
|
||||
1. **Verify before claiming** — When the brainstorm touches checkable infrastructure (database tables, routes, config files, dependencies, model definitions), read the relevant source files to confirm what actually exists. Any claim that something is absent — a missing table, an endpoint that doesn't exist, a dependency not in the Gemfile, a config option with no current support — must be verified against the codebase first; if not verified, label it as an unverified assumption. This applies to every brainstorm regardless of topic.
|
||||
|
||||
2. **Defer design decisions to planning** — Implementation details like schemas, migration strategies, endpoint structure, or deployment topology belong in planning, not here — unless the brainstorm is itself about a technical or architectural decision, in which case those details are the subject of the brainstorm and should be explored.
|
||||
|
||||
**Slack context** (opt-in, Standard and Deep only) — never auto-dispatch. Route by condition:
|
||||
|
||||
- **Tools available + user asked**: Dispatch `ce-slack-researcher` with a brief summary of the brainstorm topic alongside Phase 1.1 work. Incorporate findings into constraint and context awareness.
|
||||
- **Tools available + user didn't ask**: Note in output: "Slack tools detected. Ask me to search Slack for organizational context at any point, or include it in your next prompt."
|
||||
- **No tools + user asked**: Note in output: "Slack context was requested but no Slack tools are available. Install and authenticate the Slack plugin to enable organizational context search."
|
||||
|
||||
#### 1.2 Product Pressure Test
|
||||
|
||||
Before generating approaches, scan the user's opening for rigor gaps. Match depth to scope.
|
||||
|
||||
This is agent-internal analysis, not a user-facing checklist. Read the opening, note which gaps actually exist, and raise only those as questions during Phase 1.3 — folded into the normal flow of dialogue, not fired as a pre-flight gauntlet. A fuzzy opening may earn three or four probes; a concrete, well-framed one may earn zero because no scope-appropriate gaps were found.
|
||||
|
||||
**Lightweight:**
|
||||
- Is this solving the real user problem?
|
||||
- Are we duplicating something that already covers this?
|
||||
- Is there a clearly better framing with near-zero extra cost?
|
||||
|
||||
**Standard — scan for these gaps:**
|
||||
|
||||
- **Evidence gap.** The opening asserts want or need, but doesn't point to anything the would-be user has already done — time spent, money paid, workarounds built — that would make the want observable. When present, ask for the most concrete thing someone has already done about this.
|
||||
|
||||
- **Specificity gap.** The opening describes the beneficiary at a level of abstraction where the agent couldn't design without silently inventing who they are and what changes for them. When present, ask the user to name a specific person or narrow segment, and what changes for that person when this ships.
|
||||
|
||||
- **Counterfactual gap.** The opening doesn't make visible what users do today when this problem arises, nor what changes if nothing ships. When present, ask what the current workaround is, even if it's messy — and what it costs them.
|
||||
|
||||
- **Attachment gap.** The opening treats a particular solution shape as the thing being built, rather than the value that shape is supposed to deliver, and hasn't been examined against smaller forms that might deliver the same value. When present, ask what the smallest version that still delivers real value would look like.
|
||||
|
||||
Plus these synthesis questions — not gap lenses, product-judgment the agent weighs in its own reasoning:
|
||||
- Is there a nearby framing that creates more user value without more carrying cost? If so, what complexity does it add?
|
||||
- Given the current project state, user goal, and constraints, what is the single highest-leverage move right now: the request as framed, a reframing, one adjacent addition, a simplification, or doing nothing?
|
||||
|
||||
Favor moves that compound value, reduce future carrying cost, or make the product meaningfully more useful or compelling. Use the result to sharpen the conversation, not to bulldoze the user's intent.
|
||||
|
||||
**Deep** — Standard lenses and synthesis questions plus:
|
||||
- Is this a local patch, or does it move the broader system toward where it wants to be?
|
||||
|
||||
**Deep — product** — Deep plus:
|
||||
|
||||
- **Durability gap.** The opening's value proposition rests on a current state of the world that may shift in predictable ways within the horizon the user cares about. When present, ask how the idea fares under the most plausible near-term shifts — and push past rising-tide answers every competitor could make.
|
||||
|
||||
- What adjacent product could we accidentally build instead, and why is that the wrong one?
|
||||
- What would have to be true in the world for this to fail?
|
||||
|
||||
These questions force an explicit product thesis and feed the Scope Boundaries subsections ("Deferred for later" and "Outside this product's identity") and Dependencies / Assumptions in the requirements document.
|
||||
|
||||
#### 1.3 Collaborative Dialogue
|
||||
|
||||
Follow the Interaction Rules above. Use the platform's blocking question tool when available.
|
||||
|
||||
**Guidelines:**
|
||||
- Ask what the user is already thinking before offering your own ideas. This surfaces hidden context and prevents fixation on AI-generated framings.
|
||||
- Start broad (problem, users, value) then narrow (constraints, exclusions, edge cases)
|
||||
- **Rigor probes fire before Phase 2 and are open-ended, not menus.** Narrowing is legitimate, but Phase 1 cannot end with un-probed rigor gaps. Each scope-appropriate gap from Phase 1.2 fires as a **separate** direct open-ended probe — one probe satisfies one gap, not multiple. Standard brainstorms scan four gap lenses (evidence, specificity, counterfactual, attachment); Deep-product adds durability (five total), but only the gaps actually present in the opening must be probed. Surface those probes progressively across the conversation — interleaving with narrowing moves is fine, as long as every scope-appropriate gap that was found in Phase 1.2 has been probed open-ended before Phase 2. Rigor probes map to Interaction Rule 5(b): a 4-option menu signals which kinds of evidence count and lets the user pick rather than produce. Open-ended questions force them to produce real observation or surface their uncertainty. Examples (one per gap): *evidence — "What's the most concrete thing someone's already done about this — paid, built a workaround, quit a tool over it?"* / *specificity — "Can you name a team you've actually watched hit this, or are you reasoning?"* / *counterfactual — "What do teams do today when this breaks — who reconciles?"* / *attachment — "Before we move to shapes or approaches — what's the smallest version that would still prove the bet right, and what's excluded?"* — **attachment is the final rigor probe before Phase 2 when the attachment gap is present. Fire it regardless of whether a specific shape has emerged through narrowing; its job is to pressure-test the user's implicit framing of the product before Phase 2 inherits it** / *durability — "Under the most plausible near-term shifts, how does this bet hold?"* If the answer reveals genuine uncertainty, record it as an explicit assumption in the requirements document rather than skipping the probe.
|
||||
- Clarify the problem frame, validate assumptions, and ask about success criteria
|
||||
- Make requirements concrete enough that planning will not need to invent behavior
|
||||
- Surface dependencies or prerequisites only when they materially affect scope
|
||||
- Resolve product decisions here; leave technical implementation choices for planning
|
||||
- Bring ideas, alternatives, and challenges instead of only interviewing
|
||||
|
||||
**Before exiting Phase 1.3: integration check.** Mentally combine what the user has said so far and surface any non-obvious consequences the dialogue hasn't probed. If user-stated X plus user-stated Y plus your-default-Z produces a downstream effect the user is unlikely to have tracked through one-question-at-a-time dialogue ("if mute lives on the rule AND we don't warn on delete, then rule-delete silently loses pause state"), probe it now while you're still in dialogue. One probe per genuine combination effect, asked open-ended, same discipline as rigor probes. Phase 2.5's call-outs are a safety net for residuals (silent agent inferences, pre-loaded contexts with no dialogue) — NOT a punt list for consequences you could have asked about now.
|
||||
|
||||
**Exit condition:** Continue until the idea is clear AND no integration-check questions are pending, OR the user explicitly wants to proceed.
|
||||
|
||||
### Phase 2: Explore Approaches
|
||||
|
||||
If multiple plausible directions remain, propose **2-3 concrete approaches** based on research and conversation. Otherwise state the recommended direction directly.
|
||||
|
||||
Use at least one non-obvious angle — inversion (what if we did the opposite?), constraint removal (what if X weren't a limitation?), or analogy from how another domain solves this. The first approaches that come to mind are usually variations on the same axis.
|
||||
|
||||
Present approaches first, then evaluate. Let the user see all options before hearing which one is recommended — leading with a recommendation before the user has seen alternatives anchors the conversation prematurely.
|
||||
|
||||
When useful, include one deliberately higher-upside alternative:
|
||||
- Identify what adjacent addition or reframing would most increase usefulness, compounding value, or durability without disproportionate carrying cost. Present it as a challenger option alongside the baseline, not as the default. Omit it when the work is already obviously over-scoped or the baseline request is clearly the right move.
|
||||
|
||||
At product tier, alternatives should differ on *what* is built (product shape, actor set, positioning), not *how* it is built. Implementation-variant alternatives belong at feature tier.
|
||||
|
||||
For each approach, provide:
|
||||
- Brief description (2-3 sentences)
|
||||
- Pros and cons
|
||||
- Key risks or unknowns
|
||||
- When it's best suited
|
||||
|
||||
**Approach granularity: mechanism / product shape, not architecture.** Approach descriptions name mechanism-level distinctions ("pause as a rule property" vs "pause as an event filter" vs "pause as a separate entity") and product-relevant trade-offs (plan-tier coupling, complexity surface, migration difficulty). They do NOT name implementation specifics — column names, table names, file paths, service classes, JSON shapes, exact method names. Those are ce-plan's job. Bringing architecture forward at brainstorm time forces the user to make architectural decisions on ce-brainstorm's intentionally-shallow research, and the synthesis at Phase 2.5 then has to filter out the leak.
|
||||
|
||||
After presenting all approaches, state your recommendation and explain why. Prefer simpler solutions when added complexity creates real carrying cost, but do not reject low-cost, high-value polish just because it is not strictly necessary.
|
||||
|
||||
If one approach is clearly best and alternatives are not meaningful, skip the menu and state the recommendation directly.
|
||||
|
||||
If relevant, call out whether the choice is:
|
||||
- Reuse an existing pattern
|
||||
- Extend an existing capability
|
||||
- Build something net new
|
||||
|
||||
### Phase 2.5: Synthesis Summary
|
||||
|
||||
**STOP. Before composing the synthesis, read `references/synthesis-summary.md`.** The two-stage shape (internal three-bucket draft → chat-time scoping synthesis), the Path A / Path B gate, the four scoping synthesis sections with their keep tests, the tier-aware bullet budget with re-cut rule, anti-pattern guidance, soft-cut behavior, self-redirect support, and internal-draft routing into doc body sections all live there. Composing a synthesis without these rules loaded reliably produces malformed output — pasting the full internal three-bucket draft verbatim into chat, implementation-detail leakage into the scoping synthesis, the proposal-pitch anti-pattern. **Each scoping synthesis bullet must pass the affirmability test (can the user evaluate this without reading code?) AND the detail test (1–2 lines max, conversational not documentary); over-share and over-detail are the failure modes to avoid.** This is not optional supplementary reading; it is the source of truth for how the phase behaves.
|
||||
|
||||
Surface a scoping synthesis to the user before Phase 3 writes the requirements doc — the user's last opportunity to correct scope before the artifact lands. The scoping synthesis is shaped like what two product collaborators would confirm before writing a PRD, not like a comprehensive audit or a one-line preview.
|
||||
|
||||
Fires for **all tiers** including Lightweight. Skip Phase 2.5 entirely on the Phase 0.1b non-software (universal-brainstorming) route.
|
||||
|
||||
**Path A vs Path B:** the scoping synthesis shape depends on TWO signals — whether any blocking question fired AND what tier Phase 0.3 classified the scope as.
|
||||
|
||||
- **Path A — no blocking questions fired AND tier is Lightweight**: announce-mode. Emit "What we're building" prose only (1–3 sentences), then proceed to Phase 3 doc-write in the same turn. No other sections, no confirmation question. Do NOT end the turn waiting for acknowledgment. The user can revise after the doc lands if the shape is wrong — Lightweight Path A docs are short, post-hoc revision is cheap.
|
||||
- **Path B — at least one blocking question fired, OR tier is Standard / Deep-feature / Deep-product**: full tier-aware scoping synthesis with confirmation gate. Two scenarios fire Path B: (a) the user invested answer-time during dialogue, or (b) the user pre-loaded substantive scope content (Phase 0.2 fast-path with a richly-specified opening prompt). Either way, the substance earns a real checkpoint. Confirmation is unconditional even when zero call-outs survive the keep test.
|
||||
|
||||
**Why the tier guard on Path A**: Phase 0.2's fast path serves two very different cases — a tight one-liner that needs no dialogue ("fix the typo on line 47") and a richly pre-loaded brainstorm context that ALSO needs no dialogue because the user pre-stated everything. Without the tier guard, both route to Path A and the pre-loaded case gets a 1-sentence checkpoint for what may be 20+ items worth of scope. Tier-classifying Phase 0.3 distinguishes the two — pre-loaded substance makes the tier Standard or Deep, which then routes to Path B.
|
||||
|
||||
### Phase 3: Capture the Requirements
|
||||
|
||||
Write or update a requirements document only when the conversation produced durable decisions worth preserving — see `references/brainstorm-sections.md` "Decide whether a doc is warranted at all" for the criteria and the bug-fix stress test. Skip document creation when the user only needs brief alignment and the decisions can flow downstream (ce-plan, commit message, docs/solutions/) without a brainstorm artifact in the middle.
|
||||
|
||||
When a doc is warranted, compose it using:
|
||||
|
||||
- `references/brainstorm-sections.md` — section contract (outcomes, hard floor, include-when-material catalog, agency rules, ID conventions).
|
||||
- The format-specific rendering reference loaded at Phase 0.0 (`markdown-rendering.md` OR `html-rendering.md`) — how the resolved format presents the sections.
|
||||
|
||||
Write to `docs/brainstorms/YYYY-MM-DD-<topic>-requirements.<md|html>` — extension follows `OUTPUT_FORMAT`. Confirm with the absolute path so the reference is clickable.
|
||||
|
||||
#### Vocabulary Capture — after the requirements doc (only if CONCEPTS.md already exists)
|
||||
|
||||
**Skip this step entirely if `CONCEPTS.md` does not exist at repo root** — creation is owned by ce-compound and ce-compound-refresh.
|
||||
|
||||
Run this **after** the approaches, the scope synthesis, and the requirements doc — that is where the canonical term often gets chosen or corrected, so capturing during early dialogue (before this point) would miss the final resolved name. If it exists, scan the full dialogue and the requirements doc for **resolved** domain terms — terms where the conversation actively pinned down a precise local meaning, not terms merely mentioned in passing. **Resolved means the definition is settled, not still under discussion.** Provisional terms that may still revise stay in the conversation only.
|
||||
|
||||
For each resolved term: if missing, add it; if present but new precision surfaced, refine it; if already consistent, no action.
|
||||
|
||||
**Domain entities, named processes, and status concepts with project-specific meaning only.** Not file paths, class names, function signatures, or implementation decisions — `CONCEPTS.md` is a glossary, not a spec or catch-all.
|
||||
|
||||
Follow the format set by existing entries. Apply edits silently. (If Phase 3 skipped the doc, still run this against the resolved dialogue.)
|
||||
|
||||
### Phase 4: Handoff
|
||||
|
||||
Present next-step options and execute the user's selection. Read `references/handoff.md` for the option logic, dispatch instructions, and closing summary format.
|
||||
@@ -0,0 +1,263 @@
|
||||
# Brainstorm Sections
|
||||
|
||||
This reference describes what makes a great brainstorm requirements document.
|
||||
It does NOT prescribe how the doc looks on the page — rendering is handled by
|
||||
the format-specific references (`markdown-rendering.md`, `html-rendering.md`).
|
||||
|
||||
## The outcome
|
||||
|
||||
A great brainstorm produces a doc that enables three audiences to act:
|
||||
|
||||
- **The planning agent** (`ce-plan` or a human) produces an implementation
|
||||
plan without inventing user behavior, scope boundaries, or success
|
||||
criteria — the brainstorm answered those.
|
||||
- **The reviewer** sees the framing choices, distinguishes pinned from open,
|
||||
and catches scope gaps before planning.
|
||||
- **The future reader** traces why the proposed thing matters, who it's for,
|
||||
and what success looks like.
|
||||
|
||||
Sections earn their place by serving one of these audiences. Omit padding.
|
||||
|
||||
## Decide whether a doc is warranted at all
|
||||
|
||||
Brainstorm dialogue does not always need to produce a durable document.
|
||||
Skip document creation when **both** hold:
|
||||
|
||||
- The user only needs brief alignment — no exploration produced novel scope,
|
||||
framing, or decisions worth preserving in IDed shape.
|
||||
- Any durable decisions made during the dialogue can flow naturally to
|
||||
downstream artifacts (`ce-plan`, the commit message, `docs/solutions/`)
|
||||
without a brainstorm doc as an intermediary.
|
||||
|
||||
The trigger for creating a doc is when the dialogue surfaced enough
|
||||
structural decisions, scope boundaries, or acceptance criteria that
|
||||
downstream consumers (planner, reviewer, future reader) need them in a
|
||||
durable, IDed form — not just as conversational artifacts.
|
||||
|
||||
**Stress test:** a brainstorm about a tiny bug fix where the user asks "fix
|
||||
this with a null check or with upstream validation?" and the agent confirms
|
||||
"upstream validation, here's why" doesn't need a brainstorm doc. The
|
||||
decision flows to `ce-plan` (or directly to commit message, or to
|
||||
`docs/solutions/` if it's a pattern worth carrying) without a brainstorm
|
||||
artifact in the middle.
|
||||
|
||||
Conversely, a brainstorm about a multi-actor feature with contested scope
|
||||
and several behavioral conditions probably does need a doc — the planning
|
||||
agent needs the structured content the dialogue produced.
|
||||
|
||||
## Match depth to content
|
||||
|
||||
When a doc IS warranted, depth matches what the dialogue produced. A
|
||||
brainstorm with sparse content produces a sparse doc; one with rich content
|
||||
produces a rich doc. Don't add ceremony to make a slim brainstorm look
|
||||
substantial.
|
||||
|
||||
## Hard floor
|
||||
|
||||
When a doc is warranted, these are present.
|
||||
|
||||
- **Summary** — what is being proposed, in 1-3 lines. Forward-looking.
|
||||
Orients the reader before they invest in detail.
|
||||
- **Requirements** (with stable R-IDs) — what must be true about the
|
||||
proposed thing. For very sparse brainstorms (≤3 simple items where the
|
||||
bullets ARE the summary), plain bullets without IDs are acceptable; the
|
||||
trigger for R-IDs is whether downstream consumers will reference them.
|
||||
When requirements span distinct concerns (e.g., "Packaging" /
|
||||
"Migration and compatibility" / "Contributor workflow"), group them
|
||||
under bold inline headers within the Requirements section — group by
|
||||
capability or concern, not by the order requirements were discussed.
|
||||
The trigger is distinct concerns, not item count — even four
|
||||
requirements benefit if they cover three different topics. Skip
|
||||
grouping only when all requirements are genuinely about the same thing;
|
||||
a long flat list is a smell that subgroups were missed. R-IDs stay
|
||||
continuous across groups (R1, R2 in the first group; R3, R4 in the
|
||||
second; never restart at R1 per group).
|
||||
|
||||
## Include when material
|
||||
|
||||
The agent decides per brainstorm whether each section carries information
|
||||
that isn't covered elsewhere. Filling a section with placeholder prose is
|
||||
worse than omitting it.
|
||||
|
||||
- **Problem Frame** — include when motivation isn't obvious from Summary
|
||||
alone (the *why* needs paragraphs, not a sentence). Backward-looking /
|
||||
situational. Does NOT restate the proposal; the remedy lives in Summary.
|
||||
|
||||
- **Key Decisions** — include when the brainstorm produced opinionated
|
||||
framing choices (defaults, scope narrowings, foundational technical picks)
|
||||
that constrain Requirements / Flows / Scope below. Each entry names the
|
||||
decision in bold with prose rationale. Sits high in the rendered doc so
|
||||
readers encounter the framing choices before descending into detail.
|
||||
|
||||
- **Actors** — include when the proposed thing has multi-party behavior
|
||||
(multiple humans, agents, or systems meaningfully involved). Skip for
|
||||
non-behavioral brainstorms (naming briefs, data-shape briefs, pure
|
||||
research, decision frameworks).
|
||||
|
||||
- **Key Flows** — include when the proposed thing has multi-step behavior.
|
||||
Expected by default for behavioral brainstorms unless the proposed thing
|
||||
is genuinely non-flow-shaped (pure API surface, policy, artifact output)
|
||||
and Actors / Requirements / Scope Boundaries / Acceptance Examples
|
||||
together prevent downstream invention of paths. When omitting from a
|
||||
behavioral brainstorm, note the reason in the doc.
|
||||
|
||||
- **Visualizations** — include a diagram when the brainstorm contains a
|
||||
diagram-shaped concept that a picture carries faster than prose. Common
|
||||
shapes: a data-shape transformation (before/after schema or field
|
||||
mapping), a source-of-truth fan-out (one authority feeding many derived
|
||||
surfaces), state-or-lifecycle logic, a multi-step flow, or a quantitative
|
||||
comparison. A diagram is cross-cutting, not a section of its own — it sits
|
||||
next to the Key Decision, Requirements group, or Flow it illustrates. The
|
||||
named test: *does the picture let a reader grasp the concept faster than
|
||||
the paragraph alone?* If yes, add it; if the prose already conveys it at a
|
||||
glance, skip it. One diagram per load-bearing concept — don't add visuals
|
||||
for ceremony. This affordance is the conceptual-diagram path; it is
|
||||
distinct from the wireframe affordance (a wireframe is for visual-product
|
||||
UI and does not apply to non-visual systems like data models or agent
|
||||
workflows, but a conceptual diagram does).
|
||||
|
||||
**Diagrams complement prose; they never replace it.** A diagram is an
|
||||
on-ramp to the prose it illustrates, not a substitute. The IDed prose
|
||||
(Requirements, Key Decisions, Acceptance Examples) stays complete and
|
||||
standalone — a reader who ignores every diagram still gets the full
|
||||
content in text, and a downstream agent that reads the artifact as linear
|
||||
text is never left with a relationship that exists only in an SVG. Adding
|
||||
a before/after diagram is not license to thin the requirement or decision
|
||||
prose it depicts.
|
||||
|
||||
- **Acceptance Examples** — include when any requirement has a
|
||||
state-dependent or conditional shape ("When X, Y") where prose alone leaves
|
||||
ambiguity about edge cases. **Always include AEs covering
|
||||
behavioral-conditional requirements** — that's where the ambiguity bites
|
||||
hardest. Skip when all requirements are unconditional and unambiguous.
|
||||
|
||||
- **Success Criteria** — include when there are quality / metric / handoff
|
||||
signals that Requirements don't already carry: quantitative metrics ("p95
|
||||
latency under 200ms"), qualitative criteria ("the agent's output reads as
|
||||
one voice"), process / handoff quality ("ce-doc-review can act on this
|
||||
without follow-ups"). Skip when Requirements ARE the success criteria
|
||||
(every R is "done when the R is true").
|
||||
|
||||
- **Scope Boundaries** — include when scope is contested or there are
|
||||
tempting non-goals worth naming explicitly. When the brainstorm is about
|
||||
positioning a product against adjacent ones the team could have built but
|
||||
is rejecting, split into "Deferred for later" (eventually but not v1) and
|
||||
"Outside this product's identity" (positioning decision). Otherwise, a
|
||||
single list is fine.
|
||||
|
||||
- **Dependencies / Assumptions** — include when material upstream
|
||||
dependencies exist or when load-bearing assumptions need to be surfaced.
|
||||
|
||||
- **Outstanding Questions** — include when there are unresolved items.
|
||||
Distinguish "Resolve Before Planning" (blocks planning) from "Deferred to
|
||||
Planning" (answered during planning or codebase exploration).
|
||||
|
||||
- **Sources / Research** — surface research that orients the planner or
|
||||
justifies framing choices. The test: *"if I were the planner reading this
|
||||
cold, would this breadcrumb help me make better choices?"* Yes → surface
|
||||
(code locations, external docs, RFCs, constraints, prior plans — the
|
||||
category is inclusive, not enumerated). Process exhaust (reading the
|
||||
user's prompt, glancing at obvious files) → omit.
|
||||
|
||||
## Agent agency
|
||||
|
||||
The catalog is a floor, not a ceiling. When the brainstorm's content doesn't
|
||||
fit any catalog section, introduce a new one — don't force the content into
|
||||
a section it doesn't belong in. Content drives section choices, not vice
|
||||
versa.
|
||||
|
||||
The agent also picks per artifact:
|
||||
|
||||
- Whether Acceptance Examples render as a separate section or embed in each
|
||||
requirement
|
||||
- How much depth each present section gets
|
||||
|
||||
(Requirements grouping is covered above in the Hard Floor item — group by
|
||||
concern by default, rendering a flat list only when all requirements are
|
||||
about the same thing, with continuous R-IDs across groups.)
|
||||
|
||||
## Brainstorm metadata fields
|
||||
|
||||
Every brainstorm carries a small set of stable metadata fields that
|
||||
downstream tooling depends on. The contract is format-independent: in
|
||||
markdown these fields appear as YAML frontmatter at the top of the file; in
|
||||
HTML they appear as visible header text (typically a `<dl>` of `<dt>`/`<dd>`
|
||||
pairs or a stats strip). Field names and semantics are the same across both
|
||||
formats so consumers can locate them without knowing which format produced
|
||||
the brainstorm.
|
||||
|
||||
### Required
|
||||
|
||||
- **`date`** — creation date in ISO 8601 (`YYYY-MM-DD`), ASCII digits only.
|
||||
Used in the filename (`docs/brainstorms/YYYY-MM-DD-<topic>-requirements.<md|html>`).
|
||||
- **`topic`** — kebab-case slug identifying the brainstorm subject (e.g.,
|
||||
`surface-scope-earlier`, `demo-reel-local-save`). Used in the filename
|
||||
alongside `date` and as the resume-detection key when `ce-brainstorm`'s
|
||||
Phase 0.1 scans `docs/brainstorms/` for an existing artifact to continue.
|
||||
|
||||
### Status flip does not apply to brainstorm
|
||||
|
||||
Unlike plans, brainstorm artifacts have no `status` field — there is no
|
||||
`active → completed` lifecycle. A brainstorm is a one-time output that
|
||||
downstream consumers (`ce-plan`, `ce-doc-review`) reference via the plan's
|
||||
`origin:` field. The `<span class="status">` HTML hook described in
|
||||
`html-rendering.md` is a plan-side mechanic and does not render on
|
||||
brainstorm artifacts.
|
||||
|
||||
### Field-name stability
|
||||
|
||||
Field names are stable across brainstorm revisions — never rename a field
|
||||
or repurpose its semantics. Agents composing new brainstorms MUST use these
|
||||
exact names; adding new fields is fine, but renaming `topic` to `subject`
|
||||
or `date` to `created` breaks filename construction and resume detection.
|
||||
|
||||
## ID and content rules
|
||||
|
||||
Same shape as plan rules.
|
||||
|
||||
- **Stable IDs.** R-IDs (Requirements), A-IDs (if Actors fire), F-IDs (if
|
||||
Flows fire), AE-IDs (if Acceptance Examples fire). No other ID namespaces.
|
||||
- **Plain prefix.** `R1.`, `A1.`, `F1.`, `AE1.` as bullet prefixes. Do not
|
||||
bold; the prefix is visually distinctive on its own.
|
||||
- **Bold leader labels** inside Flows and Acceptance Examples
|
||||
(`**Trigger:**`, `**Covers R4, R8.**`) provide structure without deeper
|
||||
heading levels.
|
||||
- **Repo-relative paths.** Always. Never absolute paths.
|
||||
- **No process exhaust.** No "captured at Phase X" notes, no `## Next Steps`
|
||||
pointing to ce-plan, no italic provenance lines. Engineering process
|
||||
metadata belongs in commit messages and tool output, not the artifact.
|
||||
- **No implementation details by default.** Libraries, schemas, endpoints,
|
||||
file layouts, code structure stay out unless the brainstorm itself is
|
||||
inherently about a technical or architectural change and those details are
|
||||
the subject of the decision.
|
||||
|
||||
## Discipline: Summary vs Problem Frame
|
||||
|
||||
When both sections are present, they earn separate sections only by holding
|
||||
to different purposes:
|
||||
|
||||
| Section | Question it answers | Time direction | Length |
|
||||
|---|---|---|---|
|
||||
| `## Summary` | What is this doc proposing? | Forward-looking | 1-3 lines |
|
||||
| `## Problem Frame` | Why does this proposal exist? | Backward-looking / situational | Paragraphs |
|
||||
|
||||
- **Summary doesn't need problem context.** A reader scanning Summary gets
|
||||
the proposal at a glance.
|
||||
- **Problem Frame doesn't restate the proposal.** It establishes the
|
||||
situation, the specific moment of pain, and the cost shape — then stops.
|
||||
The remedy lives in Summary; restating it in Problem Frame is the
|
||||
duplication that makes the two sections feel redundant.
|
||||
|
||||
## Rendering
|
||||
|
||||
The format-specific references describe how to render these sections in each
|
||||
output format:
|
||||
|
||||
- **Markdown rendering:** `references/markdown-rendering.md`
|
||||
- **HTML rendering:** `references/html-rendering.md`
|
||||
|
||||
This reference (`brainstorm-sections.md`) is about WHAT the brainstorm
|
||||
contains; rendering references are about HOW each format presents it. The
|
||||
brainstorm is written in one format — markdown OR HTML, never both — based
|
||||
on the resolved output mode. The section catalog is the same regardless of
|
||||
format.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Handoff
|
||||
|
||||
This content is loaded when Phase 4 begins — after the requirements document is written.
|
||||
|
||||
---
|
||||
|
||||
#### 4.1 Present Next-Step Options
|
||||
|
||||
The Phase 4 menu's visible option count varies by state: no requirements doc hides the review and Proof options, `OUTPUT_FORMAT=html` also hides the review option (ce-doc-review is markdown-only today), unresolved `Resolve Before Planning` hides `Plan implementation` and `Build it now`, a failing direct-to-work gate hides `Build it now`. Count the visible options for the current state and choose the rendering mode accordingly:
|
||||
|
||||
- **4 or fewer visible:** use the platform's blocking question tool (`AskUserQuestion` in Claude Code — call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded; `request_user_input` in Codex; `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension)). This is the default.
|
||||
- **5 or more visible:** render as a numbered list in chat. This is the narrow option-overflow fallback; trimming would hide legitimate choices (plan, review, Proof, build, refine, pause are all distinct destinations). Include a hint that free-form input is accepted ("Pick a number or describe what you want.") so the numbered list retains the blocking tool's open-endedness.
|
||||
|
||||
Never silently skip the question.
|
||||
|
||||
If `Resolve Before Planning` contains any items:
|
||||
- Ask the blocking questions now, one at a time, by default
|
||||
- If the user explicitly wants to proceed anyway, first convert each remaining item into an explicit decision, assumption, or `Deferred to Planning` question
|
||||
- If the user chooses to pause instead, present the handoff as paused or blocked rather than complete
|
||||
- Do not offer the `Plan implementation` or `Build it now` options while `Resolve Before Planning` remains non-empty
|
||||
|
||||
In both preambles below, the "Pick a number or describe what you want." hint applies only in numbered-list mode. When using the blocking tool, omit that line and pass the remaining stem as the question.
|
||||
|
||||
**Path format:** Use absolute paths for chat-output file references — relative paths are not auto-linked as clickable in most terminals.
|
||||
|
||||
**Preamble when no blocking questions remain:**
|
||||
|
||||
```
|
||||
Brainstorm complete.
|
||||
|
||||
Requirements doc: <absolute path to requirements doc> # omit line if no doc was created
|
||||
|
||||
What would you like to do next? (Pick a number or describe what you want.)
|
||||
```
|
||||
|
||||
**Preamble when blocking questions remain and user wants to pause:**
|
||||
|
||||
```
|
||||
Brainstorm paused. Planning is blocked until the remaining questions are resolved.
|
||||
|
||||
Requirements doc: <absolute path to requirements doc> # omit line if no doc was created
|
||||
|
||||
What would you like to do next? (Pick a number or describe what you want.)
|
||||
```
|
||||
|
||||
Present only the options that apply. Renumber so visible options stay contiguous starting at 1.
|
||||
|
||||
1. **Plan implementation with `ce-plan` (Recommended)** - Move to `ce-plan` for structured implementation planning. Shown only when `Resolve Before Planning` is empty.
|
||||
2. **Agent review of requirements doc with `ce-doc-review`** - Dispatch reviewer agents to check the doc for coherence, feasibility, scope, and other persona-specific issues; auto-apply safe fixes; route remaining findings interactively. Shown only when a requirements document exists **and `OUTPUT_FORMAT=md`** — ce-doc-review's walkthrough applies markdown-only mutations (`##`/`###` heading inserts, single-file markdown edits via apply-set) and would corrupt an HTML artifact, so HTML brainstorms skip this option until ce-doc-review gains HTML-aware mutation support. Under HTML mode, surface a one-line note above the menu: `Agent review unavailable in output:html mode — ce-doc-review is markdown-only today. Switch to output:md if you want a review pass.`
|
||||
3. **Open in Proof — review and comment to iterate with the agent** - Open the doc in Every's Proof editor, iterate with the agent via comments, or copy a link to share with others. Shown only when a requirements document exists. **Render only when `OUTPUT_FORMAT=md`** (Proof operates on markdown and cannot ingest HTML).
|
||||
3. **Open in browser** — open the HTML requirements file locally for review and sharing. Shown only when a requirements document exists. **Render only when `OUTPUT_FORMAT=html`.** Replaces "Open in Proof" at the same slot under exclusive output mode — the doc is either markdown OR HTML, never both, so exactly one of the two labels applies per run.
|
||||
4. **Build it now with `ce-work` (skip planning)** - Skip planning and move to `ce-work`; suited to lightweight, well-defined changes. Shown only when `Resolve Before Planning` is empty **and** scope is lightweight, success criteria are clear, scope boundaries are clear, and no meaningful technical or research questions remain (the "direct-to-work gate").
|
||||
5. **More clarifying questions to sharpen the doc** - Keep refining scope, edge cases, constraints, and preferences through further dialogue. Always shown.
|
||||
6. **Done for now** - Pause; the requirements doc is saved and can be resumed later. Always shown.
|
||||
|
||||
**Post-review nudge (subsequent rounds only):** If the user has already run `ce-doc-review` this session and residual P0/P1 findings remain unaddressed, add a one-line prose nudge adjacent to the menu (e.g., "Document review flagged 2 P1 findings you may want to address — pick \"Agent review of requirements doc\" to run another pass."). Reference the option by label, not number: the menu renumbers when `Resolve Before Planning` hides `Plan implementation` and `Build it now`, so a hardcoded option number can point users at the wrong action. Do not add a separate menu option; reuse the existing agent-review option. Suppress this nudge when `OUTPUT_FORMAT=html` — the agent-review option is hidden in that mode, so the nudge would point users at a missing action.
|
||||
|
||||
#### 4.2 Handle the Selected Option
|
||||
|
||||
Selections may be the literal option label (when the user types the label or a close paraphrase) or the option number. Match numbers against the currently-rendered (post-trim) list. Free-form input that doesn't match an option or describe an alternative action should be treated as clarification — ask a follow-up rather than guessing.
|
||||
|
||||
**If user selects "Plan implementation with `ce-plan` (Recommended)":**
|
||||
|
||||
Immediately load the `ce-plan` skill in the current session. Pass the requirements document path when one exists; otherwise pass a concise summary of the finalized brainstorm decisions. Do not print the closing summary first.
|
||||
|
||||
**If user selects "Agent review of requirements doc with `ce-doc-review`":**
|
||||
|
||||
Load the `ce-doc-review` skill, passing the requirements document path as the argument. When ce-doc-review returns "Review complete", return to the Phase 4 options and re-render the menu (the doc may have changed, so re-evaluate `Resolve Before Planning`, direct-to-work gate, and residual findings). If residual P0/P1 findings remain unaddressed, include the post-review nudge above the menu. Do not show the closing summary yet.
|
||||
|
||||
**If user selects "Build it now with `ce-work` (skip planning)":**
|
||||
|
||||
Immediately load the `ce-work` skill in the current session using the finalized brainstorm output as context. If a compact requirements document exists, pass its path. Do not print the closing summary first.
|
||||
|
||||
**If user selects "More clarifying questions to sharpen the doc":** Return to Phase 1.3 (Collaborative Dialogue) and continue asking the user clarifying questions one at a time to further refine scope, edge cases, constraints, and preferences. Continue until the user is satisfied, then return to Phase 4. Do not show the closing summary yet.
|
||||
|
||||
**If user selects "Open in Proof — review and comment to iterate with the agent":**
|
||||
|
||||
Load the `ce-proof` skill in HITL-review mode with:
|
||||
|
||||
- **source file:** `docs/brainstorms/YYYY-MM-DD-<topic>-requirements.md`
|
||||
- **doc title:** `Requirements: <topic title>`
|
||||
- **identity:** `ai:compound-engineering` / `Compound Engineering`
|
||||
- **recommended next step:** `ce-plan` (shown in the ce-proof skill's final terminal output)
|
||||
|
||||
Follow `references/hitl-review.md` in the ce-proof skill. It uploads the doc, prompts the user for review in Proof's web UI, ingests filtered comment threads, applies agreed edits through the current Proof edit APIs, replies/resolves in-thread, and syncs the final markdown back to the source file atomically on proceed.
|
||||
|
||||
When the ce-proof skill returns control:
|
||||
|
||||
- `status: proceeded` with `localSynced: true` → the requirements doc on disk now reflects the review. Return to the Phase 4 options and re-render the menu (the doc may have changed substantially during review, so option eligibility can shift — re-evaluate `Resolve Before Planning`, direct-to-work gate, and residual ce-doc-review findings against the updated doc).
|
||||
- `status: proceeded` with `localSynced: false` → the reviewed version lives in Proof at `docUrl` but the local copy is stale. Offer to pull the Proof doc to `localPath` using the ce-proof skill's Pull workflow. Re-render the Phase 4 menu after the pull completes (or is declined). If the pull was declined, include a one-line note above the menu that `<localPath>` is stale vs. Proof — otherwise `Plan implementation` / `Build it now` / `Agent review of requirements doc` will silently read the pre-review copy.
|
||||
- `status: done_for_now` → the doc on disk may be stale if the user edited in Proof before leaving. Offer to pull the Proof doc to `localPath` so the local requirements file stays in sync, then return to the Phase 4 options. If the pull was declined, include the stale-local note above the menu. `done_for_now` means the user stopped the HITL loop without syncing — it does not mean they ended the whole brainstorm.
|
||||
- `status: aborted` → fall back to the Phase 4 options without changes.
|
||||
|
||||
If the initial upload fails (network error, Proof API down), retry once after a short wait. If it still fails, tell the user the upload didn't succeed and briefly explain why, then return to the Phase 4 options — don't leave them wondering why the option did nothing.
|
||||
|
||||
**If user selects "Open in browser":** Display the absolute path to the `.html` requirements file so the user can open it locally. Where the platform exposes a browser-opening primitive (e.g., `open` on macOS, `xdg-open` on Linux, `start` on Windows), the agent may invoke it directly; otherwise print the absolute path and let the user open it. After the path is displayed (or the browser is opened), return to the Phase 4 options so the user can pick a follow-up action.
|
||||
|
||||
**If user selects "Done for now":** Display the closing summary (see 4.3) and end the turn.
|
||||
|
||||
#### 4.3 Closing Summary
|
||||
|
||||
Use the closing summary only when this run of the workflow is ending or handing off, not when returning to the Phase 4 options.
|
||||
|
||||
In both templates below, substitute `<absolute path to requirements doc>` with the actual file path written this run — `.md` for `OUTPUT_FORMAT=md`, `.html` for `OUTPUT_FORMAT=html`. Do not emit a hardcoded `.md` path when the artifact is HTML, or the closing summary will point users at a file that was never written.
|
||||
|
||||
When complete and ready for planning, display:
|
||||
|
||||
```text
|
||||
Brainstorm complete!
|
||||
|
||||
Requirements doc: <absolute path to requirements doc> # omit line if no doc was created
|
||||
|
||||
Key decisions:
|
||||
- [Decision 1]
|
||||
- [Decision 2]
|
||||
|
||||
Recommended next step: `ce-plan`
|
||||
```
|
||||
|
||||
If the user pauses with `Resolve Before Planning` still populated, display:
|
||||
|
||||
```text
|
||||
Brainstorm paused.
|
||||
|
||||
Requirements doc: <absolute path to requirements doc> # omit line if no doc was created
|
||||
|
||||
Planning is blocked by:
|
||||
- [Blocking question 1]
|
||||
- [Blocking question 2]
|
||||
|
||||
Resume with `ce-brainstorm` when ready to resolve these before planning.
|
||||
```
|
||||
@@ -0,0 +1,538 @@
|
||||
# HTML Rendering
|
||||
|
||||
This is a format-rendering reference — it describes how to render any
|
||||
artifact in HTML, independent of which skill is producing it.
|
||||
|
||||
It is paired with a section contract (`plan-sections.md`,
|
||||
`brainstorm-sections.md`, etc.) that describes *what* the artifact contains.
|
||||
This reference describes *how* HTML specifically presents it. The same
|
||||
content rendered by different skills shares the same HTML principles.
|
||||
|
||||
The HTML artifact is the *only* artifact the skill produces for that run —
|
||||
output mode is exclusive (markdown OR HTML, never both). Downstream
|
||||
consumers that read HTML today (`ce-work`, human readers) do so directly;
|
||||
the agent-consumability rules below make that work. `ce-doc-review` is
|
||||
*not* currently an HTML consumer — its mutation mechanics are markdown-only,
|
||||
so the ce-plan handoff gates the 5.3.8 doc-review pass to `OUTPUT_FORMAT=md`
|
||||
runs and skips it for HTML.
|
||||
|
||||
## Hard invariants
|
||||
|
||||
These hold regardless of which skill produced the artifact.
|
||||
|
||||
- **Single self-contained HTML5 file.** No companion `.css`, `.js`, or
|
||||
`.svg` files. CSS lives in `<style>`. SVG lives inline. Images are
|
||||
base64 data URIs or inline SVG. The one permitted exception is a
|
||||
`<link rel="stylesheet">` to a CDN webfont CSS endpoint (Google Fonts,
|
||||
Bunny Fonts, etc.), paired with an offline-readable fallback font stack
|
||||
so the doc remains readable if the CDN is unreachable.
|
||||
- **All metadata appears as visible text — single source of truth.**
|
||||
The artifact's metadata (title, type, status, date, etc. — exact
|
||||
fields per-skill, defined in the section contract) renders as visible
|
||||
HTML elements that downstream agents and humans read. No hidden
|
||||
machine-readable copy in any form: no `<script type="application/json">`
|
||||
frontmatter block, no `data-*` attribute mirror, and no
|
||||
`<meta name="status">` / `<meta name="created">` / `<meta name="origin">`
|
||||
in `<head>` duplicating the same values that appear in the visible
|
||||
header. One representation for each value — drift across two copies is
|
||||
the failure this rule prevents.
|
||||
|
||||
The text-and-attribute redundancy in `<time datetime="2026-05-12">2026-05-12</time>`
|
||||
is acceptable because the attribute is a parser hint, not a hidden copy.
|
||||
- **Editable status renders as `<span class="status">{value}</span>`.**
|
||||
Downstream tooling (`ce-work` shipping flip, future HTML-aware
|
||||
consumers) finds and rewrites status by selector. Embedding the
|
||||
status value inside a header `<dl>` cell (`<dt>Status</dt><dd>active</dd>`),
|
||||
inside a `<meta>` tag, or as visible text without the `class="status"`
|
||||
hook all break the flip mechanic — the consumer either can't locate
|
||||
the value or can't disambiguate it from prose. The status span may
|
||||
sit anywhere in the doc (inside the header metadata, in a stats
|
||||
strip, in a hero banner); placement is a visual choice, the selector
|
||||
shape is the contract.
|
||||
- **Stable IDs as anchor IDs AND visible text.** Every ID-bearing item
|
||||
(R-IDs, U-IDs, A-IDs, F-IDs, AE-IDs, KTDs) gets `id="r1"` on its
|
||||
element AND appears as visible text inside the element (e.g., the
|
||||
text "R1." inside the table cell or heading). Downstream agents find
|
||||
the ID in source the same way they find it in markdown.
|
||||
- **Source / composition signal.** A visible footer at the bottom of
|
||||
the doc names the composition timestamp and the source identifier
|
||||
(the user prompt context, the upstream brainstorm doc when one
|
||||
exists, or just the composing skill name when there's no external
|
||||
source). Example shape:
|
||||
`<footer class="composition-signal">Composed 2026-05-17T14:23Z by ce-plan from <code>docs/brainstorms/...-requirements.md</code></footer>`.
|
||||
Under exclusive output mode this signal is the artifact's own
|
||||
provenance — there's no markdown sibling to reference. Omitting it
|
||||
leaves readers unable to tell how stale the rendering is.
|
||||
- **ASCII identifiers.** Class names, element IDs, data attribute names
|
||||
are ASCII-only.
|
||||
|
||||
## Precedence stack for style preferences
|
||||
|
||||
Honor user style preferences in this order (highest to lowest):
|
||||
|
||||
1. **In-session conversation** — explicit direction the user gave this run.
|
||||
2. **Preferred stylesheet reference** named in loaded agent-instruction
|
||||
context (typically `AGENTS.md` / `CLAUDE.md`, but scan loaded context;
|
||||
don't enumerate locations). The reference may be a file path
|
||||
(`docs/style.css`), a URL, a named library ("Tailwind"), or a style
|
||||
brand ("Stripe docs"). Agent-instruction files carry deliberate
|
||||
agent-aware preferences, so this tier sits above DESIGN.md.
|
||||
3. **DESIGN.md** discovered on the filesystem (see "DESIGN.md discovery"
|
||||
below).
|
||||
4. **Fallback default** — the opinionated palette / typography choices the
|
||||
agent makes when no preference exists.
|
||||
|
||||
### Active-recall at compose time
|
||||
|
||||
Before writing the CSS, scan loaded context for any stylesheet reference
|
||||
the user has indicated for documents like this. If found and inlinable
|
||||
(short local file, fetchable URL within budget), inline it into `<style>`.
|
||||
If found but not inlinable (large framework, paywalled stylesheet, named
|
||||
system without a fetchable source), compose CSS in its spirit — typography,
|
||||
color, density cues drawn from the named system. Only fall back to the
|
||||
default style when no preference signal exists.
|
||||
|
||||
The single-file invariant is preserved either way. External
|
||||
`<link rel="stylesheet">` is permitted only for CDN webfont CSS (with the
|
||||
offline fallback font stack); never link to an external stylesheet
|
||||
carrying layout, color, or typography rules the doc cannot read offline.
|
||||
|
||||
### DESIGN.md discovery
|
||||
|
||||
When tier 3 of the precedence stack applies, look for a DESIGN.md file in
|
||||
these locations, first match wins:
|
||||
|
||||
1. Worktree root (resolve via `git rev-parse --show-toplevel`).
|
||||
2. `docs/DESIGN.md`.
|
||||
3. `.compound-engineering/DESIGN.md`.
|
||||
|
||||
Read once at compose time. Absent → fall through to the fallback default.
|
||||
|
||||
Worktree-root only — do not fall through to a main checkout. Users
|
||||
working from a worktree who want HTML defaults can add DESIGN.md to the
|
||||
worktree.
|
||||
|
||||
**DESIGN.md is a partial override, not all-or-nothing.** Real
|
||||
DESIGN.md files vary widely: some are token tables, some are CSS
|
||||
variables, some are prose; most cover a subset of what HTML composition
|
||||
needs. Apply the tokens that fit a long-form text doc — typography roles,
|
||||
text colors, contrast targets, border-radius scale, elevation primitives,
|
||||
muted-vs-accent split. Skip the rest. Three specific failure modes to
|
||||
defend against:
|
||||
|
||||
- **Scope mismatch (product UI vs doc surface).** A DESIGN.md aimed at
|
||||
product marketing or app UI may name page-surface colors, button
|
||||
states, input borders, or hero backgrounds that are tied to *that*
|
||||
surface, not to a generic doc. Page-surface colors are the canonical
|
||||
trap — `--surface: #c0f0fb` belongs on the product's marketing page,
|
||||
not on every plan or requirements doc the team writes. Extract the
|
||||
principle (the design language uses a tinted surface) rather than the
|
||||
literal value when the token is product-UI-scoped. Apply literal
|
||||
values only when the token is generic enough to transfer (text color,
|
||||
type scale ratio, radius scale, contrast ratio).
|
||||
- **Partial coverage.** When DESIGN.md defines some categories but not
|
||||
others (e.g., colors but no spacing scale, typography but no
|
||||
elevation), use DESIGN.md for what it covers and the fallback default
|
||||
for what it doesn't. Do not require DESIGN.md to be complete before
|
||||
honoring it.
|
||||
- **Named font without a fetchable source.** When DESIGN.md names a
|
||||
font (e.g., "Signifier", "Every") without a CDN URL or local
|
||||
`@font-face` source the agent can inline, treat the name as a hint
|
||||
about the design intent, not a literal directive. Emit a system-font
|
||||
stack in the same family (serif vs sans vs mono) and pick a weight
|
||||
that matches the intent. The single-file invariant still holds; do
|
||||
not link to an external stylesheet to fetch the named font.
|
||||
- **Typography-scale mismatch.** DESIGN.md typography tokens are often
|
||||
sized for product UI — marketing pages, app screens, hero sections —
|
||||
with body text at 18-20px and headings at 32-52px. A long-form doc
|
||||
surface needs body at ~14-16px and headings at ~1.2-1.6× body. When
|
||||
the DESIGN.md size scale looks product-scaled, use the **family**,
|
||||
**weight**, and **OpenType feature** assignments (these carry the
|
||||
design language) and pick the agent's own **size scale** for the doc
|
||||
surface. Apply DESIGN.md sizes literally only when the tokens are
|
||||
clearly doc-scaled — body tokens at 14-16px, headings under ~32px.
|
||||
|
||||
## Format principles
|
||||
|
||||
These shape what "good" HTML looks like; the agent applies them per
|
||||
artifact based on content.
|
||||
|
||||
### Readable measure, not full bleed
|
||||
|
||||
Long-form text is unreadable at full viewport width — past ~80 characters
|
||||
per line the eye loses the return sweep and scanning slows. As a
|
||||
fallback-default (precedence tier 4, overridden by in-session direction or
|
||||
DESIGN.md), center the document in a content container and hold prose to a
|
||||
comfortable measure.
|
||||
|
||||
- **Page container.** A centered column with a max-width in the ~820-960px
|
||||
band (`margin-inline: auto`) keeps the doc off the far edges of wide
|
||||
monitors while leaving room for the format's richer shapes.
|
||||
- **Prose measure.** Hold running paragraphs to roughly 65-80 characters
|
||||
(`max-width: ~70ch` on text blocks). The named test: read a paragraph at
|
||||
full window width on a wide display — if the return sweep to the next
|
||||
line is effortful, the measure is too wide.
|
||||
- **Let wide content break out.** Tables, diagrams, and side-by-side
|
||||
columns may use the full container width (or wider) when the content
|
||||
needs it — the measure constraint is for prose, not for everything.
|
||||
|
||||
Express the constraint in `ch`/`rem` rather than a single hardcoded pixel
|
||||
value so it survives font-size and DESIGN.md overrides. DESIGN.md or an
|
||||
in-session instruction overrides these values; this is the fallback when no
|
||||
layout preference exists.
|
||||
|
||||
### Markdown source is content, not design
|
||||
|
||||
When markdown (or markdown-shaped chat context) is part of the input, use
|
||||
it for semantic content — what the doc is about, what sections exist,
|
||||
what facts each section establishes. Do NOT treat its bullet-vs-table
|
||||
presentation choices as authoritative; re-choose the rendering per
|
||||
content shape in HTML's richer affordance space. If the markdown rendered
|
||||
13 requirements as a bulleted list, that does NOT mean HTML must render
|
||||
them as a list — ask whether 13 items sharing `ID + body` shape deserve
|
||||
a table.
|
||||
|
||||
### Prose is authoritative
|
||||
|
||||
When a visualization disagrees with the surrounding prose, the prose
|
||||
governs. If they diverge, the visualization is wrong.
|
||||
|
||||
### Hyperlink the reference index
|
||||
|
||||
When the doc has a Sources & References (or equivalent reference-index)
|
||||
section, hyperlink each entry to its canonical destination so readers
|
||||
can open it directly. A long bare-text list of paths and ticket IDs is
|
||||
the format's biggest unforced UX miss — the reader has to copy-paste
|
||||
every entry into a browser or IDE.
|
||||
|
||||
Resolve the repo's GitHub URL once at compose time:
|
||||
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
|
||||
Apply linking to three reference shapes:
|
||||
|
||||
- **Repo-relative code/doc paths** (`services/foo.ts`,
|
||||
`docs/solutions/bar.md`) → `<repo-url>/blob/main/<path>`.
|
||||
- **Named GitHub PRs/issues** (`PR #636`, `issue #1048`) →
|
||||
`<repo-url>/pull/636` or `<repo-url>/issues/1048`.
|
||||
- **Named external trackers** (Linear `ESP-1705`, Jira `PROJ-123`) →
|
||||
link only when the workspace URL is established in loaded context
|
||||
(e.g., a `linear.app/<workspace>/...` URL appeared earlier in the
|
||||
session or in `AGENTS.md`); otherwise leave as text.
|
||||
|
||||
**Do not invent URLs.** If `origin` isn't a GitHub URL (GitLab,
|
||||
Bitbucket, internal host) and the equivalent main-tree URL pattern
|
||||
isn't obvious, leave entries as `<code>` text. If the external
|
||||
tracker workspace isn't established, leave as text. A broken or
|
||||
guessed link is worse than no link.
|
||||
|
||||
**Scope: reference index only, not inline prose.** Inline `<code>`
|
||||
mentions of paths or PRs inside paragraph prose stay as code or text.
|
||||
Linking every mention would clutter; readers expect clickable jumps
|
||||
where the doc presents itself as a reference index.
|
||||
|
||||
### Text contrast is local
|
||||
|
||||
Every text-on-background pairing must hold up on its own. A color that
|
||||
works for prose on the page background does not automatically work for
|
||||
a small label inside a tinted container. The most common violation:
|
||||
applying a generic "muted" text variable (calibrated for prose-on-bg) to
|
||||
secondary text inside an accent-soft / warn-soft / info-soft container.
|
||||
|
||||
Test by reading each filled shape's labels at the rendered scale. If the
|
||||
subtitle or secondary text feels washed-out against the fill, the choice
|
||||
is wrong for that local context — pick a color from the same family as
|
||||
the fill (accent-text for accent-soft, etc.) or drop the muting entirely
|
||||
and rely on font-size and weight for hierarchy.
|
||||
|
||||
### Body bold not colored by default
|
||||
|
||||
Reserve accent text color for status chips, ID chips, links, and section
|
||||
borders. Do NOT color `<strong>` in body content by default. Bold weight
|
||||
already carries emphasis; applying accent color to every `<strong>` in a
|
||||
long list overwhelms the eye, especially in dark mode. CSS should leave
|
||||
`strong` at `color: inherit` unless a specific surface (status pill, ID
|
||||
chip) is being styled.
|
||||
|
||||
### No JS framework runtimes
|
||||
|
||||
A small inline `<script>` for active-section TOC tracking or anchor-
|
||||
permalink behavior is acceptable. React, Vue, Svelte, or any framework
|
||||
runtime is not. The single-file invariant doesn't permit framework
|
||||
bundles, and the artifact's longevity doesn't warrant a build dependency.
|
||||
|
||||
## Section anatomy
|
||||
|
||||
How section types commonly render in HTML. These are patterns, not
|
||||
contracts — the agent picks shapes that fit the content.
|
||||
|
||||
- **Summary / Problem Frame** — semantic `<section>` with prose
|
||||
paragraphs. Optionally precede with an eyebrow label (small-caps tag
|
||||
above the title) for editorial polish.
|
||||
- **Requirements** — `<table>` is the default at 5+ uniform items;
|
||||
bullets at smaller counts. Concern-grouping takes precedence over the
|
||||
flat-table default: when requirements span distinct concerns, group them
|
||||
under bold inline headers (or per-group sections) first, then apply the
|
||||
5+ table default *within* each group rather than flattening the whole
|
||||
section into one table. Each row has the R-ID as visible text in
|
||||
its own column. Consider adding a "covered by" column for reverse
|
||||
traceability when ID-anchored items have downstream references in
|
||||
the same doc.
|
||||
- **Implementation Units** — repeating `<article>` cards with a stable
|
||||
ID chip (visible "U1" text), a metadata strip (`<dl>` with field
|
||||
labels and values for Goal, Files, Dependencies), and secondary
|
||||
content (Approach, Test Scenarios, Verification, Patterns to Follow)
|
||||
inside `<details>` collapsibles, **default-closed**. At 3+ units the
|
||||
default-closed rule is load-bearing — rendering all units fully
|
||||
expanded turns the doc into one continuous scroll where the reader
|
||||
can't see the unit list at a glance. The metadata strip is the
|
||||
primary always-visible surface; subsection labels (`<summary>`) are
|
||||
clickable affordances for readers to expand on demand. A single unit
|
||||
with no secondary content can skip `<details>` entirely; the rule
|
||||
fires when content exists to hide. The `<dl>` strip is for *descriptive*
|
||||
fields (Goal, Files, Dependencies). A *directive* field — `Execution
|
||||
note` is the canonical case, carrying a procedural instruction the
|
||||
implementer must act on (e.g. "start with a failing integration test") —
|
||||
does not belong in the strip, where it renders as a passive pair styled
|
||||
like a date and gets skimmed past. Render it as an advisory callout (see
|
||||
Tinted callout cards) so its visual weight matches its actionability. The
|
||||
test: descriptive value -> metadata pair; something the reader must act
|
||||
on -> callout.
|
||||
- **Key Technical Decisions** — repeating cards with the decision ID,
|
||||
bold decision title (often with inline code for technical
|
||||
identifiers), and prose rationale. Flat cards (not collapsibles) —
|
||||
these are reference material readers scan, not drill into.
|
||||
- **Risks** — color-coded cards with status eyebrow (e.g., "RISK ·
|
||||
MITIGATED" / "OPEN · DEFERRED FOLLOW-UP") and prose body. Color of
|
||||
the left-border or accent communicates status at a glance.
|
||||
- **Scope Boundaries** — callout cards with color-coded left borders
|
||||
(in-scope vs deferred vs outside) when the distinction is meaningful.
|
||||
|
||||
The agent picks more elaborate or simpler shapes based on what each
|
||||
specific artifact's content needs.
|
||||
|
||||
## Diagrams
|
||||
|
||||
When the section contract calls for a diagram (architecture, sequence,
|
||||
flowchart, state machine, swim lane, data-flow, quantitative
|
||||
comparison), HTML renders it as **inline SVG**. The agent picks the
|
||||
shape that conveys the content fastest — there is no fixed catalog of
|
||||
"approved" diagram types. If the content is quantitative comparison
|
||||
across categories, a bar chart is the right shape; if it's component
|
||||
relationships, a topology diagram; if it's process flow across
|
||||
participants, a swim lane; etc.
|
||||
|
||||
**Conceptual diagrams are not wireframes.** The wireframe affordance below
|
||||
is scoped to brainstorm requirements docs about *visual products* and is
|
||||
excluded for non-visual systems. That exclusion is about wireframes only —
|
||||
a brainstorm about a data model, schema, agent workflow, or migration is
|
||||
still free to use a conceptual diagram (a before/after field map, a
|
||||
source-of-truth fan-out, a state diagram). Don't let the wireframe
|
||||
exclusion suppress a conceptual diagram the content warrants.
|
||||
|
||||
**Diagrams complement prose; they never replace it.** A diagram is an
|
||||
accelerant placed next to the prose it illustrates, not a substitute. The
|
||||
IDed prose stays complete and standalone — a reader who ignores every
|
||||
diagram still gets the full content in text, and a text-reading downstream
|
||||
agent (which does not parse SVG geometry) is never left with a relationship
|
||||
that exists only in the picture. This extends the prose-is-authoritative
|
||||
rule above: prose governs not only on disagreement but on completeness, so
|
||||
adding a diagram is not license to thin the prose it depicts.
|
||||
|
||||
### Layout legibility for hand-authored SVG
|
||||
|
||||
The agent designs SVG coordinates without rendering — layouts that look
|
||||
fine in source can collide in practice. Before emitting, trace each
|
||||
labeled arrow and each text label:
|
||||
|
||||
- **No arrow path passes through a text label.** If an arrow line or
|
||||
curve crosses a label's bounding box, the text reads as struck-through
|
||||
and the arrow reads as terminating at the wrong element. Fix by
|
||||
re-routing the arrow, moving the label, or applying
|
||||
`paint-order: stroke fill` with a stroke color matching the diagram
|
||||
background to halo the label. The halo width is a judgment call:
|
||||
narrow enough not to bleed into glyph strokes (a halo whose width
|
||||
approaches the glyph's own stroke width muddies the text color), wide
|
||||
enough to mask underlying arrows (at least the arrow's stroke width
|
||||
plus a hairline). Verify by inspecting rendered text at the target
|
||||
font size — if glyphs look thicker or more colored-toward-halo than
|
||||
the same text outside the diagram, the halo is too wide.
|
||||
- **Arrow labels sit adjacent to the arrow's midpoint** (typically
|
||||
within ~10-15px above or beside the line they describe). A label
|
||||
floating at the diagram's edge that readers have to trace back to an
|
||||
arrow is broken — readers will misread.
|
||||
- **Avoid long curves that traverse the diagram** to connect a
|
||||
component on one side to one on the other. If A and D need a labeled
|
||||
connection across a multi-component layout, prefer reordering boxes
|
||||
so A and D are adjacent, numbered step badges next to each
|
||||
participant that the caption ties together, or a short
|
||||
labeled-channel notation — rather than one curve crossing multiple
|
||||
unrelated elements.
|
||||
- **Differentiate diagram shapes by geometry first, by fill semantics
|
||||
second.** Geometry (diamond = decision, rect = step, oval =
|
||||
start/end, parallelogram = data) carries the role unambiguously.
|
||||
Fill semantics (accent-soft for highlighted path, warn-soft for
|
||||
fallthrough) carry meaning. Resist introducing additional neutral-tint
|
||||
tiers (a slightly-lighter grey to mark "decision shapes are different
|
||||
from boxes") — when geometry already differentiates, an additional
|
||||
luminance tier adds no information and creates fragility: small RGB
|
||||
deltas survive native browser rendering but can be flattened or
|
||||
inverted inconsistently by dark-mode extensions, accessibility
|
||||
plugins, or printing.
|
||||
|
||||
### Plan architecture diagrams are not directional sketches
|
||||
|
||||
Do not add hedging captions or section preambles to plan SVG diagrams —
|
||||
phrases like "directional guidance for review, not implementation
|
||||
specification" do not belong on plan diagrams or on unit-card
|
||||
technical-design subsections. Plan diagrams render the same authoritative
|
||||
content as the surrounding prose; the prose-is-authoritative rule
|
||||
already governs disagreement. Hedging language is reserved for the
|
||||
wireframe affordance below, which carries a *required* directional
|
||||
caption because the wireframe is explicitly NOT a spec.
|
||||
|
||||
## Wireframe mockups (requirements docs only)
|
||||
|
||||
When a brainstorm requirements document describes a user-facing visual
|
||||
surface (UI feature, screen layout, screen flow, component placement),
|
||||
the HTML rendering may include a wireframe mockup. This affordance applies
|
||||
ONLY to brainstorm requirements docs that describe visual products — not
|
||||
to plan artifacts, and not to brainstorms about non-visual systems (API
|
||||
design, agent workflows, infrastructure).
|
||||
|
||||
When a wireframe is included:
|
||||
|
||||
- **Fidelity ceiling: wireframe, not mockup.** Gray boxes for layout
|
||||
regions, text labels for content placeholders, intentional placeholder
|
||||
copy (`[Product name]`, `[CTA label]`, `[user avatar]`). No
|
||||
pixel-perfect colors, no exact typography choices, no specific
|
||||
component-library references. The wireframe communicates spatial
|
||||
arrangement and structure, not visual style.
|
||||
- **Static only.** Inline SVG or simple HTML/CSS for layout. No JS
|
||||
interaction, no working form fields, no state changes, no live data.
|
||||
- **Anti-padding.** One wireframe per distinct visual concept.
|
||||
- **Mandatory directional caption.** Every wireframe carries an explicit
|
||||
"directional, not the spec" note adjacent to it. Required wording (or
|
||||
close paraphrase): *"Directional only — illustrates the intended
|
||||
user-facing shape. Exact colors, spacing, copy, and component choices
|
||||
are placeholders for review, not requirements."*
|
||||
|
||||
Without this caption the wireframe risks being read as a binding visual
|
||||
spec, which the affordance is explicitly designed to avoid.
|
||||
|
||||
## Affordance idioms
|
||||
|
||||
Common HTML affordances the agent can reach for when content benefits.
|
||||
These are examples, not requirements — the agent picks what each
|
||||
artifact's content warrants. Other affordances not listed here are
|
||||
fine when the content suggests them.
|
||||
|
||||
- **Sticky TOC sidebar with active-section indicator** — available when
|
||||
the agent judges navigation will materially help and the
|
||||
implementation is reliable: two-column layout on desktop, collapsed
|
||||
to top-of-page on mobile, paired with a small inline
|
||||
`IntersectionObserver` script that toggles `.active` on the matching
|
||||
nav anchor. Trade-off: a broken sticky TOC (layout collisions,
|
||||
active-section state drift, dark-mode CSS issues) is worse than a
|
||||
static top-of-doc TOC. For most long docs, default-closed `<details>`
|
||||
on repeating cards (see Implementation Units anatomy) already cuts
|
||||
the visible scroll length enough that a static TOC works — reach for
|
||||
sticky only when collapsibles alone don't solve the navigation
|
||||
problem.
|
||||
- **Within-section sub-nav** for sections containing 6+ repeating cards
|
||||
(Implementation Units, KTDs, Risks at large counts). A short list of
|
||||
card-anchor links (`<ul>` of `<a href="#u1">U1. ...</a>`) rendered at
|
||||
the top of the section gives readers a jump table — no JS needed.
|
||||
Lower-complexity alternative to the sticky TOC for the specific case
|
||||
of long card sections.
|
||||
- **Eyebrow labels** (small-caps tag above section titles) for
|
||||
editorial polish, especially when section titles are narrative
|
||||
rather than literal.
|
||||
- **Stats strip** at the top of the doc when the artifact has 3+
|
||||
quantifiable signals worth surfacing at a glance.
|
||||
- **`<details>` + `<summary>`** for collapsible secondary content
|
||||
inside repeating cards. All collapsibles start closed — `open`
|
||||
attribute should not appear on any `<details>` inside repeating
|
||||
cards by default.
|
||||
- **Side-by-side columns** for parallel content (Request / Response,
|
||||
Before / After, Two alternatives).
|
||||
- **Tinted callout cards** for content that is "different in kind"
|
||||
(Deferred, Open Questions, advisory notes, unit-level execution notes)
|
||||
— color-coded left borders communicate kind at a glance.
|
||||
|
||||
## Agent-consumability rules
|
||||
|
||||
Downstream agents that read HTML today (`ce-work`, future consumers) read
|
||||
the HTML file as text linearly, not via DOM extraction. `ce-doc-review` is
|
||||
not a current HTML consumer (see opening note). Compose so semantic
|
||||
understanding is reachable in source:
|
||||
|
||||
- **Use semantic HTML over `<div>` soup.** `<article>` per unit card,
|
||||
`<dl>` for metadata pairs, `<table>` for tabular content, `<details>`
|
||||
/ `<summary>` for collapsibles, `<section>` for top-level doc
|
||||
sections. Structure markers carry meaning to a text-reading agent.
|
||||
- **Render field labels as visible text, not as attributes.** Emit
|
||||
`<dt>GOAL</dt><dd>...</dd>`, not `<dd data-field="goal">...</dd>`.
|
||||
The label is the semantic anchor.
|
||||
- **Keep U-IDs, R-IDs, and similar as visible text** in headings and
|
||||
table cells, not only as `id=""` attributes. The agent finds "U1." in
|
||||
source the same way it finds "U1." in markdown.
|
||||
- **Match section heading vocabulary to what the section contract
|
||||
defines.** When the section contract says "Implementation Units," the
|
||||
HTML heading is "Implementation Units" — not "How we'll build it,"
|
||||
even if the narrative version reads better. Section heading
|
||||
vocabulary is the contract downstream consumers grep for. (Editorial
|
||||
re-titles can appear as eyebrow labels, sub-headings, or visual
|
||||
framing — but the load-bearing section heading matches the contract
|
||||
name.)
|
||||
- **All semantic content lives in actual HTML text.** No CSS `::before
|
||||
{ content: "..." }` carrying meaning, no background images as
|
||||
content, no semantic info that only renders. Whatever the agent sees
|
||||
in source is what it knows.
|
||||
- **Stable structure is the public API.** Element types, the ID and
|
||||
label scheme, and the field-label vocabulary do not break across
|
||||
versions. Visual styling can change freely.
|
||||
|
||||
## Post-compose audit
|
||||
|
||||
Before returning the artifact, scan it for common slips:
|
||||
|
||||
- **Single self-contained file.** No companion `.css` / `.js` / `.svg`.
|
||||
- **No hidden machine-readable metadata copy.** No
|
||||
`<script type="application/json">` frontmatter block, no `data-*`
|
||||
attributes mirroring visible values, **no `<meta name="status">` /
|
||||
`<meta name="created">` / `<meta name="origin">` etc. in `<head>`
|
||||
duplicating the visible header**. Metadata lives in visible text;
|
||||
one source of truth per value.
|
||||
- **Status renders as `<span class="status">{value}</span>`** so
|
||||
downstream tooling can flip `active → completed` by selector.
|
||||
- **All stable IDs** appear as both `id=""` and visible text.
|
||||
- **Section heading vocabulary** matches the section contract names
|
||||
(downstream agents grep these).
|
||||
- **Source / composition signal** is present as a visible footer at
|
||||
the bottom of the doc (composition timestamp + source identifier).
|
||||
- **Repeating cards with 3+ instances put secondary content inside
|
||||
default-closed `<details>`.** Fully-expanded unit cards in a long
|
||||
Implementation Units section is a failure mode — the reader can't see
|
||||
the unit list at a glance. Verify by skimming the rendered units:
|
||||
each `<article>` should render as its ID + title + metadata strip
|
||||
with collapsibles below, not as one long block.
|
||||
- **Within-section sub-nav** is present for sections with 6+ repeating
|
||||
cards.
|
||||
- **Body `<strong>`** is not colored with accent palette.
|
||||
- **`<details>`** inside repeating cards have no `open` attribute.
|
||||
- **Diagram labels** are legible — no arrow paths crossing text,
|
||||
halo width appropriate for font size.
|
||||
- **Diagrams complement prose, not replace it.** Every relationship a
|
||||
diagram conveys is also present in the surrounding IDed prose; no
|
||||
content lives only in an SVG.
|
||||
- **No JS framework runtimes** included. Small inline `<script>` for
|
||||
active-section TOC tracking or anchor-permalink behavior is the only
|
||||
acceptable JS.
|
||||
- **Each heading level** is visually distinct from others and from
|
||||
inline bold.
|
||||
- **No template placeholders** (`{skill}`, `<value>`, `[plan title]`)
|
||||
leaked into output.
|
||||
- **No process exhaust** callouts in the artifact.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Markdown Rendering
|
||||
|
||||
This is a format-rendering reference — it describes how to render any
|
||||
artifact in markdown, independent of which skill is producing it.
|
||||
|
||||
It is paired with a section contract (`plan-sections.md`,
|
||||
`brainstorm-sections.md`, etc.) that describes *what* the artifact contains.
|
||||
This reference describes *how* markdown specifically presents it. The same
|
||||
content rendered by different skills shares the same markdown principles.
|
||||
|
||||
## Hard invariants
|
||||
|
||||
These hold regardless of which skill produced the artifact.
|
||||
|
||||
- **YAML frontmatter at the top of the file.** Standard `---` delimited block
|
||||
containing the artifact's stable metadata (title, status, date, type, etc.
|
||||
— exact fields are per-skill, defined in the section contract). Editable
|
||||
in place; tools and agents that do status flips (`active → completed`)
|
||||
update the YAML directly.
|
||||
- **ASCII identifiers in anchors.** Markdown headings auto-generate anchors
|
||||
from the heading text. Keep headings ASCII so anchors are predictable
|
||||
(`#implementation-units`, not `#implementación-units`).
|
||||
- **Repo-relative paths for file references.** Always. Never absolute paths
|
||||
— they break portability across machines, worktrees, teammates.
|
||||
- **No HTML mixed in.** Keep the markdown pure. No `<div>`, no `<details>`,
|
||||
no inline `<style>`. If a layout idea only works as HTML, defer it to the
|
||||
HTML rendering. Markdown stays markdown.
|
||||
|
||||
## Format principles
|
||||
|
||||
These shape what "good" markdown looks like; the agent applies them per
|
||||
artifact based on content shape.
|
||||
|
||||
### ID prefix format
|
||||
|
||||
Stable IDs (R, U, A, F, AE, KTD) appear as plain prefixes at the start of
|
||||
the bullet or heading — do NOT bold the prefix. The prefix is visually
|
||||
distinctive on its own; bolding it inflates visual noise.
|
||||
|
||||
```markdown
|
||||
- R1. The plan returns paginated sessions. ← right
|
||||
- **R1.** The plan returns paginated sessions. ← wrong (bolded prefix)
|
||||
```
|
||||
|
||||
Same applies to unit headings: `### U1. Cloak detection in preflight contract`.
|
||||
|
||||
### Content shape: prose vs bullets vs tables
|
||||
|
||||
The same content can be rendered three ways; the agent picks per content
|
||||
shape, not by template default.
|
||||
|
||||
- **Prose** when the content has narrative flow (motivation, decision
|
||||
rationale, problem framing). Bullets fragment narrative into
|
||||
disconnected pieces.
|
||||
- **Bullets** when items share a parallel shape but each carries enough
|
||||
prose to not fit a table cell.
|
||||
- **Tables** when 5+ items share uniform structure (`ID + body`,
|
||||
`name + value`, `decision + rationale`, `risk + mitigation`). Tables
|
||||
scan faster at that scale and unlock additional columns (status,
|
||||
traceability, severity) that bullets can't accommodate cleanly.
|
||||
|
||||
The test: which shape would a reader scan fastest for this content? If
|
||||
items have parallel structure and 5+ instances, table. If items are 3-5
|
||||
and each has a few lines of prose, bullets. If the content is a single
|
||||
narrative thought, prose.
|
||||
|
||||
### Bold leader labels within bullets
|
||||
|
||||
When a bullet has substructure that benefits from named fields (Key Flows
|
||||
with Trigger / Actors / Steps / Outcome, Acceptance Examples with Covers
|
||||
/ Given / When / Then), use bold leader labels at the start of nested
|
||||
bullets — not deeper heading levels.
|
||||
|
||||
```markdown
|
||||
- F1. Anonymous capture
|
||||
- **Trigger:** Agent enters Step 2a with no session.
|
||||
- **Actors:** A1, A2
|
||||
- **Steps:** Preflight detects cloak; agent launches; capture proceeds.
|
||||
- **Covered by:** R1, R2, R5
|
||||
```
|
||||
|
||||
This gives the bullet structure without needing H4/H5 headings that would
|
||||
clutter the doc and break TOC generation.
|
||||
|
||||
### Section separators
|
||||
|
||||
For substantial artifacts, use horizontal rules (`---`) between top-level
|
||||
H2 sections. Omit for short docs where separators would dominate.
|
||||
|
||||
### Tables for genuinely comparative info only
|
||||
|
||||
Use tables for the uniform-shape case in "Content shape" above. Don't use
|
||||
tables to render content lists that are really bullets — markdown tables
|
||||
are noisier in raw form and worse for diffs.
|
||||
|
||||
## Section anatomy
|
||||
|
||||
How section types commonly render in markdown. These are patterns, not
|
||||
contracts — the agent picks the shape that fits the content.
|
||||
|
||||
- **Summary / Problem Frame** — prose paragraphs.
|
||||
- **Requirements** — bullets with `R<N>.` prefix. When requirements span
|
||||
more than one concern, grouping under bold inline headers is the default
|
||||
shape, not optional polish (group by capability, not by discussion order);
|
||||
render a flat list only when every requirement is about the same thing.
|
||||
When requirements have status, traceability, or severity that warrant
|
||||
additional columns, escalate to a table.
|
||||
- **Implementation Units** — H3 heading per unit with `U<N>.` prefix.
|
||||
Fields (Goal, Files, Patterns, Test Scenarios, Verification) render as
|
||||
bullets with bold leader labels, or as sub-headings if the field has
|
||||
multi-paragraph content.
|
||||
- **Key Technical Decisions** — bullets with bold decision name + prose
|
||||
rationale, or numbered KTD-N pattern when traceability matters.
|
||||
- **Key Flows / Acceptance Examples** — bullets with bold leader labels
|
||||
(Trigger / Actors / Steps / Outcome / Covers / Given-When-Then).
|
||||
- **Scope Boundaries** — bullets, optionally split into "Deferred for
|
||||
later" / "Outside this product's identity" sub-headings when the
|
||||
positioning distinction matters.
|
||||
|
||||
The agent picks more elaborate or simpler shapes based on what each
|
||||
specific artifact's content needs.
|
||||
|
||||
## Diagrams
|
||||
|
||||
When the section contract calls for a diagram (architecture, sequence,
|
||||
flowchart, state machine, swim lane, data-flow), markdown renders it as
|
||||
a fenced mermaid block:
|
||||
|
||||
```markdown
|
||||
` ``mermaid
|
||||
flowchart TB
|
||||
A[Start] --> B{Decision}
|
||||
B -->|yes| C[Action]
|
||||
B -->|no| D[Other action]
|
||||
` ``
|
||||
```
|
||||
|
||||
(`TB` direction default — keeps diagrams narrow in source view and in
|
||||
narrow rendered viewports.)
|
||||
|
||||
Markdown's diagram affordances are limited compared to HTML. For
|
||||
quantitative comparisons (bar charts, scatter plots) markdown has no
|
||||
native equivalent — use a table with the data and let prose or caption
|
||||
carry the interpretation. The richer visualization happens in the HTML
|
||||
rendering.
|
||||
|
||||
## Inline code and code blocks
|
||||
|
||||
- **Inline code** for identifiers (variable names, function names,
|
||||
flag names, file paths, IDs that aren't section anchors).
|
||||
- **Fenced code blocks** with language tag for code, shell commands,
|
||||
API request/response samples. Always specify the language for syntax
|
||||
highlighting and accessibility.
|
||||
|
||||
```markdown
|
||||
The flag `--cdp-url` accepts a URL.
|
||||
|
||||
` ``bash
|
||||
browser-use --cdp-url http://localhost:9222
|
||||
` ``
|
||||
```
|
||||
|
||||
## No process exhaust
|
||||
|
||||
Engineering process metadata stays out of the artifact:
|
||||
|
||||
- No "captured at Phase X" notes
|
||||
- No `## Next Steps` pointing to the next skill
|
||||
- No italic provenance lines ("*Brainstorm completed 2026-05-13*")
|
||||
- No engineering-flow shepherding ("Now read this file:", "Next, run that
|
||||
command:")
|
||||
|
||||
This information belongs in commit messages, tool output, and agent
|
||||
transcripts — not in the artifact a reader returns to weeks later.
|
||||
|
||||
## Frontmatter shape
|
||||
|
||||
Per-skill frontmatter fields are defined in each skill's section contract
|
||||
(`plan-sections.md` lists plan frontmatter; `brainstorm-sections.md` lists
|
||||
brainstorm frontmatter). Common rules:
|
||||
|
||||
- YAML at the top of the file, delimited by `---` on its own line above
|
||||
and below.
|
||||
- Field names in lowercase snake_case (`status`, `created_at`, not
|
||||
`Status`, `CreatedAt`).
|
||||
- **Status lifecycle is per-contract.** When the section contract
|
||||
defines a `status` field with a lifecycle (plans use
|
||||
`active → completed`, flipped by ce-work at shipping time via direct
|
||||
YAML edit), it is editable in place. When the section contract does
|
||||
not define a status lifecycle (brainstorms, for example, have no
|
||||
`active → completed` flip — they are upstream of plans and
|
||||
referenced via the plan's `origin:`), do not introduce one.
|
||||
- Stable across artifact revisions — never rename or repurpose a field.
|
||||
|
||||
## Post-write audit
|
||||
|
||||
Before declaring the markdown file written, scan it for these common
|
||||
slips:
|
||||
|
||||
- All stable IDs are plain-prefix format, not bolded.
|
||||
- No HTML elements mixed in.
|
||||
- All file paths are repo-relative.
|
||||
- Horizontal rule separators between H2s (for Standard / Deep artifacts).
|
||||
- No process exhaust (Phase X notes, Next Steps pointers, provenance
|
||||
lines).
|
||||
- Tables only where 5+ uniform-shape items justify them.
|
||||
- Frontmatter has all the per-skill required fields with reasonable values.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Synthesis Summary
|
||||
|
||||
**Synthesis ≠ requirements doc.** The synthesis is NOT a preview, draft, or substitute for the requirements doc — it's the scope checkpoint that doc-write consumes as input. The requirements doc itself is written in Phase 3 from the confirmed synthesis. Both the synthesis and the requirements doc stay scope-only — implementation detail (file paths, code shapes, exact error wording) is downstream (ce-plan's job), not the requirements doc.
|
||||
|
||||
**Two-stage shape: internal draft, then chat-time scoping synthesis.** The synthesis is composed in two stages. Stage 1 is an internal three-bucket draft (Stated / Inferred / Out of scope) the agent uses to think comprehensively about scope. Stage 2 is the scoping synthesis presented to the user — shaped like what two product collaborators would confirm before writing a PRD, not like a comprehensive audit and not like a one-line preview. The user only sees stage 2. The internal draft still informs the doc body via the doc-shape routing below; it just doesn't reach the user verbatim. This split exists because the comprehensive audit shape produced too much detail for the user to actually weigh in on, even when the granularity rules were followed.
|
||||
|
||||
**Three-bucket structure is the internal draft, not the user-facing artifact.** It does its scope-thinking job during stage 1 and dissolves when Phase 3 writes the doc: Stated content informs Requirements, Inferred content informs Key Decisions, Out-of-scope content informs Scope Boundaries. The doc has no parallel `## Synthesis` section — only the scoping synthesis prose embeds, as `## Summary`. See "Doc shape after confirmation" below for the routing.
|
||||
|
||||
This content is loaded when Phase 2.5 fires — after Phase 2 (approaches chosen) and before Phase 3 (write requirements doc). The synthesis is the user's last opportunity to correct the agent's interpretation before the doc lands. It serves two purposes: synthesis confirmation (the user agreed to many individual things in dialogue but never saw the whole) and a transition checkpoint ("about to write a doc").
|
||||
|
||||
Fires for **all tiers** including Lightweight. Skip Phase 2.5 entirely on the Phase 0.1b non-software (universal-brainstorming) route. The skill is interactive by design — brainstorming requires dialogue with a synchronous user. There is no non-interactive mode; if an automated workflow needs a requirements doc without dialogue, the right move is to write the doc from context directly, not to invoke `ce-brainstorm`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 1: internal three-bucket draft
|
||||
|
||||
The internal draft is structured in three labeled buckets. Items may appear in two buckets when meaningfully both — flag the inclusion-then-exclusion as Inferred so the reasoning is captured.
|
||||
|
||||
- **Stated** — what the user said directly (in the original prompt, prior conversation, dialogue answers, approach selection in Phase 2). Items here have explicit user-language anchors.
|
||||
- **Inferred** — what the agent assumed to fill gaps. Scope boundaries the user never explicitly named, success criteria extrapolated from intent, technical assumptions made because the brief interview didn't probe them. The Inferred bucket is the most actionable surface for correction — items here are the agent's bets.
|
||||
- **Out of scope** — deliberately excluded items. Adjacent work the agent considered but decided not to include, refactors, nice-to-haves, future-work items. Making exclusions explicit lets the agent spot anything that should actually be included.
|
||||
|
||||
This draft is internal. Do not paste it verbatim into chat. Compose it as a thinking step, then derive stage 2 from it.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: the chat-time scoping synthesis
|
||||
|
||||
The scoping synthesis is what the user actually sees. It reflects the dialogue's substance back so the user can pattern-match — long enough to serve a multi-turn conversation, short enough to be high-impact only. The reference shape is what two product collaborators would say to each other after a real discussion: "OK, so we're doing X, with Y trade-off, deferring Z, and one thing I want to double-check is W. Sound right?"
|
||||
|
||||
The scoping synthesis has up to four named sections, each **render-conditional** on having something to say. Empty sections are omitted, not padded.
|
||||
|
||||
1. **What we're building** (always present) — 1–3 sentences. The shape that emerged from dialogue, forward-looking, plain words. Not a transcript of "you said X."
|
||||
2. **Key trade-offs** (conditional) — 1–3 bullets, each with a brief why. Render only when real trade-offs were made in dialogue.
|
||||
3. **What's not in scope** (conditional) — 1–3 bullets, or fold into a single sentence. Render only when deferred items would surprise a downstream reader if absent.
|
||||
4. **Call outs** (conditional) — 0–3 bullets. Residual forks the dialogue didn't resolve: post-dialogue consequences (combining user answers surfaced something they couldn't see during Q&A), silent agent inferences, or — in pre-loaded contexts with no dialogue — scope bets the user is seeing for the first time. **Not "questions the agent could have asked during Phase 1.3 but didn't"** — if a call-out reads like a missed dialogue question, Phase 1.3's integration check failed; flag the gap rather than padding the section.
|
||||
|
||||
Each section answers a different question:
|
||||
|
||||
- **What's being built?** → shape
|
||||
- **What did we trade off?** → explicit choices made in conversation
|
||||
- **What did we cut?** → deferred items a reader would expect to see acknowledged
|
||||
- **Where might you redirect?** → residual forks: post-dialogue consequences, silent inferences, late-cycle bets
|
||||
|
||||
Then the confirmation: *"Confirm and I'll write the requirements doc next, drawing on our dialogue and this synthesis. Or tell me what to change."* The phrasing sets the expectation that confirm → doc-write, so the user knows what's about to happen and can interrupt without ambiguity.
|
||||
|
||||
### Path A vs Path B: the gate that fires the confirmation question
|
||||
|
||||
Phase 2.5 has two presentation modes, gated by **two signals**: (1) did any blocking question fire before Phase 2.5? AND (2) what tier did Phase 0.3 classify the scope as? Blocking questions include Phase 0.3 scope disambiguation, Phase 1.3 collaborative dialogue probes, and Phase 2 approach selection (when a menu fires). Internal classification, Phase 1.1 scan, and Phase 1.2 pressure test are not blocking questions — they don't count.
|
||||
|
||||
- **Path A — no blocking questions fired AND tier is Lightweight**: announce-mode. Emit "What we're building" prose only (no other sections, no confirmation question), then proceed to Phase 3 doc-write in the same turn. Do NOT end the turn waiting for acknowledgment. The user can revise after the doc lands if the shape is wrong — Lightweight Path A docs are short, post-hoc revision is cheap.
|
||||
- **Path B — at least one blocking question fired, OR tier is Standard / Deep-feature / Deep-product**: full tier-aware scoping synthesis with confirmation gate. Two scenarios fire Path B: (a) the user invested answer-time during dialogue, or (b) the user pre-loaded substantive scope content (Phase 0.2 fast-path with a richly-specified opening prompt). Either way, the substance earns a real checkpoint. The confirmation question is unconditional even when zero call-outs survive the keep test.
|
||||
|
||||
**Why the tier guard exists.** Phase 0.2's fast path is designed for two very different cases — a tight one-line prompt that needs no dialogue ("fix the typo on line 47"), and a richly pre-loaded brainstorm context that ALSO needs no dialogue because the user pre-stated everything (e.g., handing off accumulated decisions from a prior session for a brainstorm doc backfill). Without a tier guard, both route to Path A, and the richly-loaded case gets a 1-sentence checkpoint for what may be 20+ items worth of scope. Tier-classifying Phase 0.3 distinguishes these cases — pre-loaded substance makes the tier Standard or Deep, which then routes to Path B and produces the full scoping synthesis the substance deserves. Do not simplify the gate back to a single "no questions fired" signal — that was a real defect that produced one-sentence syntheses on Deep-tier pre-loads.
|
||||
|
||||
Path A maps to the existing "announce-mode" concept on the Phase 0.2 fast path, but only when the substance genuinely warrants 1–3 sentences. Path B is the default for every other interactive invocation.
|
||||
|
||||
### Keep tests per section
|
||||
|
||||
Each conditional section has its own keep test. Sections are render-conditional — an empty section is omitted, not padded with weak items.
|
||||
|
||||
**Trade-offs keep test:** would the user be surprised if I didn't surface this acknowledgment? Real trade-offs are choices the user explicitly weighed alternatives on in dialogue, or structural choices the agent made that the user would expect to see named. Mechanical or inevitable choices (e.g., "uses the existing rule entity") fail the test and dissolve into the doc body without surfacing.
|
||||
|
||||
**Deferred keep test:** is a reasonable downstream reader likely to ask "why isn't X here?" Items the user explicitly deferred, or items adjacent enough that a reader will look for them. Mechanical excludes (e.g., "no rate limiting because it's not in scope") fail and stay in the internal draft only.
|
||||
|
||||
**Call-outs keep test (the affirmability test):** would the user need to read code to evaluate this? If yes, it is doc-body content — cut. If no, apply the keep test — one of the following must be true:
|
||||
|
||||
- **Real scope fork** — another reasonable agent might choose a different scope on this dimension (who the primary actor is, whether case X is in/out, in scope vs deferred)
|
||||
- **Non-obvious scope inclusion** — a behavior the agent assumed is in scope that the user might want excluded
|
||||
- **Non-obvious scope exclusion** — an item the agent moved to deferred that the user might want in scope
|
||||
- **Cheap-now-expensive-later correction** — a scope bet that's cheap to fix now but expensive after the requirements doc lands and ce-plan consumes it
|
||||
- **Non-obvious consequence of multi-turn answers** — a downstream effect of combining user-stated answers that the user is unlikely to have tracked through dialogue. Surfaced forward-looking ("X means Y for the doc"), not retrospectively ("you said X"). This category is the multi-turn-dialogue reason call-outs exist at all in ce-brainstorm; do not filter these as "already implied by Stated"
|
||||
|
||||
Cut anything that doesn't match a keep-test category, including:
|
||||
|
||||
- Mechanical items where there is no real alternative
|
||||
- Implementation choices that will be settled during planning
|
||||
- Items already implied by the scoping synthesis prose
|
||||
- Re-statements of Q&A turns ("you said you wanted X") — that's transcript, not a call-out
|
||||
- Re-statements of the Phase 2 approach the user already picked
|
||||
|
||||
### Total bullet budget across sections 2–4
|
||||
|
||||
The cap is heuristic, not law. The real discipline is each section's keep test on each candidate. Typical bounds by tier, counting bullets across Trade-offs + Deferred + Call outs combined:
|
||||
|
||||
| Tier | Typical total | Hard ceiling |
|
||||
|---|---|---|
|
||||
| Lightweight | 0–1 | 2 |
|
||||
| Standard | 2–4 | 5 |
|
||||
| Deep — feature | 3–5 | 7 |
|
||||
| Deep — product | 4–7 | 9 |
|
||||
|
||||
**Above the hard ceiling, the synthesis is misshapen — do not raise the cap, re-cut at a higher level of abstraction.** Almost always, multiple bullets within a section are sub-decisions of one larger named decision. Collapse related bullets into a single one named at the level the user actually weighs in on.
|
||||
|
||||
A useful test: read the bullets aloud. If two or more sound like "and also" extensions of the same idea, they belong as one.
|
||||
|
||||
**Path A fires only for Lightweight tier with no blocking questions. Path B is the default for Standard, Deep-feature, and Deep-product regardless of question signal — substance earns the checkpoint, not interaction history.** Zero call-outs on Path B is normal for Lightweight, sometimes for Standard, almost never for Deep. If a Deep scoping synthesis produces zero call-outs after rich content (whether from dialogue or pre-loaded context), double-check the agent hasn't filtered consequence-class call-outs as "already implied."
|
||||
|
||||
### Detail level: conversational, not documentary
|
||||
|
||||
Each bullet is **1 line ideally, 2 lines maximum**. The reference shape is what two collaborators would say to each other in conversation, not what a requirements doc would say in its body. The synthesis is a forcing function for shape confirmation; the requirements doc is where the substance lives. If a bullet reads like a doc paragraph, it's wrong-shaped — the agent has compressed horizontally (fewer bullets) without compressing vertically (less per bullet), and the cap is meaningless if individual bullets bloat to fill it.
|
||||
|
||||
Two tests:
|
||||
|
||||
- **Read-aloud test**: would two product collaborators *say* this bullet, or would they *write* it in a spec? Say = right. Write = re-cut to a sentence or cut.
|
||||
- **Single-sentence test**: can the bullet land in one sentence? If it needs semicolons stringing clauses or a list within the bullet, it's probably two decisions sharing a bullet — split (and re-cut for count) or cut to the higher-level one.
|
||||
|
||||
Bad vs good — detail level:
|
||||
|
||||
| Too detailed (wrong) | Conversational (right) |
|
||||
|---|---|
|
||||
| Per-channel mute scoped to notification rules; mute applies to all events through that rule including @mentions, DMs forwarded as notifications, and bot messages; persists 24h with extension | Per-channel over per-user — support team isn't a single user |
|
||||
| Rule-delete loss path is silent and could surprise users who configured extended mutes; consider a confirmation dialog, soft-delete with state preservation, or a 7-day undo window | Rule-delete silently loses pause state — confirm no warning needed |
|
||||
|
||||
The "What we're building" prose obeys the same discipline: 1–3 sentences describing the shape, not an enumeration of requirements. If the prose lists what's in / what's out / what's how, it has become a doc preview — cut to shape only.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
Each anti-pattern below produces a bullet that fails its section's keep test, or a scoping synthesis that drifts back toward the comprehensive-audit failure mode.
|
||||
|
||||
- **Naming implementation detail in any bullet**: file paths, module names, exact JSON keys, HTTP status codes, error message wording, SQL syntax. The synthesis is scope-only; implementation is ce-plan's job. These granularity rules apply to every bullet in every section.
|
||||
- **Re-stating a Q&A turn verbatim** ("you said you wanted X"): transcript, not scoping synthesis. Reframe forward-looking ("X means Y for the doc") or cut.
|
||||
- **Re-stating the Phase 2 approach the user already picked**: the approach was chosen before Phase 2.5 — its mention belongs in one sentence of "What we're building," not as a call-out.
|
||||
- **Padding a section to meet a bullet count**: render-conditional means empty is allowed. Omit the section entirely rather than fill it with weak items.
|
||||
- **Pasting the three-bucket internal draft verbatim into chat**: that was the old shape and the volume problem it produced is why stage 2 exists. Compose internally, derive scoping synthesis sections, present compressed.
|
||||
- **Floating questions adjacent to stage 2**: if a question genuinely cannot be defaulted, pause synthesis and resolve it before presenting. Pick the question shape that matches: a blocking multiple-choice tool when options are bounded and meaningfully distinct, open-ended when option sets would unintentionally influence the user's answer per Interaction Rule 5(a). Integrate the answer, then present the scoping synthesis. Never present the scoping synthesis with adjacent floating questions — that gives the user no clear resolution path.
|
||||
|
||||
---
|
||||
|
||||
## Prompt templates
|
||||
|
||||
This is directional guidance — adjust phrasing to fit dialogue context. Open-ended feedback per Interaction Rule 5(a) (an option menu would unintentionally influence the user toward the parts the menu lists, away from anything else they might want to change).
|
||||
|
||||
**Prose discipline for "What we're building" (required):** forward-looking (what *will* be in the doc), not retrospective (what's been discussed). Lead with the actual thing being built in plain words. No qualifiers ("comprehensive," "thoughtful," "substantive"). No re-stating dialogue context the user just lived through. If the work can't be said in 1–3 sentences without filler, the synthesis isn't ready yet.
|
||||
|
||||
### Path B template (questions were asked)
|
||||
|
||||
```
|
||||
Based on our dialogue, here's the scope I'm proposing for the requirements doc:
|
||||
|
||||
**What we're building:** [1–3 sentences — the shape that emerged from dialogue, forward-looking, plain words]
|
||||
|
||||
**Key trade-offs:** [render only when real trade-offs exist]
|
||||
- [explicit choice + brief why]
|
||||
- [explicit choice + brief why]
|
||||
|
||||
**What's not in scope:** [render only when deferred items would surprise a reader]
|
||||
- [deferred item]
|
||||
- [deferred item]
|
||||
|
||||
**Call outs:** [render only when one or more survived the keep test]
|
||||
- [scope-level fork or non-obvious consequence the user can affirm or redirect]
|
||||
- [same]
|
||||
|
||||
Confirm and I'll write the requirements doc next, drawing on our dialogue and this synthesis. Or tell me what to change — even something I captured correctly earlier is fair game to revise (you may have changed your mind or want to correct an unstated assumption).
|
||||
```
|
||||
|
||||
### Path A template (no questions were asked — typically Phase 0.2 short-circuit)
|
||||
|
||||
```
|
||||
Proposing: [1–3 line shape — what the doc will say in plain words].
|
||||
|
||||
No open decisions — writing the requirements doc now. Interrupt if the shape is wrong.
|
||||
```
|
||||
|
||||
Proceed to Phase 3 doc-write in the same turn — do NOT end the turn waiting for an acknowledgment. The "interrupt if wrong" affordance means the user can revise after the doc lands, not before. Lightweight Path A docs are short, so post-hoc revision is cheap.
|
||||
|
||||
Ask the user open-ended on Path B (no `AskUserQuestion` menu). The justification is Interaction Rule 5(a) in SKILL.md — an option menu would unintentionally influence the user's feedback toward the parts the menu lists.
|
||||
|
||||
### Worked example: compression from internal draft to scoping synthesis (Standard tier)
|
||||
|
||||
For a notification-mute feature where the internal draft had 5 Stated items, 4 Inferred items, and 3 Out-of-scope items, the compressed Stage 2 looks like:
|
||||
|
||||
```
|
||||
Based on our dialogue, here's the scope I'm proposing for the requirements doc:
|
||||
|
||||
**What we're building:** Per-channel mute on notification rules, with a 24h preset for the support team's 3 AM ping problem. Mute lives on the rule itself and survives rule edits.
|
||||
|
||||
**Key trade-offs:**
|
||||
- Per-channel over per-user — support team isn't a single user
|
||||
- Mute on the rule, not a separate entity — pause state survives edits
|
||||
|
||||
**What's not in scope:**
|
||||
- Presence-based mute and quiet-hours schedules — deferred for later
|
||||
- Cross-rule mute groups — would force a rule-grouping concept we don't have
|
||||
|
||||
**Call outs:**
|
||||
- Rule-delete silently loses pause state — confirm no warning needed
|
||||
|
||||
Confirm and I'll write the requirements doc next, drawing on our dialogue and this synthesis. Or tell me what to change.
|
||||
```
|
||||
|
||||
What got cut from the 12-item internal draft and why:
|
||||
|
||||
- Stated items already covered by the "What we're building" prose dissolved silently
|
||||
- "Use existing rule entity" — mechanical, no real trade-off
|
||||
- "Use Postgres for persistence" — implementation detail (ce-plan's job), failed granularity rules
|
||||
- One Out-of-scope item ("no rate limiting") — mechanical exclude, no reader would ask about it
|
||||
- Three Inferred items rolled into the Trade-offs section as the explicit choices behind them
|
||||
|
||||
What survived: a scoping synthesis with substance proportional to the dialogue, bounded at the Standard ceiling of 5 bullets across the three conditional sections — any more would have triggered a re-cut at higher abstraction.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight re-review
|
||||
|
||||
Before emitting the scoping synthesis, re-read the draft as a user would read it. Two failure modes to catch:
|
||||
|
||||
- **The scoping synthesis reads like a requirements-doc preview.** Prose enumerates what's in/out, bullets are documentary instead of conversational. The synthesis is a shape-confirmation checkpoint, not a doc preview — if it reads as preview, Phase 2.5 and Phase 3 have collapsed into one step. Revise to conversational shape, or accept that the requirements doc itself will contain the detail and the synthesis should be lighter.
|
||||
- **The bullet count fits the cap but each bullet is over-detailed.** Hitting 5 bullets in Standard while each bullet is a paragraph means the agent met the count cap by compressing horizontally (fewer bullets) without compressing vertically (less per bullet). The cap is meaningless if individual bullets bloat to fill it. Re-cut to sentence-level bullets.
|
||||
|
||||
This is one mental act — re-read as the user — not a checklist to mechanically run. The forcing function is putting yourself in the user's reading shoes briefly, with explicit attention to detail level alongside the keep tests. Revise before emitting if either failure mode fires.
|
||||
|
||||
---
|
||||
|
||||
## Re-present after revision; write only on confirm
|
||||
|
||||
A revision is not a confirmation. After any user revision (even a trivially-understood swap like "move deferred item X back into scope"), integrate the change, re-present the revised scoping synthesis with the change reflected, and wait for explicit confirmation before writing the doc. The loop is:
|
||||
|
||||
1. Present scoping synthesis → user responds
|
||||
2. User confirms → write the doc
|
||||
3. User revises → integrate, re-present revised scoping synthesis, return to step 1
|
||||
|
||||
Doc-write fires only on explicit confirm or after the soft-cut blocking question's "proceed" option (see below). The confirmation step is what makes the scoping synthesis **confirmed** rather than "agent's last proposal" — never write immediately after a revision, even when the revision is small enough that the agent feels it understood.
|
||||
|
||||
---
|
||||
|
||||
## Soft-cut on circularity (not iteration count)
|
||||
|
||||
Track which scoping synthesis items the user touched per round. The soft-cut blocking question fires **only when the same item is revised twice** (or a third-round revision targets an item already revised in round two). New-item revisions across rounds proceed without limit — revising different aspects of a wrong scoping synthesis is exactly what the mechanism should support.
|
||||
|
||||
**Identity across rounds is by decision dimension, not surface wording or section.** A revision may cause stage 2 to re-derive — the same underlying decision can come back rephrased, merged with another bullet, or moved to a different section (e.g., what was a Trade-off in round one becomes a Call-out in round two after the user pushed back). "Same item" means the same underlying decision regardless of which section currently holds it. When a re-cut collapses multiple prior bullets into one, the new combined bullet inherits the "touched" status of any of its constituents — soft-cut fires if any underlying decision was already revised once before.
|
||||
|
||||
When the soft-cut fires, use the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi) with two options:
|
||||
|
||||
- `Proceed and write the requirements doc`
|
||||
- `Hold off — keep discussing before the doc`
|
||||
|
||||
Fall back to a numbered list in chat only when no blocking tool exists or the call errors. Never silently skip.
|
||||
|
||||
---
|
||||
|
||||
## Self-redirect
|
||||
|
||||
If the user response indicates they're in the wrong skill or want a different workflow (e.g., "this is too small, just /ce-work it" or "this needs more thought, let me brainstorm differently"):
|
||||
|
||||
- Stop ce-brainstorm
|
||||
- Suggest the alternative skill the user appears to want (e.g., `/ce-work`, `/ce-debug`)
|
||||
- Offer to load it in-session
|
||||
- Do not push back or argue — the user's redirect signal is the deliberate choice
|
||||
|
||||
This support exists because the scoping synthesis is an honest checkpoint. If the user discovers the skill choice was wrong by reading the scoping synthesis, redirecting is the right move.
|
||||
|
||||
---
|
||||
|
||||
## Doc shape after confirmation
|
||||
|
||||
After user confirmation (or after the soft-cut decision proceeds), Phase 3 writes the requirements doc. The internal draft does NOT carry into the doc as a `## Synthesis` section. Only the "What we're building" prose embeds, as `## Summary` at the top. Internal-draft content dissolves into the doc's body sections:
|
||||
|
||||
| Internal-draft element | Where it goes in the doc |
|
||||
|---|---|
|
||||
| "What we're building" prose | `## Summary` (1–3 lines, forward-looking, what's proposed) |
|
||||
| Stated bullets | `## Requirements` (numbered R-IDs, full detail) and where relevant `## Problem Frame` for narrative context |
|
||||
| Inferred bullets | `## Key Decisions` (with rationale) — bets the user accepted in dialogue become decisions in the doc. |
|
||||
| Out-of-scope bullets | `## Scope Boundaries` |
|
||||
|
||||
The chat-time Trade-offs section dissolves into `## Key Decisions` (the explicit choices acknowledged in chat become documented decisions). The chat-time What's-not-in-scope section dissolves into `## Scope Boundaries`.
|
||||
|
||||
No italic capture-context note (e.g., "Captured at Phase 2.5..."). It would leak engineering process into an artifact whose readers do not need that signal.
|
||||
|
||||
The doc's `## Summary` and `## Problem Frame` must serve distinct purposes — see `references/brainstorm-sections.md` "Discipline: Summary vs Problem Frame" for the rules.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Universal Brainstorming Facilitator
|
||||
|
||||
This file is loaded when ce-brainstorm detects a non-software task (Phase 0). It replaces the software-specific brainstorming phases (Phases 0.2 through 4) with facilitation principles for any domain. The Core Principles and **Interaction Rules** in the parent `ce-brainstorm/SKILL.md` still apply unchanged — including one-question-per-turn and the default to the platform's blocking question tool. This file extends those rules with universal-domain facilitation guidance; it does not relax them.
|
||||
|
||||
---
|
||||
|
||||
## Your role
|
||||
|
||||
Be a thinking partner, not an answer machine. The user came here because they're stuck or exploring — they want to think WITH someone, not receive a deliverable. Resist the urge to generate a complete solution immediately. A premature answer anchors the conversation and kills exploration.
|
||||
|
||||
**Match the tone to the stakes.** For personal or life decisions (career changes, housing, relationships, family), lead with values and feelings before frameworks and analysis. Ask what matters to them, not just what the options are. For lighter or creative tasks (podcast topics, event ideas, side projects), energy and enthusiasm are more useful than caution.
|
||||
|
||||
## Asking questions
|
||||
|
||||
"Thinking partner" framing does not mean "conversational prose." The parent skill's Interaction Rules apply in full: one question per turn, and default to the platform's blocking question tool (with its free-text fallback) even for opening and elicitation.
|
||||
|
||||
"What's prompting this?", "what matters most here?", and "what have you ruled out?" feel open-ended and conversational, but that's not a reason to skip the tool. The free-text option preserves flexibility while a well-crafted option set teaches the user the dimensions they might not have separated. Pick-plus-optional-note is lower activation energy than composing prose from scratch — especially for emotional or values-laden topics where prose can feel like an essay prompt.
|
||||
|
||||
Drop the blocking tool only when (a) the answer is inherently narrative ("walk me through how you got here"), (b) the question is diagnostic or introspective and presented options would unintentionally influence the user's answer, or (c) you cannot write 3-4 genuinely distinct, plausibly-correct options that cover the space without padding. If you'd be straining to fill the option slots, the question is open — ask it open-ended (see Interaction Rule 6 in SKILL.md for how to phrase open-ended questions so they earn their place).
|
||||
|
||||
## How to start
|
||||
|
||||
**Assess scope first.** Not every brainstorm needs deep exploration:
|
||||
- **Quick** (user has a clear goal, just needs a sounding board): Confirm understanding, offer a few targeted suggestions or reactions, done in 2-3 exchanges.
|
||||
- **Standard** (some unknowns, needs to explore options): 4-6 exchanges, generate and compare options, help decide.
|
||||
- **Full** (vague goal, lots of uncertainty, or high-stakes decision): Deep exploration, many exchanges, structured convergence.
|
||||
|
||||
**Ask what they're already thinking.** Before offering ideas, find out what the user has considered, tried, or rejected. This prevents fixation on AI-generated ideas and surfaces hidden constraints.
|
||||
|
||||
**When the user represents a group** (couple, family, team) — surface whose preferences are in play and where they diverge. The brainstorm shifts from "help you decide" to "help you find alignment." Ask about each person's priorities, not just the speaker's.
|
||||
|
||||
**Understand before generating.** Spend time on the problem before jumping to solutions. "What would success look like?" and "What have you already ruled out?" reveal more than "Here are 10 ideas."
|
||||
|
||||
## How to explore and generate
|
||||
|
||||
**Use diverse angles to avoid repetitive ideas.** When generating options, vary your approach across exchanges:
|
||||
- Inversion: "What if you did the opposite of the obvious choice?"
|
||||
- Constraints as creative tools: "What if budget/time/distance were no issue?" then "What if you had to do it for free?"
|
||||
- Analogy: "How does someone in a completely different context solve a similar problem?"
|
||||
- What the user hasn't considered: introduce lateral ideas from unexpected directions
|
||||
|
||||
**Separate generation from evaluation.** When exploring options, don't critique them in the same breath. Generate first, evaluate later. Make the transition explicit when it's time to narrow.
|
||||
|
||||
**Offer options to react to when the user is stuck.** People who can't generate from scratch can often evaluate presented options. Use multi-select questions to gather preferences efficiently. Always include a skip option for users who want to move faster.
|
||||
|
||||
**Keep presented options to 3-5 at any decision point.** More causes analysis paralysis.
|
||||
|
||||
## How to converge
|
||||
|
||||
When the conversation has enough material to narrow — reflect back what you've heard. Name the user's priorities as they've emerged through the conversation (what excited them, what they rejected, what they asked about). Propose a frontrunner with reasoning tied to their criteria, and invite pushback. Keep final options to 3-5 max. Don't force a final decision if the user isn't there yet — clarity on direction is a valid outcome.
|
||||
|
||||
## When to wrap up
|
||||
|
||||
**Always synthesize a summary in the chat.** Before offering any next steps, reflect back what emerged: key decisions, the direction chosen, open threads, and any assumptions made. This is the primary output of the brainstorm — the user should be able to read the summary and know what they landed on.
|
||||
|
||||
**Then offer next steps** using the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to numbered options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
|
||||
|
||||
**Question:** "Brainstorm wrapped. What would you like to do next?"
|
||||
|
||||
- **Create a plan** → hand off to `/ce-plan` with the decided goal and constraints
|
||||
- **Save summary to disk** → write the summary as a markdown file in the current working directory
|
||||
- **Open in Proof (web app) — review and comment to iterate with the agent** → load the `ce-proof` skill to open the doc in Every's Proof editor, iterate with the agent via comments, or copy a link to share with others
|
||||
- **Done** → the conversation was the value, no artifact needed
|
||||
@@ -0,0 +1,898 @@
|
||||
---
|
||||
name: ce-code-review
|
||||
description: "Structured code review using tiered persona agents, confidence-gated findings, and a merge/dedup pipeline. Use when reviewing code changes before creating a PR."
|
||||
argument-hint: "[blank to review current branch, or provide PR link]"
|
||||
---
|
||||
|
||||
# Code Review
|
||||
|
||||
Reviews code changes using dynamically selected reviewer personas. Spawns parallel sub-agents that return structured JSON, then merges and deduplicates findings into a single report.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before creating a PR
|
||||
- After completing a task during iterative implementation
|
||||
- When feedback is needed on any code changes
|
||||
- Can be invoked standalone
|
||||
- Can run as a read-only or autofix review step inside larger workflows
|
||||
|
||||
## Argument Parsing
|
||||
|
||||
Parse `$ARGUMENTS` for the following optional tokens. Strip each recognized token before interpreting the remainder as the PR number, GitHub URL, or branch name.
|
||||
|
||||
| Token | Example | Effect |
|
||||
|-------|---------|--------|
|
||||
| `mode:autofix` | `mode:autofix` | Select autofix mode (see Mode Detection below) |
|
||||
| `mode:report-only` | `mode:report-only` | Select report-only mode |
|
||||
| `mode:headless` | `mode:headless` | Select headless mode for programmatic callers (see Mode Detection below) |
|
||||
| `base:<sha-or-ref>` | `base:abc1234` or `base:origin/main` | Skip scope detection — use this as the diff base directly |
|
||||
| `plan:<path>` | `plan:docs/plans/2026-03-25-001-feat-foo-plan.md` | Load this plan for requirements verification |
|
||||
|
||||
All tokens are optional. Each one present means one less thing to infer. When absent, fall back to existing behavior for that stage.
|
||||
|
||||
**Conflicting mode flags:** If multiple mode tokens appear in arguments, stop and do not dispatch agents. If `mode:headless` is one of the conflicting tokens, emit the headless error envelope: `Review failed (headless mode). Reason: conflicting mode flags — <mode_a> and <mode_b> cannot be combined.` Otherwise emit the generic form: `Review failed. Reason: conflicting mode flags — <mode_a> and <mode_b> cannot be combined.`
|
||||
|
||||
## Quick Review Short-Circuit
|
||||
|
||||
If `$ARGUMENTS` indicates the user wants a quick, fast, or light code review, do not dispatch the multi-agent flow.
|
||||
|
||||
**Announce the chosen path** before any other work (Quick review vs Multi-agent review).
|
||||
|
||||
Programmatic callers (when `mode:autofix`, `mode:report-only`, or `mode:headless` is present) skip this announcement -- the orchestrator owns user-facing messaging.
|
||||
|
||||
Sequence:
|
||||
|
||||
1. **Run the harness's built-in code review.** If `$ARGUMENTS` contained a review target (PR number, GitHub URL, or branch name) after stripping recognized tokens, forward that target to the built-in. If no target was provided, run the bare command and let the built-in default to the current branch.
|
||||
- If you are Claude Code, run the `/review` tool, passing the target if present (e.g., `/review 123`, `/review <PR-URL>`, `/review <branch>`); otherwise run bare `/review`.
|
||||
- If you are Gemini, run a quick code review against the resolved target (or the current branch when none was provided).
|
||||
- For all other coding harnesses, run your built-in code review tool, forwarding the target when its syntax accepts one.
|
||||
|
||||
Then stop. Do not dispatch the multi-agent reviewer pipeline.
|
||||
|
||||
2. **Exemption -- no built-in code review exists.** If the current harness has no built-in code review command or skill, do not short-circuit. Continue into the full multi-agent review described in the rest of this skill (Tier 2).
|
||||
|
||||
3. **Programmatic callers bypass this short-circuit.** When `mode:autofix`, `mode:report-only`, or `mode:headless` is present, ignore quick intent and run the full multi-agent review. Skill-to-skill callers that want the lightweight pass should invoke `/review` (or the harness equivalent) directly rather than route through this short-circuit.
|
||||
|
||||
## Mode Detection
|
||||
|
||||
| Mode | When | Behavior |
|
||||
|------|------|----------|
|
||||
| **Interactive** (default) | No mode token present | Review, apply safe_auto fixes automatically, present findings, ask for policy decisions on gated/manual findings, and optionally continue into fix/push/PR next steps |
|
||||
| **Autofix** | `mode:autofix` in arguments | No user interaction. Review, apply only policy-allowed `safe_auto` fixes, re-review in bounded rounds, write a run artifact capturing residual downstream work |
|
||||
| **Report-only** | `mode:report-only` in arguments | Strictly read-only. Review and report only, then stop with no edits, artifacts, commits, pushes, or PR actions |
|
||||
| **Headless** | `mode:headless` in arguments | Programmatic mode for skill-to-skill invocation. Apply `safe_auto` fixes silently (single pass), return all other findings as structured text output, write run artifacts, and return "Review complete" signal. No interactive prompts. |
|
||||
|
||||
### Autofix mode rules
|
||||
|
||||
- **Skip all user questions.** Never pause for approval or clarification once scope has been established.
|
||||
- **Apply only `safe_auto -> review-fixer` findings.** Leave `gated_auto`, `manual`, `human`, and `release` work unresolved.
|
||||
- **Write a run artifact** under `/tmp/compound-engineering/ce-code-review/<run-id>/` summarizing findings, applied fixes, residual actionable work, and advisory outputs. Orchestrators read this artifact to route residual `downstream-resolver` findings; the skill itself does not file tickets or prompt the user in autofix.
|
||||
- **Emit a compact Residual Actionable Work summary in the autofix return** listing each residual `downstream-resolver` finding with its stable `#`, severity, file:line, title, and autofix_class. Structure the summary as two separate contiguous sections: applied `safe_auto` fixes first, then residual non-auto findings. Within the residual section, reuse each finding's stable `#` from Stage 5 -- never renumber. Include the run-artifact path. Callers read this summary directly without parsing the artifact. When no residuals exist, state `Residual actionable work: none.` explicitly.
|
||||
- **Never commit, push, or create a PR** from autofix mode. Parent workflows own those decisions.
|
||||
|
||||
### Report-only mode rules
|
||||
|
||||
- **Skip all user questions.** Infer intent conservatively if the diff metadata is thin.
|
||||
- **Never edit files or externalize work.** Do not write `/tmp/compound-engineering/ce-code-review/<run-id>/`, do not file tickets, and do not commit, push, or create a PR.
|
||||
- **Safe for parallel read-only verification.** `mode:report-only` is the only mode that is safe to run concurrently with browser testing on the same checkout.
|
||||
- **Do not switch the shared checkout.** If the caller passes an explicit PR or branch target, `mode:report-only` must run in an isolated checkout/worktree or stop instead of running `gh pr checkout` / `git checkout`.
|
||||
- **Do not overlap mutating review with browser testing on the same checkout.** If a future orchestrator wants fixes, run the mutating review phase after browser testing or in an isolated checkout/worktree.
|
||||
|
||||
### Headless mode rules
|
||||
|
||||
- **Skip all user questions.** Never use the platform question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension)) or other interactive prompts. Infer intent conservatively if the diff metadata is thin.
|
||||
- **Require a determinable diff scope.** If headless mode cannot determine a diff scope (no branch, PR, or `base:` ref determinable without user interaction), emit `Review failed (headless mode). Reason: no diff scope detected. Re-invoke with a branch name, PR number, or base:<ref>.` and stop without dispatching agents.
|
||||
- **Apply only `safe_auto -> review-fixer` findings in a single pass.** No bounded re-review rounds. Leave `gated_auto`, `manual`, `human`, and `release` work unresolved and return them in the structured output.
|
||||
- **Return all non-auto findings as structured text output.** Use the headless output envelope format (see Stage 6 below) preserving severity, autofix_class, owner, requires_verification, confidence, pre_existing, and suggested_fix per finding. Enrich with detail-tier fields (why_it_matters, evidence[]) from the per-agent artifact files on disk (see Detail enrichment in Stage 6).
|
||||
- **Write a run artifact** under `/tmp/compound-engineering/ce-code-review/<run-id>/` summarizing findings, applied fixes, and advisory outputs. Include the artifact path in the structured output.
|
||||
- **Do not file tickets or externalize work.** The caller receives structured findings and routes downstream work itself.
|
||||
- **Do not switch the shared checkout.** If the caller passes an explicit PR or branch target, `mode:headless` must run in an isolated checkout/worktree or stop instead of running `gh pr checkout` / `git checkout`. When stopping, emit `Review failed (headless mode). Reason: cannot switch shared checkout. Re-invoke with base:<ref> to review the current checkout, or run from an isolated worktree.`
|
||||
- **Not safe for concurrent use on a shared checkout.** Unlike `mode:report-only`, headless mutates files (applies `safe_auto` fixes). Callers must not run headless concurrently with other mutating operations on the same checkout.
|
||||
- **Never commit, push, or create a PR** from headless mode. The caller owns those decisions.
|
||||
- **End with "Review complete" as the terminal signal** so callers can detect completion. If all reviewers fail or time out, emit `Code review degraded (headless mode). Reason: 0 of N reviewers returned results.` followed by "Review complete".
|
||||
|
||||
### Interactive mode rules
|
||||
|
||||
- **Pre-load the platform question tool before any question fires.** In Claude Code, `AskUserQuestion` is a deferred tool — its schema is not available at session start. At the start of Interactive-mode work (before Stage 2 intent-ambiguity questions, the After-Review routing question, walk-through per-finding questions, bulk-preview Proceed/Cancel, and tracker-defer failure sub-questions), call `ToolSearch` with query `select:AskUserQuestion` to load the schema. Load it **once, eagerly, at the top of the Interactive flow** — do not wait for the first question site and do not decide it on a per-site basis. On Codex, Gemini, and Pi this preload step does not apply.
|
||||
- **The numbered-list fallback only applies when the harness genuinely lacks a blocking question tool** — `ToolSearch` returns no match, the tool call explicitly fails, or the runtime mode does not expose it (e.g., Codex edit modes where `request_user_input` is unavailable). A pending schema load is not a fallback trigger; call `ToolSearch` first per the pre-load rule. Rendering a question as narrative text because the tool feels inconvenient, because the model is in report-formatting mode, or because the instruction was buried in a long skill is a bug. A question that calls for a user decision must either fire the tool or fall back loudly.
|
||||
|
||||
## Severity Scale
|
||||
|
||||
All reviewers use P0-P3:
|
||||
|
||||
| Level | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| **P0** | Critical breakage, exploitable vulnerability, data loss/corruption | Must fix before merge |
|
||||
| **P1** | High-impact defect likely hit in normal usage, breaking contract | Should fix |
|
||||
| **P2** | Moderate issue with meaningful downside (edge case, perf regression, maintainability trap) | Fix if straightforward |
|
||||
| **P3** | Low-impact, narrow scope, minor improvement | User's discretion |
|
||||
|
||||
## Action Routing
|
||||
|
||||
Severity answers **urgency**. Routing answers **who acts next** and **whether this skill may mutate the checkout**.
|
||||
|
||||
| `autofix_class` | Default owner | Meaning |
|
||||
|-----------------|---------------|---------|
|
||||
| `safe_auto` | `review-fixer` | Local, deterministic fix suitable for the in-skill fixer when the current mode allows mutation |
|
||||
| `gated_auto` | `downstream-resolver` or `human` | Concrete fix exists, but it changes behavior, contracts, permissions, or another sensitive boundary that should not be auto-applied by default |
|
||||
| `manual` | `downstream-resolver` or `human` | Actionable work that should be handed off rather than fixed in-skill |
|
||||
| `advisory` | `human` or `release` | Report-only output such as learnings, rollout notes, or residual risk |
|
||||
|
||||
Routing rules:
|
||||
|
||||
- **Synthesis owns the final route.** Persona-provided routing metadata is input, not the last word.
|
||||
- **Choose the more conservative route on disagreement.** A merged finding may move from `safe_auto` to `gated_auto` or `manual`, but never the other way without stronger evidence.
|
||||
- **Only `safe_auto -> review-fixer` enters the in-skill fixer queue automatically.**
|
||||
- **`requires_verification: true` means a fix is not complete without targeted tests, a focused re-review, or operational validation.**
|
||||
|
||||
## Reviewers
|
||||
|
||||
14 reviewer personas in layered conditionals, plus CE-specific agents. See the persona catalog included below for the full catalog.
|
||||
|
||||
**Always-on (every review):**
|
||||
|
||||
| Agent | Focus |
|
||||
|-------|-------|
|
||||
| `ce-correctness-reviewer` | Logic errors, edge cases, state bugs, error propagation |
|
||||
| `ce-testing-reviewer` | Coverage gaps, weak assertions, brittle tests |
|
||||
| `ce-maintainability-reviewer` | Structural quality, complexity deletion, 1k-line regressions, coupling, type-boundary leaks, dead code, abstraction debt |
|
||||
| `ce-project-standards-reviewer` | CLAUDE.md and AGENTS.md compliance -- frontmatter, references, naming, portability |
|
||||
| `ce-agent-native-reviewer` | Verify new features are agent-accessible |
|
||||
| `ce-learnings-researcher` | Search docs/solutions/ for past issues related to this PR |
|
||||
|
||||
**Cross-cutting conditional (selected per diff):**
|
||||
|
||||
| Agent | Select when diff touches... |
|
||||
|-------|---------------------------|
|
||||
| `ce-security-reviewer` | Auth, public endpoints, user input, permissions |
|
||||
| `ce-performance-reviewer` | DB queries, data transforms, caching, async |
|
||||
| `ce-api-contract-reviewer` | Routes, serializers, type signatures, versioning |
|
||||
| `ce-data-migration-reviewer` | Migration files, schema dumps (`db/schema.rb`, `structure.sql`), backfills, data-transform scripts — **not** model/query-only changes without migration artifacts |
|
||||
| `ce-reliability-reviewer` | Error handling, retries, timeouts, background jobs |
|
||||
| `ce-adversarial-reviewer` | Diff >=50 changed non-test/non-generated/non-lockfile lines, or auth, payments, data mutations, external APIs |
|
||||
| `ce-previous-comments-reviewer` | Reviewing a PR that has existing review comments or threads |
|
||||
|
||||
**Stack-specific conditional (selected per diff):**
|
||||
|
||||
| Agent | Select when diff touches... |
|
||||
|-------|---------------------------|
|
||||
| `ce-julik-frontend-races-reviewer` | Stimulus/Turbo controllers, DOM events, timers, animations, or async UI flows |
|
||||
| `ce-swift-ios-reviewer` | Swift files, SwiftUI views, UIKit controllers, entitlements, privacy manifests, Core Data models, SPM manifests, storyboards/XIBs, or semantic build-setting/target/signing changes in .pbxproj |
|
||||
|
||||
**CE conditional (migration-specific):**
|
||||
|
||||
| Agent | Select when diff includes migration files |
|
||||
|-------|------------------------------------------|
|
||||
| `ce-deployment-verification-agent` | Produces deployment checklist with SQL verification queries and rollback procedures |
|
||||
|
||||
Schema drift detection is folded into `ce-data-migration-reviewer` (Step 0) and surfaces as P1 findings — not a separate agent or report section.
|
||||
|
||||
## Review Scope
|
||||
|
||||
Every review spawns all 4 always-on personas plus the 2 CE always-on agents, then adds whichever cross-cutting and stack-specific conditionals fit the diff. The model naturally right-sizes: a small config change triggers 0 conditionals = 6 reviewers. A Rails auth feature might trigger security + reliability + adversarial = 9 reviewers.
|
||||
|
||||
## Protected Artifacts
|
||||
|
||||
The following paths are compound-engineering pipeline artifacts and must never be flagged for deletion, removal, or gitignore by any reviewer:
|
||||
|
||||
- `docs/brainstorms/*` -- requirements documents created by ce-brainstorm
|
||||
- `docs/plans/*.md` -- plan files created by ce-plan (decision artifacts; execution progress is derived from git, not stored in plan bodies)
|
||||
- `docs/solutions/*.md` -- solution documents created during the pipeline
|
||||
|
||||
If a reviewer flags any file in these directories for cleanup or removal, discard that finding during synthesis.
|
||||
|
||||
## How to Run
|
||||
|
||||
### Stage 1: Determine scope
|
||||
|
||||
Compute the diff range, file list, and diff. Minimize permission prompts by combining into as few commands as possible.
|
||||
|
||||
**If `base:` argument is provided (fast path):**
|
||||
|
||||
The caller already knows the diff base. Skip all base-branch detection, remote resolution, and merge-base computation. Use the provided value directly:
|
||||
|
||||
```
|
||||
BASE_ARG="{base_arg}"
|
||||
BASE=$(git merge-base HEAD "$BASE_ARG" 2>/dev/null) || BASE="$BASE_ARG"
|
||||
```
|
||||
|
||||
Then produce the same output as the other paths:
|
||||
|
||||
```
|
||||
echo "BASE:$BASE" && echo "FILES:" && git diff --name-only $BASE && echo "DIFF:" && git diff -U10 $BASE && echo "UNTRACKED:" && git ls-files --others --exclude-standard
|
||||
```
|
||||
|
||||
This path works with any ref — a SHA, `origin/main`, a branch name. Automated callers (ce-work, lfg, slfg) should prefer this to avoid the detection overhead. **Do not combine `base:` with a PR number or branch target.** If both are present, stop with an error: "Cannot use `base:` with a PR number or branch target — `base:` implies the current checkout is already the correct branch. Pass `base:` alone, or pass the target alone and let scope detection resolve the base." This avoids scope/intent mismatches where the diff base comes from one source but the code and metadata come from another.
|
||||
|
||||
**If a PR number or GitHub URL is provided as an argument:**
|
||||
|
||||
If `mode:report-only` or `mode:headless` is active, do **not** run `gh pr checkout <number-or-url>` on the shared checkout. For `mode:report-only`, tell the caller: "mode:report-only cannot switch the shared checkout to review a PR target. Run it from an isolated worktree/checkout for that PR, or run report-only with no target argument on the already checked out branch." For `mode:headless`, emit `Review failed (headless mode). Reason: cannot switch shared checkout. Re-invoke with base:<ref> to review the current checkout, or run from an isolated worktree.` Stop here unless the review is already running in an isolated checkout.
|
||||
|
||||
**Skip-condition pre-check.** Before checkout or scope detection, run a PR-state probe to decide whether the review should proceed:
|
||||
|
||||
```
|
||||
gh pr view <number-or-url> --json state,title,body,files
|
||||
```
|
||||
|
||||
Apply skip rules in order:
|
||||
|
||||
- `state` is `CLOSED` or `MERGED` -> stop with message `PR is closed/merged; not reviewing.`
|
||||
- **Trivial-PR judgment**: spawn a lightweight sub-agent (use `model: haiku` in Claude Code; gpt-5.4-nano or equivalent in Codex) with the PR title, body, and changed file paths. The agent's task: "Is this an automated or trivial PR that does not warrant a code review? Consider: dependency lock-file or manifest-only bumps, automated release commits, chore version increments with no substantive code changes. When in doubt, answer no — false negatives (skipped reviews that should have run) are more costly than false positives (unnecessary reviews)." If the judgment returns yes: stop with message `PR appears to be a trivial automated PR; not reviewing. Run without a PR argument to review the current branch, or pass base:<ref> if review is intended.`
|
||||
|
||||
When any skip rule fires, emit the message and stop without dispatching reviewers, switching the checkout, or running scope detection. **Standalone branch mode and `base:` mode are unaffected** -- they always run the full review. **Draft PRs are reviewed normally** -- draft status is not a skip condition; early feedback on in-progress work is valuable.
|
||||
|
||||
If no skip rule fires, proceed to the checkout logic below.
|
||||
|
||||
First, verify the worktree is clean before switching branches:
|
||||
|
||||
```
|
||||
git status --porcelain
|
||||
```
|
||||
|
||||
If the output is non-empty, inform the user: "You have uncommitted changes on the current branch. Stash or commit them before reviewing a PR, or use standalone mode (no argument) to review the current branch as-is." Do not proceed with checkout until the worktree is clean.
|
||||
|
||||
Then check out the PR branch so persona agents can read the actual code (not the current checkout):
|
||||
|
||||
```
|
||||
gh pr checkout <number-or-url>
|
||||
```
|
||||
|
||||
Then fetch PR metadata. Capture the base branch name and the PR base repository identity, not just the branch name. Project `reviews` and `comments` to a `hasPriorComments` boolean via `--jq` -- counting only, not materializing review or comment bodies into the orchestrator's context. The reviews filter excludes approval-state submissions with empty bodies (approvals are not feedback to verify), so PRs with only approval clicks correctly fall through the gate. Stage 3 uses `hasPriorComments` to decide whether to spawn `previous-comments`:
|
||||
|
||||
```
|
||||
gh pr view <number-or-url> --json title,body,baseRefName,headRefName,url,reviews,comments --jq '{title, body, baseRefName, headRefName, url, hasPriorComments: ((.reviews | map(select(.state != "APPROVED" or .body != "")) | length) > 0 or (.comments | length) > 0)}'
|
||||
```
|
||||
|
||||
Use the repository portion of the returned PR URL as `<base-repo>` (for example, `EveryInc/compound-engineering-plugin` from `https://github.com/EveryInc/compound-engineering-plugin/pull/348`).
|
||||
|
||||
Then compute a local diff against the PR's base branch so re-reviews also include local fix commits and uncommitted edits. Substitute the PR base branch from metadata (shown here as `<base>`) and the PR base repository identity derived from the PR URL (shown here as `<base-repo>`). Resolve the base ref from the PR's actual base repository, not by assuming `origin` points at that repo:
|
||||
|
||||
```
|
||||
PR_BASE_REMOTE=$(git remote -v | awk 'index($2, "github.com:<base-repo>") || index($2, "github.com/<base-repo>") {print $1; exit}')
|
||||
if [ -n "$PR_BASE_REMOTE" ]; then PR_BASE_REMOTE_REF="$PR_BASE_REMOTE/<base>"; else PR_BASE_REMOTE_REF=""; fi
|
||||
PR_BASE_REF=$(git rev-parse --verify "$PR_BASE_REMOTE_REF" 2>/dev/null || git rev-parse --verify <base> 2>/dev/null || true)
|
||||
if [ -z "$PR_BASE_REF" ]; then
|
||||
if [ -n "$PR_BASE_REMOTE_REF" ]; then
|
||||
git fetch --no-tags "$PR_BASE_REMOTE" <base>:refs/remotes/"$PR_BASE_REMOTE"/<base> 2>/dev/null || git fetch --no-tags "$PR_BASE_REMOTE" <base> 2>/dev/null || true
|
||||
PR_BASE_REF=$(git rev-parse --verify "$PR_BASE_REMOTE_REF" 2>/dev/null || git rev-parse --verify <base> 2>/dev/null || true)
|
||||
else
|
||||
if git fetch --no-tags https://github.com/<base-repo>.git <base> 2>/dev/null; then
|
||||
PR_BASE_REF=$(git rev-parse --verify FETCH_HEAD 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$PR_BASE_REF" ]; then PR_BASE_REF=$(git rev-parse --verify <base> 2>/dev/null || true); fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$PR_BASE_REF" ]; then BASE=$(git merge-base HEAD "$PR_BASE_REF" 2>/dev/null) || BASE=""; else BASE=""; fi
|
||||
```
|
||||
|
||||
```
|
||||
if [ -n "$BASE" ]; then echo "BASE:$BASE" && echo "FILES:" && git diff --name-only $BASE && echo "DIFF:" && git diff -U10 $BASE && echo "UNTRACKED:" && git ls-files --others --exclude-standard; else echo "ERROR: Unable to resolve PR base branch <base> locally. Fetch the base branch and rerun so the review scope stays aligned with the PR."; fi
|
||||
```
|
||||
|
||||
Extract PR title/body, base branch, and PR URL from `gh pr view`, then extract the base marker, file list, diff content, and `UNTRACKED:` list from the local command. Do not use `gh pr diff` as the review scope after checkout -- it only reflects the remote PR state and will miss local fix commits until they are pushed. If the base ref still cannot be resolved from the PR's actual base repository after the fetch attempt, stop instead of falling back to `git diff HEAD`; a PR review without the PR base branch is incomplete.
|
||||
|
||||
**If a branch name is provided as an argument:**
|
||||
|
||||
Check out the named branch, then diff it against the base branch. Substitute the provided branch name (shown here as `<branch>`).
|
||||
|
||||
If `mode:report-only` or `mode:headless` is active, do **not** run `git checkout <branch>` on the shared checkout. For `mode:report-only`, tell the caller: "mode:report-only cannot switch the shared checkout to review another branch. Run it from an isolated worktree/checkout for `<branch>`, or run report-only on the current checkout with no target argument." For `mode:headless`, emit `Review failed (headless mode). Reason: cannot switch shared checkout. Re-invoke with base:<ref> to review the current checkout, or run from an isolated worktree.` Stop here unless the review is already running in an isolated checkout.
|
||||
|
||||
First, verify the worktree is clean before switching branches:
|
||||
|
||||
```
|
||||
git status --porcelain
|
||||
```
|
||||
|
||||
If the output is non-empty, inform the user: "You have uncommitted changes on the current branch. Stash or commit them before reviewing another branch, or provide a PR number instead." Do not proceed with checkout until the worktree is clean.
|
||||
|
||||
```
|
||||
git checkout <branch>
|
||||
```
|
||||
|
||||
Then detect the review base branch and compute the merge-base.
|
||||
|
||||
**If a PR exists for `<branch>`** (check with `gh pr view <branch> --json baseRefName,url`): reuse PR mode's `PR_BASE_REMOTE` block above. Use `baseRefName` as `<base>` and derive `<base-repo>` from the PR URL (e.g., `EveryInc/foo` from `https://github.com/EveryInc/foo/pull/123`). The block already sets `$BASE` to the merge-base SHA — `origin` may point at the user's fork, which is why naive `origin/<base>` is unsafe and the fork-safe block is required.
|
||||
|
||||
**If no PR exists**: derive the default branch. Primary source is `git symbolic-ref --quiet --short refs/remotes/origin/HEAD | sed 's#^origin/##'`; fall back to `gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'`, then to the first of `main`/`master`/`develop`/`trunk` that exists as `origin/<name>` or bare `<name>` locally. Compute `BASE=$(git merge-base HEAD <base-ref>)`, where `<base-ref>` is `origin/<base-branch>` when available, otherwise the bare local `<base-branch>` (covers single-branch clones, missing origin remote, and unfetched defaults). If `BASE` is empty and the clone is shallow (`git rev-parse --is-shallow-repository`), run `git fetch --unshallow origin` and retry.
|
||||
|
||||
If no base can be resolved, **stop**. Do not fall back to `git diff HEAD` — a branch review without the base would only show uncommitted changes and silently miss all committed work.
|
||||
|
||||
On success, produce the diff:
|
||||
|
||||
```
|
||||
echo "BASE:$BASE" && echo "FILES:" && git diff --name-only $BASE && echo "DIFF:" && git diff -U10 $BASE && echo "UNTRACKED:" && git ls-files --others --exclude-standard
|
||||
```
|
||||
|
||||
You may still fetch additional PR metadata with `gh pr view` for title, body, linked issues, and a projected `hasPriorComments` boolean (use the same `--jq` shape from PR mode above so the gate ignores approval-only reviews and stays consistent across modes). Do not fail if no PR exists -- leave `hasPriorComments=false`.
|
||||
|
||||
**If no argument (standalone on current branch):**
|
||||
|
||||
Apply the same base-detection logic as branch mode above, using the current branch (i.e., `gh pr view --json baseRefName,url` with no argument defaults to the current branch).
|
||||
|
||||
If no base can be resolved, **stop**. Do not fall back to `git diff HEAD` — a standalone review without the base would only show uncommitted changes and silently miss all committed work on the branch.
|
||||
|
||||
On success, produce the diff:
|
||||
|
||||
```
|
||||
echo "BASE:$BASE" && echo "FILES:" && git diff --name-only $BASE && echo "DIFF:" && git diff -U10 $BASE && echo "UNTRACKED:" && git ls-files --others --exclude-standard
|
||||
```
|
||||
|
||||
Using `git diff $BASE` (without `..HEAD`) diffs the merge-base against the working tree, which includes committed, staged, and unstaged changes together.
|
||||
|
||||
**Untracked file handling:** Always inspect the `UNTRACKED:` list, even when `FILES:`/`DIFF:` are non-empty. Untracked files are outside review scope until staged. If the list is non-empty, tell the user which files are excluded. If any of them should be reviewed, stop and tell the user to `git add` them first and rerun. Only continue when the user is intentionally reviewing tracked changes only. In `mode:headless` or `mode:autofix`, do not stop to ask — proceed with tracked changes only and note the excluded untracked files in the Coverage section of the output.
|
||||
|
||||
### Stage 2: Intent discovery
|
||||
|
||||
Understand what the change is trying to accomplish. The source of intent depends on which Stage 1 path was taken:
|
||||
|
||||
**PR/URL mode:** Use the PR title, body, and linked issues from `gh pr view` metadata. Supplement with commit messages from the PR if the body is sparse.
|
||||
|
||||
**Branch mode:** Run `git log --oneline ${BASE}..<branch>` using the resolved merge-base from Stage 1.
|
||||
|
||||
**Standalone (current branch):** Run:
|
||||
|
||||
```
|
||||
echo "BRANCH:" && git rev-parse --abbrev-ref HEAD && echo "COMMITS:" && git log --oneline ${BASE}..HEAD
|
||||
```
|
||||
|
||||
Combined with conversation context (plan section summary, PR description), write a 2-3 line intent summary:
|
||||
|
||||
```
|
||||
Intent: Simplify tax calculation by replacing the multi-tier rate lookup
|
||||
with a flat-rate computation. Must not regress edge cases in tax-exempt handling.
|
||||
```
|
||||
|
||||
Pass this to every reviewer in their spawn prompt. Intent shapes *how hard each reviewer looks*, not which reviewers are selected.
|
||||
|
||||
**When intent is ambiguous:**
|
||||
|
||||
- **Interactive mode:** Ask one question using the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension)): "What is the primary goal of these changes?" Do not spawn reviewers until intent is established. **Claude Code only:** if `AskUserQuestion` has not yet been loaded this session (per the Interactive mode rules pre-load), call `ToolSearch` with query `select:AskUserQuestion` first before asking. Fall back to numbered options in chat only when the harness genuinely lacks a blocking tool or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
|
||||
- **Autofix/report-only/headless modes:** Infer intent conservatively from the branch name, diff, PR metadata, and caller context. Note the uncertainty in Coverage or Verdict reasoning instead of blocking.
|
||||
|
||||
### Stage 2b: Plan discovery (requirements verification)
|
||||
|
||||
Locate the plan document so Stage 6 can verify requirements completeness. Check these sources in priority order — stop at the first hit:
|
||||
|
||||
1. **`plan:` argument.** If the caller passed a plan path, use it directly. Read the file to confirm it exists.
|
||||
2. **PR body.** If PR metadata was fetched in Stage 1, scan the body for paths matching `docs/plans/*.md`. If exactly one match is found and the file exists, use it as `plan_source: explicit`. If multiple plan paths appear, treat as ambiguous — demote to `plan_source: inferred` for the most recent match that exists on disk, or skip if none exist or none clearly relate to the PR title/intent. Always verify the selected file exists before using it — stale or copied plan links in PR descriptions are common.
|
||||
3. **Auto-discover.** Extract 2-3 keywords from the branch name (e.g., `feat/onboarding-skill` -> `onboarding`, `skill`). Glob `docs/plans/*` and filter filenames containing those keywords. If exactly one match, use it. If multiple matches or the match looks ambiguous (e.g., generic keywords like `review`, `fix`, `update` that could hit many plans), **skip auto-discovery** — a wrong plan is worse than no plan. If zero matches, skip.
|
||||
|
||||
**Confidence tagging:** Record how the plan was found:
|
||||
- `plan:` argument -> `plan_source: explicit` (high confidence)
|
||||
- Single unambiguous PR body match -> `plan_source: explicit` (high confidence)
|
||||
- Multiple/ambiguous PR body matches -> `plan_source: inferred` (lower confidence)
|
||||
- Auto-discover with single unambiguous match -> `plan_source: inferred` (lower confidence)
|
||||
|
||||
If a plan is found, read its **Requirements** section — `## Requirements` in current plans, `## Requirements Trace` in legacy ones — and the R-IDs (R1, R2, etc.) listed there, plus **Implementation Units** (current numeric subsections such as `### U1.`, `### U2.`, or `### Unit 1:` under `## Implementation Units`; legacy bullet or checkbox unit entries under that section also count). Store the extracted requirements list and `plan_source` for Stage 6. Do not block the review if no plan is found — requirements verification is additive, not required.
|
||||
|
||||
### Stage 3: Select reviewers
|
||||
|
||||
Read the diff and file list from Stage 1. The 4 always-on personas and 2 CE always-on agents are automatic. For each cross-cutting and stack-specific conditional persona in the persona catalog included below, decide whether the diff warrants it. This is agent judgment, not keyword matching.
|
||||
|
||||
**File-type awareness for conditional selection:** Instruction-prose files (Markdown skill definitions, JSON schemas, config files) are product code but do not benefit from runtime-focused reviewers. The adversarial reviewer's techniques (race conditions, cascade failures, abuse cases) target executable code behavior. For diffs that only change instruction-prose files, skip adversarial unless the prose describes auth, payment, or data-mutation behavior. Count only executable code lines toward line-count thresholds.
|
||||
|
||||
**`previous-comments` is PR-only AND comment-gated.** Only select this persona when both conditions hold:
|
||||
|
||||
1. Stage 1 gathered PR metadata (PR number or URL was provided as an argument, or `gh pr view` returned metadata for the current branch).
|
||||
2. `hasPriorComments` from Stage 1 is true (the PR has at least one review submission or issue comment).
|
||||
|
||||
Skip it for standalone branch reviews with no associated PR, and skip it for PRs with no prior feedback yet -- there is nothing for the persona to verify, and a spawned subagent that returns empty findings still costs the full subagent startup overhead (persona spec, diff, schema, plus its own gh calls).
|
||||
|
||||
Stack-specific personas are additive when runtime behavior warrants them. A Hotwire UI change may warrant `julik-frontend-races`; a TypeScript API diff may warrant `api-contract` and `reliability`. Structural and maintainability concerns are handled by the always-on `maintainability` persona — do not spawn extra reviewers for convention or philosophy passes.
|
||||
|
||||
**`data-migration` spawn gate.** Select `ce-data-migration-reviewer` only when the diff includes at least one migration or schema artifact: `db/migrate/*`, `db/schema.rb`, `db/structure.sql`, Alembic/Flyway/Liquibase migration paths, or explicit backfill/data-transform scripts (rake tasks, one-off data migration classes). **Do not spawn** for model-only changes, query-only refactors, serializers/controllers that reference columns without a migration or schema dump in the diff, or migration tests alone.
|
||||
|
||||
For `ce-deployment-verification-agent`, use the same migration-artifact gate when the change is risky (destructive DDL, backfills, NOT NULL without default, column renames/drops).
|
||||
|
||||
Announce the team before spawning:
|
||||
|
||||
```
|
||||
Review team:
|
||||
- correctness (always)
|
||||
- testing (always)
|
||||
- maintainability (always)
|
||||
- project-standards (always)
|
||||
- ce-agent-native-reviewer (always)
|
||||
- ce-learnings-researcher (always)
|
||||
- security -- new endpoint in routes.rb accepts user-provided redirect URL
|
||||
- julik-frontend-races -- Stimulus controller with async DOM updates
|
||||
- data-migration -- adds migration 20260303_add_index_to_orders
|
||||
- ce-deployment-verification-agent -- destructive migration with backfill
|
||||
```
|
||||
|
||||
This is progress reporting, not a blocking confirmation.
|
||||
|
||||
### Stage 3b: Discover project standards paths
|
||||
|
||||
Before spawning sub-agents, find the file paths (not contents) of all relevant standards files for the `project-standards` persona. Use the native file-search/glob tool to locate:
|
||||
|
||||
1. Use the native file-search tool (e.g., Glob in Claude Code) to find all `**/CLAUDE.md` and `**/AGENTS.md` in the repo.
|
||||
2. Filter to those whose directory is an ancestor of at least one changed file. A standards file governs all files below it (e.g., `plugins/compound-engineering/AGENTS.md` applies to everything under `plugins/compound-engineering/`).
|
||||
|
||||
Pass the resulting path list to the `project-standards` persona inside a `<standards-paths>` block in its review context (see Stage 4). The persona reads the files itself, targeting only the sections relevant to the changed file types. This keeps the orchestrator's work cheap (path discovery only) and avoids bloating the subagent prompt with content the reviewer may not fully need.
|
||||
|
||||
### Stage 4: Spawn sub-agents
|
||||
|
||||
#### Model tiering
|
||||
|
||||
Three reviewers inherit the session model with no override: `ce-correctness-reviewer`, `ce-security-reviewer`, and `ce-adversarial-reviewer`. These perform the highest-stakes analysis — logic bugs, security vulnerabilities, adversarial failure scenarios — and should run at whatever capability level the user has configured. If the user is on Opus, these get Opus.
|
||||
|
||||
All other persona sub-agents and CE agents use the platform's mid-tier model to reduce cost and latency. See the Spawning subsection below for the exact dispatch-time override — the imperative lives there so it lands at the point of action when spawning many agents in parallel.
|
||||
|
||||
The orchestrator (this skill) also inherits the session model; it handles intent discovery, reviewer selection, finding merge/dedup, and synthesis -- tasks that benefit from the same reasoning capability the user configured.
|
||||
|
||||
#### Run ID
|
||||
|
||||
Generate a unique run identifier before dispatching any agents. This ID scopes all agent artifact files and the post-review run artifact to the same directory.
|
||||
|
||||
```bash
|
||||
RUN_ID=$(date +%Y%m%d-%H%M%S)-$(head -c4 /dev/urandom | od -An -tx1 | tr -d ' ')
|
||||
mkdir -p "/tmp/compound-engineering/ce-code-review/$RUN_ID"
|
||||
```
|
||||
|
||||
Pass `{run_id}` to every persona sub-agent so they can write their full analysis to `/tmp/compound-engineering/ce-code-review/{run_id}/{reviewer_name}.json`.
|
||||
|
||||
**Report-only mode:** Skip run-id generation and directory creation. Do not pass `{run_id}` to agents. Agents return compact JSON only with no file write, consistent with report-only's no-write contract.
|
||||
|
||||
#### Spawning
|
||||
|
||||
Omit the `mode` parameter when dispatching sub-agents so the user's configured permission settings apply. Do not pass `mode: "auto"`.
|
||||
|
||||
**Model override at dispatch time.** Pass the platform's mid-tier model on every dispatch except `ce-correctness-reviewer`, `ce-security-reviewer`, and `ce-adversarial-reviewer`, which inherit the session model (per the Model tiering subsection above). In Claude Code, add `model: "sonnet"` to the `Agent` tool call. In Codex, pass the equivalent mid-tier on `spawn_agent` (e.g., `gpt-5.4-mini` as of April 2026). In Pi, pass the equivalent on `subagent` via the `pi-subagents` extension. On platforms where the dispatch primitive has no model-override parameter or the available model names are unknown, omit the override — a working review on the parent model beats a broken dispatch on an unrecognized name. Check this on every Agent / `spawn_agent` / `subagent` call in the parallel dispatch; omitting it on Opus sessions silently 3-4x's the cost of a review.
|
||||
|
||||
**Bounded parallel dispatch.** Respect the current harness's active-subagent limit. Queue selected reviewers, dispatch only as many as the harness accepts, and fill freed slots as reviewers complete. Treat active-agent/thread/concurrency-limit spawn errors as backpressure, not reviewer failure: leave the reviewer queued and retry after a slot frees. Record a reviewer as failed only after a successful dispatch times out/fails, or when dispatch fails for a non-capacity reason.
|
||||
|
||||
Spawn each selected persona reviewer using the subagent template included below. Each persona sub-agent receives:
|
||||
|
||||
1. Their persona file content (identity, failure modes, calibration, suppress conditions)
|
||||
2. Shared diff-scope rules from the diff-scope reference included below
|
||||
3. The JSON output contract from the findings schema included below
|
||||
4. PR metadata: title, body, and URL when reviewing a PR (empty string otherwise). Passed in a `<pr-context>` block so reviewers can verify code against stated intent
|
||||
5. Review context: intent summary, file list, diff
|
||||
6. Run ID and reviewer name for the artifact file path
|
||||
7. **For `project-standards` only:** the standards file path list from Stage 3b, wrapped in a `<standards-paths>` block appended to the review context
|
||||
8. **For `data-migration` only:** the resolved review base ref from Stage 1 (`BASE:` marker), wrapped in `<review-base>` inside the review context so schema drift checks never assume `main`
|
||||
|
||||
Persona sub-agents are **read-only** with respect to the project: they review and return structured JSON. They do not edit project files or propose refactors. The one permitted write is saving their full analysis to the run-artifact path specified in the output contract (under `/tmp/compound-engineering/ce-code-review/<run-id>/`).
|
||||
|
||||
Read-only here means **non-mutating**, not "no shell access." Reviewer sub-agents may use non-mutating inspection commands when needed to gather evidence or verify scope, including read-oriented `git` / `gh` usage such as `git diff`, `git show`, `git blame`, `git log`, and `gh pr view`. They must not edit project files, change branches, commit, push, create PRs, or otherwise mutate the checkout or repository state.
|
||||
|
||||
Each persona sub-agent writes full JSON (all schema fields) to `/tmp/compound-engineering/ce-code-review/{run_id}/{reviewer_name}.json` and returns compact JSON with merge-tier fields only:
|
||||
|
||||
```json
|
||||
{
|
||||
"reviewer": "security",
|
||||
"findings": [
|
||||
{
|
||||
"title": "User-supplied ID in account lookup without ownership check",
|
||||
"severity": "P0",
|
||||
"file": "orders_controller.rb",
|
||||
"line": 42,
|
||||
"confidence": 100,
|
||||
"autofix_class": "gated_auto",
|
||||
"owner": "downstream-resolver",
|
||||
"requires_verification": true,
|
||||
"pre_existing": false,
|
||||
"suggested_fix": "Add current_user.owns?(account) guard before lookup"
|
||||
}
|
||||
],
|
||||
"residual_risks": [...],
|
||||
"testing_gaps": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Detail-tier fields (`why_it_matters`, `evidence`) are in the artifact file only. `suggested_fix` is optional in both tiers -- included in compact returns when present so the orchestrator has fix context for auto-apply decisions. If the file write fails, the compact return still provides everything the merge needs.
|
||||
|
||||
**CE always-on agents** (ce-agent-native-reviewer, ce-learnings-researcher) are dispatched as standard Agent calls through the same bounded parallel scheduler as the persona agents. Give them the same review context bundle the personas receive: entry mode, any PR metadata gathered in Stage 1, intent summary, review base branch name when known, `BASE:` marker, file list, diff, and `UNTRACKED:` scope notes. Do not invoke them with a generic "review this" prompt. Their output is unstructured and synthesized separately in Stage 6.
|
||||
|
||||
**CE conditional agents** (`ce-deployment-verification-agent` only) are dispatched as standard Agent calls through the same bounded parallel scheduler when the migration-artifact gate applies. Pass the same review context bundle plus the applicability reason (for example, which migration files triggered the agent). Their output is unstructured and must be preserved for Stage 6 synthesis just like the CE always-on agents. Schema drift is handled by the `data-migration` persona as structured findings — not here.
|
||||
|
||||
### Stage 5: Merge findings
|
||||
|
||||
Convert multiple reviewer compact JSON returns into one deduplicated, confidence-gated finding set. The compact returns contain merge-tier fields (title, severity, file, line, confidence, autofix_class, owner, requires_verification, pre_existing) plus the optional suggested_fix. Detail-tier fields (why_it_matters, evidence) are on disk in the per-agent artifact files and are not loaded at this stage.
|
||||
|
||||
`confidence` is one of 5 discrete anchors (`0`, `25`, `50`, `75`, `100`) with behavioral definitions in the findings schema. Synthesis treats anchors as integers; do not coerce to floats.
|
||||
|
||||
1. **Validate.** Check each compact return for required top-level and per-finding fields, plus value constraints. Drop malformed returns or findings. Record the drop count.
|
||||
- **Top-level required:** reviewer (string), findings (array), residual_risks (array), testing_gaps (array). Drop the entire return if any are missing or wrong type.
|
||||
- **Per-finding required:** title, severity, file, line, confidence, autofix_class, owner, requires_verification, pre_existing
|
||||
- **Value constraints:**
|
||||
- severity: P0 | P1 | P2 | P3
|
||||
- autofix_class: safe_auto | gated_auto | manual | advisory
|
||||
- owner: review-fixer | downstream-resolver | human | release
|
||||
- confidence: integer in {0, 25, 50, 75, 100}
|
||||
- line: positive integer
|
||||
- pre_existing, requires_verification: boolean
|
||||
- Do not validate against the full schema here -- the full schema (including why_it_matters and evidence) applies to the artifact files on disk, not the compact returns.
|
||||
2. **Deduplicate.** Compute fingerprint: `normalize(file) + line_bucket(line, +/-3) + normalize(title)`. When fingerprints match, merge: keep highest severity, keep highest anchor, note which reviewers flagged it. Dedup runs over the full validated set (including anchor 50) so cross-reviewer promotion in step 3 can lift matching anchor-50 findings into the actionable tier.
|
||||
3. **Cross-reviewer agreement.** When 2+ independent reviewers flag the same issue (same fingerprint), promote the merged finding by one anchor step: `50 -> 75`, `75 -> 100`, `100 -> 100`. Cross-reviewer corroboration is a stronger signal than any single reviewer's anchor; the promotion routes a previously-soft finding into the actionable tier or strengthens its already-actionable position. Note the agreement in the Reviewer column of the output (e.g., "security, correctness").
|
||||
4. **Separate pre-existing.** Pull out findings with `pre_existing: true` into a separate list.
|
||||
5. **Resolve disagreements.** When reviewers flag the same code region but disagree on severity, autofix_class, or owner, annotate the Reviewer column with the disagreement (e.g., "security (P0), correctness (P1) -- kept P0"). This transparency helps the user understand why a finding was routed the way it was.
|
||||
6. **Normalize routing.** For each merged finding, set the final `autofix_class`, `owner`, and `requires_verification`. If reviewers disagree, keep the most conservative route. Synthesis may narrow a finding from `safe_auto` to `gated_auto` or `manual`, but must not widen it without new evidence.
|
||||
6b. **Derive the recommended action.** Interactive mode's walk-through and best-judgment paths present a per-finding recommended action (Apply / Defer / Skip / Acknowledge). The recommendation is derived from the normalized `autofix_class` and the presence of `suggested_fix` using this mapping:
|
||||
|
||||
| `autofix_class` | `suggested_fix` present? | Recommended action |
|
||||
|-----------------|--------------------------|--------------------|
|
||||
| `safe_auto` | (auto-applied before the routing question; not surfaced to best-judgment/walk-through) | Apply |
|
||||
| `gated_auto` | yes | Apply |
|
||||
| `gated_auto` | no | Defer |
|
||||
| `manual` | **yes** | **Apply** |
|
||||
| `manual` | no | Defer |
|
||||
| `advisory` | n/a | Acknowledge |
|
||||
|
||||
The presence of `suggested_fix` is the authoritative signal that the agent can act on the finding. A `manual` finding *with* a `suggested_fix` recommends Apply because the persona has committed to a concrete fix shape grounded in review context (per the subagent template's suggested_fix rule). A `manual` finding *without* a `suggested_fix` recommends Defer because the persona signaled that the fix genuinely needs cross-team input or business-rule context the reviewer cannot provide. `autofix_class` itself is not collapsed by this mapping — the report still records what the persona thought (`manual` vs `gated_auto`), and the distinction matters for downstream surfaces like the unified completion report.
|
||||
|
||||
**Cross-reviewer tie-break.** When contributing reviewers implied different actions for the same merged finding, synthesis picks the most conservative using the order `Skip > Defer > Apply > Acknowledge`. This rule fires only on multi-reviewer disagreement; the per-finding mapping above is the single-reviewer default. Tie-break guarantees that identical review artifacts produce the same recommendation deterministically, so best-judgment results are auditable after the fact and the walk-through's recommendation is stable across re-runs. The user may still override per finding via the walk-through's options; this rule only determines what gets labeled "recommended."
|
||||
6c. **Mode-aware demotion of weak general-quality findings.** Some persona output is real signal but does not warrant primary-findings attention. Reroute it to the existing soft buckets so the primary findings table stays focused on actionable issues.
|
||||
|
||||
A finding qualifies for demotion when **all** of these hold:
|
||||
- Severity is P2 or P3 (P0 and P1 always stay in primary findings)
|
||||
- `autofix_class` is `advisory` (concrete-fix findings stay in primary)
|
||||
- **All** contributing reviewers are `testing` or `maintainability` — if any other persona also flagged this finding, cross-reviewer corroboration is present and the finding stays in primary findings regardless of its severity or advisory status (expand the weak-signal list later only with evidence)
|
||||
|
||||
When a finding qualifies, route by mode:
|
||||
- **Interactive and report-only modes:** Move the finding out of the primary findings set. If the contributing reviewer is `testing`, append `<file:line> -- <title>` to `testing_gaps`. If `maintainability`, append the same to `residual_risks`. Record the demotion count for Coverage. The finding does not appear in the Stage 6 findings table. (Use title only -- the compact return omits `why_it_matters`, and report-only mode skips artifact files entirely. Soft-bucket entries are FYI items; readers who want depth can open the per-agent artifact when one exists.)
|
||||
- **Headless and autofix modes:** Suppress the finding entirely. Record the suppressed count in Coverage as "mode-aware demotion suppressions" so the user can see what was filtered.
|
||||
|
||||
Demotion is intentionally narrow. The conservative scope (testing/maintainability + P2/P3 + advisory) is the starting point; do not expand the rule by guessing which other personas overproduce noise. If real review runs show another persona consistently emitting weak signal, expand with evidence.
|
||||
|
||||
7. **Confidence gate.** After dedup, promotion, and demotion have shaped the primary set, suppress remaining findings below anchor 75. Exception: P0 findings at anchor 50+ survive the gate -- critical-but-uncertain issues must not be silently dropped. Record the suppressed count by anchor (so Coverage can report "N findings suppressed at anchor 50, M at anchor 25"). The gate runs late deliberately: anchor-50 findings need a chance to be promoted by step 3 (cross-reviewer corroboration) or rerouted by step 6c (mode-aware demotion to soft buckets) before any drop decision.
|
||||
8. **Partition the work.** Build three sets:
|
||||
- in-skill fixer queue: only `safe_auto -> review-fixer`
|
||||
- residual actionable queue: unresolved `gated_auto` or `manual` findings whose owner is `downstream-resolver`
|
||||
- report-only queue: `advisory` findings plus anything owned by `human` or `release`
|
||||
9. **Sort and number.** Order by severity (P0 first) -> anchor (descending) -> file path -> line number, then assign monotonically increasing `#` values across the full primary finding set in that sorted order. Do not restart numbering inside each severity table or autofix/routing bucket. If later sections repeat a finding (for example Residual Actionable Work after `safe_auto` fixes are applied), reuse the same stable `#` so users -- and downstream skills like `ce-resolve-pr-feedback` -- can reference findings by `#` after the autofix loop rewrites the report. Renumbering after autofix invalidates any prior reference: copied snippets, follow-up prompts citing `#3`, or tickets filed against an earlier render.
|
||||
10. **Collect coverage data.** Union residual_risks and testing_gaps across reviewers.
|
||||
11. **Preserve CE agent artifacts.** Keep the learnings, agent-native, and deployment-verification outputs alongside the merged finding set. Do not drop unstructured agent output just because it does not match the persona JSON schema. Schema drift from `data-migration` is already in the merged finding set.
|
||||
|
||||
### Stage 5b: Validation pass (externalizing modes only)
|
||||
|
||||
Independent verification gate. Spawn one validator sub-agent per surviving finding using `references/validator-template.md`. The validator's job is to re-check the finding against the diff and surrounding code with no commitment to the original persona's analysis. Findings the validator rejects are dropped; findings the validator confirms flow through unchanged.
|
||||
|
||||
**When this stage runs:**
|
||||
|
||||
| Mode | Runs Stage 5b? | Where |
|
||||
|------|---------------|-------|
|
||||
| `headless` | Yes, eagerly | Between Stage 5 and Stage 6 |
|
||||
| `autofix` | Yes, eagerly | Between Stage 5 and Stage 6 |
|
||||
| `interactive`, walk-through routing (option A) — per-finding phase | No -- the user is the per-finding validator | n/a |
|
||||
| `interactive`, walk-through routing (option A) — best-judgment-the-rest handoff | No -- the best-judgment path dispatches the fixer immediately; the fixer's apply/fail outcome is the validation | n/a |
|
||||
| `interactive`, best-judgment routing (option B) | No -- the best-judgment path dispatches the fixer immediately; the fixer's apply/fail outcome is the validation | n/a |
|
||||
| `interactive`, File-tickets routing (option C) | Yes, on all pending findings | Before tracker dispatch |
|
||||
| `interactive`, Report-only routing (option D) | No -- nothing is being externalized | n/a |
|
||||
| `report-only` | No -- read-only mode externalizes nothing | n/a |
|
||||
|
||||
The best-judgment path skips Stage 5b deliberately. Running per-finding validators before the fixer dispatches is duplicate research — the fixer naturally re-checks each finding when applying or proposing the fix, and items where the cited evidence no longer matches the code (the false-positive case Stage 5b would catch) are routed to the `failed` bucket during the fix attempt itself. The user reviews via diff and the post-run failure-handling question (see Step 2 Interactive option B), not via a pre-dispatch validator gate.
|
||||
|
||||
When Stage 5b does not run, the merged finding set from Stage 5 flows through to Stage 6 unchanged. When it runs, the steps below execute on the relevant set.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Select findings to validate.**
|
||||
- **headless/autofix:** All survivors of Stage 5.
|
||||
- **interactive File-tickets (option C):** All pending findings regardless of recommended action. Option C externalizes every finding as a ticket, so every finding needs validation.
|
||||
2. **Apply dispatch budget cap.** If the selected set exceeds 15 findings, validate the highest-severity 15 (P0 first, then P1, then P2, then P3, breaking ties by anchor descending). Drop the remainder and record the over-budget count for the Coverage section. The blunt drop is intentional; a review producing 15+ surviving findings is already in territory where a second wave would not change the user's triage approach.
|
||||
3. **Spawn validators with bounded parallelism.** One sub-agent per finding, dispatched independently using the validator template and the same bounded scheduler from Stage 4. Each validator receives:
|
||||
- The finding's title, severity, file, line, suggested_fix, original reviewer name, and confidence anchor
|
||||
- `why_it_matters` when available — loaded from the per-agent artifact file at `/tmp/compound-engineering/ce-code-review/{run_id}/{reviewer_name}.json`; omit when the file is absent or the artifact write failed. The validator proceeds without it, using the diff and cited code directly.
|
||||
- The full diff
|
||||
- Read-tool access to inspect the cited code, callers, guards, framework defaults, and git blame
|
||||
4. **Collect verdicts.** Each validator returns `{ "validated": true | false, "reason": "<one sentence>" }`.
|
||||
- `validated: true` -> finding survives unchanged into the next phase (Stage 6 for headless/autofix, dispatch for interactive)
|
||||
- `validated: false` -> finding is dropped; record the validator's reason in Coverage
|
||||
- Validator failure (timeout, dispatch error, malformed JSON) -> drop the finding with reason "validator failed"; conservative bias is correct
|
||||
5. **Use mid-tier model for validators.** Same model class (sonnet) the persona reviewers use. Validators are read-only — same constraints as persona reviewers. They may use non-mutating inspection commands (Read, Grep, Glob, git blame, gh).
|
||||
6. **Record metrics for Coverage.** Total dispatched, validated true count, validated false count (with reasons), failures, and over-budget drops.
|
||||
|
||||
**Why per-finding bounded dispatch (not batched):** Independence is the point. A single batched validator looking at all findings together pattern-matches across them and recreates the persona-bias problem. Per-finding dispatch preserves fresh context while the scheduler respects harness limits. Per-file batching is a plausible future optimization for reviews with many findings clustered in few files; not implemented today.
|
||||
|
||||
### Stage 6: Synthesize and present
|
||||
|
||||
Assemble the final report using **pipe-delimited markdown tables for findings** from the review output template included below. The table format is mandatory for finding rows in interactive mode — do not render findings as freeform text blocks or horizontal-rule-separated prose. Other report sections (Applied Fixes, Learnings, Coverage, etc.) use bullet lists and the `---` separator before the verdict, as shown in the template.
|
||||
|
||||
1. **Header.** Scope, intent, mode, reviewer team with per-conditional justifications.
|
||||
2. **Findings.** Rendered as pipe-delimited tables grouped by severity (`### P0 -- Critical`, `### P1 -- High`, `### P2 -- Moderate`, `### P3 -- Low`). Each finding row shows `#`, file, issue, reviewer(s), confidence, and synthesized route. Omit empty severity levels. Never render findings as freeform text blocks or numbered lists. Finding numbers come from the stable assignment in Stage 5 -- never re-derive them per severity table.
|
||||
3. **Requirements Completeness.** Include only when a plan was found in Stage 2b. For each requirement (R1, R2, etc.) and implementation unit in the plan, report whether corresponding work appears in the diff. Use a simple checklist: met / not addressed / partially addressed. Routing depends on `plan_source`:
|
||||
- **`explicit`** (caller-provided or PR body): Flag unaddressed requirements or implementation units as P1 findings with `autofix_class: manual`, `owner: downstream-resolver`. These enter the residual actionable queue.
|
||||
- **`inferred`** (auto-discovered): Flag unaddressed requirements or implementation units as P3 findings with `autofix_class: advisory`, `owner: human`. These stay in the report only — no autonomous follow-up. An inferred plan match is a hint, not a contract.
|
||||
Omit this section entirely when no plan was found — do not mention the absence of a plan.
|
||||
4. **Applied Fixes.** Include only if a fix phase ran in this invocation.
|
||||
5. **Residual Actionable Work.** Include when unresolved actionable findings were handed off or should be handed off.
|
||||
6. **Pre-existing.** Separate section, does not count toward verdict.
|
||||
7. **Learnings & Past Solutions.** Surface ce-learnings-researcher results: if past solutions are relevant, flag them as "Known Pattern" with links to docs/solutions/ files.
|
||||
8. **Agent-Native Gaps.** Surface ce-agent-native-reviewer results. Omit section if no gaps found.
|
||||
9. **Deployment Notes.** If ce-deployment-verification-agent ran, surface the key Go/No-Go items: blocking pre-deploy checks, the most important verification queries, rollback caveats, and monitoring focus areas. Keep the checklist actionable rather than dropping it into Coverage. Schema drift appears in the findings tables as `data-migration` P1 rows — do not add a separate Schema Drift section.
|
||||
10. **Coverage.** Suppressed count by anchor (e.g., "N findings suppressed at anchor 50, M at anchor 25"), mode-aware demotion count (interactive/report-only) or suppression count (headless/autofix), validator drop count and reasons (when Stage 5b ran), validator over-budget drops (when the 15-cap fired), residual risks, testing gaps, failed/timed-out reviewers, and any intent uncertainty carried by non-interactive modes.
|
||||
11. **Verdict.** Ready to merge / Ready with fixes / Not ready. Fix order if applicable. When an `explicit` plan has unaddressed requirements or implementation units, the verdict must reflect it — a PR that's code-clean but missing planned requirements is "Not ready" unless the omission is intentional. When an `inferred` plan has unaddressed requirements or implementation units, note it in the verdict reasoning but do not block on it alone.
|
||||
|
||||
Do not include time estimates.
|
||||
|
||||
**Format verification:** Before delivering the report, verify the findings sections use pipe-delimited table rows (`| # | File | Issue | ... |`) not freeform text. If you catch yourself rendering findings as prose blocks separated by horizontal rules or bullet points, stop and reformat into tables.
|
||||
|
||||
### Headless output format
|
||||
|
||||
In `mode:headless`, replace the interactive pipe-delimited table report with a structured text envelope. The envelope follows the same structural pattern as document-review's headless output (completion header, metadata block, findings grouped by autofix_class, trailing sections) while using ce-code-review's own section headings and per-finding fields.
|
||||
|
||||
```
|
||||
Code review complete (headless mode).
|
||||
|
||||
Scope: <scope-line>
|
||||
Intent: <intent-summary>
|
||||
Reviewers: <reviewer-list with conditional justifications>
|
||||
Verdict: <Ready to merge | Ready with fixes | Not ready>
|
||||
Artifact: /tmp/compound-engineering/ce-code-review/<run-id>/
|
||||
|
||||
Applied N safe_auto fixes.
|
||||
|
||||
Gated-auto findings (concrete fix, changes behavior/contracts):
|
||||
|
||||
[P1][gated_auto -> downstream-resolver][needs-verification] File: <file:line> -- <title> (<reviewer>, confidence <N>)
|
||||
Why: <why_it_matters>
|
||||
Suggested fix: <suggested_fix or "none">
|
||||
Evidence: <evidence[0]>
|
||||
Evidence: <evidence[1]>
|
||||
|
||||
Manual findings (actionable, needs handoff):
|
||||
|
||||
[P1][manual -> downstream-resolver] File: <file:line> -- <title> (<reviewer>, confidence <N>)
|
||||
Why: <why_it_matters>
|
||||
Evidence: <evidence[0]>
|
||||
|
||||
Advisory findings (report-only):
|
||||
|
||||
[P2][advisory -> human] File: <file:line> -- <title> (<reviewer>, confidence <N>)
|
||||
Why: <why_it_matters>
|
||||
|
||||
Pre-existing issues:
|
||||
[P2][gated_auto -> downstream-resolver] File: <file:line> -- <title> (<reviewer>, confidence <N>)
|
||||
Why: <why_it_matters>
|
||||
|
||||
Residual risks:
|
||||
- <risk>
|
||||
|
||||
Learnings & Past Solutions:
|
||||
- <learning>
|
||||
|
||||
Agent-Native Gaps:
|
||||
- <gap description>
|
||||
|
||||
Deployment Notes:
|
||||
- <deployment note>
|
||||
|
||||
Testing gaps:
|
||||
- <gap>
|
||||
|
||||
Coverage:
|
||||
- Suppressed: <N> findings below anchor 75 (P0 at anchor 50+ retained)
|
||||
- Mode-aware demotion suppressions: <N> findings suppressed (testing/maintainability advisory P2-P3)
|
||||
- Validator drops: <N> findings rejected by Stage 5b validator
|
||||
- <file:line> -- <reason>
|
||||
- Validator over-budget drops: <N> findings exceeded the 15-cap and were not validated
|
||||
- Untracked files excluded: <file1>, <file2>
|
||||
- Failed reviewers: <reviewer>
|
||||
|
||||
Review complete
|
||||
```
|
||||
|
||||
**Detail enrichment (headless only):** The headless envelope includes `Why:`, `Evidence:`, and `Suggested fix:` lines. After merge (Stage 5), read the per-agent artifact files from `/tmp/compound-engineering/ce-code-review/{run_id}/` for only the findings that survived dedup and confidence gating.
|
||||
- **Field tiers:** `Why:` and `Evidence:` are detail-tier -- load from per-agent artifact files. `Suggested fix:` is merge-tier -- use it directly from the compact return without artifact lookup.
|
||||
- **Artifact matching:** For each surviving finding, look up its detail-tier fields in the artifact files of the contributing reviewers. Match on `file + line_bucket(line, +/-3)` (the same tolerance used in Stage 5 dedup) within each contributing reviewer's artifact. When multiple artifact entries fall within the line bucket, apply `normalize(title)` to both the merged finding's title and each candidate entry's title as a tie-breaker.
|
||||
- **Reviewer order:** Try contributing reviewers in the order they appear in the merged finding's reviewer list; use the first match.
|
||||
- **No-match fallback:** If no artifact file contains a match (all writes failed, or the finding was synthesized during merge), omit the `Why:` and `Evidence:` lines for that finding and note the gap in Coverage. The `Suggested fix:` line can still be populated from the compact return since it is merge-tier.
|
||||
|
||||
**Formatting rules:**
|
||||
- The `[needs-verification]` marker appears only on findings where `requires_verification: true`.
|
||||
- The `Artifact:` line gives callers the path to the full run artifact for machine-readable access to the complete findings schema. The text envelope is the primary handoff; the artifact is for debugging and full-fidelity access.
|
||||
- Findings with `owner: release` appear in the Advisory section (they are operational/rollout items, not code fixes).
|
||||
- Findings with `pre_existing: true` appear in the Pre-existing section regardless of autofix_class.
|
||||
- The Verdict appears in the metadata header (deliberately reordered from the interactive format where it appears at the bottom) so programmatic callers get the verdict first.
|
||||
- Omit any section with zero items.
|
||||
- If all reviewers fail or time out, emit `Code review degraded (headless mode). Reason: 0 of N reviewers returned results.` followed by "Review complete".
|
||||
- End with "Review complete" as the terminal signal so callers can detect completion.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Before delivering the review, verify:
|
||||
|
||||
1. **Every finding is actionable.** Re-read each finding. If it says "consider", "might want to", or "could be improved" without a concrete fix, rewrite it with a specific action. Vague findings waste engineering time.
|
||||
2. **No false positives from skimming.** For each finding, verify the surrounding code was actually read. Check that the "bug" isn't handled elsewhere in the same function, that the "unused import" isn't used in a type annotation, that the "missing null check" isn't guarded by the caller.
|
||||
3. **Severity is calibrated.** A style nit is never P0. A SQL injection is never P3. Re-check every severity assignment.
|
||||
4. **Line numbers are accurate.** Verify each cited line number against the file content. A finding pointing to the wrong line is worse than no finding.
|
||||
5. **Protected artifacts are respected.** Discard any findings that recommend deleting or gitignoring files in `docs/brainstorms/`, `docs/plans/`, or `docs/solutions/`.
|
||||
6. **Findings don't duplicate linter output.** Don't flag things the project's linter/formatter would catch (missing semicolons, wrong indentation). Focus on semantic issues.
|
||||
|
||||
## Language-Aware Conditionals
|
||||
|
||||
This skill uses stack-specific reviewer agents when the diff touches runtime behavior those stacks specialize in (async UI races, iOS/Swift lifecycle). Structural quality — complexity deletion, 1k-line regressions, spaghetti growth, type-boundary leaks — lives in the always-on `ce-maintainability-reviewer`. Do not spawn extra reviewers for language conventions, philosophy, or "strict bar" passes; that signal is folded into maintainability.
|
||||
|
||||
Do not spawn stack reviewers mechanically from file extensions alone. The trigger is meaningful changed behavior in that stack's runtime domain.
|
||||
|
||||
## After Review
|
||||
|
||||
### Mode-Driven Post-Review Flow
|
||||
|
||||
After presenting findings and verdict (Stage 6), route the next steps by mode. Review and synthesis stay the same in every mode; only mutation and handoff behavior changes.
|
||||
|
||||
#### Step 1: Build the action sets
|
||||
|
||||
- **Clean review** means zero findings after suppression and pre-existing separation. Skip the fix/handoff phase when the review is clean.
|
||||
- **Fixer queue:** final findings routed to `safe_auto -> review-fixer`.
|
||||
- **Residual actionable queue:** unresolved `gated_auto` or `manual` findings whose final owner is `downstream-resolver`.
|
||||
- **Report-only queue:** `advisory` findings and any outputs owned by `human` or `release`.
|
||||
- **Never convert advisory-only outputs into fix work or ticket handoff.** Deployment notes, residual risks, and release-owned items stay in the report.
|
||||
|
||||
#### Step 2: Choose policy by mode
|
||||
|
||||
**Interactive mode**
|
||||
|
||||
- Apply `safe_auto -> review-fixer` findings automatically without asking. These are safe by definition.
|
||||
- **Zero-remaining case:** if no `gated_auto` or `manual` findings remain after the `safe_auto` pass, skip the routing question entirely. Emit a one-line completion summary phrased so advisory and pre-existing findings (which are not handled by this flow) are not implied to be cleared. When no advisory or pre-existing findings remain in the report, `All findings resolved — N safe_auto fixes applied.` is accurate. When advisory and/or pre-existing findings do remain, use the qualified form `All actionable findings resolved — N safe_auto fixes applied. (K advisory, J pre-existing findings remain in the report.)`, omitting any zero-count clause. Follow the summary with the existing end-of-review verdict, then proceed to Step 5 per the gating rule there.
|
||||
- **Tracker pre-detection:** before rendering the routing question, consult `references/tracker-defer.md` for the session's tracker tuple `{ tracker_name, confidence, named_sink_available, any_sink_available }`. The probe runs at most once per session and is cached for the rest of the run. `named_sink_available` drives the option C label (inline tracker name only when the named sink can actually be invoked). `any_sink_available` drives whether option C is offered at all (it can still be offered when the named tracker is unreachable but GitHub Issues via `gh` works).
|
||||
- **Verify question-tool pre-load (checklist, Claude Code only).** Before firing the routing question in Claude Code, confirm `AskUserQuestion` is loaded (per Interactive mode rules at the top of this skill). If not yet loaded this session, call `ToolSearch` with query `select:AskUserQuestion` now. Do not proceed to the routing question without this verification. Rendering the question as narrative text because the schema isn't loaded yet is a bug, not a valid fallback. On Codex, Gemini, and Pi this checklist does not apply — there is no `ToolSearch` preload step to perform. (If `request_user_input` is unavailable in the current Codex runtime mode, use the numbered-list fallback described below.)
|
||||
- **Routing question.** Ask using the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension)). Stem: `What should the agent do with the remaining N findings?` — use third-person voice referring to "the agent", not first-person "me" / "I". Options:
|
||||
|
||||
```
|
||||
(A) Review each finding one by one — accept the recommendation or choose another action
|
||||
(B) Auto-resolve with best judgment — apply per-finding fixes the agent can defend, surface the rest
|
||||
(C) File a [TRACKER] ticket per finding without applying fixes
|
||||
(D) Report only — take no further action
|
||||
```
|
||||
|
||||
Render option C per `references/tracker-defer.md`: when `confidence = high` AND `named_sink_available = true`, replace `[TRACKER]` with the concrete name and keep the full label (e.g., `File a Linear ticket per finding without applying fixes`). When `any_sink_available = true` but either `confidence = low` or `named_sink_available = false` (GitHub Issues via `gh` is working as the fallback), use the generic label `File an issue per finding without applying fixes` — this is a whole-label substitution, not a `[TRACKER]` token swap. When `any_sink_available = false`, **omit option C entirely** and add one line to the stem explaining that no issue tracker is configured for this checkout (Linear, GitHub Issues, etc., were probed and unavailable). Phrase it for a developer audience — avoid `tracker sink` jargon, and avoid `platform` since the missing piece is per-project, not per-agent-platform. The three remaining options (A, B, D) survive.
|
||||
|
||||
The numbered-list text fallback applies when `ToolSearch` explicitly returns no match for the platform's question tool or the tool call errors (including Codex runtime modes where `request_user_input` is unavailable). It does not apply when the agent simply hasn't loaded the tool yet — in that case, load it now (see the verification checklist above). When the fallback applies, present the options as a numbered list and wait for the user's reply — never silently skip the question.
|
||||
|
||||
- **Dispatch on selection.** Route by the option letter (A / B / C / D), not by the rendered label string. The option-C label varies by tracker-detection confidence (`File a [TRACKER] ticket per finding without applying fixes` for a named tracker, `File an issue per finding without applying fixes` as the generic fallback, or omitted entirely when no sink is available — see `references/tracker-defer.md`), and options A / B / D have a single canonical label each. The letter is the stable dispatch signal; the canonical labels below are shown for documentation only. A low-confidence run that rendered option C as the generic label routes to the same branch as a high-confidence run that rendered it with the named tracker.
|
||||
- (A) `Review each finding one by one` — **before presenting the first finding, read `references/walkthrough.md` in full.** It is the canonical spec for the per-finding presentation format and the option menu. Do not improvise from memory; do not paraphrase the format; do not invent custom option variants. Then enter the per-finding walk-through loop. Decision handling:
|
||||
- When the user picks `Apply`, queue the fix for end-of-loop dispatch — do not apply it immediately.
|
||||
- When the user picks `Defer`, file the ticket inline via `references/tracker-defer.md`.
|
||||
- When the user picks `Skip` or `Acknowledge`, record the decision as no-action.
|
||||
- When the user picks the option to auto-resolve the rest, exit the loop and dispatch **one** fixer pass on the union of (queued Apply set ∪ remaining undecided findings) — there is no second end-of-loop dispatch in this branch, so the "one fixer, consistent tree" contract holds.
|
||||
|
||||
When the user works through every finding without invoking the auto-resolve-the-rest option, dispatch one fixer subagent for the queued Apply set at end of loop (Step 3). Emit the unified completion report after dispatch.
|
||||
- (B) `Auto-resolve with best judgment — apply per-finding fixes the agent can defend, surface the rest` — dispatch the fixer subagent (Step 3) immediately on the full pending action set (`gated_auto` + `manual` + `advisory`). No Stage 5b validator pre-pass. No bulk-preview approval gate. The fixer applies items with concrete `suggested_fix`, no-ops on advisory items, and routes items where the fix cannot be applied cleanly (or where the cited evidence no longer matches the code) to a `failed` bucket with a one-line reason.
|
||||
|
||||
**After the fixer returns, the order is:**
|
||||
1. **If `failed` is empty:** emit the unified completion report and proceed to Step 5 per its gating rule. No question fires.
|
||||
2. **If `failed` is non-empty:** fire the post-run failure-handling question *first* — emitting the report before the user resolves the failed bucket would produce a stale or duplicated report, since `File tickets` and `Walk through` both change the final action state. Stem: `N findings could not be auto-resolved. What should the agent do with them?` Three options:
|
||||
- `File tickets for these` — route the failed set through `references/tracker-defer.md` Interactive mode. Omit this option when the cached tracker-detection tuple reports `any_sink_available = false`, and append one line to the stem explaining that no issue tracker is configured for this checkout (Linear, GitHub Issues, etc., were probed and unavailable). Phrase it for a developer audience — avoid `tracker sink` jargon, and avoid `platform` since the missing piece is per-project, not per-agent-platform.
|
||||
- `Walk through these one at a time` — re-enter the walk-through loop scoped to the failed set. Each finding's recommended action is recomputed via the Stage 5 step 6b mapping: items that have a `suggested_fix` recommend Apply (and join the in-memory Apply set if the user picks Apply, dispatching at end-of-walk-through to a focused fixer pass on those items only); items without a `suggested_fix` recommend Defer (Apply is not offered for them; menu is Defer / Skip / `Auto-resolve with best judgment on the rest`).
|
||||
- `Ignore — leave them in the report` — record the failed list as residual actionable work in the report. No further action.
|
||||
|
||||
After the user's choice executes (tickets filed, walk-through completed, or ignore recorded), emit the unified completion report. The report reflects the final state including any tickets filed or additional fixes applied during walk-through re-entry.
|
||||
|
||||
Numbered-list fallback applies when `ToolSearch` explicitly returns no match or the tool call errors (Codex edit modes without `request_user_input`) — never silently skip the question.
|
||||
|
||||
- (C) `File a [TRACKER] ticket per finding without applying fixes` (or the generic `File an issue per finding without applying fixes` when the named-tracker label is not used) — first run Stage 5b validation on every pending finding. Drop validator-rejected findings with their reasons recorded in Coverage. Then load `references/bulk-preview.md` with every surviving finding in the file-tickets bucket. On `Proceed`, route every finding through `references/tracker-defer.md`; no fixes are applied. On `Cancel`, return to this routing question. Emit the unified completion report.
|
||||
- (D) `Report only — take no further action` — do not enter any dispatch phase. Emit the completion report, then proceed to Step 5 per its gating rule (`fixes_applied_count > 0` from earlier `safe_auto` passes). If no fixes were applied this run, stop after the report.
|
||||
|
||||
- The walk-through's completion report, the best-judgment / File-tickets completion report, and the zero-remaining completion summary all follow the unified completion-report structure documented in `references/walkthrough.md`. Use the same structure across every terminal path.
|
||||
|
||||
**Autofix mode**
|
||||
|
||||
- Ask no questions.
|
||||
- Apply only the `safe_auto -> review-fixer` queue.
|
||||
- Leave `gated_auto`, `manual`, `human`, and `release` items unresolved.
|
||||
- Prepare residual work only for unresolved actionable findings whose final owner is `downstream-resolver`.
|
||||
|
||||
**Report-only mode**
|
||||
|
||||
- Ask no questions.
|
||||
- Do not build a fixer queue.
|
||||
- Do not write run artifacts.
|
||||
- Stop after Stage 6. Everything remains in the report.
|
||||
|
||||
**Headless mode**
|
||||
|
||||
- Ask no questions.
|
||||
- Apply only the `safe_auto -> review-fixer` queue in a single pass. Do not enter the bounded re-review loop (Step 3). Spawn one fixer subagent, apply fixes, then proceed directly to Step 4.
|
||||
- Leave `gated_auto`, `manual`, `human`, and `release` items unresolved — they appear in the structured text output.
|
||||
- Output the headless output envelope (see Stage 6) instead of the interactive report.
|
||||
- Write a run artifact (Step 4). Do not file tickets or externalize work — the caller owns that.
|
||||
- Stop after the structured text output and "Review complete" signal. No commit/push/PR.
|
||||
|
||||
#### Step 3: Apply fixes with one fixer
|
||||
|
||||
- Spawn exactly one fixer subagent for the current fixer queue in the current checkout. That fixer applies all approved changes and runs the relevant targeted tests in one pass against a consistent tree.
|
||||
- Do not fan out multiple fixers against the same checkout. Parallel fixers require isolated worktrees/branches and deliberate mergeback.
|
||||
- Do not start a mutating review round concurrently with browser testing on the same checkout. Future orchestrators that want both must either run `mode:report-only` during the parallel phase or isolate the mutating review in its own checkout/worktree.
|
||||
|
||||
**Queue contract by caller path:**
|
||||
|
||||
The fixer accepts two queue shapes depending on which caller invoked it:
|
||||
|
||||
- **Homogeneous queue (autofix, headless, walk-through Apply set):** every item is `safe_auto -> review-fixer` (autofix, headless), or every item carries a concrete `suggested_fix` (walk-through Apply set, where the user picked Apply on each finding). The fixer applies each item. **Defensive backstop for the walk-through Apply set:** the walk-through suppresses the Apply option for findings without a `suggested_fix` (see `references/walkthrough.md` adaptations) and the post-run failure-handling re-entry suppresses it as well, so this queue should not contain such items in normal runs. If one slips through, route it to `failed` with reason `no fix proposed by reviewer` rather than attempting an undefined apply — mirroring the heterogeneous queue's handling. Autofix and headless callers are unaffected; they only ever process `safe_auto` items.
|
||||
- **Heterogeneous queue (best-judgment path — interactive option B and walk-through's `Auto-resolve with best judgment on the rest`):** the queue mixes `gated_auto`, `manual`, and `advisory` findings. Each item carries: `autofix_class`, `severity`, `file:line`, `title`, `suggested_fix` (may be null), `why_it_matters`, and `evidence`. The fixer routes each item to one of four buckets — the routing categories are fixed; the failure *reason string* should be specific enough that the post-run question's framing (`N findings could not be auto-resolved...`) reads meaningfully to the user. Use the category's default phrasing below when nothing more specific applies; prefer richer, finding-specific reasons that capture *why this particular item didn't land* (e.g., `needs intent confirmation; was the field narrowing deliberate, or do clients still need the full payload?` is more useful than the generic default).
|
||||
- **`safe_auto` / `gated_auto` / `manual` with `suggested_fix`:** light evidence-match check (verify the cited code at `file:line` still resembles the persona's evidence — concretely: at least one identifier or distinctive token from the evidence appears at the cited location, and the line has not been deleted). If the check passes, attempt to apply the fix. On clean apply, route to `applied`. On fix-application failure (line moved, conflicting edit, syntax issue), route to `failed` with a concrete reason — default phrasing `fix did not apply cleanly: <error>` when no richer description fits.
|
||||
- **`gated_auto` or `manual` without `suggested_fix`:** route to `failed` — default phrasing `no fix proposed by reviewer` when no richer description fits. For `manual` this signal indicates the persona judged the finding to need cross-team input or context outside the review; a richer reason naming the specific decision (intent ambiguity, contract decision, design choice) is more useful when the persona's `why_it_matters` or `evidence` makes that clear. For `gated_auto` this is a defensive case (the persona shouldn't normally produce `gated_auto` without a concrete fix) — surface it in `failed` rather than skipping it, to preserve the apply-or-fail contract.
|
||||
- **Advisory items (`autofix_class: advisory`):** no-op. Route to `advisory` (recorded as acknowledged).
|
||||
- **Evidence-match check fails:** route to `failed` — default phrasing `evidence no longer matches code at <file:line>` when no richer description fits. This is the false-positive case — the finding cited something that has since changed or was already handled.
|
||||
|
||||
**Best-judgment path is single-pass.** No `max_rounds: 2` re-review loop. After the fixer returns, the orchestrator follows Step 2 Interactive option B's post-fixer ordering: when the `failed` bucket is empty, emit the unified completion report directly; when it is non-empty, fire the post-run failure-handling question first, execute the user's choice, then emit the unified completion report so it reflects the final action state.
|
||||
|
||||
**Other paths retain the bounded-rounds loop.** For autofix and the walk-through Apply set, re-review only the changed scope after fixes land, bound the loop with `max_rounds: 2`, and if issues remain after the second round, hand them off as residual work or report them as unresolved.
|
||||
|
||||
**Verification.** If any applied finding has `requires_verification: true`, the fixer runs the targeted verification (focused tests or operational checks) for that item before declaring it `applied`. Verification failure routes the item to `failed` — default phrasing `verification failed: <test-name>` when no richer description fits (e.g., `verification failed: payment_spec timed out after 30s` is more useful than the bare default). This applies on every path.
|
||||
|
||||
**Fixer return shape (best-judgment path).** The fixer returns the partition `{applied, failed, advisory}` where each entry includes the finding identifier, original `autofix_class`, `severity`, `file:line`, and (for `failed`) a one-line reason. The orchestrator uses this partition to assemble the unified completion report and gate the post-run failure-handling question.
|
||||
|
||||
#### Step 4: Emit artifacts and downstream handoff
|
||||
|
||||
- In interactive, autofix, and headless modes, write a per-run artifact under `/tmp/compound-engineering/ce-code-review/<run-id>/` containing:
|
||||
- synthesized findings (merged output from Stage 5)
|
||||
- applied fixes
|
||||
- residual actionable work
|
||||
- advisory-only outputs
|
||||
Per-agent full-detail JSON files (`{reviewer_name}.json`) are already present in this directory from Stage 4 dispatch.
|
||||
- Also write `metadata.json` alongside the findings so downstream skills (e.g., `ce-polish-beta`) can verify the artifact matches the current branch and HEAD. Minimum fields:
|
||||
```json
|
||||
{
|
||||
"run_id": "<run-id>",
|
||||
"branch": "<git branch --show-current at dispatch time>",
|
||||
"head_sha": "<git rev-parse HEAD at dispatch time>",
|
||||
"verdict": "<Ready to merge | Ready with fixes | Not ready>",
|
||||
"completed_at": "<ISO 8601 UTC timestamp>"
|
||||
}
|
||||
```
|
||||
Capture `branch` and `head_sha` at dispatch time (before any autofixes land), and write the file after the verdict is finalized. This file is additive -- pre-existing artifacts that predate this field are still valid, and downstream skills fall back to file mtime when it is missing.
|
||||
- In autofix mode, the run artifact is the handoff. Orchestrators read the artifact's residual actionable work and route it as appropriate. The skill itself does not file tickets or prompt the user in autofix.
|
||||
- Interactive mode may offer to externalize residual actionable work via `references/tracker-defer.md` (named tracker -> GitHub Issues via `gh`), but it is not required to finish the review.
|
||||
|
||||
#### Step 5: Final next steps
|
||||
|
||||
**Interactive mode only.** After the fix-review cycle completes (clean verdict or the user chose to stop), offer next steps based on the entry mode. Reuse the resolved review base/default branch from Stage 1 when known; do not hard-code only `main`/`master`.
|
||||
|
||||
**The gate is total fixes applied this run, not routing option.** Track `fixes_applied_count` across the whole Interactive invocation. This counter includes both the `safe_auto` fixes applied automatically before the routing question (see Step 2 Interactive mode) AND any Apply decisions executed by routing option A (walk-through) or option B (best-judgment). Routing options C (File tickets) and D (Report only) add zero to this counter; neither does a walk-through that ends with only Skip / Defer / Acknowledge, and neither does a best-judgment dispatch whose findings were all routed to `failed` or `advisory`.
|
||||
|
||||
Step 5 runs only when `fixes_applied_count > 0`. If the counter is zero — no `safe_auto` fixes were applied AND the routing path produced no additional Apply — skip Step 5 entirely and exit after the completion report. Asking "push fixes?" when nothing changed in the working tree is incoherent.
|
||||
|
||||
Common outcomes:
|
||||
|
||||
- `safe_auto` produced fixes AND the user picked any routing option → Step 5 runs (counter > 0 from the safe_auto pass alone).
|
||||
- No `safe_auto` fixes AND the user picked option C or D → Step 5 skipped.
|
||||
- No `safe_auto` fixes AND walk-through / best-judgment finished with zero Applies → Step 5 skipped.
|
||||
- Zero-remaining case (no `gated_auto` / `manual` after `safe_auto`) with at least one `safe_auto` fix → Step 5 runs; the routing question was never asked but the counter is > 0.
|
||||
|
||||
- **PR mode (entered via PR number/URL):**
|
||||
- **Push fixes** -- push commits to the existing PR branch
|
||||
- **Exit** -- done for now
|
||||
- **Branch mode (feature branch with no PR, and not the resolved review base/default branch):**
|
||||
- **Create a PR (Recommended)** -- push and open a pull request
|
||||
- **Continue without PR** -- stay on the branch
|
||||
- **Exit** -- done for now
|
||||
- **On the resolved review base/default branch:**
|
||||
- **Continue** -- proceed with next steps
|
||||
- **Exit** -- done for now
|
||||
|
||||
If "Create a PR": first publish the branch with `git push --set-upstream origin HEAD`, then use `gh pr create` with a title and summary derived from the branch changes.
|
||||
If "Push fixes": push the branch with `git push` to update the existing PR.
|
||||
|
||||
**Autofix, report-only, and headless modes:** stop after the report, artifact emission, and residual-work handoff. Do not commit, push, or create a PR.
|
||||
|
||||
## Fallback
|
||||
|
||||
If the platform doesn't support parallel sub-agents, run reviewers sequentially. If the platform supports sub-agents but caps active concurrency, use the bounded queueing rules in Stage 4 rather than treating cap-related spawn failures as reviewer failures. Everything else (stages, output format, merge pipeline) stays the same.
|
||||
|
||||
---
|
||||
|
||||
## Included References
|
||||
|
||||
### Persona Catalog
|
||||
|
||||
@./references/persona-catalog.md
|
||||
|
||||
### Subagent Template
|
||||
|
||||
@./references/subagent-template.md
|
||||
|
||||
### Diff Scope Rules
|
||||
|
||||
@./references/diff-scope.md
|
||||
|
||||
### Findings Schema
|
||||
|
||||
@./references/findings-schema.json
|
||||
|
||||
### Review Output Template
|
||||
|
||||
@./references/review-output-template.md
|
||||
@@ -0,0 +1,112 @@
|
||||
# Bulk Action Preview
|
||||
|
||||
This reference defines the compact plan preview that Interactive mode shows before the file-tickets routing option (option C) executes. The preview gives the user a single-screen view of what the agent is about to do, with exactly two options to Proceed or Cancel.
|
||||
|
||||
Interactive mode only. Option C only.
|
||||
|
||||
The best-judgment path (routing option B and the walk-through's `Auto-resolve with best judgment on the rest`) does **not** use the bulk preview. The best-judgment path dispatches the fixer immediately and surfaces failures in a post-run question, per the `(B)` handler in `SKILL.md` Step 2 Interactive mode. Filing tickets is the one bulk action that benefits from a preview because filing produces durable external state that is expensive to undo — applying local fixes on uncommitted edits is not.
|
||||
|
||||
---
|
||||
|
||||
## When the preview fires
|
||||
|
||||
One call site:
|
||||
|
||||
- **Routing option C (top-level File tickets)** — after the user picks `File a [TRACKER] ticket per finding without applying fixes` but before any ticket is filed. Scope: every pending `gated_auto` / `manual` finding. Every finding appears under `Filing [TRACKER] tickets (N):` regardless of the agent's natural recommendation, because option C is batch-defer.
|
||||
|
||||
The user confirms with `Proceed` or backs out with `Cancel`. No per-item decisions inside the preview — per-item decisioning is the walk-through's role (option A).
|
||||
|
||||
---
|
||||
|
||||
## Preview structure
|
||||
|
||||
The preview is grouped by the action the agent intends to take. Bucket headers appear only when their bucket is non-empty.
|
||||
|
||||
```
|
||||
<Path label> — <scope summary>[ (tracker: <name>)]:
|
||||
|
||||
Applying (N):
|
||||
[P0] <file>:<line> — <one-line plain-English summary>
|
||||
[P1] <file>:<line> — <one-line plain-English summary>
|
||||
|
||||
Filing [TRACKER] tickets (N):
|
||||
[P2] <file>:<line> — <one-line plain-English summary>
|
||||
|
||||
Skipping (N):
|
||||
[P2] <file>:<line> — <one-line plain-English summary>
|
||||
|
||||
Acknowledging (N):
|
||||
[P3] <file>:<line> — <one-line plain-English summary>
|
||||
```
|
||||
|
||||
Worked example, for routing option C (file tickets):
|
||||
|
||||
```
|
||||
File plan — 8 findings as Linear tickets:
|
||||
|
||||
Filing Linear tickets (8):
|
||||
[P0] orders_controller.rb:42 — Missing ownership guard on order lookup
|
||||
[P1] webhook_handler.rb:120 — Unhandled error swallowed in webhook
|
||||
[P2] user_serializer.rb:14 — internal_id leaks in serialized response
|
||||
[P2] billing_service.rb:230 — N+1 on refund batch
|
||||
[P2] session_helper.rb:12 — Session reset behavior unclear
|
||||
[P2] report_worker.rb:55 — Worker timeout under heavy load
|
||||
[P3] string_utils.rb:8 — Ambiguous helper name
|
||||
[P3] readme.md:14 — Documentation gap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope summary wording
|
||||
|
||||
- **Routing option C (top-level File tickets):** header reads `File plan — N findings as [TRACKER] tickets:`. Every finding lands in the `Filing [TRACKER] tickets (N):` bucket. Option C is batch-defer — no Apply / Skip / Acknowledge buckets render in the preview, since every finding is being filed.
|
||||
|
||||
When the detected tracker is low-confidence or generic (see `tracker-defer.md`), the `(tracker: <name>)` annotation is omitted from the header and the `Filing [TRACKER] tickets` bucket header uses the generic form (`Filing tickets (N):`).
|
||||
|
||||
---
|
||||
|
||||
## Per-finding line format
|
||||
|
||||
Each line uses the compressed form of the framing-quality bar from the plan (R22-R25 — observable-behavior-first, no function / variable names unless needed to locate). The one-line summary is drawn from the persona-produced `why_it_matters` by taking the first sentence (and, when the first sentence is too long for the preview width, paraphrasing it tightly to fit).
|
||||
|
||||
- **Shape:** `[<severity>] <file>:<line> — <one-line summary>`
|
||||
- **Width target:** keep lines near 80 columns so the preview renders cleanly in narrow terminals. Truncate with ellipsis when necessary.
|
||||
- **No function / variable names inline** unless the reader needs them to locate the issue.
|
||||
- **Advisory bucket phrasing:** the `Acknowledging (N):` bucket describes the advisory content in one line. No "fix" phrase — advisory findings have no concrete fix.
|
||||
|
||||
When no `why_it_matters` is available for a finding (e.g., Unit 2's template upgrade hasn't fully propagated through the persona run, or the artifact file was unreadable), fall back to the finding's title directly. Note the gap in the completion report's Coverage section if it affects more than a few findings in the same run.
|
||||
|
||||
---
|
||||
|
||||
## Question and options
|
||||
|
||||
After the preview body is rendered, ask the user using the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension)). In Claude Code, the tool should already be loaded from the Interactive-mode pre-load step — if it isn't, call `ToolSearch` with query `select:AskUserQuestion` now. The text fallback below applies only when the harness genuinely lacks a blocking tool — `ToolSearch` returns no match, the tool call explicitly fails, or the runtime mode does not expose it (e.g., Codex edit modes without `request_user_input`). A pending schema load is not a fallback trigger. Never silently skip the question.
|
||||
|
||||
Stem: `The agent is about to file the tickets above. Proceed?`
|
||||
|
||||
Options (exactly two):
|
||||
- `Proceed` — file every ticket in the preview
|
||||
- `Cancel` — do nothing, return to the routing question
|
||||
|
||||
Only when `ToolSearch` explicitly returns no match or the tool call errors — or on a platform with no blocking question tool — fall back to presenting numbered options and waiting for the user's next reply.
|
||||
|
||||
---
|
||||
|
||||
## Cancel semantics
|
||||
|
||||
`Cancel` returns the user to the routing question (the four-option menu in `SKILL.md` Step 2 Interactive mode). No tickets are filed; no state is recorded. The session's cached tracker-detection tuple is preserved.
|
||||
|
||||
---
|
||||
|
||||
## Proceed semantics
|
||||
|
||||
When the user picks `Proceed`, every finding in the preview routes through `references/tracker-defer.md` for ticket creation. No fixes are applied. After all tickets have been filed (or failed), emit the unified completion report (see `references/walkthrough.md`).
|
||||
|
||||
Failure during `Proceed` (e.g., ticket creation fails for one finding during a batch Defer) follows the failure path defined in `tracker-defer.md` — surface the failure inline with Retry / Fallback / Skip, continue with the rest of the plan, and capture the failure in the completion report's failure section.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **N=1 preview (only one finding in scope):** the preview still renders with a single-line bucket. `Proceed` / `Cancel` still apply.
|
||||
- **No tracker available:** option C is not offered upstream (see `tracker-defer.md` no sink handling). The bulk preview is therefore never invoked when `any_sink_available` is false.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Diff Scope Rules
|
||||
|
||||
These rules apply to every reviewer. They define what is "your code to review" versus pre-existing context.
|
||||
|
||||
## Scope Discovery
|
||||
|
||||
Determine the diff to review using this priority order:
|
||||
|
||||
1. **User-specified scope.** If the caller passed `BASE:`, `FILES:`, or `DIFF:` markers, use that scope exactly.
|
||||
2. **Working copy changes.** If there are unstaged or staged changes (`git diff HEAD` is non-empty), review those.
|
||||
3. **Unpushed commits vs base branch.** If the working copy is clean, review `git diff $(git merge-base HEAD <base>)..HEAD` where `<base>` is the default branch (main or master).
|
||||
|
||||
The scope step in the SKILL.md handles discovery and passes you the resolved diff. You do not need to run git commands yourself.
|
||||
|
||||
## Finding Classification Tiers
|
||||
|
||||
Every finding you report falls into one of three tiers based on its relationship to the diff:
|
||||
|
||||
### Primary (directly changed code)
|
||||
|
||||
Lines added or modified in the diff. This is your main focus. Report findings against these lines at full confidence.
|
||||
|
||||
### Secondary (immediately surrounding code)
|
||||
|
||||
Unchanged code within the same function, method, or block as a changed line. If a change introduces a bug that's only visible by reading the surrounding context, report it -- but note that the issue exists in the interaction between new and existing code.
|
||||
|
||||
### Pre-existing (unrelated to this diff)
|
||||
|
||||
Issues in unchanged code that the diff didn't touch and doesn't interact with. Mark these as `"pre_existing": true` in your output. They're reported separately and don't count toward the review verdict.
|
||||
|
||||
**The rule:** If you'd flag the same issue on an identical diff that didn't include the surrounding file, it's pre-existing. If the diff makes the issue *newly relevant* (e.g., a new caller hits an existing buggy function), it's secondary.
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Code Review Findings",
|
||||
"description": "Structured output schema for code review sub-agents",
|
||||
"type": "object",
|
||||
"required": ["reviewer", "findings", "residual_risks", "testing_gaps"],
|
||||
"properties": {
|
||||
"reviewer": {
|
||||
"type": "string",
|
||||
"description": "Persona name that produced this output (e.g., 'correctness', 'security')"
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"description": "List of code review findings. Empty array if no issues found.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
"severity",
|
||||
"file",
|
||||
"line",
|
||||
"why_it_matters",
|
||||
"autofix_class",
|
||||
"owner",
|
||||
"requires_verification",
|
||||
"confidence",
|
||||
"evidence",
|
||||
"pre_existing"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short, specific issue title. 10 words or fewer.",
|
||||
"maxLength": 100
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["P0", "P1", "P2", "P3"],
|
||||
"description": "Issue severity level"
|
||||
},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"description": "Relative file path from repository root"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Primary line number of the issue",
|
||||
"minimum": 1
|
||||
},
|
||||
"why_it_matters": {
|
||||
"type": "string",
|
||||
"description": "Impact and failure mode -- not 'what is wrong' but 'what breaks'"
|
||||
},
|
||||
"autofix_class": {
|
||||
"type": "string",
|
||||
"enum": ["safe_auto", "gated_auto", "manual", "advisory"],
|
||||
"description": "Routing class for downstream fixer dispatch. safe_auto = local mechanical fix the fixer applies without approval (test: a one-sentence fix with no 'depends on' clauses, AND no change to function signature, public-API/error contract, security posture, or permission model; for helper extraction, naming/placement must follow mechanically from the shared shape). gated_auto = concrete fix that changes contracts/permissions or whose placement requires a design conversation; needs user approval before apply. manual = actionable work needing design decisions; usually paired with a suggested_fix the user can confirm. advisory = report-only, no code change. The wrong-side cost is symmetric -- bias toward safe_auto when the rubric permits, since misclassifying mechanical fixes as gated_auto makes users triage findings the fixer could have applied."
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"enum": ["review-fixer", "downstream-resolver", "human", "release"],
|
||||
"description": "Who should own the next action for this finding after synthesis"
|
||||
},
|
||||
"requires_verification": {
|
||||
"type": "boolean",
|
||||
"description": "Whether any fix for this finding must be re-verified with targeted tests or a follow-up review pass"
|
||||
},
|
||||
"suggested_fix": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Concrete minimal fix the reviewer can defend from the diff and surrounding code. Propose one whenever any defensible code change is reachable from review context (parallel patterns, framework conventions, or the cited code itself). Imperfect information is not grounds for omission -- propose the most defensible default given what you can see, name any assumption you are making, and let the user override. 'I need <specific input> to commit' is a soft punt: the right question is 'what code change would I propose if I had to choose now?' and propose that, with the assumption named. Omit only when there is genuinely no code-level change to propose -- e.g., the finding is a question rather than a fix ('what is the intended SLA here?'), or the resolution is purely an organizational action with no code component (legal sign-off, business policy decision). These cases are rare in code review. A bad suggestion is still worse than none, but a soft punt is the failure mode this field is designed to prevent."
|
||||
},
|
||||
"confidence": {
|
||||
"type": "integer",
|
||||
"enum": [0, 25, 50, 75, 100],
|
||||
"description": "Anchored confidence score. Use exactly one of 0, 25, 50, 75, 100. Each anchor has a behavioral criterion the reviewer must honestly self-apply. 0: Not confident. This is a false positive that does not stand up to light scrutiny, or a pre-existing issue this PR did not introduce. 25: Somewhat confident. Might be a real issue but could also be a false positive; the reviewer could not verify from the diff and surrounding code alone. 50: Moderately confident. The reviewer verified this is a real issue but it may be a nitpick, narrow edge case, or have minimal practical impact. Relative to the diff's other concerns, it is not very important. Style preferences and subjective improvements land here. 75: Highly confident. The reviewer double-checked the diff and confirmed the issue will affect users, downstream callers, or runtime behavior in normal usage. The bug, vulnerability, or contract violation is clearly present and actionable. 100: Absolutely certain. The issue is verifiable from the code itself -- compile error, type mismatch, definitive logic bug, or an explicit project-standards violation with a quotable rule. No interpretation required."
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"description": "Code-grounded evidence: snippets, line references, or pattern descriptions. At least 1 item.",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
},
|
||||
"pre_existing": {
|
||||
"type": "boolean",
|
||||
"description": "True if this issue exists in unchanged code unrelated to the current diff"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"residual_risks": {
|
||||
"type": "array",
|
||||
"description": "Risks the reviewer noticed but could not confirm as findings",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"testing_gaps": {
|
||||
"type": "array",
|
||||
"description": "Missing test coverage the reviewer identified",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
|
||||
"_meta": {
|
||||
"confidence_anchors": {
|
||||
"description": "Confidence is one of 5 discrete anchors (0, 25, 50, 75, 100), each tied to a behavioral criterion the reviewer can honestly self-apply. Float values (e.g., 0.73) are not valid -- the model cannot meaningfully calibrate at finer granularity, and discrete anchors prevent false-precision gaming.",
|
||||
"0": "False positive or pre-existing -- do not report",
|
||||
"25": "Speculative; could not verify -- do not report",
|
||||
"50": "Verified real but minor or stylistic -- report only when P0 or when synthesis routes to advisory/soft buckets",
|
||||
"75": "Highly confident, will affect users or runtime in normal usage -- report",
|
||||
"100": "Verifiable from code alone (compile error, type mismatch, definitive logic bug, quoted standards violation) -- report"
|
||||
},
|
||||
"confidence_thresholds": {
|
||||
"suppress": "Below anchor 75 -- do not report. Exception: P0 findings at anchor 50+ may be reported (critical-but-uncertain issues must not be silently dropped).",
|
||||
"report": "Anchor 75 or 100 -- include with full evidence."
|
||||
},
|
||||
"severity_definitions": {
|
||||
"P0": "Critical breakage, exploitable vulnerability, data loss/corruption. Must fix before merge.",
|
||||
"P1": "High-impact defect likely hit in normal usage, breaking contract. Should fix.",
|
||||
"P2": "Moderate issue with meaningful downside (edge case, perf regression, maintainability trap). Fix if straightforward.",
|
||||
"P3": "Low-impact, narrow scope, minor improvement. User's discretion."
|
||||
},
|
||||
"autofix_classes": {
|
||||
"safe_auto": "Local, deterministic code or test fix suitable for the in-skill fixer. Examples: extract duplicated helper, add missing nil check, fix off-by-one, add missing test, remove dead code. Do not default to advisory when a concrete safe fix exists.",
|
||||
"gated_auto": "Concrete fix exists, but it changes behavior, permissions, contracts, or other sensitive areas that deserve explicit approval. Examples: add auth to unprotected endpoint, change API response shape.",
|
||||
"manual": "Actionable issue that requires design decisions or cross-cutting changes. Examples: redesign data model, add pagination strategy, choose between architectural approaches.",
|
||||
"advisory": "Informational or operational item that should be surfaced in the report only. Examples: design asymmetry the PR improves but does not fully resolve, residual risk notes, deployment considerations."
|
||||
},
|
||||
"owners": {
|
||||
"review-fixer": "The in-skill fixer can own this when policy allows.",
|
||||
"downstream-resolver": "Turn this into residual work for later resolution.",
|
||||
"human": "A person must make a judgment call before code changes should continue.",
|
||||
"release": "Operational or rollout follow-up; do not convert into code-fix work automatically."
|
||||
},
|
||||
"return_tiers": {
|
||||
"description": "Finding fields are split into two tiers. The full schema (with all required fields) applies to the artifact file on disk. The compact return to the orchestrator omits detail-tier fields. Both are valid uses of this schema in different contexts.",
|
||||
"merge_tier": "Returned to orchestrator: title, severity, file, line, confidence, autofix_class, owner, requires_verification, pre_existing, suggested_fix (optional). Plus top-level reviewer, residual_risks, testing_gaps.",
|
||||
"detail_tier": "Required in artifact file, omitted from compact return: why_it_matters, evidence. The artifact file must pass full schema validation including all required fields. Headless output depends on why_it_matters and evidence being present in the artifact."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Persona Catalog
|
||||
|
||||
14 reviewer personas organized into always-on, cross-cutting conditional, and stack-specific conditional layers, plus CE-specific agents. The orchestrator uses this catalog to select which reviewers to spawn for each review.
|
||||
|
||||
## Always-on (4 personas + 2 CE agents)
|
||||
|
||||
Spawned on every review regardless of diff content.
|
||||
|
||||
**Persona agents (structured JSON output):**
|
||||
|
||||
| Persona | Agent | Focus |
|
||||
|---------|-------|-------|
|
||||
| `correctness` | `ce-correctness-reviewer` | Logic errors, edge cases, state bugs, error propagation, intent compliance |
|
||||
| `testing` | `ce-testing-reviewer` | Coverage gaps, weak assertions, brittle tests, missing edge case tests |
|
||||
| `maintainability` | `ce-maintainability-reviewer` | Structural quality, complexity deletion, 1k-line regressions, coupling, type-boundary leaks, dead code, premature abstraction |
|
||||
| `project-standards` | `ce-project-standards-reviewer` | CLAUDE.md and AGENTS.md compliance -- frontmatter, references, naming, cross-platform portability, tool selection |
|
||||
|
||||
**CE agents (unstructured output, synthesized separately):**
|
||||
|
||||
| Agent | Focus |
|
||||
|-------|-------|
|
||||
| `ce-agent-native-reviewer` | Verify new features are agent-accessible |
|
||||
| `ce-learnings-researcher` | Search docs/solutions/ for past issues related to this PR's modules and patterns |
|
||||
|
||||
## Conditional (7 personas)
|
||||
|
||||
Spawned when the orchestrator identifies relevant patterns in the diff. The orchestrator reads the full diff and reasons about selection -- this is agent judgment, not keyword matching.
|
||||
|
||||
| Persona | Agent | Select when diff touches... |
|
||||
|---------|-------|---------------------------|
|
||||
| `security` | `ce-security-reviewer` | Auth middleware, public endpoints, user input handling, permission checks, secrets management |
|
||||
| `performance` | `ce-performance-reviewer` | Database queries, ORM calls, loop-heavy data transforms, caching layers, async/concurrent code |
|
||||
| `api-contract` | `ce-api-contract-reviewer` | Route definitions, serializer/interface changes, event schemas, exported type signatures, API versioning |
|
||||
| `data-migration` | `ce-data-migration-reviewer` | Migration files, schema dumps (`db/schema.rb`, `structure.sql`), backfill scripts, data transformations — **not** model/query-only changes without migration artifacts |
|
||||
| `reliability` | `ce-reliability-reviewer` | Error handling, retry logic, circuit breakers, timeouts, background jobs, async handlers, health checks |
|
||||
| `adversarial` | `ce-adversarial-reviewer` | Diff has >=50 changed non-test, non-generated, non-lockfile lines, OR touches auth, payments, data mutations, external API integrations, or other high-risk domains |
|
||||
| `previous-comments` | `ce-previous-comments-reviewer` | **PR-only AND comment-gated.** Reviewing a PR that has existing review comments or review threads from prior review rounds. Skip entirely when no PR metadata was gathered in Stage 1, OR when Stage 1's `hasPriorComments` flag is false (no `reviews` and no `comments` on the PR). |
|
||||
|
||||
## Stack-Specific Conditional (2 personas)
|
||||
|
||||
These reviewers cover runtime behavior the always-on personas do not specialize in. Structural and maintainability concerns live in the always-on `maintainability` persona — do not spawn extra stack reviewers for philosophy or convention-only passes.
|
||||
|
||||
| Persona | Agent | Select when diff touches... |
|
||||
|---------|-------|---------------------------|
|
||||
| `julik-frontend-races` | `ce-julik-frontend-races-reviewer` | Stimulus/Turbo controllers, DOM event wiring, timers, async UI flows, animations, or frontend state transitions with race potential |
|
||||
| `swift-ios` | `ce-swift-ios-reviewer` | Swift files, SwiftUI views, UIKit controllers, `.entitlements`, `PrivacyInfo.xcprivacy`, `.xcdatamodeld`, `Package.swift`, `Package.resolved`, storyboards, XIBs, or semantic build-setting / target-membership / code-signing changes in `.pbxproj` |
|
||||
|
||||
## CE Conditional Agents (migration-specific)
|
||||
|
||||
Spawn `ce-deployment-verification-agent` when the migration-artifact gate applies **and** the change is risky (destructive DDL, backfills, NOT NULL without default, column renames/drops). Schema drift and migration safety live in the `data-migration` persona — not separate CE agents.
|
||||
|
||||
| Agent | Focus |
|
||||
|-------|-------|
|
||||
| `ce-deployment-verification-agent` | Go/No-Go deployment checklist with SQL verification queries and rollback procedures |
|
||||
|
||||
## Selection rules
|
||||
|
||||
1. **Always spawn all 4 always-on personas** plus the 2 CE always-on agents.
|
||||
2. **For each cross-cutting conditional persona**, the orchestrator reads the diff and decides whether the persona's domain is relevant. This is a judgment call, not a keyword match.
|
||||
3. **For each stack-specific conditional persona**, use file types and changed patterns as a starting point, then decide whether the diff actually introduces meaningful work for that reviewer. Do not spawn language-specific reviewers just because one config or generated file happens to match the extension.
|
||||
4. **For `data-migration`**, spawn only when the diff includes migration or schema artifacts (`db/migrate/*`, `db/schema.rb`, `db/structure.sql`, Alembic/Flyway/Liquibase paths, or explicit backfill/data-transform scripts). Do **not** spawn for model-only or query-only changes without those files.
|
||||
5. **For CE conditional agents**, spawn `ce-deployment-verification-agent` when the migration-artifact gate applies and the change is risky (see above).
|
||||
6. **Announce the team** before spawning with a one-line justification per conditional reviewer selected.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Code Review Output Template
|
||||
|
||||
Use this **exact format** when presenting synthesized review findings. Findings are grouped by severity, not by reviewer.
|
||||
|
||||
**IMPORTANT:** Use pipe-delimited markdown tables (`| col | col |`). Do NOT use ASCII box-drawing characters.
|
||||
|
||||
**IMPORTANT:** Escape literal pipe characters in table cells. Any `|` that appears inside a finding title, issue description, code snippet, regex pattern, or delimited-string example (e.g. cache key examples like `userName + "|" + groups`) must be written as `\|` so column boundaries are determined only by unescaped pipes. Unescaped pipes split the cell across columns and corrupt the row's `Reviewer`, `Confidence`, and `Route` values.
|
||||
|
||||
## Example
|
||||
|
||||
```markdown
|
||||
## Code Review Results
|
||||
|
||||
**Scope:** merge-base with the review base branch -> working tree (14 files, 342 lines)
|
||||
**Intent:** Add order export endpoint with CSV and JSON format support
|
||||
**Mode:** autofix
|
||||
|
||||
**Reviewers:** correctness, testing, maintainability, security, api-contract
|
||||
- security -- new public endpoint accepts user-provided format parameter
|
||||
- api-contract -- new /api/orders/export route with response schema
|
||||
|
||||
### P0 -- Critical
|
||||
|
||||
| # | File | Issue | Reviewer | Confidence | Route |
|
||||
|---|------|-------|----------|------------|-------|
|
||||
| 1 | `orders_controller.rb:42` | User-supplied ID in account lookup without ownership check | security | 100 | `gated_auto -> downstream-resolver` |
|
||||
|
||||
### P1 -- High
|
||||
|
||||
| # | File | Issue | Reviewer | Confidence | Route |
|
||||
|---|------|-------|----------|------------|-------|
|
||||
| 2 | `export_service.rb:87` | Loads all orders into memory -- unbounded for large accounts | performance | 100 | `safe_auto -> review-fixer` |
|
||||
| 3 | `export_service.rb:91` | No pagination -- response size grows linearly with order count | api-contract, performance | 75 | `manual -> downstream-resolver` |
|
||||
|
||||
### P2 -- Moderate
|
||||
|
||||
| # | File | Issue | Reviewer | Confidence | Route |
|
||||
|---|------|-------|----------|------------|-------|
|
||||
| 4 | `export_service.rb:45` | Missing error handling for CSV serialization failure | correctness | 75 | `safe_auto -> review-fixer` |
|
||||
|
||||
### P3 -- Low
|
||||
|
||||
| # | File | Issue | Reviewer | Confidence | Route |
|
||||
|---|------|-------|----------|------------|-------|
|
||||
| 5 | `export_helper.rb:12` | Format detection could use early return instead of nested conditional | maintainability | 75 | `advisory -> human` |
|
||||
|
||||
### Applied Fixes
|
||||
|
||||
- `safe_auto`: Added bounded export pagination guard and CSV serialization failure test coverage in this run
|
||||
|
||||
### Residual Actionable Work
|
||||
|
||||
| # | File | Issue | Route | Next Step |
|
||||
|---|------|-------|-------|-----------|
|
||||
| 1 | `orders_controller.rb:42` | Ownership check missing on export lookup | `gated_auto -> downstream-resolver` | Defer via tracker (requires explicit approval before behavior change) |
|
||||
| 3 | `export_service.rb:91` | Pagination contract needs a broader API decision | `manual -> downstream-resolver` | Defer via tracker with contract and client impact details |
|
||||
|
||||
### Pre-existing Issues
|
||||
|
||||
| # | File | Issue | Reviewer |
|
||||
|---|------|-------|----------|
|
||||
| 1 | `orders_controller.rb:12` | Broad rescue masking failed permission check | correctness |
|
||||
|
||||
### Learnings & Past Solutions
|
||||
|
||||
- [Known Pattern] `docs/solutions/export-pagination.md` -- previous export pagination fix applies to this endpoint
|
||||
|
||||
### Agent-Native Gaps
|
||||
|
||||
- New export endpoint has no CLI/agent equivalent -- agent users cannot trigger exports
|
||||
|
||||
### Deployment Notes
|
||||
|
||||
- Pre-deploy: capture baseline row counts before enabling the export backfill
|
||||
- Verify: `SELECT COUNT(*) FROM exports WHERE status IS NULL;` should stay at `0`
|
||||
- Rollback: keep the old export path available until the backfill has been validated
|
||||
|
||||
### Coverage
|
||||
|
||||
- Suppressed: 2 findings below anchor 75 (1 at anchor 50, 1 at anchor 25)
|
||||
- Residual risks: No rate limiting on export endpoint
|
||||
- Testing gaps: No test for concurrent export requests
|
||||
|
||||
---
|
||||
|
||||
> **Verdict:** Ready with fixes
|
||||
>
|
||||
> **Reasoning:** 1 critical auth bypass must be fixed. The memory/pagination issues (P1) should be addressed for production safety.
|
||||
>
|
||||
> **Fix order:** P0 auth bypass -> P1 memory/pagination -> P2 error handling if straightforward
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
Do NOT produce output like this. The following is wrong:
|
||||
|
||||
```markdown
|
||||
Findings
|
||||
|
||||
Sev: P1
|
||||
File: foo.go:42
|
||||
Issue: Some problem description
|
||||
Reviewer(s): adversarial
|
||||
Confidence: 75
|
||||
Route: advisory -> human
|
||||
────────────────────────────────────────
|
||||
Sev: P2
|
||||
File: bar.go:99
|
||||
Issue: Another problem
|
||||
```
|
||||
|
||||
This fails because: no pipe-delimited tables, no severity-grouped `###` headers, uses box-drawing horizontal rules, no numbered findings, no `## Code Review Results` title, and the verdict is not in a blockquote. Always use the table format from the example above.
|
||||
|
||||
## Formatting Rules
|
||||
|
||||
- **Pipe-delimited markdown tables** for findings -- never ASCII box-drawing characters or per-finding horizontal-rule separators between entries (the report-level `---` before the verdict is still required)
|
||||
- **Escape literal `|` in table cells** -- any `|` inside a finding title, issue description, code snippet, regex pattern, or delimited-string example must be written as `\|`. Unescaped pipes are parsed as column separators and corrupt the row's `Reviewer`, `Confidence`, and `Route` columns. Applies especially to cache-key delimiter examples, regex alternations, and logical-OR operators quoted inside findings.
|
||||
- **Severity-grouped sections** -- `### P0 -- Critical`, `### P1 -- High`, `### P2 -- Moderate`, `### P3 -- Low`. Omit empty severity levels.
|
||||
- **Stable sequential finding numbers** -- assign finding numbers once after sorting, continue them across severity sections, and reuse those same numbers when findings are repeated in Residual Actionable Work. Do not restart at `1` for each severity or route bucket.
|
||||
- **Always include file:line location** for code review issues
|
||||
- **Reviewer column** shows which persona(s) flagged the issue. Multiple reviewers = cross-reviewer agreement.
|
||||
- **Confidence column** shows the finding's anchor as an integer (`50`, `75`, or `100`). Never render as a float.
|
||||
- **Route column** shows the synthesized handling decision as ``<autofix_class> -> <owner>``.
|
||||
- **Header includes** scope, intent, and reviewer team with per-conditional justifications
|
||||
- **Mode line** -- include `interactive`, `autofix`, `report-only`, or `headless`
|
||||
- **Applied Fixes section** -- include only when a fix phase ran in this review invocation
|
||||
- **Residual Actionable Work section** -- include only when unresolved actionable findings were handed off for later work
|
||||
- **Pre-existing section** -- separate table, no confidence column (these are informational)
|
||||
- **Learnings & Past Solutions section** -- results from ce-learnings-researcher, with links to docs/solutions/ files
|
||||
- **Agent-Native Gaps section** -- results from ce-agent-native-reviewer. Omit if no gaps found.
|
||||
- **Deployment Notes section** -- key checklist items from ce-deployment-verification-agent. Omit if the agent did not run. Schema drift surfaces as `data-migration` findings — no separate section.
|
||||
- **Coverage section** -- suppressed count, residual risks, testing gaps, failed reviewers
|
||||
- **Summary uses blockquotes** for verdict, reasoning, and fix order
|
||||
- **Horizontal rule** (`---`) separates findings from verdict
|
||||
- **`###` headers** for each section -- never plain text headers
|
||||
|
||||
## Headless Mode Format
|
||||
|
||||
In `mode:headless`, replace the interactive pipe-delimited table report with a structured text envelope. The headless format is defined in the `### Headless output format` section of SKILL.md. Key differences from the interactive format:
|
||||
|
||||
- **No pipe-delimited tables.** Findings use `[severity][autofix_class -> owner] File: <file:line> -- <title>` line format with indented Why/Evidence/Suggested fix lines.
|
||||
- **Findings grouped by autofix_class** (gated-auto, manual, advisory) instead of severity. Within each group, findings are sorted by severity.
|
||||
- **Verdict in header** (top of output) instead of bottom, so programmatic callers get it first.
|
||||
- **`Artifact:` line** in metadata header gives callers the path to the full run artifact.
|
||||
- **`[needs-verification]` marker** on findings where `requires_verification: true`.
|
||||
- **Evidence lines** included per finding.
|
||||
- **Completion signal:** "Review complete" as the final line.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Sub-agent Prompt Template
|
||||
|
||||
This template is used by the orchestrator to spawn each reviewer sub-agent. Variable substitution slots are filled at spawn time.
|
||||
|
||||
---
|
||||
|
||||
## Template
|
||||
|
||||
```
|
||||
You are a specialist code reviewer.
|
||||
|
||||
<persona>
|
||||
{persona_file}
|
||||
</persona>
|
||||
|
||||
<scope-rules>
|
||||
{diff_scope_rules}
|
||||
</scope-rules>
|
||||
|
||||
<output-contract>
|
||||
You produce up to two outputs depending on whether a run ID was provided:
|
||||
|
||||
1. **Artifact file (when run ID is present).** If a Run ID appears in <review-context> below, WRITE your full analysis (all schema fields, including why_it_matters, evidence, and suggested_fix) as JSON to:
|
||||
/tmp/compound-engineering/ce-code-review/{run_id}/{reviewer_name}.json
|
||||
This is the ONE write operation you are permitted to make. Use the platform's file-write tool.
|
||||
If the write fails, continue -- the compact return still provides everything the merge needs.
|
||||
If no Run ID is provided (the field is empty or absent), skip this step entirely -- do not attempt any file write.
|
||||
|
||||
2. **Compact return (always).** RETURN compact JSON to the parent with ONLY merge-tier fields per finding:
|
||||
title, severity, file, line, confidence, autofix_class, owner, requires_verification, pre_existing, suggested_fix.
|
||||
Do NOT include why_it_matters or evidence in the returned JSON.
|
||||
Include reviewer, residual_risks, and testing_gaps at the top level.
|
||||
|
||||
The full file preserves detail for downstream consumers (headless output, debugging).
|
||||
The compact return keeps the orchestrator's context lean for merge and synthesis.
|
||||
|
||||
The schema below describes the **full artifact file format** (all fields required). For the compact return, follow the field list above -- omit why_it_matters and evidence even though the schema marks them as required.
|
||||
|
||||
{schema}
|
||||
|
||||
**Schema conformance — hard constraints (use these exact values; validation rejects anything else):**
|
||||
|
||||
- `severity`: one of `"P0"`, `"P1"`, `"P2"`, `"P3"` — use these exact strings. Do NOT use `"high"`, `"medium"`, `"low"`, `"critical"`, or any other vocabulary, even if your persona's prose discusses priorities in those terms conceptually.
|
||||
- `autofix_class`: one of `"safe_auto"`, `"gated_auto"`, `"manual"`, `"advisory"`.
|
||||
- `owner`: one of `"review-fixer"`, `"downstream-resolver"`, `"human"`, `"release"`.
|
||||
- `evidence`: an ARRAY of strings with at least one element. A single string value is a validation failure — wrap every quote in `["..."]` even when there is only one.
|
||||
- `pre_existing`: boolean, never null.
|
||||
- `requires_verification`: boolean, never null.
|
||||
- `confidence`: one of exactly `0`, `25`, `50`, `75`, or `100` — a discrete anchor, NOT a continuous number. Any other value (e.g., `72`, `0.85`, `"high"`) is a validation failure. Pick the anchor whose behavioral criterion you can honestly self-apply to this finding (see "Confidence rubric" below).
|
||||
|
||||
If your persona description uses severity vocabulary like "high-priority" or "critical" in its rubric text, translate to the P0-P3 scale at emit time. "Critical / must-fix" → P0, "important / should-fix" → P1, "worth-noting / could-fix" → P2, "low-signal" → P3. Same for priorities described qualitatively in your analysis — map to P0-P3 on the way out.
|
||||
|
||||
**Confidence rubric — use these exact behavioral anchors.** Pick the single anchor whose criterion you can honestly self-apply. Do not pick a value between anchors; only `0`, `25`, `50`, `75`, and `100` are valid. The rubric is anchored on behavior you performed, not on a vague sense of certainty — if you cannot truthfully attach the behavioral claim to the finding, step down to the next anchor.
|
||||
|
||||
- **`0` — Not confident at all.** A false positive that does not stand up to light scrutiny, or a pre-existing issue this PR did not introduce. **Do not emit — suppress silently.** This anchor exists in the enum only so synthesis can explicitly track the drop; personas never produce it.
|
||||
- **`25` — Somewhat confident.** Might be a real issue but could also be a false positive; you could not verify from the diff and surrounding code alone. **Do not emit — suppress silently.** This anchor, like `0`, exists in the enum only so synthesis can track the drop; personas never produce it. If your domain is genuinely uncertain, either gather more evidence (read related files, check call sites, inspect git blame) until you can honestly anchor at `50` or higher, or suppress entirely.
|
||||
- **`50` — Moderately confident.** You verified this is a real issue but it is a nitpick, narrow edge case, or has minimal practical impact. Style preferences and subjective improvements land here. Surfaces only when synthesis routes weak findings to advisory / residual_risks / testing_gaps soft buckets, or when the finding is P0 (critical-but-uncertain issues are not silently dropped).
|
||||
- **`75` — Highly confident.** You double-checked the diff and surrounding code and confirmed the issue will affect users, downstream callers, or runtime behavior in normal usage. The bug, vulnerability, or contract violation is clearly present and actionable.
|
||||
|
||||
**Anchor `75` requires naming a concrete observable consequence** — a wrong result, an unhandled error path, a contract mismatch, a security exposure, missing coverage that a real test scenario would surface. "This could be cleaner" or "I would have written this differently" do not meet this bar — they are advisory observations and land at anchor `50`. When in doubt between `50` and `75`, ask: "will a user, caller, or operator concretely encounter this in normal usage, or is this my opinion about the code's quality?" The former is `75`; the latter is `50`.
|
||||
- **`100` — Absolutely certain.** The issue is verifiable from the code itself — compile error, type mismatch, definitive logic bug (off-by-one in a tested algorithm, wrong return type, swapped arguments), or an explicit project-standards violation with a quotable rule. No interpretation required.
|
||||
|
||||
Anchor and severity are independent axes. A P2 finding can be anchor `100` if the evidence is airtight; a P0 finding can be anchor `50` if it is an important concern you could not fully verify. Anchor gates where the finding surfaces (drop / soft bucket / actionable); severity orders it within the actionable surface.
|
||||
|
||||
Synthesis suppresses anchors `0` and `25` silently. Anchor `50` is dropped from primary findings unless the severity is P0 (P0+50 survives) or synthesis routes it to a soft bucket (testing_gaps, residual_risks, advisory) per mode-aware demotion. Anchors `75` and `100` enter the actionable tier.
|
||||
|
||||
Example of a schema-valid finding (all required fields, correct enum values, correct array shape):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "User-supplied ID in account lookup without ownership check",
|
||||
"severity": "P0",
|
||||
"file": "app/controllers/orders_controller.rb",
|
||||
"line": 42,
|
||||
"why_it_matters": "Any signed-in user can read another user's orders by pasting the target account ID into the URL. The controller looks up the account and returns its orders without verifying the current user owns it. The shipments controller already uses a current_user.owns?(account) guard for the same attack class; matching that pattern fixes this finding.",
|
||||
"autofix_class": "gated_auto",
|
||||
"owner": "downstream-resolver",
|
||||
"requires_verification": true,
|
||||
"suggested_fix": "Add current_user.owns?(account) guard before lookup, matching the pattern in shipments_controller.rb",
|
||||
"confidence": 100,
|
||||
"evidence": [
|
||||
"orders_controller.rb:42 -- account = Account.find(params[:account_id])",
|
||||
"shipments_controller.rb:38 -- raise NotAuthorized unless current_user.owns?(account)"
|
||||
],
|
||||
"pre_existing": false
|
||||
}
|
||||
```
|
||||
|
||||
The `confidence: 100` is justified because the issue is verifiable from the code alone — the controller fetches by user-supplied ID and returns data without any guard, and the parallel pattern in shipments_controller.rb confirms the project's own convention is being violated.
|
||||
|
||||
Writing `why_it_matters` (required field, every finding):
|
||||
|
||||
The `why_it_matters` field is how the reader — a developer triaging findings, a ticket-body reader months later, or a downstream automated surface — understands the problem without re-reading the file. Treat it as the most important prose field in your output; every downstream surface (walk-through questions, bulk-action previews, ticket bodies, headless output) depends on it being good.
|
||||
|
||||
- **Lead with observable behavior.** Describe what the bug does from the outside — what a user, attacker, operator, or downstream caller experiences. Do not lead with code structure ("The function X does Y..."). Start with the effect ("Any signed-in user can read another user's orders..."). Function and variable names appear later, only when the reader needs them to locate the issue.
|
||||
- **Explain why the fix resolves the problem.** If you include a `suggested_fix`, the `why_it_matters` should make clear why that specific fix addresses the root cause. When a similar pattern exists elsewhere in the codebase (an existing guard, an established convention, a parallel handler), reference it so the recommendation is grounded in the project's own conventions rather than theoretical best practice.
|
||||
- **Keep it tight.** Approximately 2-4 sentences plus the minimum code quoted inline to ground the point. Longer framings are a regression — downstream surfaces have narrow display budgets, and verbose `why_it_matters` content gets truncated or skimmed.
|
||||
- **Always produce substantive content.** `why_it_matters` is required by the schema. Empty strings, nulls, and single-phrase entries are validation failures. If you found something worth flagging at anchor `50` or higher, you can explain it — the field exists because every finding needs a reason.
|
||||
|
||||
Illustrative pair — same finding, weak vs. strong framing:
|
||||
|
||||
```
|
||||
WEAK (code-citation first; fails the observable-behavior rule):
|
||||
orders_controller.rb:42 has a missing authorization check.
|
||||
Add current_user.owns?(account) guard before the query.
|
||||
|
||||
STRONG (observable behavior first, grounded fix reasoning):
|
||||
Any signed-in user can read another user's orders by pasting the
|
||||
target account ID into the URL. The controller looks up the account
|
||||
and returns its orders without verifying the current user owns it.
|
||||
Adding a one-line ownership guard before the lookup matches the
|
||||
pattern already used in the shipments controller for the same attack.
|
||||
```
|
||||
|
||||
False-positive categories to actively suppress. Do NOT emit a finding when any of these apply — not even at anchor `25` or `50`. These are not edge cases you should route to soft buckets; they are non-findings.
|
||||
|
||||
- **Pre-existing issues unrelated to this diff.** Mark `pre_existing: true` only for unchanged code the diff does not interact with. If the diff makes a previously-dormant issue newly relevant (e.g., changes a caller in a way that exposes a bug downstream), it is a secondary finding, not pre-existing. PR-comment and headless externalization filter pre-existing entirely; interactive review surfaces them in a separate section.
|
||||
- **Pedantic style nitpicks that a linter or formatter would catch.** Missing semicolons, indentation, import ordering, unused-variable warnings the project's tooling already catches. Style belongs to the toolchain.
|
||||
- **Code that looks wrong but is intentional.** Check comments, commit messages, PR description, or surrounding code for evidence of intent before flagging. A persona-flagged "missing null check" guarded by an upstream `.present?` call is a false positive.
|
||||
- **Issues already handled elsewhere.** Check callers, guards, middleware, framework defaults, and parallel handlers before flagging. If a controller's input is already validated by a parent middleware, the controller-level check the persona wants to add is redundant.
|
||||
- **Suggestions that restate what the code already does in different words.** "Consider extracting this into a helper" when the code is already a small helper, "consider adding a guard" when a guard one line up already enforces it.
|
||||
- **Generic "consider adding" advice without a concrete failure mode.** If you cannot name what breaks, the finding is not actionable. Either find the failure mode or suppress.
|
||||
- **Issues with a relevant lint-ignore comment.** Code that carries an explicit lint disable comment for the rule you are about to flag (`eslint-disable-next-line no-unused-vars`, `# rubocop:disable Style/StringLiterals`, `# noqa: E501`, etc.) — suppress unless the suppression itself violates a project-standards rule that explicitly forbids disabling that lint for this code shape. The author already chose to suppress; re-flagging it via a different reviewer creates noise and ignores their decision.
|
||||
- **General code-quality concerns not codified in CLAUDE.md / AGENTS.md.** "This file is getting long," "this method has too many parameters," "this is hard to read" — without a project-standards rule to anchor the concern, these are subjective and waste reviewer time. If the project explicitly bans long files or sets a parameter-count limit in its standards, that is a project-standards finding; otherwise suppress.
|
||||
- **Speculative future-work concerns with no current signal.** "This might break under load," "what if the requirements change," "this could be hard to test later" — not findings unless the diff introduces concrete evidence the concern is reachable now.
|
||||
|
||||
**Advisory observations — route to advisory autofix_class, do not force a decision.** If the honest answer to "what actually breaks if we do not fix this?" is "nothing breaks, but…", the finding is advisory. Set `autofix_class: advisory` and `confidence: 50` so synthesis routes the finding to a soft bucket rather than surfacing it as a primary action item. Do not suppress — the observation may have value; it just does not warrant user judgment. Typical advisory shapes: design asymmetry the PR improves but does not fully resolve, opportunity to consolidate two similar helpers when neither is broken, residual risk worth noting in the report.
|
||||
|
||||
**Precedence over the false-positive catalog.** The false-positive catalog above is stricter than the advisory rule — if a shape matches the FP catalog, it is a non-finding and must be suppressed entirely. Do NOT route it to anchor `50` / advisory. The advisory rule applies only to shapes that are NOT in the FP catalog.
|
||||
|
||||
Rules:
|
||||
- You are a leaf reviewer inside an already-running compound-engineering review workflow. Do not invoke compound-engineering skills or agents unless this template explicitly instructs you to. Perform your analysis directly and return findings in the required output format only.
|
||||
- Suppress any finding you cannot honestly anchor at `50` or higher (the actionable floor is `50`; anchors `0` and `25` are suppressed by synthesis anyway, so emitting them only adds noise). If your persona's domain description sets a stricter floor (e.g., anchor `75` minimum), honor it.
|
||||
- Every finding in the full artifact file MUST include at least one evidence item grounded in the actual code. The compact return omits evidence -- the evidence requirement applies to the disk artifact only.
|
||||
- Set `pre_existing` to true ONLY for issues in unchanged code that are unrelated to this diff. If the diff makes the issue newly relevant, it is NOT pre-existing.
|
||||
- You are operationally read-only. The one permitted exception is writing your full analysis to the `.context/` artifact path when a run ID is provided. You may also use non-mutating inspection commands, including read-oriented `git` / `gh` commands, to gather evidence. Do not edit project files, change branches, commit, push, create PRs, or otherwise mutate the checkout or repository state.
|
||||
- Set `autofix_class` accurately. The classification governs whether the fixer applies the change automatically (`safe_auto`) or surfaces it for explicit review (`gated_auto` / `manual` / `advisory`). **The wrong-side cost is symmetric:** classifying a contract-change as `safe_auto` produces an unwanted edit; classifying a mechanical fix as `gated_auto` makes the user manually triage findings the fixer could have applied. Bias toward `safe_auto` when the rubric permits it. Use this decision guide:
|
||||
- `safe_auto`: The fix is local and deterministic — the fixer can apply it mechanically. **The test:** you can articulate the fix in one sentence with no "depends on" clauses, AND applying it doesn't change any of {function signature, public-API/response contract, error contract, security posture, permission model}. Examples: extracting a duplicated helper, adding a missing nil/null guard inside an internal function, fixing an off-by-one when the parallel pattern is in scope, adding a missing test for an existing public method, removing dead code, removing an unused import.
|
||||
|
||||
**Boundary cases that often feel risky but are still `safe_auto`:**
|
||||
- A nil guard that turns a crash into a nil-return is `safe_auto` when the function is internal and no public-API/error contract is documented. The contract is the function body itself — adding a precondition check isn't a behavior change worth gating.
|
||||
- An off-by-one fix is `safe_auto` when the corrected behavior is verifiable from a parallel pattern visible in the surrounding code or from explicit documentation. Matching an established pattern isn't a design decision.
|
||||
- Dead-code removal is `safe_auto` when the code's deadness is signaled in scope: no callers reachable from the diff, in-file comment says "superseded" / "unused" / "no callers", or the surrounding refactor obviously displaces it. "Someone might want this someday" isn't a design call the reviewer is empowered to make.
|
||||
- Helper extraction is `safe_auto` when the duplication is identical, all callers update in lockstep within the same diff, and the consolidation point is mechanical (a shared method on the same class, or a new helper named after the shared shape). Cross-file extraction qualifies when both files ship in the same diff and the shared shape dictates the name. The discriminator is whether **naming or placement requires a design conversation** ("service object vs concern? where does it live in the layering?"). If yes, gated_auto. If the name follows mechanically from the body, safe_auto.
|
||||
|
||||
- `gated_auto`: A concrete fix exists but applying it changes a contract, permission, or module boundary in a way the user should approve before it lands. Examples: adding authentication to an unprotected endpoint, changing a public API response shape (even by narrowing fields), switching from soft-delete to hard-delete, modifying error-handling in ways downstream callers can observe.
|
||||
- `manual`: Actionable work that requires design decisions or cross-cutting changes. Examples: redesigning a data model, choosing between two equally-defensible architectural approaches, adding pagination to an unbounded query when no parallel pattern exists. **Pair `manual` with a concrete `suggested_fix` whenever you can defend one from the diff and surrounding code** — see the suggested_fix rule below. Omit `suggested_fix` only when the fix genuinely requires cross-team input, business context, or research outside this review.
|
||||
- `advisory`: Report-only items that should not become code-fix work. Examples: noting a design asymmetry the PR improves but doesn't fully resolve, flagging a residual risk, deployment notes.
|
||||
|
||||
Do not default to `advisory` when uncertain — if a concrete fix is obvious, classify it as `safe_auto` or `gated_auto`. Do not default to `gated_auto` when the fix is mechanical but the change feels substantive — apply the safe_auto test above. The "feels risky" reflex is exactly the asymmetry this rubric is designed to neutralize.
|
||||
- Set `owner` to the default next actor for this finding: `review-fixer`, `downstream-resolver`, `human`, or `release`.
|
||||
- Set `requires_verification` to true whenever the likely fix needs targeted tests, a focused re-review, or operational validation before it should be trusted.
|
||||
- **Propose a `suggested_fix` whenever any defensible code change is reachable from the diff and surrounding code.** This is the persona's commitment that "I, the reviewer with the diff and evidence in front of me, can articulate what the fix looks like." The suggested fix becomes the authoritative signal that downstream surfaces use to decide whether the agent can act on the finding. Three rules:
|
||||
- **Defensible from review context:** the fix should be reachable from the diff, the cited code, parallel patterns elsewhere in the repo, or framework conventions you can verify. If you cannot ground the fix in evidence the reader can check, omit it.
|
||||
- **Concrete, not generic:** "add a guard before the query" with the specific guard named is concrete; "consider adding validation" is generic. Generic advice is suppressed by the false-positive catalog above.
|
||||
- **Imperfect information is not grounds for omission.** When you don't have full context for the optimal fix, propose the most defensible default and name the assumption. Do not omit because "the right answer depends on X" — name the assumption you're making, propose the default, and let the user override.
|
||||
Examples of imperfect-info findings that should still get a `suggested_fix`:
|
||||
- Pagination strategy unclear → propose offset pagination matching the existing pattern at `file:line`, with assumption named. If product needs cursor-based, the user can switch.
|
||||
- Rate limit value uncertain → propose the value that matches existing rate limits in the project, with assumption named. The user can tune.
|
||||
- Auth model unknown → propose authentication via the existing middleware pattern at `file:line`, with assumption named. If a different service owns the auth flow, the user can route through it.
|
||||
The "I need `<specific input>` before I can commit" framing is a soft punt. The question to ask instead is "what code change would I propose if I had to choose now?" — and propose that, with the assumption named so the user can correct it.
|
||||
- **Genuinely-omit cases are rare.** Omit `suggested_fix` only when there is no code-level change to propose — for example:
|
||||
- The finding is a question, not a fix request: "What is the intended SLA here?" with no clear default to assume.
|
||||
- The resolution is purely organizational with no code component: legal sign-off, business policy decision, or a process change that doesn't touch code.
|
||||
These shapes are the exception, not the norm. Most "manual" findings in code review have a defensible code-level proposal even when context is incomplete. A `manual` finding without `suggested_fix` routes to the best-judgment path's `failed` bucket with reason "no fix proposed by reviewer" — owning that omission is the persona's responsibility.
|
||||
A bad fix suggestion is still worse than none — the false-positive catalog and grounding rule above prevent that. The bias is toward proposing when you can; the omission case is narrow.
|
||||
- If you find no issues, return an empty findings array. Still populate residual_risks and testing_gaps if applicable.
|
||||
- **Intent verification:** Compare the code changes against the stated intent (and PR title/body when available). If the code does something the intent does not describe, or fails to do something the intent promises, flag it as a finding. Mismatches between stated intent and actual code are high-value findings.
|
||||
</output-contract>
|
||||
|
||||
<pr-context>
|
||||
{pr_metadata}
|
||||
</pr-context>
|
||||
|
||||
<review-context>
|
||||
Run ID: {run_id}
|
||||
Reviewer name: {reviewer_name}
|
||||
|
||||
Intent: {intent_summary}
|
||||
|
||||
Changed files: {file_list}
|
||||
|
||||
Diff:
|
||||
{diff}
|
||||
</review-context>
|
||||
```
|
||||
|
||||
## Variable Reference
|
||||
|
||||
| Variable | Source | Description |
|
||||
|----------|--------|-------------|
|
||||
| `{persona_file}` | Agent markdown file content | The full persona definition (identity, failure modes, calibration, suppress conditions) |
|
||||
| `{diff_scope_rules}` | `references/diff-scope.md` content | Primary/secondary/pre-existing tier rules |
|
||||
| `{schema}` | `references/findings-schema.json` content | The JSON schema reviewers must conform to |
|
||||
| `{intent_summary}` | Stage 2 output | 2-3 line description of what the change is trying to accomplish |
|
||||
| `{pr_metadata}` | Stage 1 output | PR title, body, and URL when reviewing a PR. Empty string when reviewing a branch or standalone checkout |
|
||||
| `{file_list}` | Stage 1 output | List of changed files from the scope step |
|
||||
| `{diff}` | Stage 1 output | The actual diff content to review |
|
||||
| `{run_id}` | Stage 4 output | Unique review run identifier for the artifact directory |
|
||||
| `{reviewer_name}` | Stage 3 output | Persona or agent name used as the artifact filename stem |
|
||||
@@ -0,0 +1,149 @@
|
||||
# Tracker Detection and Defer Execution
|
||||
|
||||
This reference covers how Defer actions file tickets in the project's tracker. It is loaded by `SKILL.md` when Interactive mode's routing question needs to decide whether to offer option C (File tickets), when the walk-through's Defer option executes, and when the bulk-preview of option C is shown. It is also loaded by autonomous callers (e.g., `lfg`) that need to file residual actionable findings without user prompts — see Execution Modes below.
|
||||
|
||||
---
|
||||
|
||||
## Execution Modes
|
||||
|
||||
Tracker-defer has two execution modes. The caller selects one; the detection, fallback chain, and ticket composition are shared.
|
||||
|
||||
### Interactive mode (default)
|
||||
|
||||
Used by `ce-code-review` Interactive mode's routing question, walk-through Defer actions, and bulk-preview option C. All user-facing prompts fire:
|
||||
|
||||
- First Defer of the session with a generic (non-named) label confirms the effective tracker choice.
|
||||
- Execution failures prompt with Retry / Fall back to next sink / Convert to Skip.
|
||||
- Labels in the routing question reflect `named_sink_available` (name the tracker) vs fallback generics.
|
||||
|
||||
### Non-interactive mode
|
||||
|
||||
Used by autonomous callers like `lfg` that must not prompt. All blocking questions are skipped; the fallback chain is executed silently in order. Behavior:
|
||||
|
||||
- No confirmation on the first generic-label Defer; proceed directly.
|
||||
- On execution failure, automatically fall to the next tier without prompting. Record the failure.
|
||||
- On total chain exhaustion (every tier failed or no sink available), return findings in the `no_sink` bucket so the caller can route them to another surface (e.g., inline them in a PR description).
|
||||
- Return a structured result: `{ filed: [{ finding_id, tracker, url }], failed: [{ finding_id, tracker, reason }], no_sink: [{ finding_id, title, severity, file, line }] }`.
|
||||
|
||||
The caller decides how to surface the result to the user. The non-interactive mode treats "no sink available" as a data-producing outcome, not a prompt trigger.
|
||||
|
||||
---
|
||||
|
||||
## Detection
|
||||
|
||||
The agent determines the project's tracker from whatever documentation is obvious. Primary sources: `CLAUDE.md` and `AGENTS.md` at the repo root and in relevant subdirectories. Supplementary signals (when primary documentation is ambiguous): `CONTRIBUTING.md`, `README.md`, PR templates under `.github/`, visible tracker URLs in the repo.
|
||||
|
||||
A tracker can be surfaced via MCP tool (e.g., a Linear MCP server), CLI (e.g., `gh`), or direct API. All are acceptable. The detection output is a tuple with two availability flags — one for the named tracker specifically (drives label confidence in Interactive mode) and one for the full fallback chain (drives whether Defer is offered at all):
|
||||
|
||||
```
|
||||
{ tracker_name, confidence, named_sink_available, any_sink_available }
|
||||
```
|
||||
|
||||
Where:
|
||||
- `tracker_name` — human-readable name ("Linear", "GitHub Issues", "Jira"), or `null` when detection cannot identify a specific tracker
|
||||
- `confidence` — `high` when the tracker is named explicitly in documentation (or via a linked URL to a specific project/workspace) and is unambiguously the project's canonical tracker; `low` when the signal is thin, conflicting, or implied only
|
||||
- `named_sink_available` — `true` only when the agent can actually invoke the detected tracker (MCP tool is loaded, CLI is authenticated, or API credentials are in environment); `false` when the tracker is documented but no tool reaches it, or when no tracker is found at all. Drives label confidence: inline tracker naming requires this to be `true`.
|
||||
- `any_sink_available` — `true` when any tier in the fallback chain (named tracker or GitHub Issues via `gh`) can be invoked this session. Drives whether Defer is offered in Interactive mode, and drives the `no_sink` bucket in Non-interactive mode.
|
||||
|
||||
Detection is reasoning-based. Do not maintain an enumerated checklist of files to read. Read the obvious sources and form a confident conclusion; when the obvious sources don't resolve, the label falls back to generic wording and the agent confirms with the user before executing (Interactive mode only).
|
||||
|
||||
---
|
||||
|
||||
## Probe timing and caching
|
||||
|
||||
Availability probes run **at most once per session** and **only when Defer execution is imminent**. Never speculatively at review start, never per-Defer, never per-walk-through-finding. The cached tuple is reused for every Defer action in the same run.
|
||||
|
||||
Typical probe sequence:
|
||||
|
||||
1. Read `CLAUDE.md` / `AGENTS.md` for tracker references. If nothing found, set `tracker_name = null`, `confidence = low`.
|
||||
2. **Probe the named tracker when one was found.** For GitHub Issues, run `gh auth status` and `gh repo view --json hasIssuesEnabled`. For Linear or other MCP-backed trackers, verify the relevant MCP tool is loaded and responsive. For API-backed trackers, verify credentials in environment. Set `named_sink_available` from the probe result.
|
||||
3. **Probe the GitHub Issues fallback to compute `any_sink_available`.** Even when the named tracker was found and probed, `gh` matters for the `no_sink` bucket decision so that a run with no documented tracker but working `gh` still offers Defer.
|
||||
- If `named_sink_available = true`: `any_sink_available = true` (no further probes needed).
|
||||
- Otherwise, probe GitHub Issues via `gh auth status` + `gh repo view --json hasIssuesEnabled` (skip if already probed in step 2). If it works, `any_sink_available = true`.
|
||||
- Otherwise, `any_sink_available = false`.
|
||||
|
||||
When Interactive mode's routing question is skipped entirely (R2 zero-findings case), no probes run. When the cached tuple is reused across a session, any `named_sink_available = true` from the session's first probe stays cached — do not re-probe per Defer.
|
||||
|
||||
---
|
||||
|
||||
## Label logic (Interactive mode)
|
||||
|
||||
- When `confidence = high` AND `named_sink_available = true`: the routing question's option C and the walk-through's per-finding Defer option both include the tracker name verbatim. Example: `File a Linear ticket per finding`, `Defer — file a Linear ticket`.
|
||||
- When `any_sink_available = true` but either `confidence = low` or `named_sink_available = false` (a fallback tier is working instead): the labels read generically — `File an issue per finding`, `Defer — file a ticket`. Before executing the first Defer of the session, the agent confirms the effective tracker choice with the user using the platform's blocking question tool.
|
||||
- When `any_sink_available = false`: option C is omitted from the routing question, option B (Defer) is omitted from the walk-through per-finding options, and the agent tells the user why in the routing question's stem.
|
||||
|
||||
Non-interactive mode skips label decisions entirely — it acts silently on the detected sink.
|
||||
|
||||
---
|
||||
|
||||
## Fallback chain
|
||||
|
||||
When the named tracker is unavailable or no tracker is named, fall back in this order. Prefer the project's detected tracker; use `gh` only when no named tracker was found or the named one is unreachable.
|
||||
|
||||
1. **Named tracker** (MCP tool, CLI, or API the agent can invoke directly, identified via Detection above)
|
||||
2. **GitHub Issues via `gh`** — when `gh auth status` succeeds and the current repo has issues enabled (`gh repo view --json hasIssuesEnabled` returns `true`)
|
||||
3. **No sink** — findings remain in the review report's residual-work section (Interactive mode) or are returned in the `no_sink` bucket for the caller to route (Non-interactive mode). The agent does not re-display them through a transient surface.
|
||||
|
||||
Previously this chain included a third in-session fallback tier. That tier was removed because in-session tasks do not survive past the session and therefore do not meet the "durable filing" intent of a Defer action. When no durable tracker exists, the correct behavior is to leave findings in the report (Interactive) or return them to the caller (Non-interactive).
|
||||
|
||||
---
|
||||
|
||||
## Ticket composition
|
||||
|
||||
Every Defer action creates a ticket with the following content, adapted to the tracker's capabilities:
|
||||
|
||||
- **Title:** the merged finding's `title` (schema-capped at 10 words).
|
||||
- **Body:**
|
||||
- Plain-English problem statement — reads the persona-produced `why_it_matters` from the contributing reviewer's artifact file at `/tmp/compound-engineering/ce-code-review/<run-id>/{reviewer}.json`, using the same `file + line_bucket(line, +/-3) + normalize(title)` matching headless mode uses (see SKILL.md Stage 6 detail enrichment). Falls back to the merged finding's `title`, `severity`, `file`, and `suggested_fix` (when present) when no artifact match is available — these fields are guaranteed in the merge-tier compact return.
|
||||
- Suggested fix (when present in the finding's `suggested_fix`).
|
||||
- Evidence (direct quotes from the reviewer's artifact).
|
||||
- Metadata block: `Severity: <level>`, `Confidence: <score>`, `Reviewer(s): <list>`, `Finding ID: <fingerprint>`.
|
||||
- **Labels** (when the tracker supports labels): severity tag (`P0`, `P1`, `P2`, `P3`) and, when the tracker convention supports it, a category label sourced from the reviewer name.
|
||||
- **Length cap:** when the composed body would exceed a tracker's body length limit, truncate with `... (continued in ce-code-review run artifact: /tmp/compound-engineering/ce-code-review/<run-id>/)` and include the finding_id in both the truncated body and the metadata block so the artifact is discoverable.
|
||||
|
||||
The finding_id is a stable fingerprint composed as `normalize(file) + line_bucket(line, +/-3) + normalize(title)` — the same fingerprint used by the merge pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Failure path
|
||||
|
||||
When ticket creation fails at execution (API error, auth expiry mid-session, rate limit, malformed body rejected, 4xx/5xx response):
|
||||
|
||||
**Interactive mode:** surface the failure inline and ask the user using the platform's blocking question tool.
|
||||
|
||||
Stem:
|
||||
> Defer failed: <tracker name> returned <error summary>. How should the agent handle this finding?
|
||||
|
||||
Options:
|
||||
- `Retry on <tracker>` — re-attempt the same tracker once more (useful for transient errors)
|
||||
- `Fall back to next sink` — move this finding's Defer to the next tier in the fallback chain (e.g., from Linear to GitHub Issues)
|
||||
- `Convert to Skip — record the failure` — abandon this Defer, note the failure in the completion report's failure section, and continue the walk-through or bulk flow
|
||||
|
||||
**Non-interactive mode:** do not prompt. Automatically fall through to the next tier. If every tier fails, record the finding in the `failed` bucket of the structured return and continue. If the chain exhausts with no sink ever available, the finding ends up in the `no_sink` bucket.
|
||||
|
||||
When a high-confidence named tracker fails at execution, the cached `named_sink_available` is set to `false` for the rest of the session. Subsequent Defer actions fall straight through to the next tier without retrying a confirmed-broken sink. `any_sink_available` is only downgraded to `false` when every tier has been confirmed broken — a failed Linear call that succeeds via `gh` keeps `any_sink_available = true`.
|
||||
|
||||
Only when `ToolSearch` explicitly returns no match or the tool call errors — or on a platform with no blocking question tool — fall back to numbered options and waiting for the user's reply (Interactive mode only).
|
||||
|
||||
---
|
||||
|
||||
## Per-tracker behavior
|
||||
|
||||
Concrete behavior per tracker at execution time. The agent may invoke any of these through the appropriate interface (MCP, CLI, or API) — the choice depends on what is available in the current environment.
|
||||
|
||||
| Tracker | Interface | Invocation sketch | Body format | Labels |
|
||||
|---------|-----------|-------------------|-------------|--------|
|
||||
| Linear | MCP (preferred) or API | Create issue in the project/workspace identified by documentation; assign to the reporter if the MCP tool exposes user context | Markdown | Severity priority field if the MCP exposes it; otherwise include severity in body |
|
||||
| GitHub Issues | `gh issue create` | Repo defaults to the current repo. Use `--label` for severity tag when labels exist; omit `--label` if the repo has no label fixture. Fall back to a label-less issue on first failure. | Markdown | `--label P0` / `--label P1` / etc. when labels exist |
|
||||
| Jira | MCP or API | Create issue in the project identified by documentation; Jira's markdown dialect differs from GitHub's — use plain text in the body when MCP does not handle conversion | Plain text when MCP does not handle markdown | Severity priority field |
|
||||
| No sink available | — | Interactive: Defer option omitted, findings remain in the report's residual-work section. Non-interactive: findings returned in the `no_sink` bucket for caller routing. | — | — |
|
||||
|
||||
When uncertain, prefer "drop with explicit user-facing notice" over "pass through silently and hope." A Defer that produces no durable artifact and no user message is data loss.
|
||||
|
||||
---
|
||||
|
||||
## Cross-platform notes
|
||||
|
||||
The question-tool name varies by platform. In Interactive mode, use the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension)). In Claude Code the tool should already be loaded from the Interactive-mode pre-load step — if it isn't, call `ToolSearch` with query `select:AskUserQuestion` now. Fall back to numbered options in chat only when the harness genuinely lacks a blocking tool — `ToolSearch` returns no match, the tool call explicitly fails, or the runtime mode does not expose it (e.g., Codex edit modes without `request_user_input`). A pending schema load is not a fallback trigger. Never silently skip the question.
|
||||
|
||||
Non-interactive mode is platform-agnostic: it never prompts, so the platform's question tool is not relevant.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Validator Sub-agent Prompt Template
|
||||
|
||||
This template is used by Stage 5b to spawn one validator sub-agent per surviving finding before externalization. The validator's job is **independent re-verification**, not re-reasoning. It is a fresh second opinion, not a critic of the original persona's analysis.
|
||||
|
||||
---
|
||||
|
||||
## Template
|
||||
|
||||
```
|
||||
You are an independent validator for a code review finding. Another reviewer flagged the issue described below. Your job is to verify whether the finding holds up under fresh inspection.
|
||||
|
||||
You have no commitment to the original finding. If it is wrong, say so. False positives are common; do not feel pressure to confirm.
|
||||
|
||||
<finding-to-validate>
|
||||
Title: {finding_title}
|
||||
Severity: {finding_severity}
|
||||
File: {finding_file}
|
||||
Line: {finding_line}
|
||||
|
||||
Why it matters (the original reviewer's framing):
|
||||
{finding_why_it_matters}
|
||||
|
||||
Suggested fix (if any):
|
||||
{finding_suggested_fix}
|
||||
|
||||
Original reviewer: {finding_reviewer}
|
||||
Confidence anchor: {finding_confidence}
|
||||
</finding-to-validate>
|
||||
|
||||
<diff>
|
||||
{diff}
|
||||
</diff>
|
||||
|
||||
<scope-context>
|
||||
The diff above is the full change being reviewed. The finding is about file {finding_file} around line {finding_line}. Use read tools (Read, Grep, Glob, git blame) to inspect the cited code and its callers, guards, middleware, or framework defaults that might handle the concern elsewhere.
|
||||
</scope-context>
|
||||
|
||||
Your task is to answer three questions:
|
||||
|
||||
1. **Is the issue real in the code as written?** Read the cited file and surrounding code. If the code does not actually have the problem the finding describes, the finding is invalid. Common false-positive shapes:
|
||||
- The persona missed an existing guard / null check / validation that handles the case
|
||||
- The persona misread types or signatures
|
||||
- The persona flagged a pattern that is intentional in this codebase (check comments, parallel handlers, project conventions)
|
||||
|
||||
2. **Is the issue introduced by THIS diff?** Use git blame or diff inspection. If the cited line predates this PR's commits and the diff does not interact with it (does not call into it, does not change its callers in a way that newly exposes the issue), the finding is pre-existing — not validated for externalization regardless of whether it is a real issue.
|
||||
|
||||
3. **Is the issue not handled elsewhere?** Look for guards in callers, middleware in the request chain, framework defaults, type system constraints, or parallel handlers that already address the concern. If the issue is functionally prevented by surrounding infrastructure, the finding is invalid.
|
||||
|
||||
Return ONLY this JSON, no prose:
|
||||
|
||||
```json
|
||||
{
|
||||
"validated": true | false,
|
||||
"reason": "<one sentence explaining the verdict>"
|
||||
}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `{ "validated": true, "reason": "Cited line is new in this diff and lacks the ownership guard used by parallel controllers." }`
|
||||
- `{ "validated": false, "reason": "Line 87 already guards user.email with .present? check; the null deref the finding describes cannot occur." }`
|
||||
- `{ "validated": false, "reason": "Cited line dates to 2024-08 (pre-existing); diff does not modify or interact with it." }`
|
||||
- `{ "validated": false, "reason": "Framework handles the timeout case via Faraday default; no application-level retry needed." }`
|
||||
|
||||
Rules:
|
||||
- Be honest. If the original reviewer was right, validate. If they were wrong, reject. Conservative bias is preferred — when in doubt, reject.
|
||||
- Do not invent new findings. Your scope is this one finding; surface anything else as a no-vote with reason.
|
||||
- Do not edit, commit, push, or modify any files. You are operationally read-only.
|
||||
- If you cannot read the cited file, return `{ "validated": false, "reason": "Could not access file path to verify." }` rather than guessing.
|
||||
- Return JSON only. No prose, no markdown, no explanation outside the JSON object.
|
||||
```
|
||||
|
||||
## Variable Reference
|
||||
|
||||
| Variable | Source | Description |
|
||||
|----------|--------|-------------|
|
||||
| `{finding_title}` | Stage 5 merged finding | The persona's title for the issue |
|
||||
| `{finding_severity}` | Stage 5 merged finding | P0 / P1 / P2 / P3 |
|
||||
| `{finding_file}` | Stage 5 merged finding | Repo-relative file path |
|
||||
| `{finding_line}` | Stage 5 merged finding | Primary line number |
|
||||
| `{finding_why_it_matters}` | Per-agent artifact file (detail tier) | Loaded from disk for this validation; required for the validator to understand the finding |
|
||||
| `{finding_suggested_fix}` | Stage 5 merged finding (optional) | Pass empty string if not present |
|
||||
| `{finding_reviewer}` | Stage 5 merged finding | Original persona name (informational; helps validator interpret the framing) |
|
||||
| `{finding_confidence}` | Stage 5 merged finding | The persona's anchor (informational) |
|
||||
| `{diff}` | Stage 1 output | Full diff for context |
|
||||
@@ -0,0 +1,249 @@
|
||||
# Per-finding Walk-through
|
||||
|
||||
This reference defines Interactive mode's per-finding walk-through — the path the user enters by picking option A (`Review each finding one by one — accept the recommendation or choose another action`) from the routing question. It also covers the unified completion report that every terminal path (walk-through, best-judgment, File tickets, zero findings) emits.
|
||||
|
||||
Interactive mode only.
|
||||
|
||||
---
|
||||
|
||||
## Entry
|
||||
|
||||
The walk-through receives, from the orchestrator:
|
||||
|
||||
- The merged findings list in severity order (P0 → P1 → P2 → P3), filtered to `gated_auto` and `manual` findings that survived the Stage 5 anchor gate (anchor 75+, with P0 escape at anchor 50). Advisory findings are included when they were surfaced to this phase (advisory findings normally live in the report-only queue, but when the review flow routes them here for acknowledgment they take the advisory variant below).
|
||||
- The cached tracker-detection tuple from `tracker-defer.md` (`{ tracker_name, confidence, named_sink_available, any_sink_available }`). `any_sink_available` determines whether the Defer option is offered; `named_sink_available` + `confidence` determine whether the label names the tracker inline.
|
||||
- The run id for artifact lookups.
|
||||
|
||||
Each finding's recommended action has already been normalized by Stage 5 (step 7b — tie-break on action). The walk-through surfaces that recommendation to the user but does not recompute it.
|
||||
|
||||
---
|
||||
|
||||
## Per-finding presentation
|
||||
|
||||
Each finding is presented in two parts: a **terminal output block** carrying the explanation, and a **question** via the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension)) carrying the decision. Never merge the two — the terminal block uses markdown; the question uses plain text.
|
||||
|
||||
In Claude Code the tool should already be loaded from the Interactive-mode pre-load step in `SKILL.md` — if it isn't, call `ToolSearch` with query `select:AskUserQuestion` now. Fall back to presenting the per-finding options as a numbered list only when the harness genuinely lacks a blocking tool — `ToolSearch` returns no match, the tool call explicitly fails, or the runtime mode does not expose it (e.g., Codex edit modes without `request_user_input`). A pending schema load is not a fallback trigger. Never silently skip the question.
|
||||
|
||||
### Terminal output block (print before firing the question)
|
||||
|
||||
Render as markdown. Labels on their own line, blank lines between sections:
|
||||
|
||||
```
|
||||
## Finding {N} of {M} — {severity} {plain-English title}
|
||||
|
||||
{file}:{line}
|
||||
|
||||
**What's wrong**
|
||||
|
||||
{plain-English problem statement from why_it_matters}
|
||||
|
||||
**Proposed fix**
|
||||
|
||||
{suggested_fix — rendered per the substitution rules below: prose-first, intent-language}
|
||||
|
||||
**Why it works**
|
||||
|
||||
{short reasoning, grounded in a codebase pattern when available}
|
||||
|
||||
{R15 conflict context line, when applicable}
|
||||
```
|
||||
|
||||
Substitutions:
|
||||
|
||||
- **`{plain-English title}`:** a 3-8 word summary suitable as a heading. Derived from the merged finding's `title` field but rephrased so it reads as observable behavior (e.g., "Path traversal in loadUserFromCache" rather than "Missing userId validation on line 36").
|
||||
- **`why_it_matters`:** read the contributing reviewer's artifact file at `/tmp/compound-engineering/ce-code-review/{run_id}/{reviewer_name}.json` using the same `file + line_bucket(line, +/-3) + normalize(title)` matching that headless mode uses (see `SKILL.md` Stage 6 detail enrichment). When multiple reviewers flagged the merged finding, try them in the order they appear in the merged finding's reviewer list. Use the first match.
|
||||
- **`suggested_fix`:** from the merged finding's `suggested_fix` field. Render as prose describing **intent**, not as syntax. The fixer subagent owns the exact code — the walk-through just needs enough for the user to trust or reject the action. Rules:
|
||||
- **Default — one sentence describing the effect.** What does the fix achieve, and where does it live? Prefer intent language over quoted code.
|
||||
- ✅ `Throw on non-2xx response before parsing JSON.`
|
||||
- ✅ `` Replace `==` with `===` on line 42. ``
|
||||
- ✅ `` Add a `response.ok` check after the fetch and throw on non-2xx. ``
|
||||
- ✅ `Extract the request-building logic into a helper and call it from both sites.`
|
||||
- ❌ `` Add `if (!response.ok) throw new Error(`HTTP ${response.status}`);` after the `await fetch(...)` call, before `response.json()`. `` — nested backticks, multiple code spans, full statement quoted; renders broken in terminal.
|
||||
- **Code-span budget: at most 2 inline backtick spans per sentence, each a single identifier, operator, or short phrase** (e.g., `` `response.ok` ``, `` `===` ``, `` `fetchUserById` ``). Never embed full statements, template literals, or code requiring nested backticks. If the intent can't be stated within that budget, the prose is too close to syntax — restate at a higher level, or switch to summary + artifact pointer.
|
||||
- **Always leave a space before and after every backtick span.** Without it, the terminal's markdown renderer eats the delimiters and runs the words together.
|
||||
- **Raw code block — only for short (≤5 line) genuinely additive new code** where no before-state exists (new file, new function, new guard at the top of an empty body). Above 5 lines, switch to summary + pointer.
|
||||
- **Summary + artifact pointer** — when prose can't capture the fix: one-sentence transformation + key symbol/location + `Full fix: /tmp/compound-engineering/ce-code-review/{run_id}/{reviewer_name}.json → findings[].suggested_fix`.
|
||||
- **No diff blocks.** Modifications to existing code render as prose.
|
||||
- **`Why it works`:** grounded reasoning that, where possible, references a similar pattern already used elsewhere in the codebase (e.g., "matches the format-validation pattern already used at src/cli/io.ts:41"). One to three sentences.
|
||||
- **R15 conflict context line (when applicable):** when contributing reviewers implied different actions for this finding and Stage 5 step 7b broke the tie, surface that briefly. Example: `Correctness recommends Apply; Testing recommends Skip (low confidence). Agent's recommendation: Skip.` The orchestrator's recommendation — the post-tie-break value — is what the menu labels "recommended."
|
||||
|
||||
When no artifact match exists for the finding (merge-synthesized finding, or the persona's artifact write failed), the terminal block degrades to the heading + `suggested_fix` only (omit the `What's wrong` and `Why it works` sections) and records the gap for the Coverage section of the completion report.
|
||||
|
||||
### Question stem (short, decision-focused)
|
||||
|
||||
After the terminal block renders, fire the platform's blocking question tool with a compact two-line stem:
|
||||
|
||||
```
|
||||
Finding {N} of {M} — {severity} {short handle}.
|
||||
{Action framing in a phrase}?
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- **Short handle:** matches the `{plain-English title}` from the terminal block heading.
|
||||
- **Action framing:** one phrase describing what the *single recommended action* does, as a yes/no question. Examples: `Apply the format-validation + path.resolve guard?`, `Skip the fix since the fixture is being deleted?`, `Defer and file a rotation ticket?`.
|
||||
|
||||
Never enumerate alternatives in the stem. One recommendation as a yes/no — the option list carries the alternatives. When the recommendation is close, surface the disagreement in the R15 conflict context line, not as a multi-option stem.
|
||||
|
||||
Example (recommendation = Apply):
|
||||
|
||||
```
|
||||
Finding 3 of 8 — P1 path traversal in loadUserFromCache.
|
||||
Apply the format-validation + path.resolve guard?
|
||||
```
|
||||
|
||||
Example (recommendation = Skip because content context overrides default):
|
||||
|
||||
```
|
||||
Finding 1 of 9 — P0 hardcoded admin token.
|
||||
Skip the fix since the fixture is being deleted?
|
||||
(Security recommends Apply; file context recommends Skip. Agent's recommendation: Skip.)
|
||||
```
|
||||
|
||||
Never embed code blocks, diff syntax, or the full fix/reasoning in the stem.
|
||||
|
||||
### Confirmation between findings
|
||||
|
||||
After the user answers and before printing the next finding's terminal block, emit a one-line confirmation of the action taken. Examples: `→ Applied. Fix staged at src/utils/api-client.ts:36-37.`, `→ Deferred. Ticket filed: <url>.`, `→ Skipped.`, `→ Acknowledged.`
|
||||
|
||||
### Options (four, or adapted as noted)
|
||||
|
||||
Fixed order. Never reorder:
|
||||
|
||||
```
|
||||
1. Apply the proposed fix
|
||||
2. Defer — file a [TRACKER] ticket
|
||||
3. Skip — don't apply, don't track
|
||||
4. Auto-resolve with best judgment on the rest
|
||||
```
|
||||
|
||||
Render the `[TRACKER]` label per `tracker-defer.md`: when `confidence = high` AND `named_sink_available = true`, replace `[TRACKER]` with the concrete tracker name (e.g., `Defer — file a Linear ticket`). When `any_sink_available = true` but either `confidence = low` or `named_sink_available = false`, use the generic whole label `Defer — file a ticket` — whole-label substitution, not a `[TRACKER]` token swap.
|
||||
|
||||
**Mark the post-tie-break recommendation with `(recommended)` on its option label.** Required, not optional. Any of the four options can carry it:
|
||||
|
||||
```
|
||||
1. Apply the proposed fix (recommended)
|
||||
2. Defer — file a ticket
|
||||
3. Skip — don't apply, don't track
|
||||
4. Auto-resolve with best judgment on the rest
|
||||
```
|
||||
|
||||
```
|
||||
1. Apply the proposed fix
|
||||
2. Defer — file a ticket
|
||||
3. Skip — don't apply, don't track (recommended)
|
||||
4. Auto-resolve with best judgment on the rest
|
||||
```
|
||||
|
||||
When reviewers disagreed or content context cuts against the default, still mark one option — whichever Stage 5 step 7b produced — and surface the disagreement in the R15 conflict context line.
|
||||
|
||||
### Adaptations
|
||||
|
||||
- **No `suggested_fix` (Apply suppressed):** when the finding has no concrete `suggested_fix` (`gated_auto` or `manual` with `suggested_fix == null`), option A (`Apply`) is **omitted from the menu**. Stage 5 step 6b already maps these to a `Defer` recommendation, so the `(recommended)` marker lands on a still-visible option. The menu shows three options: `Defer` / `Skip` / `Auto-resolve with best judgment on the rest` (and reduces to `Skip` / `Auto-resolve with best judgment on the rest` when combined with the no-sink adaptation). When this combines with the advisory variant, the same suppression is moot because option A is already replaced with `Acknowledge`. This rule mirrors the suppression applied during `SKILL.md` Step 2 Interactive option B's post-run `Walk through these one at a time` re-entry, so the same handling applies regardless of which entry path the user came in through.
|
||||
- **Advisory-only finding:** when the finding's `autofix_class` is `advisory` (no actionable fix), option A is replaced with `Acknowledge — mark as reviewed`. The other three options remain. The advisory variant is the only case where `Acknowledge` appears in the menu.
|
||||
- **N=1 (exactly one pending finding):** the terminal block's heading omits `Finding N of M` and renders as `## {severity} {plain-English title}`. The stem's first line drops the position counter, becoming `{severity} {short handle}.` Option D (`Auto-resolve with best judgment on the rest`) is suppressed because no subsequent findings exist — the menu shows three options: Apply / Defer / Skip (or Acknowledge, for advisory).
|
||||
- **No sink (Defer option unavailable):** when the tracker-detection tuple reports `any_sink_available: false` (every tier in the fallback chain — named tracker and GitHub Issues via `gh` — is unreachable), option B (`Defer`) is omitted. The stem appends one line explaining that no issue tracker is configured for this checkout (Linear, GitHub Issues, etc., were probed and unavailable). Phrase it for a developer audience — avoid `tracker sink` jargon, and avoid `platform` since the missing piece is per-project, not per-agent-platform. The menu shows three options: Apply / Skip / Auto-resolve with best judgment on the rest (and Acknowledge in place of Apply for advisory-only findings). **Before rendering the options, remap any per-finding `Defer` recommendation produced by Stage 5 step 7b to `Skip`** so the `(recommended)` marker always lands on an option that is actually in the menu. When the remap fires, surface it on the R15 conflict context line — name what was downgraded and why (so the reader sees the cross-reviewer Defer recommendation hasn't silently disappeared). This is a render-time runtime step; Stage 5 step 7b has no knowledge of sink availability and only orders conflicting reviewer recommendations.
|
||||
- **Combined N=1 + no sink:** the menu shows two options: Apply / Skip (or Acknowledge / Skip).
|
||||
|
||||
Only when `ToolSearch` explicitly returns no match or the tool call errors — or on a platform with no blocking question tool — fall back to presenting the options as a numbered list and waiting for the user's next reply.
|
||||
|
||||
---
|
||||
|
||||
## Per-finding routing
|
||||
|
||||
For each finding's answer:
|
||||
|
||||
- **Apply the proposed fix** — add the finding's id to an in-memory Apply set. Advance to the next finding. Do not dispatch the fixer inline — Apply accumulates for end-of-walk-through batch dispatch.
|
||||
- **Acknowledge — mark as reviewed** (advisory variant) — record Acknowledge in the in-memory decision list. Advance to the next finding. No side effects.
|
||||
- **Defer — file a [TRACKER] ticket** — invoke the tracker-defer flow from `tracker-defer.md`. The walk-through's position indicator stays on the current finding during any failure-path sub-question (Retry / Fall back / Convert to Skip). On success, record the tracker URL / reference in the in-memory decision list and advance. On conversion-to-Skip from the failure path, advance with the failure noted in the completion report.
|
||||
- **Skip — don't apply, don't track** — record Skip in the in-memory decision list. Advance. No side effects.
|
||||
- **Auto-resolve with best judgment on the rest** — exit the walk-through loop and dispatch the fixer subagent (`SKILL.md` Step 3) immediately on the remaining action set: the current finding plus everything not yet decided. No Stage 5b pre-pass. No bulk-preview approval gate. The fixer applies items with concrete `suggested_fix`, no-ops on advisory items, and routes items where the fix cannot be applied cleanly (or where evidence no longer matches the code) to a `failed` bucket with a one-line reason. Apply findings the user already picked during the walk-through are dispatched in the same fixer pass — the remaining set joins the in-memory Apply set so the fixer receives the union and applies all changes against a consistent tree. After the fixer returns, follow the post-run failure-handling logic in `SKILL.md` Step 2 Interactive option B — when the `failed` bucket is non-empty, fire one question with three options (file tickets / walk through / ignore). When the `failed` bucket is empty, emit the unified completion report directly.
|
||||
|
||||
---
|
||||
|
||||
## Override rule
|
||||
|
||||
"Override" means the user picks a different preset action (Defer or Skip in place of Apply, or Apply in place of the agent's recommendation). No inline freeform custom-fix authoring — the walk-through is a decision loop, not a pair-programming surface. A user who wants a variant of the proposed fix picks Skip and hand-edits outside the flow; if they also want the finding tracked, they file a ticket manually. This trade is explicit in v1's scope boundaries.
|
||||
|
||||
---
|
||||
|
||||
## State
|
||||
|
||||
Walk-through state is **in-memory only**. The orchestrator maintains:
|
||||
|
||||
- An Apply set (finding ids the user picked Apply on)
|
||||
- A decision list (every answered finding with its action and any metadata like `tracker_url` for Deferred or `reason` for Skipped)
|
||||
- The current position in the findings list
|
||||
|
||||
Nothing is written to disk per-decision. An interrupted walk-through (user cancels the prompt, session compacts, network dies) discards all in-memory state. Defer actions that already executed remain in the tracker — those are external side effects and cannot be rolled back. Apply decisions have not been dispatched yet (they batch at end-of-walk-through), so they are cleanly lost with no code changes.
|
||||
|
||||
Formal cross-session resumption is out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
## End-of-walk-through dispatch
|
||||
|
||||
This section covers the run-to-completion path only — every finding has been answered Apply / Defer / Skip / Acknowledge and the loop ended naturally. The `Auto-resolve with best judgment on the rest` path exits the walk-through earlier and dispatches its own fixer pass on the union of (accumulated Apply set ∪ remaining undecided findings); see that bullet under "Per-finding routing" above. There is no second dispatch in that branch.
|
||||
|
||||
When the loop runs to completion, the walk-through hands off to the dispatch phase:
|
||||
|
||||
1. **Apply set:** spawn one fixer subagent for the full accumulated Apply set. The fixer receives the set as its input queue and applies all changes in one pass against the current working tree. This preserves the existing "one fixer, consistent tree" mechanic and gives the fixer the full set at once to handle inter-fix dependencies (two Applies touching overlapping regions). The existing Step 3 fixer prompt needs a small update to acknowledge this queue may be heterogeneous (`gated_auto` and `manual` mix, not just `safe_auto`) — authored alongside this reference.
|
||||
2. **Defer set:** already executed inline during the walk-through. Nothing to dispatch here.
|
||||
3. **Skip / Acknowledge:** no-op.
|
||||
|
||||
After dispatch completes, emit the unified completion report described below.
|
||||
|
||||
---
|
||||
|
||||
## Unified completion report
|
||||
|
||||
Every terminal path of Interactive mode emits the same completion report structure. This covers:
|
||||
|
||||
- Walk-through completed (all findings answered)
|
||||
- Walk-through bailed via `Auto-resolve with best judgment on the rest`
|
||||
- Top-level best-judgment (routing option B) completed
|
||||
- Top-level File tickets (routing option C) completed
|
||||
- Zero findings after `safe_auto` (routing question was skipped — the completion summary is a one-line degenerate case of this structure)
|
||||
|
||||
### Minimum required fields (per R12)
|
||||
|
||||
- **Per-finding entries:** for every finding the flow touched, a line with — at minimum — title, severity, the action taken (Applied / Deferred / Skipped / Acknowledged), the tracker URL or in-session task reference for Deferred entries, and a one-line reason for Skipped entries (grounded in the finding's confidence or the one-line `why_it_matters` snippet).
|
||||
- **Summary counts by action:** totals per bucket (e.g., `4 applied, 2 deferred, 2 skipped`).
|
||||
- **Failures called out explicitly:** any fix application that failed, any ticket creation that failed (with the reason returned by the tracker). Failures are surfaced above the per-finding list so they are not missed.
|
||||
- **End-of-review verdict:** the existing Stage 6 verdict (Ready to merge / Ready with fixes / Not ready), computed from the residual state after all actions complete.
|
||||
|
||||
### Coverage section
|
||||
|
||||
Carry forward the existing Coverage data (suppressed-finding count, residual risks, testing gaps, failed reviewers) and add one new element:
|
||||
|
||||
- **Framing-enrichment gaps:** count of findings where artifact lookup returned no match (merge-synthesized findings, or failed persona artifact writes). Name the personas contributing those gaps so the data feeds any future persona-upgrade decision. A trail of gaps per run tells the team which persona agents still need attention.
|
||||
|
||||
### Report ordering
|
||||
|
||||
The report appears after all execution completes. Ordering inside the report: failures first (above the per-finding list), then per-finding entries grouped by action bucket in the order `Applied / Deferred / Skipped / Acknowledged`, then summary counts, then Coverage, then the verdict.
|
||||
|
||||
### Zero-findings degenerate case
|
||||
|
||||
When the routing question was skipped because no `gated_auto` / `manual` findings remained after `safe_auto`, the completion report collapses to its summary-counts + verdict form with one added line — the count of `safe_auto` fixes applied. The summary wording mirrors `SKILL.md` Step 2 Interactive mode's zero-remaining case: the unqualified `All findings resolved` form is only accurate when no advisory or pre-existing findings remain. When advisory and/or pre-existing findings remain in the report, use the qualified form that names what was cleared and names what still remains. Examples:
|
||||
|
||||
No remaining advisory or pre-existing findings:
|
||||
|
||||
```
|
||||
All findings resolved — 3 safe_auto fixes applied.
|
||||
|
||||
Verdict: Ready with fixes.
|
||||
```
|
||||
|
||||
Advisory and/or pre-existing findings remain in the report:
|
||||
|
||||
```
|
||||
All actionable findings resolved — 3 safe_auto fixes applied. (2 advisory, 1 pre-existing findings remain in the report.)
|
||||
|
||||
Verdict: Ready with fixes.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution posture
|
||||
|
||||
The walk-through is operationally read-only except for two permitted writes: the in-memory Apply set / decision list (managed by the orchestrator) and the tracker-defer dispatch (external ticket creation, described in `tracker-defer.md`). Persona agents remain strictly read-only. The end-of-walk-through fixer dispatch is the single point where file modifications happen — governed by the existing Step 3 fixer contract in `SKILL.md`.
|
||||
@@ -0,0 +1,628 @@
|
||||
---
|
||||
name: ce-compound
|
||||
description: Document a recently solved problem to compound your team's knowledge or CONCEPTS.md, the project's shared domain vocabulary.
|
||||
argument-hint: "[optional: brief context] [mode:headless] "
|
||||
---
|
||||
|
||||
# /ce-compound
|
||||
|
||||
Coordinate multiple subagents working in parallel to document a recently solved problem.
|
||||
|
||||
## Purpose
|
||||
|
||||
Captures problem solutions while context is fresh, creating structured documentation in `docs/solutions/` with YAML frontmatter for searchability and future reference. Uses parallel subagents for maximum efficiency.
|
||||
|
||||
**Why "compound"?** Each documented solution compounds your team's knowledge. The first time you solve a problem takes research. Document it, and the next occurrence takes minutes. Knowledge compounds.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/ce-compound # Document the most recent fix
|
||||
/ce-compound [brief context] # Provide additional context hint
|
||||
/ce-compound mode:headless # Non-interactive run for automations
|
||||
/ce-compound mode:headless [context] # Non-interactive run with context hint
|
||||
```
|
||||
|
||||
## CONCEPTS.md bootstrap requests
|
||||
|
||||
If invoked specifically to create or bootstrap `CONCEPTS.md` from scratch rather than to document a solved problem, do not run the normal phases — `ce-compound` populates `CONCEPTS.md` only as a side effect of documenting a real learning (it seeds the *learning's area*, not the whole repo; see Phase 2.4). Repo-wide concept-map creation is `ce-compound-refresh`'s job. Redirect a standalone bootstrap request to `ce-compound-refresh` (which asks whether to build the concept map or run a refresh cycle), then exit.
|
||||
|
||||
## Mode Detection
|
||||
|
||||
Check `$ARGUMENTS` for a `mode:headless` token. Tokens starting with `mode:` are flags, not context — strip `mode:headless` from arguments before treating the remainder as the brief context hint.
|
||||
|
||||
| Mode | When | Behavior |
|
||||
|------|------|----------|
|
||||
| **Interactive** (default) | No mode token present | Ask Full vs Lightweight, ask about session history (Full only), prompt for Discoverability Check consent, end with "What's next?" |
|
||||
| **Headless** | `mode:headless` in arguments | No blocking questions. Run **Full mode without session history**. Apply the Discoverability Check edit silently if a gap exists. Skip Phase 3 specialized reviews. End with a structured terminal report — no "What's next?" menu. |
|
||||
|
||||
Headless mode is intended for automations and skill-to-skill invocation where no human is present to answer questions. The doc itself is identical to what an interactive Full run would produce — classification work (track, category, overlap) follows the same rules and writes nothing extra into the artifact. Once detected, headless mode applies for the entire run.
|
||||
|
||||
## Pre-resolved context
|
||||
|
||||
**Git branch (pre-resolved):** !`git rev-parse --abbrev-ref HEAD 2>/dev/null || true`
|
||||
|
||||
If the line above resolved to a plain branch name (like `feat/my-branch`), include it in the `ce-sessions` invocation payload in Phase 1 so the orchestrator does not waste a turn deriving it. If it still contains a backtick command string or is empty, omit it and let `ce-sessions` derive it at runtime.
|
||||
|
||||
## Support Files
|
||||
|
||||
These files are the durable contract for the workflow. Read them on-demand at the step that needs them — do not bulk-load at skill start.
|
||||
|
||||
- `references/schema.yaml` — canonical frontmatter fields and enum values (read when validating YAML)
|
||||
- `references/yaml-schema.md` — category mapping from problem_type to directory (read when classifying)
|
||||
- `references/concepts-vocabulary.md` — CONCEPTS.md format and inclusion rules (read in Phase 2.4 when domain terms surface)
|
||||
- `assets/resolution-template.md` — section structure for new docs (read when assembling)
|
||||
|
||||
When spawning subagents, pass the relevant file contents into the task prompt so they have the contract without needing cross-skill paths.
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
**In headless mode**, skip both questions below and go directly to **Full Mode** with session history disabled. Phase 1's session-history step (step 4) is omitted. Proceed straight to research.
|
||||
|
||||
**In interactive mode**, present the user with two options before proceeding, using the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to presenting options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
|
||||
|
||||
```
|
||||
1. Full (recommended) — the complete compound workflow. Researches,
|
||||
cross-references, and reviews your solution to produce documentation
|
||||
that compounds your team's knowledge.
|
||||
|
||||
2. Lightweight — same documentation, single pass. Faster and uses
|
||||
fewer tokens, but won't detect duplicates or cross-reference
|
||||
existing docs. Best for simple fixes or long sessions nearing
|
||||
context limits.
|
||||
```
|
||||
|
||||
In interactive mode, do NOT pre-select a mode, do NOT skip this prompt, and wait for the user's choice before proceeding. (Headless mode bypasses this prompt per the "**In headless mode**" rule above and runs Full directly — these "do not skip" directives do not apply to headless.)
|
||||
|
||||
**If the user chooses Full** (interactive mode only), ask one follow-up question before proceeding. Detect which harness is running (Claude Code, Codex, or Cursor) and ask:
|
||||
|
||||
```
|
||||
Would you also like to search your [harness name] session history
|
||||
for relevant knowledge to help the Compound process? This adds
|
||||
time and token usage.
|
||||
```
|
||||
|
||||
If the user says yes, invoke `ce-sessions` in Phase 1 (see step 4). If no, skip it. Do not ask this in lightweight mode or headless mode.
|
||||
|
||||
---
|
||||
|
||||
### Full Mode
|
||||
|
||||
<critical_requirement>
|
||||
**The primary deliverable is ONE file - the final documentation.**
|
||||
|
||||
Phase 1 subagents return TEXT DATA to the orchestrator. They must NOT use Write, Edit, or create any files. Only the orchestrator writes files. Beyond the Phase 2 solution doc, its other writes are maintenance side effects — not additional deliverables, and creating one when absent is expected, not a violation of this rule:
|
||||
- **`CONCEPTS.md`** — create or update in Phase 2.4 (Vocabulary Capture) when a qualifying domain term surfaces.
|
||||
- **A project instruction file** (AGENTS.md or CLAUDE.md) — a small edit when the Discoverability Check finds a gap.
|
||||
|
||||
Both ensure future agents can discover and ground in the knowledge store; neither makes the documentation any less the single deliverable.
|
||||
</critical_requirement>
|
||||
|
||||
### Phase 0.5: Auto Memory Scan
|
||||
|
||||
Before launching Phase 1 subagents, check the auto-memory block injected into your system prompt for notes relevant to the problem being documented.
|
||||
|
||||
1. Look for a block labeled "user's auto-memory" (Claude Code only) already present in your system prompt context — MEMORY.md's entries are inlined there
|
||||
2. If the block is absent, empty, or this is a non-Claude-Code platform, skip this step and proceed to Phase 1 unchanged
|
||||
3. Scan the entries for anything related to the problem being documented -- use semantic judgment, not keyword matching
|
||||
4. If relevant entries are found, prepare a labeled excerpt block:
|
||||
|
||||
```
|
||||
## Supplementary notes from auto memory
|
||||
Treat as additional context, not primary evidence. Conversation history
|
||||
and codebase findings take priority over these notes.
|
||||
|
||||
[relevant entries here]
|
||||
```
|
||||
|
||||
5. Pass this block as additional context to the Context Analyzer and Solution Extractor task prompts in Phase 1. If any memory notes end up in the final documentation (e.g., as part of the investigation steps or root cause analysis), tag them with "(auto memory [claude])" so their origin is clear to future readers.
|
||||
|
||||
If no relevant entries are found, proceed to Phase 1 without passing memory context.
|
||||
|
||||
### Phase 1: Research
|
||||
|
||||
Launch research subagents. Each returns text data to the orchestrator.
|
||||
|
||||
**Dispatch order:**
|
||||
- Launch `Context Analyzer`, `Solution Extractor`, and `Related Docs Finder` in parallel (background)
|
||||
- **Then** invoke the `ce-sessions` skill via the platform's skill-invocation primitive (see step 4 below) — only if the user opted in to session history. The skill call is synchronous from this orchestrator's main-context turn, but the already-dispatched background subagents continue running in parallel underneath, so the wall-clock benefit is preserved (`max(ce-sessions, slowest background subagent)`, not their sum). Issuing the skill call before the parallel block would serialize ce-sessions in front of the research subagents and regress wall-clock time.
|
||||
|
||||
<parallel_tasks>
|
||||
|
||||
#### 1. **Context Analyzer**
|
||||
- Extracts conversation history
|
||||
- Reads `references/schema.yaml` for enum validation and **track classification**
|
||||
- Determines the track (bug or knowledge) from the problem_type
|
||||
- Identifies problem type, component, and track-appropriate fields:
|
||||
- **Bug track**: symptoms, root_cause, resolution_type
|
||||
- **Knowledge track**: applies_when (symptoms/root_cause/resolution_type optional)
|
||||
- Incorporates auto memory excerpts (if provided by the orchestrator) as supplementary evidence
|
||||
- Reads `references/yaml-schema.md` for category mapping into `docs/solutions/`
|
||||
- Suggests a filename using the pattern `[sanitized-problem-slug].md` — no date suffix, even if existing files in the target directory have one; the `date:` frontmatter field is the canonical creation date
|
||||
- Returns: YAML frontmatter skeleton (must include `category:` field mapped from problem_type), category directory path, suggested filename, and which track applies
|
||||
- Does not invent enum values, categories, or frontmatter fields from memory; reads the schema and mapping files above
|
||||
- Does not force bug-track fields onto knowledge-track learnings or vice versa
|
||||
|
||||
#### 2. **Solution Extractor**
|
||||
- Reads `references/schema.yaml` for track classification (bug vs knowledge)
|
||||
- Adapts output structure based on the problem_type track
|
||||
- Incorporates auto memory excerpts (if provided by the orchestrator) as supplementary evidence -- conversation history and the verified fix take priority; if memory notes contradict the conversation, note the contradiction as cautionary context
|
||||
|
||||
**Bug track output sections:**
|
||||
|
||||
- **Problem**: 1-2 sentence description of the issue
|
||||
- **Symptoms**: Observable symptoms (error messages, behavior)
|
||||
- **What Didn't Work**: Failed investigation attempts and why they failed
|
||||
- **Solution**: The actual fix with code examples (before/after when applicable)
|
||||
- **Why This Works**: Root cause explanation and why the solution addresses it
|
||||
- **Prevention**: Strategies to avoid recurrence, best practices, and test cases. Include concrete code examples where applicable (e.g., gem configurations, test assertions, linting rules)
|
||||
|
||||
**Knowledge track output sections:**
|
||||
|
||||
- **Context**: What situation, gap, or friction prompted this guidance
|
||||
- **Guidance**: The practice, pattern, or recommendation with code examples when useful
|
||||
- **Why This Matters**: Rationale and impact of following or not following this guidance
|
||||
- **When to Apply**: Conditions or situations where this applies
|
||||
- **Examples**: Concrete before/after or usage examples showing the practice in action
|
||||
|
||||
#### 3. **Related Docs Finder**
|
||||
- Searches `docs/solutions/` for related documentation
|
||||
- Identifies cross-references and links
|
||||
- Finds related GitHub issues
|
||||
- Flags any related learning or pattern docs that may now be stale, contradicted, or overly broad
|
||||
- **Assesses overlap** with the new doc being created across five dimensions: problem statement, root cause, solution approach, referenced files, and prevention rules. Score as:
|
||||
- **High**: 4-5 dimensions match — essentially the same problem solved again
|
||||
- **Moderate**: 2-3 dimensions match — same area but different angle or solution
|
||||
- **Low**: 0-1 dimensions match — related but distinct
|
||||
- Returns: Links, relationships, refresh candidates, and overlap assessment (score + which dimensions matched)
|
||||
|
||||
**Search strategy (grep-first filtering for efficiency):**
|
||||
|
||||
1. Extract keywords from the problem context: module names, technical terms, error messages, component types
|
||||
2. If the problem category is clear, narrow search to the matching `docs/solutions/<category>/` directory
|
||||
3. Use the native content-search tool (e.g., Grep in Claude Code) to pre-filter candidate files BEFORE reading any content. Run multiple searches in parallel, case-insensitive, targeting frontmatter fields. These are template patterns -- substitute actual keywords:
|
||||
- `title:.*<keyword>`
|
||||
- `tags:.*(<keyword1>|<keyword2>)`
|
||||
- `module:.*<module name>`
|
||||
- `component:.*<component>`
|
||||
4. If search returns >25 candidates, re-run with more specific patterns. If <3, broaden to full content search
|
||||
5. Read only frontmatter (first 30 lines) of candidate files to score relevance
|
||||
6. Fully read only strong/moderate matches
|
||||
7. Return distilled links and relationships, not raw file contents
|
||||
|
||||
**GitHub issue search:**
|
||||
|
||||
Prefer the `gh` CLI for searching related issues: `gh issue list --search "<keywords>" --state all --limit 5`. If `gh` is not installed, fall back to the GitHub MCP tools (e.g., `unblocked` data_retrieval) if available. If neither is available, skip GitHub issue search and note it was skipped in the output.
|
||||
|
||||
</parallel_tasks>
|
||||
|
||||
#### 4. **Session History via `ce-sessions`** (synchronous skill call, after launching the parallel block — only if the user opted in)
|
||||
- **Skip entirely** if the user declined session history in the follow-up question, if running in lightweight mode, or if running in headless mode.
|
||||
- Invoke the `ce-sessions` skill via the platform's skill-invocation primitive (`Skill` in Claude Code, `Skill` in Codex, the equivalent on Gemini/Pi). Pass the dispatch payload below as the skill argument string. `ce-sessions` runs in main context — it owns discovery, branch/keyword filtering, scan-window selection, the deep-dive cap, per-session extraction to a `mktemp` scratch dir, and dispatch of the synthesis-only `ce-session-historian` subagent. The compound orchestrator only needs to pass the topic and time window and read back the findings text.
|
||||
|
||||
**Dispatch payload — keep tight.** A long, keyword-rich payload licenses ce-sessions to keep widening. Use this shape:
|
||||
|
||||
- **Pre-resolved context** (only if values resolved cleanly above; otherwise omit): repo name, current git branch.
|
||||
- **Time window**: explicit `7 days` unless the documented problem clearly spans a longer arc.
|
||||
- **Problem topic**: one sentence naming the concrete issue — error message, module name, what broke and how it was fixed. Not a paragraph; not a bullet list of related topics.
|
||||
- **Filter rule (one line)**: "Only surface findings directly relevant to this specific problem. Ignore unrelated work from the same sessions or branches."
|
||||
- **Output schema**:
|
||||
|
||||
```
|
||||
Structure your response with these sections (omit any with no findings):
|
||||
- What was tried before
|
||||
- What didn't work
|
||||
- Key decisions
|
||||
- Related context
|
||||
```
|
||||
|
||||
Do not append additional context blocks, exclusion lists, or topic-keyword bullets — verbose payloads give ce-sessions license to keep widening the search and rapidly compound wall time. If keyword search is needed, ce-sessions owns that decision internally based on the topic.
|
||||
- Returns: structured digest of findings from prior sessions, or "no relevant prior sessions" if none found.
|
||||
- **ce-sessions is the final Phase 1 input, not a workflow stop.** When it returns, proceed directly to Phase 2 with its output as the last input — do not emit a summary and do not pause for the user. A "no relevant prior sessions" return is still a valid input; the documentation gets written without session context.
|
||||
|
||||
### Phase 2: Assembly & Write
|
||||
|
||||
<sequential_tasks>
|
||||
|
||||
**WAIT for all Phase 1 inputs to complete before proceeding** — the three parallel subagents and, when the user opted in, the synchronous `ce-sessions` skill call. ce-sessions is a Phase 1 input even though it is a skill rather than a subagent.
|
||||
|
||||
The orchestrating agent (main conversation) performs these steps:
|
||||
|
||||
1. Collect all text results from Phase 1 subagents
|
||||
2. **Check the overlap assessment** from the Related Docs Finder before deciding what to write:
|
||||
|
||||
| Overlap | Action |
|
||||
|---------|--------|
|
||||
| **High** — existing doc covers the same problem, root cause, and solution | **Update the existing doc** with fresher context (new code examples, updated references, additional prevention tips) rather than creating a duplicate. The existing doc's path and structure stay the same. |
|
||||
| **Moderate** — same problem area but different angle, root cause, or solution | **Create the new doc** normally. Flag the overlap for Phase 2.5 to recommend consolidation review. |
|
||||
| **Low or none** | **Create the new doc** normally. |
|
||||
|
||||
The reason to update rather than create: two docs describing the same problem and solution will inevitably drift apart. The newer context is fresher and more trustworthy, so fold it into the existing doc rather than creating a second one that immediately needs consolidation.
|
||||
|
||||
When updating an existing doc, preserve its file path and frontmatter structure. Update the solution, code examples, prevention tips, and any stale references. Add a `last_updated: YYYY-MM-DD` field to the frontmatter. Do not change the title unless the problem framing has materially shifted.
|
||||
|
||||
3. **Incorporate session history findings** (if available). When `ce-sessions` returned relevant prior-session context:
|
||||
- Fold investigation dead ends and failed approaches into the **What Didn't Work** section (bug track) or **Context** section (knowledge track)
|
||||
- Use cross-session patterns to enrich the **Prevention** or **Why This Matters** sections
|
||||
- Tag session-sourced content with "(session history)" so its origin is clear to future readers
|
||||
- If findings are thin or "no relevant prior sessions," proceed without session context
|
||||
4. Assemble complete markdown file from the collected pieces, reading `assets/resolution-template.md` for the section structure of new docs
|
||||
5. Validate YAML frontmatter against `references/schema.yaml`, including the YAML-safety quoting rule for array items (see `references/yaml-schema.md` > YAML Safety Rules)
|
||||
6. Create directory if needed: `mkdir -p docs/solutions/[category]/`
|
||||
7. Write the file: either the updated existing doc or the new `docs/solutions/[category]/[filename].md`
|
||||
8. **Run `python3 scripts/validate-frontmatter.py <output-path>`** to catch silent-corruption parser-safety issues that the prose rules miss: malformed `---` delimiter lines, unquoted ` #` in scalar values (silent comment truncation), and unquoted `: ` in scalar values (silent mapping confusion). Exit 0 means the doc is parser-safe; exit 1 means the script's stderr names the offending field(s) and what to fix — quote the value(s), re-write the doc, and re-run until exit 0. Do not declare success while validation fails. The script does not enforce schema rules and does not flag YAML reserved-indicator characters (those produce loud parser errors downstream rather than silent corruption — out of scope). Uses Python 3 stdlib only (no PyYAML or other deps).
|
||||
|
||||
When creating a new doc, preserve the section order from `assets/resolution-template.md` unless the user explicitly asks for a different structure.
|
||||
|
||||
</sequential_tasks>
|
||||
|
||||
### Phase 2.4: Vocabulary Capture
|
||||
|
||||
**First, read `references/concepts-vocabulary.md`.** This is unconditional. Do not pre-judge from memory that nothing qualifies — the reference's criteria are non-obvious and qualifying terms often live in the surrounding conversation rather than the new doc itself. Reading the reference is what makes the rest of the phase possible.
|
||||
|
||||
Then, applying those criteria, scan the new doc **and** the surrounding conversation for qualifying domain terms. If `CONCEPTS.md` exists at repo root, add missing qualifying terms and refine existing entries when new precision surfaced. If it does not exist and at least one qualifying term surfaced, create it.
|
||||
|
||||
**Seed the learning's area at creation — don't write a lone term.** When `CONCEPTS.md` does not yet exist, alongside the surfaced term also seed the core domain nouns of the area this learning touched, following the **Seed goal** and **Scope of a seed** rules in `references/concepts-vocabulary.md`. The seed is scoped to the learning's area (the modules and domain the fix touched) and defines only terms investigated here — it does not reach for repo-wide nouns. This anchors the surfaced term so it does not dangle against undefined siblings. A repo-wide concept map is `ce-compound-refresh`'s bootstrap path, not this one.
|
||||
|
||||
**At creation, hold the qualifying bar conservatively for borderline terms.** A borderline term, or a class/table/file name dressed up as an entity, defers to a later run — clear core nouns are seeded, borderline ones wait. The conservatism is about quality, not count; updates to an existing file follow the normal criteria.
|
||||
|
||||
**When bootstrapping the file, start with this preamble under the `# Concepts` heading**, then add the qualifying entries below it:
|
||||
|
||||
> Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.
|
||||
|
||||
**Refresh the coherence neighborhood of any entry you touch.** When adding or editing an entry, also inspect its *coherence neighborhood* — its cluster siblings and the terms it cross-references or that reference it. Within that neighborhood, do two things: fix glossary violations (implementation specifics — file paths, class names, function signatures, current-config values), and refresh entries the learning's own evidence shows have drifted. Bounds: neighborhood only, never a full-file audit; refresh only on evidence already in hand; if judging a neighbor would require investigation this learning did not do, flag it for `ce-compound-refresh` rather than editing on a guess. The test: after the edit, would a reader find the touched entry's siblings or referenced terms inconsistent with it? Broader audit is `ce-compound-refresh`'s job.
|
||||
|
||||
If no terms qualified after applying the reference's criteria, record that outcome explicitly in the success output (e.g., "Vocabulary capture: scanned, no qualifying terms"). Do not silently skip — the visible scan-and-no-result record is the audit signal that the reference was consulted.
|
||||
|
||||
**Apply edits silently in every mode — no user prompt in interactive, lightweight, or headless.** Vocabulary capture is a side effect of compounding, not a decision the user makes per run. Lightweight mode reaches this through its own single-pass step (see Lightweight Mode), and runs an **update-only** version — it refines an existing `CONCEPTS.md` but defers creation/seeding to a Full run.
|
||||
|
||||
### Phase 2.5: Selective Refresh Check
|
||||
|
||||
After writing the new learning, decide whether this new solution is evidence that older docs should be refreshed.
|
||||
|
||||
`ce-compound-refresh` is **not** a default follow-up. Use it selectively when the new learning suggests an older learning or pattern doc may now be inaccurate.
|
||||
|
||||
It makes sense to invoke `ce-compound-refresh` when one or more of these are true:
|
||||
|
||||
1. A related learning or pattern doc recommends an approach that the new fix now contradicts
|
||||
2. The new fix clearly supersedes an older documented solution
|
||||
3. The current work involved a refactor, migration, rename, or dependency upgrade that likely invalidated references in older docs
|
||||
4. A pattern doc now looks overly broad, outdated, or no longer supported by the refreshed reality
|
||||
5. The Related Docs Finder surfaced high-confidence refresh candidates in the same problem space
|
||||
6. The Related Docs Finder reported **moderate overlap** with an existing doc — there may be consolidation opportunities that benefit from a focused review
|
||||
|
||||
It does **not** make sense to invoke `ce-compound-refresh` when:
|
||||
|
||||
1. No related docs were found
|
||||
2. Related docs still appear consistent with the new learning
|
||||
3. The overlap is superficial and does not change prior guidance
|
||||
4. Refresh would require a broad historical review with weak evidence
|
||||
|
||||
Use these rules:
|
||||
|
||||
- If there is **one obvious stale candidate**, invoke `ce-compound-refresh` with a narrow scope hint after the new learning is written
|
||||
- If there are **multiple candidates in the same area**, ask the user whether to run a targeted refresh for that module, category, or pattern set
|
||||
- If context is already tight or you are in lightweight mode, do not expand into a broad refresh automatically; instead recommend `ce-compound-refresh` as the next step with a scope hint
|
||||
- **In headless mode**, never invoke `ce-compound-refresh` and never ask the user. Surface the recommended scope hint in the terminal report's "Refresh recommendation" line and let the caller decide
|
||||
|
||||
When invoking or recommending `ce-compound-refresh`, be explicit about the argument to pass. Prefer the narrowest useful scope:
|
||||
|
||||
- **Specific file** when one learning or pattern doc is the likely stale artifact
|
||||
- **Module or component name** when several related docs may need review
|
||||
- **Category name** when the drift is concentrated in one solutions area
|
||||
- **Pattern filename or pattern topic** when the stale guidance lives in `docs/solutions/patterns/`
|
||||
|
||||
Examples:
|
||||
|
||||
- `/ce-compound-refresh plugin-versioning-requirements`
|
||||
- `/ce-compound-refresh payments`
|
||||
- `/ce-compound-refresh performance-issues`
|
||||
- `/ce-compound-refresh critical-patterns`
|
||||
|
||||
A single scope hint may still expand to multiple related docs when the change is cross-cutting within one domain, category, or pattern area.
|
||||
|
||||
Do not invoke `ce-compound-refresh` without an argument unless the user explicitly wants a broad sweep.
|
||||
|
||||
Always capture the new learning first. Refresh is a targeted maintenance follow-up, not a prerequisite for documentation.
|
||||
|
||||
### Discoverability Check
|
||||
|
||||
After the learning is written and the refresh decision is made, check whether the project's instruction files would lead an agent to discover and search `docs/solutions/` before starting work in a documented area. This runs every time — the knowledge store only compounds value when agents can find it.
|
||||
|
||||
1. Identify which root-level instruction files exist (AGENTS.md, CLAUDE.md, or both). Read the file(s) and determine which holds the substantive content — one file may just be a shim that `@`-includes the other (e.g., `CLAUDE.md` containing only `@AGENTS.md`, or vice versa). The substantive file is the assessment and edit target; ignore shims. If neither file exists, skip this check entirely.
|
||||
2. Assess whether an agent reading the instruction files would learn three things:
|
||||
- That a searchable knowledge store of documented solutions exists
|
||||
- Enough about its structure to search effectively (category organization, YAML frontmatter fields like `module`, `tags`, `problem_type`)
|
||||
- When to search it (before implementing features, debugging issues, or making decisions in documented areas — learnings may cover bugs, best practices, workflow patterns, or other institutional knowledge)
|
||||
|
||||
This is a semantic assessment, not a string match. The information could be a line in an architecture section, a bullet in a gotchas section, spread across multiple places, or expressed without ever using the exact path `docs/solutions/`. Use judgment — if an agent would reasonably discover and use the knowledge store after reading the file, the check passes.
|
||||
|
||||
3. If the spirit is already met, no action needed — move on.
|
||||
4. If not:
|
||||
a. Based on the file's existing structure, tone, and density, identify where a mention fits naturally. Before creating a new section, check whether the information could be a single line in the closest related section — an architecture tree, a directory listing, a documentation section, or a conventions block. A line added to an existing section is almost always better than a new headed section. Only add a new section as a last resort when the file has clear sectioned structure and nothing is even remotely related.
|
||||
b. Draft the smallest addition that communicates the three things. Match the file's existing style and density. The addition should describe the knowledge store itself, not the plugin — an agent without the plugin should still find value in it.
|
||||
|
||||
Keep the tone informational, not imperative. Express timing as description, not instruction — "relevant when implementing or debugging in documented areas" rather than "check before implementing or debugging." Imperative directives like "always search before implementing" cause redundant reads when a workflow already includes a dedicated search step. The goal is awareness: agents learn the folder exists and what's in it, then use their own judgment about when to consult it.
|
||||
|
||||
Examples of calibration (not templates — adapt to the file):
|
||||
|
||||
When there's an existing directory listing or architecture section — add a line:
|
||||
```
|
||||
docs/solutions/ # documented solutions to past problems (bugs, best practices, workflow patterns), organized by category with YAML frontmatter (module, tags, problem_type)
|
||||
```
|
||||
|
||||
When nothing in the file is a natural fit — a small headed section is appropriate:
|
||||
```
|
||||
## Documented Solutions
|
||||
|
||||
`docs/solutions/` — documented solutions to past problems (bugs, best practices, workflow patterns), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas.
|
||||
```
|
||||
c. In full interactive mode, explain to the user why this matters — agents working in this repo (including fresh sessions, other tools, or collaborators without the plugin) won't know to check `docs/solutions/` unless the instruction file surfaces it. Show the proposed change and where it would go, then use the platform's blocking question tool to get consent before making the edit: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to presenting the proposal in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question. In lightweight mode, output a one-liner note and move on. In headless mode, apply the edit directly without prompting and surface it in the terminal report under "Instruction-file edit"
|
||||
|
||||
5. **If `CONCEPTS.md` exists at repo root, run a parallel discoverability check for it.** Assess whether the instruction file would lead an agent to discover the project's shared domain vocabulary. Use the same workflow as the `docs/solutions/` check above: same target file, same edit-placement judgment, same consent-then-edit interaction shape per mode. A line in an existing section is almost always better than a new headed section. Example calibration when nothing else fits:
|
||||
|
||||
```
|
||||
CONCEPTS.md # shared domain vocabulary (entities, named processes, status concepts) — relevant when orienting to the codebase or discussing domain concepts
|
||||
```
|
||||
|
||||
**Skip this step entirely if `CONCEPTS.md` does not exist** — never nag for an artifact the project has not adopted. When skipped, this step produces no output and no edit.
|
||||
|
||||
### Phase 3: Optional Enhancement
|
||||
|
||||
**WAIT for Phase 2 to complete before proceeding.**
|
||||
|
||||
**Skip Phase 3 entirely in headless mode** to bound token usage — the caller does not have a human-in-the-loop to act on reviewer findings, and downstream automations can run specialized reviewers themselves if they want that pass.
|
||||
|
||||
<parallel_tasks>
|
||||
|
||||
Based on problem type, optionally invoke specialized agents to review the documentation:
|
||||
|
||||
- **performance_issue** → `ce-performance-oracle`
|
||||
- **security_issue** → `ce-security-sentinel`
|
||||
- **database_issue** → `ce-data-integrity-guardian`
|
||||
- Any code-heavy issue → always run `ce-code-simplicity-reviewer` for minimal, clear examples. Structural concerns in the diff are already covered when the same work goes through `/ce-code-review` (maintainability persona).
|
||||
|
||||
</parallel_tasks>
|
||||
|
||||
---
|
||||
|
||||
### Lightweight Mode
|
||||
|
||||
<critical_requirement>
|
||||
**Single-pass alternative — same documentation, fewer tokens.**
|
||||
|
||||
This mode skips parallel subagents entirely. The orchestrator performs all work in a single pass, producing the same solution document without cross-referencing or duplicate detection.
|
||||
|
||||
Headless mode forces Full and does not enter Lightweight — automations get the cross-reference and overlap detection benefits without the interactive overhead.
|
||||
</critical_requirement>
|
||||
|
||||
The orchestrator (main conversation) performs ALL of the following in one sequential pass:
|
||||
|
||||
1. **Extract from conversation**: Identify the problem and solution from conversation history. Also scan the "user's auto-memory" block injected into your system prompt, if present (Claude Code only) -- use any relevant notes as supplementary context alongside conversation history. Tag any memory-sourced content incorporated into the final doc with "(auto memory [claude])"
|
||||
2. **Classify**: Read `references/schema.yaml` and `references/yaml-schema.md`, then determine track (bug vs knowledge), category, and filename
|
||||
3. **Write minimal doc**: Create `docs/solutions/[category]/[filename].md` using the appropriate track template from `assets/resolution-template.md`, with:
|
||||
- YAML frontmatter with track-appropriate fields, applying the YAML-safety quoting rule for array items (see `references/yaml-schema.md` > YAML Safety Rules)
|
||||
- Bug track: Problem, root cause, solution with key code snippets, one prevention tip
|
||||
- Knowledge track: Context, guidance with key examples, one applicability note
|
||||
4. **Vocabulary capture (update-only)**: if `CONCEPTS.md` exists at repo root, read `references/concepts-vocabulary.md`, then scan the new doc and the conversation for qualifying terms and add/refine entries silently (same criteria as Phase 2.4). Do **not** bootstrap or seed in lightweight mode — if `CONCEPTS.md` does not exist, defer creation to a Full run, which owns seeding. Record the outcome in the output (e.g., "Vocabulary: 1 entry refined" or "scanned, no qualifying terms"). If you refined `CONCEPTS.md` and a quick read of `AGENTS.md`/`CLAUDE.md` shows it isn't surfaced there, add the discoverability tip to the output below — lightweight **tips**, it does not edit instruction files (a Full run owns that edit).
|
||||
5. **Skip specialized agent reviews** (Phase 3) to conserve context
|
||||
|
||||
**Lightweight output:**
|
||||
```
|
||||
✓ Documentation complete (lightweight mode)
|
||||
|
||||
File created:
|
||||
- docs/solutions/[category]/[filename].md
|
||||
|
||||
[If discoverability check found instruction files don't surface the knowledge store:]
|
||||
Tip: Your AGENTS.md/CLAUDE.md doesn't surface docs/solutions/ to agents —
|
||||
a brief mention helps all agents discover these learnings.
|
||||
|
||||
[If CONCEPTS.md was refined this run and isn't surfaced in the instruction files:]
|
||||
Tip: Your AGENTS.md/CLAUDE.md doesn't surface CONCEPTS.md —
|
||||
a one-line mention helps agents find the shared vocabulary.
|
||||
|
||||
Note: This was created in lightweight mode. For richer documentation
|
||||
(cross-references, detailed prevention strategies, specialized reviews),
|
||||
re-run /ce-compound in a fresh session.
|
||||
```
|
||||
|
||||
**No subagents are launched. No parallel tasks. The solution doc is the one deliverable** (Phase 2.4's update-only vocabulary capture may also refine an existing `CONCEPTS.md`).
|
||||
|
||||
In lightweight mode, the overlap check is skipped (no Related Docs Finder subagent). This means lightweight mode may create a doc that overlaps with an existing one. That is acceptable — `ce-compound-refresh` will catch it later. Only suggest `ce-compound-refresh` if there is an obvious narrow refresh target. Do not broaden into a large refresh sweep from a lightweight session.
|
||||
|
||||
---
|
||||
|
||||
## What It Captures
|
||||
|
||||
- **Problem symptom**: Exact error messages, observable behavior
|
||||
- **Investigation steps tried**: What didn't work and why
|
||||
- **Root cause analysis**: Technical explanation
|
||||
- **Working solution**: Step-by-step fix with code examples
|
||||
- **Prevention strategies**: How to avoid in future
|
||||
- **Cross-references**: Links to related issues and docs
|
||||
|
||||
## Preconditions
|
||||
|
||||
<preconditions enforcement="advisory">
|
||||
<check condition="problem_solved">
|
||||
Problem has been solved (not in-progress)
|
||||
</check>
|
||||
<check condition="solution_verified">
|
||||
Solution has been verified working
|
||||
</check>
|
||||
<check condition="non_trivial">
|
||||
Non-trivial problem (not simple typo or obvious error)
|
||||
</check>
|
||||
</preconditions>
|
||||
|
||||
## What It Creates
|
||||
|
||||
**Organized documentation:**
|
||||
|
||||
- File: `docs/solutions/[category]/[filename].md`
|
||||
|
||||
**Categories auto-detected from problem:**
|
||||
|
||||
Bug track:
|
||||
- build-errors/
|
||||
- test-failures/
|
||||
- runtime-errors/
|
||||
- performance-issues/
|
||||
- database-issues/
|
||||
- security-issues/
|
||||
- ui-bugs/
|
||||
- integration-issues/
|
||||
- logic-errors/
|
||||
|
||||
Knowledge track:
|
||||
- architecture-patterns/ — architectural or structural patterns (agent/skill/pipeline/workflow shape decisions)
|
||||
- design-patterns/ — reusable non-architectural design approaches (content generation, interaction patterns, prompt shapes)
|
||||
- tooling-decisions/ — language, library, or tool choices with durable rationale
|
||||
- conventions/ — team-agreed way of doing something, captured so it survives turnover
|
||||
- workflow-issues/
|
||||
- developer-experience/
|
||||
- documentation-gaps/
|
||||
- best-practices/ — fallback only, use when no narrower knowledge-track value applies
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
| ❌ Wrong | ✅ Correct |
|
||||
|----------|-----------|
|
||||
| Subagents write files like `context-analysis.md`, `solution-draft.md` | Subagents return text data; orchestrator writes one final file |
|
||||
| Research and assembly run in parallel | Research completes → then assembly runs |
|
||||
| Multiple files created during workflow | One solution doc written or updated: `docs/solutions/[category]/[filename].md` (plus optional maintenance writes: a `CONCEPTS.md` create/update from Phase 2.4 and a small instruction-file edit for discoverability) |
|
||||
| Creating a new doc when an existing doc covers the same problem | Check overlap assessment; update the existing doc when overlap is high |
|
||||
|
||||
## Success Output
|
||||
|
||||
### Headless mode
|
||||
|
||||
Emit a structured terminal report and end the turn. No "What's next?" question, no blocking prompt. End with `Documentation complete` as the terminal signal so callers can detect completion.
|
||||
|
||||
```
|
||||
✓ Documentation complete (headless mode)
|
||||
|
||||
File: docs/solutions/<category>/<filename>.md (created | updated)
|
||||
Track: <bug | knowledge>
|
||||
Category: <category>
|
||||
Overlap: <none | low | moderate — see <path> | high — existing doc updated>
|
||||
Instruction-file edit: <none needed | applied to <path> | gap noted, not applied>
|
||||
CONCEPTS.md: <scanned, no qualifying terms | created with N entries (M seeded from the learning's area) | updated — N added, N refined>
|
||||
Refresh recommendation: <none | scope hint for /ce-compound-refresh>
|
||||
|
||||
Documentation complete
|
||||
```
|
||||
|
||||
When no doc was written (e.g., headless invoked on a session where the problem is not yet solved), emit a structured failure instead and end with `Documentation skipped` so callers can distinguish success from no-op:
|
||||
|
||||
```
|
||||
✗ Documentation skipped (headless mode)
|
||||
|
||||
Reason: <one-sentence explanation — e.g., "no solved problem detected in
|
||||
conversation history" or "solution not yet verified">
|
||||
|
||||
Documentation skipped
|
||||
```
|
||||
|
||||
### Interactive mode
|
||||
|
||||
```
|
||||
✓ Documentation complete
|
||||
|
||||
Auto memory: 2 relevant entries used as supplementary evidence
|
||||
|
||||
Subagent Results:
|
||||
✓ Context Analyzer: Identified performance_issue in brief_system, category: performance-issues/
|
||||
✓ Solution Extractor: 3 code fixes, prevention strategies
|
||||
✓ Related Docs Finder: 2 related issues
|
||||
✓ Session History: 3 prior sessions on same branch, 2 failed approaches surfaced
|
||||
|
||||
Specialized Agent Reviews (Auto-Triggered):
|
||||
✓ ce-performance-oracle: Validated query optimization approach
|
||||
✓ ce-code-simplicity-reviewer: Solution is appropriately minimal
|
||||
|
||||
Files written:
|
||||
- docs/solutions/performance-issues/n-plus-one-brief-generation.md (created)
|
||||
- CONCEPTS.md (created with 3 entries: BriefSystem, EmailQueue, Brief Status)
|
||||
|
||||
This documentation will be searchable for future reference when similar
|
||||
issues occur in the Email Processing or Brief System modules.
|
||||
|
||||
What's next?
|
||||
1. Continue workflow (recommended)
|
||||
2. Link related documentation
|
||||
3. Update other references
|
||||
4. View documentation
|
||||
5. Other
|
||||
```
|
||||
|
||||
**After displaying the interactive success output above, present the "What's next?" options using the platform's blocking question tool:** `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to numbered options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question. Do not continue the workflow or end the turn without the user's selection. (Interactive mode only — headless skips this per the headless block above.)
|
||||
|
||||
**Alternate interactive output (when updating an existing doc due to high overlap):** in headless mode, this case is communicated via the `Overlap: high — existing doc updated` line of the headless terminal report above, not as a separate output block.
|
||||
|
||||
```
|
||||
✓ Documentation updated (existing doc refreshed with current context)
|
||||
|
||||
Overlap detected: docs/solutions/performance-issues/n-plus-one-queries.md
|
||||
Matched dimensions: problem statement, root cause, solution, referenced files
|
||||
Action: Updated existing doc with fresher code examples and prevention tips
|
||||
|
||||
File updated:
|
||||
- docs/solutions/performance-issues/n-plus-one-queries.md (added last_updated: 2026-03-24)
|
||||
```
|
||||
|
||||
## The Compounding Philosophy
|
||||
|
||||
This creates a compounding knowledge system:
|
||||
|
||||
1. First time you solve "N+1 query in brief generation" → Research (30 min)
|
||||
2. Document the solution → docs/solutions/performance-issues/n-plus-one-briefs.md (5 min)
|
||||
3. Next time similar issue occurs → Quick lookup (2 min)
|
||||
4. Knowledge compounds → Team gets smarter
|
||||
|
||||
The feedback loop:
|
||||
|
||||
```
|
||||
Build → Test → Find Issue → Research → Improve → Document → Validate → Deploy
|
||||
↑ ↓
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Each unit of engineering work should make subsequent units of work easier—not harder.**
|
||||
|
||||
## Auto-Invoke
|
||||
|
||||
<auto_invoke> <trigger_phrases> - "that worked" - "it's fixed" - "working now" - "problem solved" </trigger_phrases>
|
||||
|
||||
<manual_override> Use /ce-compound [context] to document immediately without waiting for auto-detection. </manual_override> </auto_invoke>
|
||||
|
||||
## Output
|
||||
|
||||
Writes the final learning directly into `docs/solutions/`.
|
||||
|
||||
## Applicable Specialized Agents
|
||||
|
||||
Based on problem type, these agents can enhance documentation:
|
||||
|
||||
### Code Quality & Review
|
||||
- **ce-code-simplicity-reviewer**: Ensures solution code is minimal and clear
|
||||
- **ce-pattern-recognition-specialist**: Identifies anti-patterns or repeating issues
|
||||
|
||||
### Specific Domain Experts
|
||||
- **ce-performance-oracle**: Analyzes performance_issue category solutions
|
||||
- **ce-security-sentinel**: Reviews security_issue solutions for vulnerabilities
|
||||
- **ce-data-integrity-guardian**: Reviews database_issue migrations and queries
|
||||
|
||||
### Enhancement & Research
|
||||
- **ce-best-practices-researcher**: Enriches solution with industry best practices
|
||||
- **ce-framework-docs-researcher**: Links to framework/library documentation references
|
||||
|
||||
### When to Invoke
|
||||
- **Auto-triggered** (optional): Agents can run post-documentation for enhancement
|
||||
- **Manual trigger**: User can invoke agents after /ce-compound completes for deeper review
|
||||
|
||||
## Related Commands
|
||||
|
||||
- `/research [topic]` - Deep investigation (searches docs/solutions/ for patterns)
|
||||
- `/ce-plan` - Planning workflow (references documented solutions)
|
||||
@@ -0,0 +1,94 @@
|
||||
# Resolution Templates
|
||||
|
||||
Choose the template matching the problem_type track (see `references/schema.yaml`).
|
||||
|
||||
---
|
||||
|
||||
## Bug Track Template
|
||||
|
||||
Use for: `build_error`, `test_failure`, `runtime_error`, `performance_issue`, `database_issue`, `security_issue`, `ui_bug`, `integration_issue`, `logic_error`
|
||||
|
||||
<!-- YAML safety: array items (symptoms, applies_when, tags, related_components) starting with ` [ * & ! | > % @ ? or containing ": " must be wrapped in double quotes. See references/yaml-schema.md > "YAML Safety Rules". -->
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: [Clear problem title]
|
||||
date: [YYYY-MM-DD]
|
||||
category: [docs/solutions subdirectory]
|
||||
module: [Module or area]
|
||||
problem_type: [schema enum]
|
||||
component: [schema enum]
|
||||
symptoms:
|
||||
- [Observable symptom 1]
|
||||
root_cause: [schema enum]
|
||||
resolution_type: [schema enum]
|
||||
severity: [schema enum]
|
||||
tags: [keyword-one, keyword-two]
|
||||
---
|
||||
|
||||
# [Clear problem title]
|
||||
|
||||
## Problem
|
||||
[1-2 sentence description of the issue and user-visible impact]
|
||||
|
||||
## Symptoms
|
||||
- [Observable symptom or error]
|
||||
|
||||
## What Didn't Work
|
||||
- [Attempted fix and why it failed]
|
||||
|
||||
## Solution
|
||||
[The fix that worked, including code snippets when useful]
|
||||
|
||||
## Why This Works
|
||||
[Root cause explanation and why the fix addresses it]
|
||||
|
||||
## Prevention
|
||||
- [Concrete practice, test, or guardrail]
|
||||
|
||||
## Related Issues
|
||||
- [Related docs or issues, if any]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Track Template
|
||||
|
||||
Use for: `best_practice`, `documentation_gap`, `workflow_issue`, `developer_experience`
|
||||
|
||||
<!-- YAML safety: array items (symptoms, applies_when, tags, related_components) starting with ` [ * & ! | > % @ ? or containing ": " must be wrapped in double quotes. See references/yaml-schema.md > "YAML Safety Rules". -->
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: [Clear, descriptive title]
|
||||
date: [YYYY-MM-DD]
|
||||
category: [docs/solutions subdirectory]
|
||||
module: [Module or area]
|
||||
problem_type: [schema enum]
|
||||
component: [schema enum]
|
||||
severity: [schema enum]
|
||||
applies_when:
|
||||
- [Condition where this applies]
|
||||
tags: [keyword-one, keyword-two]
|
||||
---
|
||||
|
||||
# [Clear, descriptive title]
|
||||
|
||||
## Context
|
||||
[What situation, gap, or friction prompted this guidance]
|
||||
|
||||
## Guidance
|
||||
[The practice, pattern, or recommendation with code examples when useful]
|
||||
|
||||
## Why This Matters
|
||||
[Rationale and impact of following or not following this guidance]
|
||||
|
||||
## When to Apply
|
||||
- [Conditions or situations where this applies]
|
||||
|
||||
## Examples
|
||||
[Concrete before/after or usage examples showing the practice in action]
|
||||
|
||||
## Related
|
||||
- [Related docs or issues, if any]
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# CONCEPTS.md vocabulary rules
|
||||
|
||||
`CONCEPTS.md` defines the words that mean something specific in this codebase — substrate that `docs/solutions/` and AGENTS.md can cite without redefinition. Lives at the repo root. Terms enter two ways — accretion and seeding (below) — and the file is created the first time either path produces a qualifying entry.
|
||||
|
||||
## How terms enter: accretion and seeding
|
||||
|
||||
Two paths populate the file, and they cover different gaps:
|
||||
|
||||
- **Accretion** — a learning surfaces a term whose meaning wasn't obvious, so it gets defined. This reliably catches *peripheral* terms, because friction is what surfaces them.
|
||||
- **Seeding** — a run proactively defines the **core domain nouns** of the area it is working in. This catches the *stable-central* terms accretion never reaches: the nouns a system is built around rarely break, so they rarely appear in a learning, yet they are exactly what a reader needs to orient. Without seeding, the file fills with peripheral mechanics and never names what the project is about.
|
||||
|
||||
### Seed goal
|
||||
|
||||
Define the core domain nouns the area's **declared domain model** exposes that meet the qualifying bar (see "What earns a slot"). The codebase sets the count: seed every term that genuinely qualifies, none added to reach a number and none pulled from beyond the declared model to inflate one. A small domain yields a few; a large one, more. The bound is the **source** (the declared domain model of the area in scope — schema, core types, primary models, top-level domain docs — not a full-codebase trawl) and the **bar** (the same "a new engineer would need this defined" test), never a fixed quantity.
|
||||
|
||||
### Scope of a seed
|
||||
|
||||
- A **scoped run** — a learning capture, or a refresh narrowed to an area — seeds only that area's core nouns, and defines only terms it actually investigated against code. It does not reach for repo-wide nouns it never touched.
|
||||
- A **repo-wide bootstrap** — an explicit "create CONCEPTS.md" request — seeds the whole project's declared domain model. This is the only path that produces a coherent "what is this project" glossary; a scoped run cannot, and should not pretend to.
|
||||
|
||||
## Be opinionated
|
||||
|
||||
When the team uses several words for the same concept, pick the best one and retire the rest. Record retired synonyms as aliases on the entry (see "Per entry"). Settled distinctions go to the Flagged ambiguities tail. The glossary is not a record of all words the team has ever used — it is the team's agreed-upon vocabulary.
|
||||
|
||||
## The file stands on its own
|
||||
|
||||
Each entry teaches its concept to a reader with no access to anything else — no codebase, no PR history, no architecture meetings, no Slack. This rules out:
|
||||
|
||||
- Implementation specifics (file paths, class names, function signatures, table names, library calls)
|
||||
- Status fields, dates, owners on the entries
|
||||
- Examples or current-config values drawn from the code — specific thresholds, counts, or enum values that will change. State the behavior, not the number: "each skill sets its own actionable threshold" rather than "surfaces at 50, fixes at 75."
|
||||
- Links to PRs, issues, channels, or roadmap milestones
|
||||
- Version-specific claims ("currently uses X; migrating to Y")
|
||||
|
||||
Cross-references between entries within `CONCEPTS.md` are fine — they resolve internally. General programming vocabulary (caches, queues, jobs, sessions) and everyday domain English need no redefinition either. But if an entry leans on another *project-specific* term to make sense, that term must be defined here too — an undefined project-specific sibling is itself a candidate to add.
|
||||
|
||||
## What earns a slot
|
||||
|
||||
A term qualifies when its meaning here is precise enough that a new engineer would need it defined to follow conversations, tickets, or code. General programming vocabulary does not belong, even when used heavily.
|
||||
|
||||
## Per entry
|
||||
|
||||
Definition is one sentence — what the term means in this domain, what makes it distinct from neighbors. A term with non-obvious behavioral rules (lifecycle, cancellation semantics, ownership invariants) earns a second paragraph for those rules — never for elaborating the definition itself.
|
||||
|
||||
When retired synonyms exist, list them as an aliases line directly under the definition: *Avoid: Booking, appointment*. Entities typically need more depth than value types; status concepts may need transition notes.
|
||||
|
||||
## Relationships (optional)
|
||||
|
||||
When relationships between entries carry load-bearing meaning (ownership, cardinality, lifecycle dependencies that span entries), capture them in a `## Relationships` section near the top of the file or its cluster. Skip when entries stand on their own without structural context — relationships are a lift for domains where structure is part of what makes terms meaningful, not a routine section.
|
||||
|
||||
## Organization
|
||||
|
||||
Cluster concepts by domain relationship — entities with their states, processes with their stages — so a reader sees structure without effort. A flat list works when the file is small. Reshape as the file grows.
|
||||
|
||||
## Flagged ambiguities (tail of file)
|
||||
|
||||
When two terms were used interchangeably and the team settled on a distinction, record the resolution as a one-line note: *"'account' had been used for both Customer and User — these are distinct."* This section is the audit trail for opinions the team has formed.
|
||||
|
||||
## One illustrative entry — the shape, not a template
|
||||
|
||||
```
|
||||
## Booking
|
||||
|
||||
### Reservation
|
||||
A future commitment to seat a Party at a specified date and time.
|
||||
*Avoid:* Booking, appointment
|
||||
|
||||
A Reservation owns its Party but does not own a Table — Tables are acquired only when the Party arrives, through a Seating. Lifecycle: Booked, Seated, Completed, No-Show. Cancellation before a Seating is non-destructive; cancellation after a Seating is recorded as a No-Show.
|
||||
|
||||
### Party
|
||||
The guests committed to a Reservation. Each Reservation has exactly one Party. Party size is the count promised at booking, not the count who arrive.
|
||||
|
||||
### Table
|
||||
A physical seating unit with fixed capacity. Tables are shared resources — they do not belong to Reservations and are allocated only on the day-of through Seatings.
|
||||
|
||||
### Seating
|
||||
The act of placing a Party at a Table once the Party arrives. A Reservation has at most one Seating; a Table accumulates many Seatings across its lifetime.
|
||||
```
|
||||
@@ -0,0 +1,231 @@
|
||||
# Documentation schema for learnings written by ce-compound
|
||||
# Treat this as the canonical frontmatter contract for docs/solutions/.
|
||||
#
|
||||
# The schema has two tracks based on problem_type:
|
||||
# Bug track — problem_type is a defect or failure (build_error, test_failure, etc.)
|
||||
# Knowledge track — problem_type is guidance or practice (best_practice, workflow_issue, etc.)
|
||||
#
|
||||
# Both tracks share the same required core fields. The tracks differ in which
|
||||
# additional fields are required vs optional (see track_rules below).
|
||||
|
||||
# --- Track classification ---------------------------------------------------
|
||||
tracks:
|
||||
bug:
|
||||
description: "Defects, failures, and errors that were diagnosed and fixed"
|
||||
problem_types:
|
||||
- build_error
|
||||
- test_failure
|
||||
- runtime_error
|
||||
- performance_issue
|
||||
- database_issue
|
||||
- security_issue
|
||||
- ui_bug
|
||||
- integration_issue
|
||||
- logic_error
|
||||
knowledge:
|
||||
description: "Practices, patterns, conventions, decisions, workflow improvements, and documentation"
|
||||
problem_types:
|
||||
- best_practice
|
||||
- documentation_gap
|
||||
- workflow_issue
|
||||
- developer_experience
|
||||
- architecture_pattern
|
||||
- design_pattern
|
||||
- tooling_decision
|
||||
- convention
|
||||
|
||||
# --- Fields required by BOTH tracks -----------------------------------------
|
||||
required_fields:
|
||||
module:
|
||||
type: string
|
||||
description: "Module or area affected"
|
||||
|
||||
date:
|
||||
type: string
|
||||
pattern: '^\d{4}-\d{2}-\d{2}$'
|
||||
description: "Date documented (YYYY-MM-DD)"
|
||||
|
||||
problem_type:
|
||||
type: enum
|
||||
values:
|
||||
- build_error
|
||||
- test_failure
|
||||
- runtime_error
|
||||
- performance_issue
|
||||
- database_issue
|
||||
- security_issue
|
||||
- ui_bug
|
||||
- integration_issue
|
||||
- logic_error
|
||||
- developer_experience
|
||||
- workflow_issue
|
||||
- best_practice
|
||||
- documentation_gap
|
||||
- architecture_pattern
|
||||
- design_pattern
|
||||
- tooling_decision
|
||||
- convention
|
||||
description: "Primary category — determines track (bug vs knowledge). Prefer the narrowest applicable value; best_practice is the fallback when no narrower knowledge-track value fits."
|
||||
|
||||
component:
|
||||
type: enum
|
||||
values:
|
||||
- rails_model
|
||||
- rails_controller
|
||||
- rails_view
|
||||
- service_object
|
||||
- background_job
|
||||
- database
|
||||
- frontend_stimulus
|
||||
- hotwire_turbo
|
||||
- email_processing
|
||||
- brief_system
|
||||
- assistant
|
||||
- authentication
|
||||
- payments
|
||||
- development_workflow
|
||||
- testing_framework
|
||||
- documentation
|
||||
- tooling
|
||||
description: "Component involved"
|
||||
|
||||
severity:
|
||||
type: enum
|
||||
values:
|
||||
- critical
|
||||
- high
|
||||
- medium
|
||||
- low
|
||||
description: "Impact severity"
|
||||
|
||||
# --- Track-specific rules ----------------------------------------------------
|
||||
track_rules:
|
||||
bug:
|
||||
required:
|
||||
symptoms:
|
||||
type: array[string]
|
||||
min_items: 1
|
||||
max_items: 5
|
||||
description: "Observable symptoms such as errors or broken behavior"
|
||||
root_cause:
|
||||
type: enum
|
||||
values:
|
||||
- missing_association
|
||||
- missing_include
|
||||
- missing_index
|
||||
- wrong_api
|
||||
- scope_issue
|
||||
- thread_violation
|
||||
- async_timing
|
||||
- memory_leak
|
||||
- config_error
|
||||
- logic_error
|
||||
- test_isolation
|
||||
- missing_validation
|
||||
- missing_permission
|
||||
- missing_workflow_step
|
||||
- inadequate_documentation
|
||||
- missing_tooling
|
||||
- incomplete_setup
|
||||
description: "Fundamental technical cause of the problem"
|
||||
resolution_type:
|
||||
type: enum
|
||||
values:
|
||||
- code_fix
|
||||
- migration
|
||||
- config_change
|
||||
- test_fix
|
||||
- dependency_update
|
||||
- environment_setup
|
||||
- workflow_improvement
|
||||
- documentation_update
|
||||
- tooling_addition
|
||||
- seed_data_update
|
||||
description: "Type of fix applied"
|
||||
|
||||
knowledge:
|
||||
optional:
|
||||
applies_when:
|
||||
type: array[string]
|
||||
max_items: 5
|
||||
description: "Conditions or situations where this guidance applies"
|
||||
symptoms:
|
||||
type: array[string]
|
||||
max_items: 5
|
||||
description: "Observable gaps or friction that prompted this guidance (optional for knowledge track)"
|
||||
root_cause:
|
||||
type: enum
|
||||
values:
|
||||
- missing_association
|
||||
- missing_include
|
||||
- missing_index
|
||||
- wrong_api
|
||||
- scope_issue
|
||||
- thread_violation
|
||||
- async_timing
|
||||
- memory_leak
|
||||
- config_error
|
||||
- logic_error
|
||||
- test_isolation
|
||||
- missing_validation
|
||||
- missing_permission
|
||||
- missing_workflow_step
|
||||
- inadequate_documentation
|
||||
- missing_tooling
|
||||
- incomplete_setup
|
||||
description: "Underlying cause, if there is a specific one (optional for knowledge track)"
|
||||
resolution_type:
|
||||
type: enum
|
||||
values:
|
||||
- code_fix
|
||||
- migration
|
||||
- config_change
|
||||
- test_fix
|
||||
- dependency_update
|
||||
- environment_setup
|
||||
- workflow_improvement
|
||||
- documentation_update
|
||||
- tooling_addition
|
||||
- seed_data_update
|
||||
description: "Type of change, if applicable (optional for knowledge track)"
|
||||
|
||||
# --- Fields optional for BOTH tracks ----------------------------------------
|
||||
optional_fields:
|
||||
related_components:
|
||||
type: array[string]
|
||||
description: "Other components involved"
|
||||
|
||||
tags:
|
||||
type: array[string]
|
||||
max_items: 8
|
||||
description: "Search keywords, lowercase and hyphen-separated"
|
||||
|
||||
# --- Fields optional for bug track only -------------------------------------
|
||||
bug_optional_fields:
|
||||
rails_version:
|
||||
type: string
|
||||
pattern: '^\d+\.\d+\.\d+$'
|
||||
description: "Rails version in X.Y.Z format. Only relevant for bug-track docs."
|
||||
|
||||
# --- Backward compatibility --------------------------------------------------
|
||||
# Docs created before the track system was introduced may have bug-track
|
||||
# fields (symptoms, root_cause, resolution_type) on knowledge-type
|
||||
# problem_types. These are valid legacy docs:
|
||||
# - Bug-track fields present on a knowledge-track doc are harmless. Do not
|
||||
# strip them during refresh unless the doc is being rewritten for other reasons.
|
||||
# - When creating NEW docs, follow the track rules above.
|
||||
|
||||
# --- Validation rules --------------------------------------------------------
|
||||
validation_rules:
|
||||
- "Determine track from problem_type using the tracks section above"
|
||||
- "All shared required_fields must be present"
|
||||
- "Bug-track required fields (symptoms, root_cause, resolution_type) must be present on bug-track docs"
|
||||
- "Knowledge-track docs have no additional required fields beyond the shared ones"
|
||||
- "Bug-track fields on existing knowledge-track docs are harmless (see backward compatibility note)"
|
||||
- "Track-specific optional fields may be included but are not required"
|
||||
- "Enum fields must match allowed values exactly"
|
||||
- "Array fields must respect min_items/max_items when specified"
|
||||
- "date must match YYYY-MM-DD format"
|
||||
- "rails_version, if provided, must match X.Y.Z format and only applies to bug-track docs"
|
||||
- "tags should be lowercase and hyphen-separated"
|
||||
- "Array-of-strings frontmatter items (symptoms, applies_when, tags, related_components, or any future array field) must be wrapped in double quotes when the value starts with a YAML reserved indicator (`, [, *, &, !, |, >, %, @, ?) or contains the substring `: ` — otherwise strict YAML parsers reject the file"
|
||||
@@ -0,0 +1,118 @@
|
||||
# YAML Frontmatter Schema
|
||||
|
||||
`schema.yaml` in this directory is the canonical contract for `docs/solutions/` frontmatter written by `ce-compound`.
|
||||
|
||||
Use this file as the quick reference for:
|
||||
- required fields
|
||||
- enum values
|
||||
- validation expectations
|
||||
- category mapping
|
||||
- track classification (bug vs knowledge)
|
||||
|
||||
## Tracks
|
||||
|
||||
The `problem_type` determines which **track** applies. Each track has different required and optional fields.
|
||||
|
||||
| Track | problem_types | Description |
|
||||
|-------|--------------|-------------|
|
||||
| **Bug** | `build_error`, `test_failure`, `runtime_error`, `performance_issue`, `database_issue`, `security_issue`, `ui_bug`, `integration_issue`, `logic_error` | Defects and failures that were diagnosed and fixed |
|
||||
| **Knowledge** | `best_practice`, `documentation_gap`, `workflow_issue`, `developer_experience`, `architecture_pattern`, `design_pattern`, `tooling_decision`, `convention` | Practices, patterns, conventions, decisions, workflow improvements, and documentation. Prefer the narrowest applicable value; `best_practice` is the fallback. |
|
||||
|
||||
## Required Fields (both tracks)
|
||||
|
||||
- **module**: Module or area affected
|
||||
- **date**: ISO date in `YYYY-MM-DD`
|
||||
- **problem_type**: One of the values listed in the Tracks table above
|
||||
- **component**: One of `rails_model`, `rails_controller`, `rails_view`, `service_object`, `background_job`, `database`, `frontend_stimulus`, `hotwire_turbo`, `email_processing`, `brief_system`, `assistant`, `authentication`, `payments`, `development_workflow`, `testing_framework`, `documentation`, `tooling`
|
||||
- **severity**: One of `critical`, `high`, `medium`, `low`
|
||||
|
||||
## Bug Track Fields
|
||||
|
||||
Required:
|
||||
- **symptoms**: YAML array with 1-5 observable symptoms (errors, broken behavior)
|
||||
- **root_cause**: One of `missing_association`, `missing_include`, `missing_index`, `wrong_api`, `scope_issue`, `thread_violation`, `async_timing`, `memory_leak`, `config_error`, `logic_error`, `test_isolation`, `missing_validation`, `missing_permission`, `missing_workflow_step`, `inadequate_documentation`, `missing_tooling`, `incomplete_setup`
|
||||
- **resolution_type**: One of `code_fix`, `migration`, `config_change`, `test_fix`, `dependency_update`, `environment_setup`, `workflow_improvement`, `documentation_update`, `tooling_addition`, `seed_data_update`
|
||||
|
||||
## Knowledge Track Fields
|
||||
|
||||
No additional required fields beyond the shared ones. All fields below are optional:
|
||||
|
||||
- **applies_when**: Conditions or situations where this guidance applies
|
||||
- **symptoms**: Observable gaps or friction that prompted this guidance
|
||||
- **root_cause**: Underlying cause, if there is a specific one
|
||||
- **resolution_type**: Type of change, if applicable
|
||||
|
||||
## Optional Fields (both tracks)
|
||||
|
||||
- **related_components**: Other components involved
|
||||
- **tags**: Search keywords, lowercase and hyphen-separated
|
||||
|
||||
## Optional Fields (bug track only)
|
||||
|
||||
- **rails_version**: Rails version in `X.Y.Z` format
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
Docs created before the track system may have `symptoms`/`root_cause`/`resolution_type` on knowledge-type problem_types. These are valid legacy docs:
|
||||
|
||||
- Bug-track fields present on a knowledge-track doc are harmless. Do not strip them during refresh unless the doc is being rewritten for other reasons.
|
||||
- When creating **new** docs, follow the track rules above.
|
||||
|
||||
## Category Mapping
|
||||
|
||||
- `build_error` -> `docs/solutions/build-errors/`
|
||||
- `test_failure` -> `docs/solutions/test-failures/`
|
||||
- `runtime_error` -> `docs/solutions/runtime-errors/`
|
||||
- `performance_issue` -> `docs/solutions/performance-issues/`
|
||||
- `database_issue` -> `docs/solutions/database-issues/`
|
||||
- `security_issue` -> `docs/solutions/security-issues/`
|
||||
- `ui_bug` -> `docs/solutions/ui-bugs/`
|
||||
- `integration_issue` -> `docs/solutions/integration-issues/`
|
||||
- `logic_error` -> `docs/solutions/logic-errors/`
|
||||
- `developer_experience` -> `docs/solutions/developer-experience/`
|
||||
- `workflow_issue` -> `docs/solutions/workflow-issues/`
|
||||
- `best_practice` -> `docs/solutions/best-practices/`
|
||||
- `documentation_gap` -> `docs/solutions/documentation-gaps/`
|
||||
- `architecture_pattern` -> `docs/solutions/architecture-patterns/`
|
||||
- `design_pattern` -> `docs/solutions/design-patterns/`
|
||||
- `tooling_decision` -> `docs/solutions/tooling-decisions/`
|
||||
- `convention` -> `docs/solutions/conventions/`
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. Determine the track from `problem_type` using the Tracks table.
|
||||
2. All shared required fields must be present.
|
||||
3. Bug-track required fields (`symptoms`, `root_cause`, `resolution_type`) must be present on bug-track docs.
|
||||
4. Knowledge-track docs have no additional required fields beyond the shared ones.
|
||||
5. Bug-track fields on existing knowledge-track docs are harmless (see Backward Compatibility).
|
||||
6. Enum fields must match the allowed values exactly.
|
||||
7. Array fields must respect min/max item counts.
|
||||
8. `date` must match `YYYY-MM-DD`.
|
||||
9. `rails_version`, if present, must match `X.Y.Z` and only applies to bug-track docs.
|
||||
|
||||
## YAML Safety Rules
|
||||
|
||||
Strict YAML 1.2 parsers (`yq`, `js-yaml` strict, PyYAML) reject array items
|
||||
that start with a reserved indicator character as unquoted scalars. When
|
||||
writing items for any array-of-strings field (`symptoms`, `applies_when`,
|
||||
`tags`, `related_components`, or any future array field), wrap the value in
|
||||
double quotes if it starts with any of:
|
||||
|
||||
`` ` ``, `[`, `*`, `&`, `!`, `|`, `>`, `%`, `@`, `?`
|
||||
|
||||
Also quote if the value contains the substring `": "` — that punctuation
|
||||
confuses flow-style parsers.
|
||||
|
||||
Example — before (breaks strict YAML):
|
||||
|
||||
symptoms:
|
||||
- `sudo dscacheutil -flushcache` does not restore in-container mDNS
|
||||
|
||||
Example — after (parses cleanly):
|
||||
|
||||
symptoms:
|
||||
- "`sudo dscacheutil -flushcache` does not restore in-container mDNS"
|
||||
|
||||
This rule applies to all array-of-strings frontmatter fields. Scalar string
|
||||
fields like `description:` have their own quoting rules (see plugin
|
||||
`AGENTS.md` under "YAML Frontmatter").
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate ce-compound docs/solutions/ frontmatter for parser-safety issues.
|
||||
|
||||
Usage:
|
||||
python3 validate-frontmatter.py <doc-path>
|
||||
|
||||
Exit codes:
|
||||
0 — frontmatter passes all checks
|
||||
1 — validation failure (diagnostics on stderr)
|
||||
2 — usage error (bad arguments, missing file)
|
||||
|
||||
Scope: this script catches *parser-safety* issues — frontmatter that strict
|
||||
YAML parsers will silently misread. It does NOT validate against the
|
||||
schema's required-field or enum-value rules; that's a separate concern. The
|
||||
intent is to prevent the silent-data-loss bug class where YAML's quoting
|
||||
rules truncate or reframe scalar values without raising.
|
||||
|
||||
Checks (regex-based, no YAML parser dependency):
|
||||
1. File starts and ends frontmatter with `---` lines (matched as full
|
||||
lines, not substrings — `----` and `---extra` are rejected)
|
||||
2. No top-level scalar value contains ` #` unquoted (silent comment
|
||||
truncation — what Codex caught on PR #695)
|
||||
3. No top-level scalar value contains `: ` unquoted (mapping confusion —
|
||||
what surfaced in a 2026-04-16 plan doc's `title:` field)
|
||||
|
||||
The script does NOT flag values starting with YAML reserved indicators
|
||||
(`` ` ``, `*`, `&`, `!`, etc.) because those produce loud parser errors
|
||||
downstream rather than silent corruption — they're already caught by
|
||||
whatever consumes the doc. This validator's purpose is silent-corruption
|
||||
prevention, not lint.
|
||||
|
||||
Pure-stdlib (no PyYAML or other third-party deps). Runs in <50ms typical.
|
||||
Designed to produce concrete, actionable error messages so the calling
|
||||
agent can fix and retry without ambiguity.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def usage_fail(msg: str) -> "NoReturn":
|
||||
sys.stderr.write(f"validate-frontmatter: {msg}\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
usage_fail(f"usage: {os.path.basename(argv[0])} <doc-path>")
|
||||
|
||||
doc_path = argv[1]
|
||||
if not os.path.isfile(doc_path):
|
||||
usage_fail(f"file not found: {doc_path}")
|
||||
|
||||
with open(doc_path) as f:
|
||||
text = f.read()
|
||||
|
||||
issues: list[str] = []
|
||||
|
||||
# Check 1: frontmatter delimiters. Match the delimiter as a complete
|
||||
# line whose stripped content is exactly `---` — substring matching
|
||||
# (e.g. `text.find("\n---", 4)`) would falsely accept `----` or
|
||||
# `---extra` as a terminator and let malformed docs slip through to
|
||||
# downstream parsers that require a strict `---` line.
|
||||
lines = text.split("\n")
|
||||
if not lines or lines[0].rstrip() != "---":
|
||||
sys.stderr.write(
|
||||
f"FAIL: {doc_path}\n"
|
||||
f" file does not start with '---' frontmatter delimiter line\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
end_idx: int | None = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].rstrip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
sys.stderr.write(
|
||||
f"FAIL: {doc_path}\n"
|
||||
f" frontmatter not closed (no '---' line after the opening delimiter)\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
fm_text = "\n".join(lines[1:end_idx])
|
||||
|
||||
# Checks 2 & 3: silent-corruption quoting risks on top-level scalar
|
||||
# fields. We scan line-by-line and only flag top-level mapping entries
|
||||
# (no leading whitespace) whose value isn't already quoted/structured.
|
||||
for lineno, line in enumerate(fm_text.split("\n"), start=2):
|
||||
stripped = line.lstrip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if ":" not in line:
|
||||
continue
|
||||
# Top-level mapping keys only — skip nested values, array items
|
||||
if line.startswith((" ", "\t")):
|
||||
continue
|
||||
# Skip pure list-marker lines like "- item" (these can't be top-level
|
||||
# in our frontmatter convention, but be defensive)
|
||||
if stripped.startswith("- "):
|
||||
continue
|
||||
|
||||
key, _, val = line.partition(":")
|
||||
val_stripped = val.strip()
|
||||
if not val_stripped:
|
||||
# Key with no value on this line — likely a parent of a nested
|
||||
# block (`tags:` followed by `- foo`). Nothing to validate here.
|
||||
continue
|
||||
# Already quoted or structured (block scalar, flow collection)
|
||||
if val_stripped[0] in '"\'[{|>':
|
||||
continue
|
||||
|
||||
if re.search(r"\s#", val_stripped):
|
||||
issues.append(
|
||||
f"line {lineno}: '{key.strip()}' value contains ' #' — quote it. "
|
||||
"YAML treats space-then-# as a comment delimiter and silently "
|
||||
"drops the rest of the value."
|
||||
)
|
||||
if re.search(r":\s", val_stripped):
|
||||
issues.append(
|
||||
f"line {lineno}: '{key.strip()}' value contains ': ' — quote it. "
|
||||
"Strict YAML parsers may treat this as a nested mapping."
|
||||
)
|
||||
|
||||
if issues:
|
||||
sys.stderr.write(f"FAIL: {doc_path}\n")
|
||||
for issue in issues:
|
||||
sys.stderr.write(f" {issue}\n")
|
||||
return 1
|
||||
|
||||
print(f"OK: {doc_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,400 @@
|
||||
---
|
||||
name: ce-ideate
|
||||
description: "Generate and critically evaluate grounded ideas about a topic. Use when asking what to improve, requesting idea generation, exploring surprising directions, or wanting the AI to proactively suggest strong options before brainstorming one in depth. Triggers on phrases like 'what should I improve', 'give me ideas', 'ideate on X', 'surprise me', 'what would you change', or any request for AI-generated suggestions rather than refining the user's own idea."
|
||||
argument-hint: "[feature, focus area, or constraint]"
|
||||
|
||||
---
|
||||
|
||||
# Generate Improvement Ideas
|
||||
|
||||
**Note: The current year is 2026.** Use this when dating ideation documents and checking recent ideation artifacts.
|
||||
|
||||
`ce-ideate` precedes `ce-brainstorm`.
|
||||
|
||||
- `ce-ideate` answers: "What are the strongest ideas worth exploring?"
|
||||
- `ce-brainstorm` answers: "What exactly should one chosen idea mean?"
|
||||
- `ce-plan` answers: "How should it be built?"
|
||||
|
||||
This workflow produces a ranked ideation artifact in `docs/ideation/`. It does **not** produce requirements, plans, or code.
|
||||
|
||||
## Interaction Method
|
||||
|
||||
Use the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to numbered options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
|
||||
|
||||
Ask one question at a time. Prefer concise single-select choices when natural options exist.
|
||||
|
||||
## Focus Hint
|
||||
|
||||
<focus_hint> #$ARGUMENTS </focus_hint>
|
||||
|
||||
Interpret any provided argument as optional context. It may be:
|
||||
|
||||
- a concept such as `DX improvements`
|
||||
- a path such as `plugins/compound-engineering/skills/`
|
||||
- a constraint such as `low-complexity quick wins`
|
||||
- a volume hint such as `top 3`, `100 ideas`, or `raise the bar`
|
||||
|
||||
If no argument is provided, proceed with open-ended ideation.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Ground before ideating** - Scan the actual codebase first. Do not generate abstract product advice detached from the repository.
|
||||
2. **Generate many -> critique all -> explain survivors only** - The quality mechanism is explicit rejection with reasons, not optimistic ranking. Do not let extra process obscure this pattern.
|
||||
3. **Route action into brainstorming** - Ideation identifies promising directions; `ce-brainstorm` defines the selected one precisely enough for planning. Do not skip to planning from ideation output.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### Phase 0: Resume and Scope
|
||||
|
||||
#### 0.1 Check for Recent Ideation Work
|
||||
|
||||
Look in `docs/ideation/` for ideation documents created within the last 30 days.
|
||||
|
||||
Treat a prior ideation doc as relevant when:
|
||||
|
||||
- the topic matches the requested focus
|
||||
- the path or subsystem overlaps the requested focus
|
||||
- the request is open-ended and there is an obvious recent open ideation doc
|
||||
- the issue-grounded status matches: do not offer to resume a non-issue ideation when the current argument indicates issue-tracker intent, or vice versa — treat these as distinct topics
|
||||
|
||||
If a relevant doc exists, ask whether to:
|
||||
|
||||
1. continue from it
|
||||
2. start fresh
|
||||
|
||||
If continuing:
|
||||
|
||||
- read the document
|
||||
- summarize what has already been explored
|
||||
- preserve previous idea statuses
|
||||
- update the existing file instead of creating a duplicate
|
||||
|
||||
#### 0.2 Subject-Identification Gate
|
||||
|
||||
Before classifying mode or dispatching any grounding, check whether the subject of ideation is identifiable. Every downstream agent — grounding and ideation — needs to know what it's working on. If the subject is ambiguous enough that reasonable sub-agents would diverge on what the topic even is (bare words like `improvements`, `ideas`, `birthday cakes`, `vacation destinations`), the output will be scattered.
|
||||
|
||||
**Questioning principles (apply in this phase and in 0.4):**
|
||||
|
||||
- Questions exist only to supply what sub-agents need to operate: an identifiable subject (this phase) and enough context for the agent to say something specific about it (0.4, elsewhere modes only). Nothing else.
|
||||
- Never ask about solution direction, constraints, audience, tone, success criteria, or anything that characterizes the subject — those belong to `ce-brainstorm`.
|
||||
- Always keep "Surprise me" (letting the agent decide the focus) as a real option, not a fallback for when the user can't name a subject. Ideation is allowed to be greenfield by design.
|
||||
- Stop as soon as the subject is identifiable or the user has delegated to "Surprise me." More than 3 total questions across 0.2 and 0.4 is a smell that ideation is not the right workflow — consider suggesting `ce-brainstorm`.
|
||||
|
||||
**Detection — issue-tracker intent (repo mode only; subject-identifying).**
|
||||
|
||||
Issue-tracker intent requires an explicit reference to the tracker or to reports filed in it. Trigger only when the prompt uses phrases like `github issues`, `open issues`, `issue patterns`, `issue themes`, `what users are reporting`, or `bug reports` — the subject is "issues in the tracker." Proceed to 0.3 with issue-tracker intent flagged.
|
||||
|
||||
Do NOT trigger on arguments that merely mention bugs as a focus: `bug in auth`, `fix the login issue`, `the signup bug`, `top 3 bugs in authentication` — these are focus hints on regular ideation, not requests to analyze the issue tracker. A bare `bugs` with no tracker phrasing is handled by the vagueness check below, not here.
|
||||
|
||||
When combined (e.g., `top 3 issue themes in authentication`, `biggest bug reports about checkout`): detect issue-tracker intent first, volume override in 0.5, remainder is the focus hint. The focus narrows which issues matter; the volume override controls survivor count.
|
||||
|
||||
**Detection — subject identifiability.**
|
||||
|
||||
The test: would a reader, seeing only this prompt, know what subject the agent should ideate on? Apply judgment to what the words *refer to*, not to their length or surface form.
|
||||
|
||||
- **Vague — ask the scope question.** The prompt refers to a quality, category, or placeholder without naming a specific thing. Reasonable readers would pick different subjects. Illustrative cases: `improvements`, `ideas`, `things to fix`, `quick wins`, `what to build`, `bugs` (as the whole prompt, not as a topic like "bugs in auth"), an empty prompt. These are examples of the pattern, not a lookup table — recognize vagueness by what the words point to (a catch-all quality), not by matching specific words.
|
||||
|
||||
- **Identifiable — proceed to 0.3.** The prompt names or plausibly names a specific subject: a feature, concept, document, subsystem, page, flow, or concrete topic. A reader would know where to direct thought even without knowing the domain. Illustrative cases: `authentication system`, `our sign-up page`, `browser sniff`, `dark mode`, `cache invalidation`, `a unicorn cake for my 7-year-old`, `plot ideas for a short story`.
|
||||
|
||||
**Key distinction:** vagueness is about what the words *refer to*, not phrase length. `browser sniff` is two words but plausibly names a feature, so it is identifiable. `quick wins` is two words but refers only to a quality, so it is vague. Do not treat short phrases as vague by default.
|
||||
|
||||
**Being inside a repo does not settle vagueness.** `improvements` in any repo is still scattered across DX, reliability, features, docs, tests, architecture. The repo provides material for grounding *after* a subject is settled, not the subject itself. Do not silently interpret a vague prompt as "about this repo" and proceed.
|
||||
|
||||
**Genuine ambiguity (repo mode).** When judgment leaves real doubt on a short phrase — it could be a named feature or a vague concept — a single cheap check settles it: Glob for the phrase in filenames, or Grep for it in README/docs. If it appears anywhere, treat as identifiable and proceed. If it has no repo footprint and still reads vaguely, ask the scope question.
|
||||
|
||||
When in doubt otherwise, err toward asking — one question is trivial compared to dispatching ~9 agents on a scattered interpretation.
|
||||
|
||||
**The scope question.**
|
||||
|
||||
Use the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to numbered options in chat only when no blocking tool exists or the call errors — not because a schema load is required. Never silently skip.
|
||||
|
||||
- **Stem:** "What should the agent ideate about?"
|
||||
- **Options:**
|
||||
- "Specify a subject the agent should ideate on"
|
||||
- "Surprise me — let the agent decide what to focus on"
|
||||
- "Cancel — let me rephrase"
|
||||
|
||||
Routing:
|
||||
|
||||
- **Specify** → accept the user's follow-up as the subject. Re-apply the identifiability check once. If still ambiguous, ask once more with "Surprise me" still on the menu. Do not cascade toward specificity about *how* to solve — only about *what* the subject is.
|
||||
- **Surprise me** → mark the run as **surprise-me mode**. The agent will discover subjects from Phase 1 material rather than carry a user-specified subject. This is a first-class mode — it changes how Phase 1 scans and how Phase 2 sub-agents operate (see those phases). **Dispatch routing for surprise-me is deterministic:** if CWD is inside a git repo, route to repo-grounded (the codebase supplies substance); otherwise route to elsewhere-software and require Phase 0.4 to collect at least one piece of substance (URL, description, draft, or paste) before dispatching — "surprise me" outside a repo is only viable once the user has supplied something to surprise them about. Skip Decision 1/2 in Phase 0.3: with no user subject there is no prompt content to weigh, and surprise-me never routes to elsewhere-non-software (no way to infer naming/narrative/personal intent without a subject). The user can correct by interrupting and re-invoking with a named subject.
|
||||
- **Cancel** → exit cleanly. Narrate that the user can rephrase and re-invoke.
|
||||
|
||||
#### 0.3 Mode Classification
|
||||
|
||||
Classify the **subject of ideation** (settled in 0.2) into one of three modes for dispatch routing. A user inside any repo can ideate about something unrelated to that repo; a user in `/tmp` can ideate about code they hold in their head.
|
||||
|
||||
**Surprise-me short-circuit.** When Phase 0.2 routed to surprise-me mode, skip the two-decision classification below and use the deterministic rule stated in 0.2: repo-grounded when CWD is inside a git repo, elsewhere-software otherwise. The ambiguity-confirmation step at the end of this section also does not fire for surprise-me — there is no user subject to be ambiguous about. State the chosen mode in one sentence and proceed to 0.4.
|
||||
|
||||
For specified subjects, make two sequential binary decisions, enumerating negative signals at each:
|
||||
|
||||
**Decision 1 — repo-grounded vs elsewhere.** Weigh prompt content first, topic-repo coherence second, and CWD repo presence as supporting evidence only.
|
||||
|
||||
- Positive signals for **repo-grounded**: prompt references repo files, code, architecture, modules, tests, or workflows; topic is clearly bounded by the current codebase. Issue-tracker intent from 0.2 is always repo-grounded.
|
||||
- Negative signals (push toward **elsewhere**): prompt names things absent from the repo (pricing, naming, narrative, business model, personal decisions, brand, content, market positioning); topic is creative, business, or personal with no code surface.
|
||||
|
||||
**Decision 2 (only fires if Decision 1 = elsewhere) — software vs non-software.** Classify by whether the *subject* of ideation is a software artifact or system, not by where the individual ideas will eventually land. If the topic concerns a product, app, SaaS, web/mobile UI, feature, page, or service, it is **elsewhere-software** — even when the ideas themselves are about copy, UX, CRO, pricing, onboarding, visual design, or positioning *for that software product*. **Elsewhere-non-software** is reserved for topics with no software surface at all: company or brand naming (independent of product), narrative and creative writing, personal decisions, non-digital business strategy, physical-product design.
|
||||
|
||||
Sample classifications:
|
||||
|
||||
- "Improve conversion on our sign-up page" → elsewhere-software (the subject is a page)
|
||||
- "Redesign the onboarding flow" → elsewhere-software (the subject is a flow)
|
||||
- "Pricing page A/B test ideas" → elsewhere-software (the subject is a page)
|
||||
- "Features to add to our note-taking app" → elsewhere-software
|
||||
- "Name my new coffee shop" → elsewhere-non-software (the subject is a brand)
|
||||
- "Plot ideas for a short story" → elsewhere-non-software (the subject is a narrative)
|
||||
- "Options for my next career move" → elsewhere-non-software (the subject is a personal decision)
|
||||
|
||||
State the inferred approach in one sentence at the top, using plain language the user will recognize. Never print the internal taxonomy label (`repo-grounded`, `elsewhere-software`, `elsewhere-non-software`) to the user — those names are for routing only. Adapt the template below to the actual topic; pick a domain word from the topic itself (e.g., "landing page", "onboarding flow", "naming", "career decision") instead of a mode label.
|
||||
|
||||
- **Repo-grounded:** "Treating this as a topic in this codebase — about X."
|
||||
- **Elsewhere-software:** "Treating this as a product/software topic outside this repo — about X."
|
||||
- **Elsewhere-non-software:** "Treating this as a [naming | narrative | business | personal] topic — about X."
|
||||
|
||||
Do not prescribe correction phrases ("say X to switch"). State the inferred mode plainly and proceed. If the user disagrees, they will correct in their own words or interrupt to re-invoke — reclassify and re-run any affected routing when that happens.
|
||||
|
||||
**Active confirmation on mode ambiguity.** Only fire when mode classification is genuinely ambiguous *after* 0.2 settled the subject — e.g., "our docs" could mean repo docs (repo-grounded) or public marketing docs (elsewhere-software). Most subjects settled in 0.2 classify cleanly here. When ambiguous, ask one confirmation question via the blocking tool with two self-contained labels naming the two candidate interpretations in plain language (e.g., "Treat as repo docs in this codebase" vs "Treat as public marketing docs") — never leak internal mode names. Otherwise the one-sentence inferred-mode statement is sufficient; do not ask.
|
||||
|
||||
**Routing rule (non-software mode).** When Decision 2 = non-software, still run Phase 1 Elsewhere-mode grounding (user-context synthesis + web-research by default; skip phrases honored). Learnings-researcher is skipped by default in this mode — the CWD's `docs/solutions/` rarely transfers to naming, narrative, personal, or non-digital business topics; see Phase 1 for the full rationale. Then load `references/universal-ideation.md` and follow it in place of Phase 2's software frame dispatch and the Phase 6 menu narrative. This load is non-optional — the file contains the domain-agnostic generation frames, critique rubric, and wrap-up menu that replace Phase 2 and the post-ideation menu for this mode, and none of those details live in this main body. Improvising from memory produces the wrong facilitation for non-software topics. Do not run the repo-specific codebase scan at any point. The §6.5 Proof Failure Ladder in `references/post-ideation-workflow.md` still applies — load and follow it whenever a Proof save (the elsewhere-mode default for Save and end) fails, so the local-save fallback path stays reachable in non-software elsewhere runs.
|
||||
|
||||
#### 0.4 Context-Substance Gate (Elsewhere Modes Only)
|
||||
|
||||
Skip in repo mode — the repo provides the substance Phase 1 agents work from. In elsewhere modes (both software and non-software), Phase 1 agents depend on user-supplied context for substance. A bare prompt with no description, URL, or artifact leaves the user-context-synthesis agent with nothing to synthesize and weakens web research's relevance.
|
||||
|
||||
Apply the discrimination test: would swapping one piece of the user's stated context for a contrasting alternative materially change which ideas survive? If yes, context is load-bearing — proceed. If no, ask 1-3 narrowly chosen questions focused on **supplying substance, not characterizing the subject**:
|
||||
|
||||
- A URL or file to read
|
||||
- A brief description of the current state
|
||||
- A paste of an existing draft or brief
|
||||
|
||||
Build on what the user already provided rather than starting from a template. Default to free-form questions; use single-select only when the answer space is small and discrete. After each answer, re-apply the test before asking another. Stop on dismissive responses ("idk just go") — treat genuine "no context" answers as real answers and note context is thin in the summary so Phase 2 can compensate with broader generation.
|
||||
|
||||
**Surprise-me exception.** When the run is in surprise-me mode and routed to elsewhere-software (per 0.2's deterministic routing for no-repo CWDs), at least one piece of substance is required — there is no subject AND no repo, so Phase 1 and 2 agents would have nothing to discover subjects from. Dismissive responses are not acceptable here; if the user still has no context after one ask, tell them the run needs a URL, description, or paste to proceed and end cleanly so they can re-invoke with material.
|
||||
|
||||
When the user provides rich context up front (a paste, a brief, an existing draft, a URL), confirm understanding in one line and skip this step entirely.
|
||||
|
||||
If this step materially changes the topic (not just adds context but shifts the subject), re-run 0.2 and 0.3 against the refined scope before dispatching Phase 1 — classify on what's actually being ideated on, not the scope at first read.
|
||||
|
||||
#### 0.5 Interpret Focus and Volume
|
||||
|
||||
Infer two things from the argument and any intake so far:
|
||||
|
||||
- **Focus context** — concept, path, constraint, or open-ended
|
||||
- **Volume override** — any hint that changes candidate or survivor counts
|
||||
|
||||
Default volume:
|
||||
|
||||
- each ideation sub-agent generates about 6-8 ideas (yielding ~36-48 raw ideas across 6 frames in the default path, or ~24-32 across 4 frames in issue-tracker mode; roughly 25-30 survivors after dedupe in the 6-frame path and fewer in the 4-frame path)
|
||||
- keep the top 5-7 survivors
|
||||
|
||||
Honor clear overrides such as:
|
||||
|
||||
- `top 3`
|
||||
- `100 ideas`
|
||||
- `go deep`
|
||||
- `raise the bar`
|
||||
|
||||
**Tactical scope detection.** Parse the focus hint (and any intake answers from 0.2 specify path) for tactical signals: `polish`, `typo`, `typos`, `quick wins`, `small improvements`, `cleanup`, `small fixes`. When present, lower the Phase 2 ambition floor — the user has explicitly opted into tactical scope. Default otherwise is step-function (see Phase 2 meeting-test floor).
|
||||
|
||||
Use reasonable interpretation rather than formal parsing.
|
||||
|
||||
#### 0.6 Cost Transparency Notice
|
||||
|
||||
Before dispatching Phase 1, surface the agent count for the inferred mode in one short line so multi-agent cost is not invisible. Compute the count from the actual dispatch decision: 1 grounding-context agent (codebase scan in repo mode; user-context synthesis in elsewhere) + 1 learnings (skip in elsewhere-non-software) + 1 web researcher + 6 ideation = baseline 9 in repo mode and elsewhere-software, 8 in elsewhere-non-software. When issue-tracker intent triggers (repo mode only): add 1 for the issue-intelligence agent and drop ideation from 6 to 4, for a net -1 (baseline 8). Add 1 if the user opted into Slack research. Subtract 1 if the user issued a web-research skip phrase or V15 reuse will fire. In **surprise-me mode**, agent count is the same but per-agent exploration is deeper — note "(surprise-me mode: deeper exploration per agent)" when active. Phase 2's axis-coverage check may dispatch up to 2 additional recovery sub-agents when generation leaves any topic axis empty (skipped in surprise-me mode); when not in surprise-me, append "(+up to 2 if axis-coverage requires recovery)" to the count line.
|
||||
|
||||
Examples (defaults, no skips, no opt-ins):
|
||||
|
||||
- **Repo mode, specified subject:** "Will dispatch ~9 agents: codebase scan + learnings + web research + 6 ideation sub-agents. Skip phrases: 'no external research', 'no slack'."
|
||||
- **Repo mode, surprise-me:** "Will dispatch ~9 agents (surprise-me mode: deeper exploration per agent): codebase scan + learnings + web research + 6 ideation sub-agents. Skip phrases: 'no external research', 'no slack'."
|
||||
- **Repo mode, issue-tracker intent:** "Will dispatch ~8 agents: codebase scan + learnings + web research + issue intelligence + 4 ideation sub-agents. Skip phrases: 'no external research', 'no slack'." Reflects the successful-theme path; if issue intelligence returns insufficient signal (see Phase 1), ideation falls back to 6 sub-agents and the total becomes ~9.
|
||||
- **Elsewhere-software:** "Will dispatch ~9 agents: context synthesis + learnings + web research + 6 ideation sub-agents. Skip phrases: 'no external research'."
|
||||
- **Elsewhere-non-software:** "Will dispatch ~8 agents: context synthesis + web research + 6 ideation sub-agents. Skip phrases: 'no external research'."
|
||||
|
||||
The line is informational; users do not need to acknowledge it.
|
||||
|
||||
### Phase 1: Mode-Aware Grounding
|
||||
|
||||
Before generating ideas, gather grounding. The dispatch set depends on the mode chosen in Phase 0.3. Web research runs in all modes (skip phrases honored). Learnings runs in repo mode and elsewhere-software, and is **skipped by default in elsewhere-non-software** — the CWD repo's `docs/solutions/` almost always contains engineering patterns that do not transfer to naming, narrative, personal, or non-digital business topics.
|
||||
|
||||
**Surprise-me grounding depth.** When Phase 0.2 routed to surprise-me mode, Phase 1 must produce richer material than specified mode — Phase 2 sub-agents will discover their own subjects from what Phase 1 returns, so texture matters:
|
||||
|
||||
- **Repo mode surprise-me:** the codebase-scan sub-agent samples a few representative files per top-level area (not just reads the top-level layout + AGENTS.md), surfaces recent PR/commit activity as signal about what's actively being worked on, and — when issue intelligence runs — passes issue themes as first-class input rather than footnote. Keep the scan bounded: representative, not exhaustive.
|
||||
- **Elsewhere mode surprise-me:** user-context synthesis extracts themes, recurring language, tensions, and omissions from whatever the user supplied, rather than just restating it. Web research broadens beyond narrow prior-art for a single subject toward the domain's landscape.
|
||||
- Specified mode keeps the current shallower scan — the user's named subject anchors what's relevant, so broader exploration is unnecessary.
|
||||
|
||||
Generate a `<run-id>` once at the start of Phase 1 (8 hex chars). Reuse it for the V15 cache file (this phase) and the V17 checkpoints (Phases 2 and 4) so they share one per-run scratch directory.
|
||||
|
||||
**Pre-resolve the scratch directory path.** Scratch lives directly under `/tmp` (not under `$TMPDIR` and not under `.context/`). `$TMPDIR` on macOS resolves to an obscure per-user path like `/var/folders/64/.../T/` that is hostile for users who want to inspect checkpoints, copy them elsewhere, or reference them later — `/tmp` is universally accessible on macOS, Linux, and WSL, and the per-user isolation `$TMPDIR` provides is not valuable for ephemeral ideation scratch. Run one bash command to create the directory and capture its absolute path for downstream use.
|
||||
|
||||
```bash
|
||||
SCRATCH_DIR="/tmp/compound-engineering/ce-ideate/<run-id>"
|
||||
mkdir -p "$SCRATCH_DIR"
|
||||
echo "$SCRATCH_DIR"
|
||||
```
|
||||
|
||||
Use the echoed absolute path (`/tmp/compound-engineering/ce-ideate/<run-id>`) as `<scratch-dir>` for every subsequent checkpoint write and cache read in this run. The run directory is not deleted on Phase 6 completion — the V15 cache is session-scoped and reused across run-ids, and the checkpoints follow the cross-invocation-reusable convention of leaving session-scoped artifacts for later invocations to find.
|
||||
|
||||
Run grounding agents in parallel in the **foreground** (do not background — results are needed before Phase 2):
|
||||
|
||||
**Repo mode dispatch:**
|
||||
|
||||
1. **Quick context scan** — dispatch a general-purpose sub-agent using the platform's cheapest capable model (e.g., `model: "haiku"` in Claude Code) with this prompt:
|
||||
|
||||
> Read the project's AGENTS.md (or CLAUDE.md only as compatibility fallback, then README.md if neither exists), then discover the top-level directory layout using the native file-search/glob tool (e.g., `Glob` with pattern `*` or `*/*` in Claude Code). Also read `STRATEGY.md` if it exists — it captures the product's target problem, approach, persona, metrics, and tracks.
|
||||
>
|
||||
> **Two paths for other root-level `*.md` files**, depending on whether the focus hint names them:
|
||||
>
|
||||
> - **User-named references** — if the focus hint names a specific root-level `*.md` file (e.g., focus is "ideate based on FEEDBACK.md", "use NOTES.md as input", "review the gaps in TODO.md"), fully read that file and include its content under a heading `User-named references`. Phase 2 treats these as *constraint*, so sub-agents need actual content, not a gist. Quote or summarize substantive sections; keep one-line gists for files that are mentioned but not the actual subject.
|
||||
> - **Additional context** — for any other root-level `*.md` files (not named in the focus), read briefly and include a one-line gist under a heading `Additional context`. Phase 2 treats these as *background*, so a gist is sufficient.
|
||||
>
|
||||
> Return a concise summary (under 40 lines, longer if user-named references include substantive content) covering:
|
||||
>
|
||||
> - project shape (language, framework, top-level directory layout)
|
||||
> - notable patterns or conventions
|
||||
> - obvious pain points or gaps
|
||||
> - likely leverage points for improvement
|
||||
> - product strategy summary, if `STRATEGY.md` was present — include the approach and active tracks verbatim so ideation can weight toward strategy-aligned directions
|
||||
> - `User-named references` section (when the focus hint named root-level `*.md` files)
|
||||
> - `Additional context` section (when other root-level `*.md` files exist that the focus did not name)
|
||||
>
|
||||
> Keep the scan shallow otherwise — read only top-level documentation and directory structure. Do not analyze GitHub issues, templates, or contribution guidelines. Do not do deep code search.
|
||||
>
|
||||
> Focus hint: {focus_hint}
|
||||
|
||||
2. **Learnings search** — dispatch `ce-learnings-researcher` with a brief summary of the ideation focus.
|
||||
|
||||
3. **Web research** (always-on; see "Web research" subsection below for skip-phrase and V15 cache handling).
|
||||
|
||||
4. **Issue intelligence** (conditional) — if issue-tracker intent was detected in Phase 0.3, dispatch `ce-issue-intelligence-analyst` with the focus hint. Run in parallel with the other agents.
|
||||
|
||||
If the agent returns an error (gh not installed, no remote, auth failure), log a warning to the user ("Issue analysis unavailable: {reason}. Proceeding with standard ideation.") and continue with the remaining grounding.
|
||||
|
||||
If the agent reports fewer than 5 total issues, note "Insufficient issue signal for theme analysis" and proceed with default ideation frames in Phase 2.
|
||||
|
||||
**Elsewhere mode dispatch (skip the codebase scan; user-supplied context is the primary grounding):**
|
||||
|
||||
1. **User-context synthesis** — dispatch a general-purpose sub-agent (cheapest capable model) to read the user-supplied context from Phase 0.4 intake plus any rich-prompt material, and return a structured grounding summary that mirrors the codebase-context shape (project shape → topic shape; notable patterns → stated constraints; pain points → user-named pain points; leverage points → opportunity hooks the context implies). This keeps Phase 2 sub-agents agnostic to grounding source.
|
||||
|
||||
2. **Learnings search** *(elsewhere-software only; skipped by default in elsewhere-non-software)* — dispatch `ce-learnings-researcher` with the topic summary in case relevant institutional knowledge exists (skill-design patterns, prior solutions in similar shape). Skip for elsewhere-non-software: the CWD's `docs/solutions/` is unlikely to be topically relevant for non-digital topics, and running it risks polluting generation with unrelated engineering patterns.
|
||||
|
||||
3. **Web research** — same as repo mode (see subsection below).
|
||||
|
||||
Issue intelligence does not apply in elsewhere mode. Slack research is opt-in for both modes (see "Slack context" below).
|
||||
|
||||
#### Web Research (V5, V15)
|
||||
|
||||
Always-on for both modes. Skip when the user said "no external research", "skip web research", or equivalent in their prompt or earlier answers; in that case, omit `ce-web-researcher` from dispatch and note the skip in the consolidated grounding summary.
|
||||
|
||||
Reuse prior web research within a session via a sidecar cache — see `references/web-research-cache.md` for the cache file shape, reuse check, append behavior, and platform-degradation rules. Read it the first time `ce-web-researcher` would be dispatched in this run (and on every subsequent dispatch where the cache might apply).
|
||||
|
||||
When dispatching `ce-web-researcher`, pass: the focus hint, a brief planning context summary (one or two sentences), and the mode. Do not pass codebase content — the agent operates externally.
|
||||
|
||||
#### Consolidated Grounding Summary
|
||||
|
||||
Consolidate all dispatched results into a short grounding summary using these sections (omit any section that produced nothing). Phase 1.5 will append a `Topic axes` section to this same summary after consolidation completes:
|
||||
|
||||
- **Codebase context** *(repo mode)* — project shape, notable patterns, pain points, leverage points (project-defining files: AGENTS.md/CLAUDE.md/README.md/STRATEGY.md) OR **Topic context** *(elsewhere mode)* — topic shape, stated constraints, user-named pain points, opportunity hooks
|
||||
- **User-named references** *(repo mode, when the focus hint named root-level `*.md` files)* — full content from files the user explicitly named in their prompt or focus. Phase 2 treats these as constraint
|
||||
- **Additional context** *(repo mode, when other root-level markdown was discovered but not named)* — one-line gists per file. Phase 2 treats these as background, not direction
|
||||
- **Past learnings** — relevant institutional knowledge from `docs/solutions/`
|
||||
- **Issue intelligence** *(when present, repo mode only)* — theme summaries with titles, descriptions, issue counts, and trend directions
|
||||
- **External context** *(when web research ran)* — prior art, adjacent solutions, market signals, cross-domain analogies. Note "(reused from earlier dispatch)" when V15 reuse fired
|
||||
- **Slack context** *(when present)* — organizational context
|
||||
|
||||
**Failure handling.** Grounding agent failures follow "warn and proceed" — never block on grounding failure. If `ce-web-researcher` fails (network, tool unavailable), log a warning ("External research unavailable: {reason}. Proceeding with internal grounding only.") and continue. If elsewhere-mode intake produced no usable context, note in the grounding summary that context is thin so Phase 2 sub-agents can compensate with broader generation.
|
||||
|
||||
**Slack context** (opt-in, both modes) — never auto-dispatch. When the user asks for Slack context and Slack tools are available (look for any `slack-researcher` agent or `slack` MCP tools in the current environment), dispatch `ce-slack-researcher` with the focus hint in parallel with other Phase 1 agents. When tools are present but the user did not ask, mention availability in the grounding summary so they can opt in. When the user asked but no Slack tools are reachable, surface the install hint instead.
|
||||
|
||||
### Phase 1.5: Topic-Surface Decomposition
|
||||
|
||||
Before dispatching frame agents in Phase 2, decompose the topic into 3-5 orthogonal **axes** that name *what aspects of the subject to think about*. Phase 2 frames determine *how to think* (the lens); axes determine *what to think on* (the surface). Without an explicit axis list, parallel frames tend to converge on whichever interpretation of the subject is most salient at first read — other parts of the surface go unexamined regardless of how many frames run. Lens diversity alone does not produce surface coverage.
|
||||
|
||||
This step is a single orchestrator-side analysis against the grounding summary already in context. No sub-agent dispatch, no additional grounding read, no user-facing question.
|
||||
|
||||
**Axis criteria:**
|
||||
|
||||
- **3-5 axes.** Fewer than 3 means the topic is atomic — skip per the rule below. More than 5 fragments dispatch and produces thin coverage on each.
|
||||
- **Orthogonal.** A single idea should naturally fall on one axis, not span multiple. Merge axes that overlap heavily.
|
||||
- **Derived from grounding.** The grounding summary contains the substance the axes name; do not pick axes from a generic template (e.g., "discovery / engagement / retention" applied to every topic).
|
||||
- **At the same level.** Don't mix "the entire pricing page" with "the $9.99 tier copy" in the same list.
|
||||
- **Named in the topic's language.** "Send mechanics" beats "outbound flow optimization." Use words a reader of the topic would recognize, not meta-language about ideation.
|
||||
|
||||
**Worked examples (illustrative, not a template — derive from actual grounding):**
|
||||
|
||||
| Topic | Axes |
|
||||
|---|---|
|
||||
| Social sharing of crossfire and convergence pages | Send mechanics; discovery (receive side); arrival/dwell experience; compounding over time; actor types (first-party, expert, reader) |
|
||||
| Improve our authentication system | Sign-in flow; session management; account recovery; permissions; identity providers |
|
||||
| Dark mode for our app | Visual surfaces; toggle UX; system-preference detection; asset variants; edge cases (third-party content) |
|
||||
| Cache invalidation in the data layer | Trigger surfaces; coordination across replicas; staleness tolerance per data class; observability of invalidation events |
|
||||
|
||||
**Skip condition.** Some subjects are atomic and resist meaningful decomposition — a single string output (a name, a tagline), a narrowly-scoped tactical fix ("the typo on line 47 of README"), or a topic where the candidate axes *are* the deliverable (e.g., "what surface should the API expose?"). When 3+ orthogonal axes that pass the criteria above cannot be generated, skip decomposition. Note `Decomposition skipped — atomic subject` in the grounding summary so the artifact records the choice.
|
||||
|
||||
**Surprise-me skip.** In surprise-me mode there is no settled subject to decompose — different frames will surface different subjects in Phase 2, and the cross-cutting synthesis step there serves the analogous coverage role. Skip Phase 1.5 in surprise-me mode and note `Decomposition skipped — surprise-me mode` in the grounding summary.
|
||||
|
||||
Append the axis list (or skip-reason) to the consolidated grounding summary under a section labeled `Topic axes`. Phase 2 reads this section to thread axes into sub-agent prompts; Phase 3 uses it for axis-spread scoring; Phase 5's artifact template includes it under Grounding Context.
|
||||
|
||||
### Phase 2: Divergent Ideation
|
||||
|
||||
Generate the full candidate list before critiquing any idea.
|
||||
|
||||
Dispatch parallel ideation sub-agents on the inherited model (do not tier down -- creative ideation needs the orchestrator's reasoning level). Omit the `mode` parameter so the user's configured permission settings apply. Dispatch count is mode-conditional: **4 sub-agents only when issue-tracker intent was detected in Phase 0.2 AND the issue intelligence agent returned usable themes** (see override below — cluster-derived frames capped at 4); **6 sub-agents otherwise**, including the insufficient-issue-signal fallback from Phase 1 where intent triggered but themes were not returned. Each targets ~6-8 ideas (yielding ~36-48 raw ideas across 6 frames or ~24-32 across 4 frames, roughly 25-30 survivors after dedupe in the 6-frame path and fewer in the 4-frame path). Adjust per-agent targets when volume overrides apply (e.g., "100 ideas" raises it, "top 3" may lower the survivor count instead).
|
||||
|
||||
Give each sub-agent: the grounding summary, the focus hint, the per-agent volume target, the **topic axis list from Phase 1.5** (when decomposition produced one), and an instruction to generate raw candidates only (not critique). Each agent's first few ideas tend to be obvious -- push past them. Ground every idea in the Phase 1 grounding summary.
|
||||
|
||||
**Axis spread instruction.** When an axis list is present, instruct each sub-agent to distribute its ideas across multiple axes — the frame's lens applies to every axis, but ideas should not all cluster on one. Each idea must be tagged with the axis it targets. The frame is a lens; the axis list is the surface map. A frame that plausibly reaches an axis should produce at least one idea there before doubling up on a different axis. When decomposition was skipped (atomic subject or surprise-me), omit the axis instruction entirely — do not invent axes at dispatch time.
|
||||
|
||||
**Constraint vs background.** In the dispatch prompt, mark the user's prompt, focus hint, and any *User-named references* (root-level files the user named in their focus and the codebase-scan fully read) as *constraints* — ideas that violate them are out regardless of basis. Mark the rest of the grounding summary (codebase context, additional context, learnings, external context) as *background* — informative, not directive. Background can support an idea's basis and inform direction; it must not pull ideation toward whatever was loudest in the corpus when the user named a different focus. This is the primary defense against grounding noise (an unrelated `FEEDBACK.md` the user did not name, a tangentially-cited prior-art result) shaping survivors against user intent.
|
||||
|
||||
Assign each sub-agent a different ideation frame as a **starting bias, not a constraint**. Prompt each to begin from its assigned perspective but follow any promising thread -- cross-cutting ideas that span multiple frames are valuable.
|
||||
|
||||
**Frame selection (mode-symmetric — same six frames in repo and elsewhere modes):**
|
||||
|
||||
1. **Pain and friction** — user, operator, or topic-level pain points; what is consistently slow, broken, or annoying.
|
||||
2. **Inversion, removal, or automation** — invert a painful step, remove it entirely, or automate it away.
|
||||
3. **Assumption-breaking and reframing** — what is being treated as fixed that is actually a choice; reframe one level up or sideways.
|
||||
4. **Leverage and compounding** — choices that, once made, make many future moves cheaper or stronger; second-order effects.
|
||||
5. **Cross-domain analogy** — generate ideas by asking how completely different fields solve a structurally analogous problem. The grounding domain is the user's topic; the analogy domain is anywhere else (other industries, biology, games, infrastructure, history). Push past the obvious analogy to non-obvious ones.
|
||||
6. **Constraint-flipping** — invert the obvious constraint to its opposite or extreme. What if the budget were 10x or 0? What if the team were 100 people or 1? What if there were no users, or 1M? Use the resulting design as a candidate even if the constraint flip itself is not realistic.
|
||||
|
||||
**Issue-tracker mode override (repo mode only).** When issue-tracker intent is active and themes were returned by the issue intelligence agent: each high/medium-confidence theme becomes a frame. Pad with frames from the 6-frame default pool (in the order listed above) if fewer than 3 cluster-derived frames. Cap at 4 total — issue-tracker mode keeps its tighter dispatch by design.
|
||||
|
||||
**Per-idea output contract (uniform across all frames, all modes):**
|
||||
|
||||
Each sub-agent returns this structure per idea:
|
||||
|
||||
- **title**
|
||||
- **summary** (2-4 sentences)
|
||||
- **axis** — required when Phase 1.5 produced an axis list. Pick the one axis this idea most centrally targets; do not span. Omit entirely when decomposition was skipped.
|
||||
- **basis** (required, tagged) — one of:
|
||||
- `direct:` quoted line / specific file / named issue / explicit user-supplied context
|
||||
- `external:` named prior art, domain research, adjacent pattern, with source
|
||||
- `reasoned:` explicit first-principles argument for why this move likely applies — not a gesture; the argument is written out
|
||||
- **why_it_matters** — connects the basis to the move's significance
|
||||
- **meeting_test** — one line confirming this would warrant team discussion (waived when Phase 0.5 detected tactical focus signals)
|
||||
|
||||
Basis is required, not optional. If a sub-agent cannot articulate a basis of at least one type, the idea does not surface. The failure mode to prevent is generic "AI-slop" ideas that sound plausible but lack a basis the user can verify.
|
||||
|
||||
**Generation rules (uniform across frames, all modes):**
|
||||
|
||||
- Every idea carries an articulated basis. Unjustified speculation does not surface, regardless of how plausible it sounds.
|
||||
- Bias toward the basis type your frame naturally produces — pain/inversion/leverage tend toward `direct:`; analogy and constraint-flipping tend toward `reasoned:`; assumption-breaking is mixed — but don't exclude other basis types.
|
||||
- Apply the meeting-test as a default floor: would this idea warrant team discussion? If not, it's below the floor and does not surface. The floor is relaxed only when Phase 0.5 detected tactical focus signals.
|
||||
- Stay within the subject's identity. Product expansions, new surfaces, new markets, retirements, and architectural pivots are fair game when the basis supports them. Subject-replacement moves (abandoning the project, pivoting to unrelated domains, becoming a different organization) are out regardless of basis.
|
||||
- **Honor the asked scope.** When the focus hint names a part of the subject (a flow, a stage, a section, a feature within a larger product — e.g., "account settings", "onboarding flow", "pricing page copy", "gameplay rules"), ideate at full ambition *within that scope*. Expanding the surface to the whole subject — proposing fundamental changes to the broader product when the user named one slice — is a scope mismatch even when no subject-replacement occurred. Big-picture thinking still applies; it just operates inside the bounded surface the user named, not by widening the surface.
|
||||
|
||||
**Surprise-me mode addendum.** When Phase 0.2 routed to surprise-me, include this additional instruction in each sub-agent's dispatch prompt:
|
||||
|
||||
> No user-specified subject. Through your frame's lens, explore the Phase 1 material and identify the subject(s) you find most interesting for this frame. Different frames finding different subjects is the feature — cross-subject divergence is what makes surprise-me valuable. Each idea still carries a basis; the basis may include identification of the subject itself (why *this* subject is worth ideating on through your lens, citing what in the Phase 1 material signals it).
|
||||
|
||||
After all sub-agents return:
|
||||
|
||||
1. Merge and dedupe into one master candidate list.
|
||||
2. Synthesize cross-cutting combinations -- scan for ideas from different frames that combine into something stronger. In specified mode, expect 3-5 additions at most. **In surprise-me mode, cross-cutting is the magic layer** — frames often converge on overlapping subjects or find complementary angles; expect 5-8 additions and give this step more attention. Surface combinations that span multiple frame-chosen subjects as a distinctive surprise-me output pattern.
|
||||
3. **Axis-coverage check (when Phase 1.5 produced an axis list; skipped otherwise).** Count ideas per axis after dedupe. For any axis with zero ideas, dispatch one recovery sub-agent (any unused frame, or the frame whose lens fits the missing axis best — e.g., Pain & friction for usability axes, Cross-domain analogy for distribution or compounding axes) targeting that axis specifically. The recovery dispatch carries the same per-idea output contract and ~3-5 ideas as its target. **Cap recovery at 2 axes total** — if more than 2 axes are empty after the first round, accept thin coverage rather than fanning out further. After recovery returns, merge into the master list and dedupe again. Note empty axes that were not recovered in the rejection summary as "axis: <name> — recovery skipped (cap reached)" so the gap is visible to the user.
|
||||
4. If a focus was provided, weight the merged list toward it without excluding stronger adjacent ideas.
|
||||
5. Spread ideas across multiple dimensions when justified: workflow/DX, reliability, extensibility, missing capabilities, docs/knowledge compounding, quality/maintenance, leverage on future work.
|
||||
|
||||
**Checkpoint A (V17).** Immediately after the cross-cutting synthesis step completes and the raw candidate list is consolidated, write `<scratch-dir>/raw-candidates.md` (using the absolute path captured in Phase 1) containing the full candidate list with sub-agent attribution. This protects the most expensive output (6 parallel sub-agent dispatches + dedupe) before Phase 3 critique potentially compacts context. Best-effort: if the write fails (disk full, permissions), log a warning and proceed; the checkpoint is not load-bearing. Not cleaned up at the end of the run (the run directory is preserved so the V15 cache remains reusable across run-ids in the same session — see Phase 6).
|
||||
|
||||
After merging and synthesis — and before presenting survivors — load `references/post-ideation-workflow.md`. This load is non-optional. The file contains the adversarial filtering rubric, artifact template, quality bar, and the canonical Phase 6 handoff menu (Refine, Open and iterate in Proof, Brainstorm, Save and end) — these options do not appear anywhere in this main body. Skipping the load silently degrades every subsequent step; the agent improvises the menu from memory instead of presenting the documented options. "Quickly" means fewer Phase 2 sub-agents, not skipping references. Do not load this file before Phase 2 agent dispatch completes.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Post-Ideation Workflow
|
||||
|
||||
Read this file after Phase 2 ideation agents return and the orchestrator has merged and deduped their outputs into a master candidate list. Do not load before Phase 2 completes.
|
||||
|
||||
## Phase 3: Adversarial Filtering
|
||||
|
||||
Review every candidate idea critically. The orchestrator performs this filtering directly -- do not dispatch sub-agents for critique.
|
||||
|
||||
Do not generate replacement ideas in this phase unless explicitly refining.
|
||||
|
||||
For each rejected idea, write a one-line reason.
|
||||
|
||||
Rejection criteria:
|
||||
- too vague
|
||||
- not actionable
|
||||
- duplicates a stronger idea
|
||||
- not grounded in the stated context
|
||||
- too expensive relative to likely value
|
||||
- already covered by existing workflows or docs
|
||||
- interesting but better handled as a brainstorm variant, not a product improvement
|
||||
- **unjustified — no articulated basis** (sub-agent failed to provide `direct:`, `external:`, or `reasoned:` justification, or the stated basis does not actually support the claimed move)
|
||||
- **below ambition floor** (fails the meeting-test: would not warrant team discussion — except when Phase 0.5 detected tactical focus signals, in which case this criterion is waived)
|
||||
- **subject-replacement** (abandons or replaces the subject of ideation rather than operating on it — e.g., "pivot to an unrelated domain," "become a different organization")
|
||||
- **scope overrun** (expands beyond the asked scope rather than ideating within it — e.g., proposes changes to the whole product when the user asked about one flow, stage, or section). Allowed only when the basis explicitly justifies the expansion; default is reject or downgrade.
|
||||
|
||||
Score survivors using a consistent rubric weighing: groundedness in stated context, **basis strength** (`direct:` > `external:` > `reasoned:`; none excluded, but direct-evidence ideas score higher all else equal), expected value, novelty, pragmatism, leverage on future work, implementation burden, overlap with stronger ideas, and **axis spread** (when Phase 1.5 produced an axis list) — survivor sets that cover the topic's surface outscore sets that cluster on one axis, all else equal.
|
||||
|
||||
**Axis coverage as a list-level concern.** When axes were defined, axis spread is evaluated across the survivor set, not per-idea. After per-idea filtering, check the survivor set: if axis coverage is uneven and stronger candidates exist on under-represented axes, prefer the spread when promoting borderline candidates. Phase 2's recovery dispatch should already have surfaced candidates for empty axes; this is a polish step on the survivor selection. If an axis ends up with zero survivors despite recovery (or because recovery hit the 2-axis cap), note it in the rejection summary as a deliberate gap rather than an oversight.
|
||||
|
||||
Target output:
|
||||
- keep 5-7 survivors by default
|
||||
- if too many survive, run a second stricter pass
|
||||
- if fewer than 5 survive, report that honestly rather than lowering the bar
|
||||
|
||||
## Phase 4: Present the Survivors
|
||||
|
||||
**Checkpoint B (V17).** Before presenting, write `<scratch-dir>/survivors.md` (using the absolute path captured in Phase 1) containing the survivor list plus key context (focus hint, grounding summary, rejection summary). This protects the post-critique state before the user reaches the persistence menu. Best-effort: if the write fails (disk full, permissions), log a warning and proceed; the checkpoint is not load-bearing. Reuses the same `<run-id>` and `<scratch-dir>` generated in Phase 1; not cleaned up at the end of the run (the run directory is preserved so the V15 cache remains reusable across run-ids in the same session — see Phase 6).
|
||||
|
||||
Present the surviving ideas to the user. The terminal review loop is a complete ideation cycle in itself — persistence is opt-in (Phase 5), and refinement happens in conversation with no file or network cost (Phase 6).
|
||||
|
||||
Present only the surviving ideas in structured form:
|
||||
|
||||
- title
|
||||
- description
|
||||
- **axis** (when Phase 1.5 produced an axis list)
|
||||
- **basis** (tagged `direct:` / `external:` / `reasoned:`, with the quoted evidence, cited source, or written-out argument)
|
||||
- rationale (how the basis connects to the move's significance)
|
||||
- downsides
|
||||
- confidence score
|
||||
- estimated complexity
|
||||
|
||||
Then include a brief rejection summary so the user can see what was considered and cut.
|
||||
|
||||
Keep the presentation concise. Allow brief follow-up questions and lightweight clarification.
|
||||
|
||||
## Phase 5: Persistence (Opt-In, Mode-Aware)
|
||||
|
||||
Persistence is opt-in. The terminal review loop is a complete ideation cycle. Refinement loops happen in conversation with no file or network cost. Persistence triggers only when the user explicitly chooses to save, share, or hand off (selected in Phase 6).
|
||||
|
||||
When the user picks an option in Phase 6 that requires a durable record (Open and iterate in Proof, Brainstorm, Save and end), ensure a record exists first. When the user chooses to keep refining, no record is needed unless the user asks.
|
||||
|
||||
**Mode-determined defaults:**
|
||||
|
||||
| Action | Repo mode default | Elsewhere mode default |
|
||||
|---|---|---|
|
||||
| Save | `docs/ideation/YYYY-MM-DD-<topic>-ideation.md` | Proof |
|
||||
| Share | Proof (additional) | Proof (primary) |
|
||||
| Brainstorm handoff | `ce-brainstorm` | `ce-brainstorm` (universal-brainstorming) |
|
||||
| End | Conversation only is fine | Conversation only is fine |
|
||||
|
||||
Either mode can also use the other destination on explicit request ("save to Proof even though this is repo mode", "save to a local file even though this is elsewhere"). Honor such overrides directly.
|
||||
|
||||
### 5.1 File Save (default for repo mode; on request for elsewhere mode)
|
||||
|
||||
1. Ensure `docs/ideation/` exists
|
||||
2. Choose the file path:
|
||||
- `docs/ideation/YYYY-MM-DD-<topic>-ideation.md`
|
||||
- `docs/ideation/YYYY-MM-DD-open-ideation.md` when no focus exists
|
||||
3. Write or update the ideation document
|
||||
|
||||
Use this structure and omit clearly irrelevant fields only when necessary:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: YYYY-MM-DD
|
||||
topic: <kebab-case-topic>
|
||||
focus: <optional focus hint>
|
||||
mode: <repo-grounded | elsewhere-software | elsewhere-non-software>
|
||||
---
|
||||
|
||||
# Ideation: <Title>
|
||||
|
||||
## Grounding Context
|
||||
[Grounding summary from Phase 1 — labeled "Codebase Context" in repo mode, "Topic Context" in elsewhere mode]
|
||||
|
||||
## Topic Axes
|
||||
[3-5 axes from Phase 1.5, one per line, OR a single line `Decomposition skipped — atomic subject` / `Decomposition skipped — surprise-me mode` when Phase 1.5 was skipped. Omit this section entirely if not applicable.]
|
||||
|
||||
## Ranked Ideas
|
||||
|
||||
### 1. <Idea Title>
|
||||
**Description:** [Concrete explanation]
|
||||
**Axis:** [Topic axis this idea targets — omit when decomposition was skipped]
|
||||
**Basis:** [`direct:` / `external:` / `reasoned:` — quoted, cited, or written-out argument]
|
||||
**Rationale:** [How the basis connects to the move's significance]
|
||||
**Downsides:** [Tradeoffs or costs]
|
||||
**Confidence:** [0-100%]
|
||||
**Complexity:** [Low / Medium / High]
|
||||
**Status:** [Unexplored / Explored]
|
||||
|
||||
## Rejection Summary
|
||||
|
||||
| # | Idea | Reason Rejected |
|
||||
|---|------|-----------------|
|
||||
| 1 | <Idea> | <Reason rejected> |
|
||||
|
||||
[When applicable, append axis-coverage gaps as their own rows so the gap is visible:]
|
||||
| - | axis: <name> | recovery skipped (cap reached) — no survivors on this axis |
|
||||
```
|
||||
|
||||
If resuming:
|
||||
- update the existing file in place
|
||||
- preserve explored markers
|
||||
|
||||
### 5.2 Proof Save (default for elsewhere mode; on request for repo mode)
|
||||
|
||||
Hand off the ideation content to the `ce-proof` skill in HITL review mode. This uploads the doc, runs an iterative review loop (user annotates in Proof, agent ingests feedback, applies agreed edits, and replies/resolves in-thread), and (in repo mode) syncs the reviewed markdown back to `docs/ideation/`.
|
||||
|
||||
Load the `ce-proof` skill in HITL-review mode with:
|
||||
|
||||
- **source content:** the survivors and rejection summary from Phase 4 (in repo mode, this is the file written in 5.1; in elsewhere mode, render to a temp file as the source for upload)
|
||||
- **doc title:** `Ideation: <topic>` or the H1 of the ideation doc
|
||||
- **identity:** `ai:compound-engineering` / `Compound Engineering`
|
||||
- **recommended next step:** `/ce-brainstorm` (shown in the proof skill's final terminal output)
|
||||
|
||||
The Proof failure ladder in Phase 6.5 governs what happens when this hand-off fails.
|
||||
|
||||
**Caller-aware return.** The return-rule bullets below describe the default control flow, but the next step depends on which Phase 6 option invoked the Proof save. Apply the right branch for the caller:
|
||||
|
||||
- **§6.2 Open and iterate in Proof.** Behavior is mode-aware:
|
||||
- *Repo mode:* return to the Phase 6 menu on every status. The Proof-reviewed content is now synced locally, and the user typically has a follow-up action in the repo (brainstorm toward a plan, save and end, or keep refining).
|
||||
- *Elsewhere mode:* on a successful Proof return (`proceeded` or `done_for_now`), exit cleanly — narrate that the artifact lives at `docUrl` (including any stale-local note if applicable) and stop. Proof iteration is often the terminal act in elsewhere mode; forcing another menu choice after the user already got what they came for produces decision fatigue. Only the `aborted` branch returns to the Phase 6 menu so the user can retry or pick another path.
|
||||
- **§6.3 Brainstorm a selected idea.** On a successful Proof return (`proceeded` or `done_for_now`), do **not** stop at the Phase 6 menu — after applying the per-status handling below (including any stale-local pull offer), continue into §6.3's remaining bullets (mark the chosen idea as `Explored`, then load `ce-brainstorm`). Only the `aborted` branch returns to the Phase 6 menu, since no durable record was written.
|
||||
- **§6.4 Save and end.** On a successful Proof return (`proceeded` or `done_for_now`), exit cleanly: narrate that the ideation was saved, surface the `docUrl` (and the local-path note if applicable), and stop. Do **not** re-ask the Phase 6 question — the user already chose to end. Only the `aborted` branch returns to the Phase 6 menu so the user can retry or pick a different path.
|
||||
|
||||
When the proof skill returns control:
|
||||
|
||||
- `status: proceeded` with `localSynced: true` → the ideation doc on disk now reflects the review. Apply the caller-aware return rule above for the invoking branch.
|
||||
- `status: proceeded` with `localSynced: false` → the reviewed version lives in Proof at `docUrl` but the local copy is stale. Offer to pull the Proof doc to `localPath` using the proof skill's Pull workflow. Apply the caller-aware return rule above; if the pull was declined, include a one-line note that `<localPath>` is stale vs. Proof so the next handoff (or final exit narration) doesn't read the old content silently. Placement: above the Phase 6 menu when the caller-aware rule returns to it, in the handoff preamble to `ce-brainstorm` for §6.3, or alongside the final save/exit narration for §6.2 elsewhere / §6.4.
|
||||
- `status: done_for_now` → the doc on disk may be stale if the user edited in Proof before leaving. Offer to pull the Proof doc to `localPath` so the local ideation artifact stays in sync, then apply the caller-aware return rule above. `done_for_now` means the user stopped the HITL loop — it does not mean they ended the whole ideation session unless the caller-aware rule exits (§6.2 elsewhere mode or §6.4). If the pull was declined, include the stale-local note at the placement described in the previous bullet.
|
||||
- `status: aborted` → fall back to the Phase 6 menu without changes, regardless of caller. No durable record was written, so §6.3 must not proceed with the brainstorm handoff and §6.4 must not end — the menu lets the user retry or pick another path.
|
||||
|
||||
## Phase 6: Refine or Hand Off
|
||||
|
||||
Ask what should happen next using the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to numbered options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
|
||||
|
||||
**Question:** "What should the agent do next?"
|
||||
|
||||
Offer these four options (labels are self-contained with the distinguishing word front-loaded so options stay distinct when truncated):
|
||||
|
||||
1. **Refine the ideation in conversation (or stop here — no save)** — add ideas, re-evaluate, or deepen analysis. No file or network side effects; ending the conversation at any point after this pick is a valid no-save exit.
|
||||
2. **Open and iterate in Proof** — save the ideation to Proof and enter the proof skill's HITL review loop: iterate via comments in the Proof editor; reviewed edits sync back to `docs/ideation/` in repo mode.
|
||||
3. **Brainstorm a selected idea** — load `ce-brainstorm` with the chosen idea as the seed. The orchestrator first writes a durable record using the mode default in Phase 5.
|
||||
4. **Save and end** — persist the ideation using the mode default (file in repo mode, Proof in elsewhere mode), then end.
|
||||
|
||||
No-save exit is supported without a dedicated menu option. Pick option 1 and stop the conversation, or use the question tool's free-text escape to say so directly — persistence is opt-in and the terminal review loop is already a complete ideation cycle.
|
||||
|
||||
Do not delete the run's scratch directory (`<scratch-dir>` resolved in Phase 1) on completion. The V15 web-research cache is session-scoped and reused across run-ids by later ideation invocations in the same session (see `references/web-research-cache.md`); per-run cleanup would defeat that reuse. Checkpoint A (`raw-candidates.md`) and Checkpoint B (`survivors.md`) are cheap to leave behind and follow the repo's Scratch Space cross-invocation-reusable convention — OS handles eventual cleanup.
|
||||
|
||||
### 6.1 Refine the Ideation in Conversation
|
||||
|
||||
Route refinement by intent:
|
||||
|
||||
- `add more ideas` or `explore new angles` -> return to Phase 2
|
||||
- `re-evaluate` or `raise the bar` -> return to Phase 3
|
||||
- `dig deeper on idea #N` -> expand only that idea's analysis
|
||||
|
||||
No persistence triggers during refinement. The user can choose Save and end (or Brainstorm, or Open and iterate in Proof) when they are ready to persist.
|
||||
|
||||
Ending after refinement — or without any refinement at all — is a valid no-save exit. There is no required next step; stopping the conversation here leaves no durable artifact, which matches the opt-in persistence contract.
|
||||
|
||||
### 6.2 Open and Iterate in Proof
|
||||
|
||||
Invoke the Proof HITL review path via §5.2 with §6.2 as the caller. In repo mode, ensure the local file exists first (run §5.1) so the HITL sync-back has a target; in elsewhere mode, §5.2 renders to a temp file as usual. Honor Phase 5's "ensure a record exists first" contract either way.
|
||||
|
||||
Apply §5.2's caller-aware return rule for the §6.2 branch — behavior is mode-aware. In repo mode, return to the Phase 6 menu on every status so the user can pick a follow-up (brainstorm toward a plan, save-and-end, or keep refining) now that the Proof review is reflected in the local file. In elsewhere mode, exit cleanly on a successful Proof return since Proof iteration is often the terminal act — the artifact lives at `docUrl` and is the canonical record; only the `aborted` status returns to the menu.
|
||||
|
||||
If the Proof handoff fails, the §6.5 Proof Failure Ladder governs recovery.
|
||||
|
||||
### 6.3 Brainstorm a Selected Idea
|
||||
|
||||
- Write or update the durable record per the mode default in Phase 5 (file in repo mode, Proof in elsewhere mode). When this routes through §5.2 Proof Save, apply §5.2's caller-aware return rule: continue into the next bullet on a successful Proof return instead of bouncing back to the Phase 6 menu. If Proof returned `aborted` (no durable record written), go back to the Phase 6 menu and do **not** proceed with the brainstorm handoff.
|
||||
- Mark the chosen idea as `Explored` in the saved record
|
||||
- Load the `ce-brainstorm` skill with the chosen idea as the seed
|
||||
|
||||
**Repo mode only:** do **not** skip brainstorming and go straight to `ce-plan` from ideation output — `ce-plan` wants brainstorm-grounded requirements. In elsewhere modes, ideation (or ideation + Proof iteration) is a legitimate terminal state; brainstorming is optional deeper development of one idea, not a required next rung on an implementation ladder that does not exist in these modes.
|
||||
|
||||
### 6.4 Save and End
|
||||
|
||||
Persist via the mode default (5.1 in repo mode, 5.2 in elsewhere mode), then end. If the user instead asked to use the non-default destination, honor that explicit request.
|
||||
|
||||
When the path lands in a Proof save (5.2), apply §5.2's caller-aware return rule for the §6.4 branch: on a successful Proof return, exit cleanly — narrate the save, surface the `docUrl` (and any stale-local note if the pull was declined), and stop. Do **not** loop back to the Phase 6 menu; the user already chose to end. Only a `status: aborted` from Proof returns to the menu so the user can retry or pick another path (file save, custom path, or keep refining). The §6.5 Proof Failure Ladder still governs persistent Proof failures and ends at the Phase 6 menu — that failure-recovery path is distinct from the successful-save exit described here.
|
||||
|
||||
When the path lands in a file save (5.1):
|
||||
|
||||
- offer to commit only the ideation doc
|
||||
- do not create a branch
|
||||
- do not push
|
||||
- if the user declines, leave the file uncommitted
|
||||
|
||||
After the file save (and optional commit), end the session — do not return to the Phase 6 menu.
|
||||
|
||||
### 6.5 Proof Failure Ladder
|
||||
|
||||
The `ce-proof` skill performs single-retry-once internally on transient failures (`STALE_BASE`, `BASE_TOKEN_REQUIRED`) before surfacing failure. The proof skill's return contract does not expose typed error classes to callers — the orchestrator cannot distinguish retryable vs terminal failures from outside.
|
||||
|
||||
**Orchestrator-side retry harness (intentionally minimal):** wrap the proof skill invocation in **one** additional best-effort retry with a short pause (~2 seconds). The proof skill already retried internally, so this catches transient races at the orchestrator boundary without compounding latency. Do not classify error types from outside the skill — no detection mechanism exists.
|
||||
|
||||
Distinguish create-failure from ops-failure by inspecting whether the proof skill returned a `docUrl` before failing:
|
||||
|
||||
- **Create-failure** (no `docUrl` returned): retry the create.
|
||||
- **Ops-failure** (a `docUrl` was returned, but a later operation failed): retry only the failing operation. **Do not recreate** the document.
|
||||
|
||||
**Failure narration.** Narrate the single retry to the terminal so the pause does not look like a hang ("Retrying Proof... attempt 2/2"). On persistent failure, narrate that retry exhausted before showing the fallback menu.
|
||||
|
||||
**Fallback menu after persistent failure.** Use the platform's blocking question tool. Present these options (omit option (a) if no repo exists at CWD):
|
||||
|
||||
- "Save to `docs/ideation/` instead" (repo-mode default destination, available when CWD is inside a git repo)
|
||||
- "Save to a custom path the user provides" (validate writable; create parent dirs)
|
||||
- "Skip save and keep the ideation in conversation" (no persistence)
|
||||
|
||||
If proof returned a partial `docUrl` before failing, surface that URL alongside the fallback options so the user can recover or share the partial record.
|
||||
|
||||
After the fallback completes (any path), continue back to the Phase 6 menu so the user can still refine, iterate in Proof, brainstorm, or save and end.
|
||||
|
||||
## Quality Bar
|
||||
|
||||
Before finishing, check:
|
||||
|
||||
- the idea set is grounded in the stated context (codebase in repo mode; user-supplied context in elsewhere mode)
|
||||
- **every surviving idea has an articulated basis** (`direct:`, `external:`, or `reasoned:`) that actually supports the claimed move — speculation dressed as ambition was rejected, with reasons
|
||||
- **every surviving idea passes the meeting-test** unless Phase 0.5 detected tactical focus signals that waived the floor
|
||||
- **no surviving idea replaces the subject** rather than operating on it
|
||||
- when Phase 1.5 produced an axis list, the survivor set spreads across axes rather than clustering on one — and any axis with zero survivors is noted as a deliberate gap in the rejection summary, not silently absent
|
||||
- the candidate list was generated before filtering
|
||||
- the original many-ideas -> critique -> survivors mechanism was preserved
|
||||
- if sub-agents were used, they improved diversity without replacing the core workflow
|
||||
- every rejected idea has a reason
|
||||
- survivors are materially better than a naive "give me ideas" list
|
||||
- persistence followed user choice — terminal-only sessions did not write a file or call Proof
|
||||
- when persistence did trigger, the mode default was respected unless the user explicitly overrode it
|
||||
- acting on an idea routes to `ce-brainstorm`, not directly to implementation
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user