diff --git a/AGENTS.md b/AGENTS.md index 7b2661f3..7bda2674 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,15 +37,16 @@ ccam stage --tool codex --evidence "" a step of the actual work. The dashboard also *infers* your stage from your tool calls (it reads Codex's -own rollout journals — see `docs/LANES.md#stage-detection`), but an inference -only paints a dashed amber badge. It never counts as evidence and never renders -a node green. Only your own declaration does. +own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane +that inference does move the pipeline map forward on its own. It still never +counts as **evidence**: a node it moved through stays amber and never renders +green. Only your own declaration does that — which is why the call above is +worth making even when the map already looks right. -If you go a while without declaring while that inference has clearly moved -past your last declared stage, the dashboard may type a one-line `[ccam]` -reminder straight into this pane asking you to run `ccam stage`. That is the -dashboard, not the user — treat it as the same instruction as this section and -just make the real call. +If you hold a stage for a while with no evidence recorded, the dashboard may +type a one-line `[ccam]` reminder straight into this pane asking you to run +`ccam stage ... --evidence "..."`. That is the dashboard, not the user — treat +it as the same instruction as this section and just make the real call. ## Where to work - `server/` for API/routes/data processing. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8f0f224d..79abcff6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1569,11 +1569,21 @@ at a live rollout file, so reading the JSONL avoids coupling to a schema the next Codex release renames. `rolloutFiles()` is the single function to re-point if that changes. -### Codex declared-stage reliability (`server/lib/codex-nudge.js`) +A written detection then **also advances the lane's declared `stage`** — +`setStage(lane.id, { stage: nodeId, keepDetection: true })`, the only place in +CCAM where an inference moves the blue `current` ring. It is gated on +`recordDetection` having written, so it inherits forward-only/declared-wins/TTL +unchanged; it passes no `evidence`, so the node renders `passed-no-evidence` +(amber) and never `done`; and `keepDetection` is the sole exception to +`setStage`'s clearing of the detection columns, so the card still shows which +command moved it rather than looking like an agent's own claim. No template's +last node carries `detect` rules, so a lane can never be auto-declared `done`. -Detection alone only paints the amber badge; a codex lane's *declared* stage -depends on the session calling `ccam stage` itself, so two mechanisms make -that more likely without weakening declared-wins/inferred-never-`done`: +### Codex evidence reliability (`server/lib/codex-nudge.js`) + +Auto-advance moves the ring but can never supply evidence; only the session's +own `ccam stage --evidence` call can, so three mechanisms make that more likely +without weakening declared-wins/inferred-never-`done`: - **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex pane's first prompt is pre-filled with a plain-text checklist of @@ -1581,17 +1591,23 @@ that more likely without weakening declared-wins/inferred-never-`done`: — the same up-front nudge a Claude lane gets from its pre-filled `/ccam:ship-feature-lane` skill command, in plain text since Codex has no slash commands. -- **Stale-detection nudge** (`sweepCodexNudges`, an unref'd 60 s interval from +- **`AGENTS.md` stage block** (`server/lib/lane-agents.js`): on a `codex` lane, + `ccam lanes agents install` writes a marker-delimited + `` section into `/AGENTS.md` instead of + copying the two agent templates into `.claude/agents/`, which Codex never + reads. Appended to an existing file (user prose preserved), and a reinstall + replaces exactly that block. +- **Missing-evidence nudge** (`sweepCodexNudges`, an unref'd 60 s interval from `server/index.js`, tunable via `DASHBOARD_CODEX_NUDGE_MS`, `0` disables): - when a codex lane's fresh (non-stale) `detected_stage` has sat ahead of its - declared `stage` for at least two minutes and hasn't already been nudged for - that exact detected stage, it types a one-line `[ccam] ... run: ccam stage - ...` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if + when a codex lane's current stage has been held for at least two minutes with + `stages[stage].evidence` still null and hasn't already been nudged for that + exact stage, it types a one-line `[ccam] ... run: ccam stage ... --evidence + "..."` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if the pane isn't running an interactive program — `pty-run.js`'s `SHELL_COMMANDS` — since Enter there would execute the reminder as a shell - command instead of submitting it as a chat turn). It never writes `stage` or - `detected_stage` — any resulting declaration is still one the codex session - made itself, through the real `ccam stage` call. + command instead of submitting it as a chat turn). It never writes `stage`, + `stages` or `detected_stage` — any resulting evidence is still something the + codex session declared itself, through the real `ccam stage` call. --- diff --git a/CLAUDE.md b/CLAUDE.md index 94129999..15bcf1a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: 13b2bc4 (2026-09-03)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: 8a812a1 (2026-09-03) + codex auto-advance._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -23,7 +23,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - Sessions are bound to lanes by longest path-boundary prefix match on their `cwd` (set in hook data from `claude` CLI). - A lane's stage is **declared** by the driving Claude session (via `ccam stage [--evidence "..."]`), not inferred by the dashboard. - **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/ccam.js`) answers in order: `--lane`/leading id → `CCAM_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work. -- **The console never writes a lane's stage.** Declared stages come from `ccam stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion. +- **The console never writes a lane's stage.** Declared stages come from `ccam stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **One exception, `codex` lanes only:** `codex-ingest.js` advances `stage` from its own written detection (`setStage(..., { keepDetection: true })`) — a codex session has no hook and rarely calls `ccam stage`, so nothing else would ever move its map. It passes NO evidence, so the node renders `passed-no-evidence` (amber) and never `done`, and `keepDetection` preserves the detection columns so the card still shows the command that moved it. Do not extend this to `claude` lanes or to the console. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion. - **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `ccam feature activate ` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it. - **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life. - **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently. diff --git a/README.md b/README.md index 4a9ff2e5..077b6df2 100644 --- a/README.md +++ b/README.md @@ -131,13 +131,18 @@ choices, and resumes with `codex resume --last`. Codex emits none of this dashboard's hooks, so a Codex lane records its runs but stays absent from Sessions and Analytics. Its **stage detection** works anyway, from Codex's own rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every -`DASHBOARD_CODEX_POLL_MS` ms, default 15000) — what a Codex lane cannot get for -free is a *declared* stage, since only the session itself can call `ccam stage`. -Two things help it get there: the run console pre-fills a codex pane's opening -prompt with a stage-declaration checklist built from its own pipeline nodes, -and a 60s sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line -reminder into a lane's pane if its detected stage sits fresh and undeclared for -more than two minutes. +`DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection +also **advances the declared stage** — the one place an inference moves the blue +`current` ring, because a Codex session has no hook and nothing else would ever +move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced +node stays amber (`passed-no-evidence`), never green, since only the session +itself can say what it finished. Three things help it get there: the run console +pre-fills a codex pane's opening prompt with a stage-declaration checklist built +from its own pipeline nodes, `ccam lanes agents install` writes the same +instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s +sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into +a lane's pane if its current stage sits without evidence for more than two +minutes. The declared stage comes from `ccam stage`. The inferred stage comes from tool events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can diff --git a/docs/LANES.md b/docs/LANES.md index 1062003f..8dd876c9 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -73,11 +73,14 @@ Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tr **These are not bugs; they reflect Codex's architecture.** The stage you declare via `ccam stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented. **Stage detection is not in that list.** It does work for a codex lane, from -Codex's own on-disk rollout journals rather than from hooks — see "Stage -detection" below. What a codex lane cannot get for free is a *declared* stage: -the run console never pre-fills a Claude slash command into a codex pane (Codex +Codex's own on-disk rollout journals rather than from hooks, and unlike a claude +lane a written detection there also **advances the lane's declared `stage`** — +see "Stage detection" below. What a codex lane cannot get for free is +*evidence*: an auto-advanced node renders `passed-no-evidence` (amber), never +`done`, because only the session itself can say what it actually finished. The +run console never pre-fills a Claude slash command into a codex pane (Codex would read `/ccam:ship-feature-lane` as prose), so a codex session declares -stages only when something tells it to. Two things do: +evidence only when something tells it to. Three things do: - **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's first prompt with a plain-text list of `ccam stage --tool codex` @@ -86,14 +89,20 @@ stages only when something tells it to. Two things do: the same up-front priming a Claude lane gets from its pre-filled `/ccam:ship-feature-lane` skill command, just as plain text since Codex has no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`. -- **A stale-detection nudge.** `server/lib/codex-nudge.js` sweeps every 60s +- **An `AGENTS.md` block.** `ccam lanes agents install` on a codex lane writes + a marker-delimited `` section into + `/AGENTS.md` — the one instruction channel Codex actually reads, + since it never loads `.claude/agents/`. Appended, never overwriting your own + prose, and a reinstall replaces exactly that block. (On a `claude` lane the + same command still copies the two agent templates instead.) +- **A missing-evidence nudge.** `server/lib/codex-nudge.js` sweeps every 60s (`DASHBOARD_CODEX_NUDGE_MS` to change, `0` to disable) for a codex lane whose - fresh, non-stale `detected_stage` has sat ahead of its declared `stage` for - at least two minutes, and types a one-line `[ccam] ... run: ccam stage ...` - reminder straight into that lane's live tmux pane (`tmux.sendText`, skipped - if the pane is at a bare shell prompt). It never writes `stage` or - `detected_stage` itself — declared-wins and inferred-never-`done` are - untouched — it only prods the session into making the real declaration. + current stage has been held for at least two minutes with no evidence + recorded, and types a one-line `[ccam] ... run: ccam stage ... --evidence + "..."` reminder straight into that lane's live tmux pane (`tmux.sendText`, + skipped if the pane is at a bare shell prompt). It never writes `stage`, + `stages` or `detected_stage` itself — the evidence that results is a real + declaration the session made. ## Destructive lane actions @@ -852,9 +861,25 @@ shape a Claude hook delivers: | anything else (messages, reasoning, web search, tool OUTPUT) | ignored | Everything past that point is shared: the same templates, the same -forward-only/declared-wins rules, the same amber-never-green boundary. Two +forward-only/declared-wins rules, the same amber-never-green boundary. Three codex-only details: +- **A written detection also advances the lane's declared `stage`.** This is + the one place in CCAM where an inference moves the blue `current` ring, and + it exists because nothing else can: a codex session has no hook and rarely + calls `ccam stage` on its own, so without it the card sits at `idle` forever + while the amber badge is the only thing that ever moves. It is gated on + `recordDetection` having *written*, so it inherits that function's + forward-only, declared-wins and TTL guards unchanged — a detection behind the + declared stage still advances nothing. The `setStage` call passes **no + evidence**, so every node it moves through renders `passed-no-evidence` + (amber), never `done`: a detection sees a tool call, never its outcome. And + it passes `keepDetection: true`, the sole exception to `setStage`'s clearing + of the detection columns — here the detection is the *reason* for the + declaration, so the card keeps showing which command caused it rather than + looking like a claim the agent made. The last node of every template carries + no `detect` rules, so this can never auto-declare a lane `done`. + - Only bytes appended since the last pass are read. On the first sight of a journal only its recent tail is read, and nothing at all when that file is already idle — replaying a thread that finished days ago would paint stages @@ -869,14 +894,15 @@ codex-only details: `provisioning` or `failed`). It writes nothing else: not `stage`, not `notes`, not `slot`/`ports`. -**A codex lane's *declared* stage is a separate matter.** CCAM cannot inject an -instruction into a CLI it merely spawns, so a codex session declares a stage -only if something tells it to — an `AGENTS.md` in the repository (this repo has -one) is the only lever. A codex lane in a repo without one shows detected stages -and no declarations. Note that from inside a codex session `ccam stage` needs to -know which lane it is: a pane the dashboard started carries `CCAM_LANE_ID`, and -anywhere else `--tool codex` disambiguates (see "Which lane a CLI command -addresses" below). +**A codex lane's *evidence* is a separate matter.** Auto-advance moves the ring +but can never supply evidence, so every node a codex lane passes through stays +amber until the session itself runs `ccam stage --evidence "..."`. CCAM +cannot inject an instruction into a CLI it merely spawns, so an `AGENTS.md` in +the working copy is the only lever — `ccam lanes agents install` writes the +marker-delimited block for you (see "Installing the QC/gate agents"). Note that +from inside a codex session `ccam stage` needs to know which lane it is: a pane +the dashboard started carries `CCAM_LANE_ID`, and anywhere else `--tool codex` +disambiguates (see "Which lane a CLI command addresses" below). ### The evidence boundary @@ -1413,6 +1439,17 @@ ccam lanes agents install Writes both agent definitions (from the repo's own `agents/`, the same files the plugin ships) into `/.claude/agents/` and adds that directory to the lane's local `.git/info/exclude` (never the tracked `.gitignore` — this is a per-clone runtime concern, not an app-repo change). Idempotent and never automatic, the same shape as `ccam lanes proof-link` — a session installs the agents explicitly, once, before a lane's first run through Stage 6/7 (or after a CCAM upgrade ships updated templates — reinstalling overwrites, it doesn't merge). +**On a `codex` lane the same command installs something else.** Codex never +reads `.claude/agents/`, so copying agent definitions there would be dead +files. Instead it writes the stage-declaration block into +`/AGENTS.md` — the project doc Codex *does* load from the working +directory — delimited by `` / `` +so a reinstall replaces exactly that block and nothing else. The file is +appended to when it already exists (your own prose is preserved) and created +when it does not; `installed` comes back as `["AGENTS.md"]`. This is the lever +that gets a codex session to supply the *evidence* auto-advance cannot — see +"Stage detection". + **Credentials are not embedded.** Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. `qc-local` degrades gracefully: if it hits a login page with no credentials block present, it reports `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile` rather than guessing. **`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — `ticketer` is invoked by Stage 9 (skipped), and `dev-qc` by Stage 13's dev-QC half (unavailable); `pr-reviewer` isn't referenced anywhere in this skill's text. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 566838f3..c1336276 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -106,7 +106,9 @@ root holds all five (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`, `.claude/agents/` — the highest-priority agent source — which is what makes the bare `subagent_type: qc-local` in the pipeline skill resolve there. One source file, two ways to reach it; the skill names the bare form first and falls back -to the `ccam:` form. +to the `ccam:` form. On a `codex` lane the same command installs the +`` block in the lane's `AGENTS.md` instead — Codex +never reads `.claude/agents/`. Because the plugin ships the hooks itself, `npm run install-hooks` is not needed for plugin users — and must not be run alongside it. Events carry no id, so two diff --git a/server/__tests__/codex-ingest.test.js b/server/__tests__/codex-ingest.test.js index 4427c1e8..052afca2 100644 --- a/server/__tests__/codex-ingest.test.js +++ b/server/__tests__/codex-ingest.test.js @@ -158,6 +158,39 @@ describe("codex ingest pass", () => { lanes.deleteLane(lane.id); }); + it("advances the declared stage from the detection, without evidence", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + writeRollout("rollout-2026-08-27T10-00-10-adv.jsonl", cwd); + codex.__resetOffsets(); + + codex.ingestCodexOnce({}); + const after = lanes.getLane(lane.id); + // The ring moves — a codex session has nothing else that would move it… + assert.equal(after.stage, "tests"); + // …but the node stays amber: an inference sees the tool call, not its result. + assert.equal(after.stages.tests.evidence, null); + // …and the badge survives, so the card still says WHY it moved. + assert.equal(after.detected_stage, "tests"); + assert.match(after.detected_signal, /test:server/); + lanes.deleteLane(lane.id); + }); + + it("never drags an auto-advanced stage backwards", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + lanes.setStage(lane.id, { stage: "review", evidence: "declared by the session" }); + writeRollout("rollout-2026-08-27T10-00-11-back.jsonl", cwd); // detects `tests`, behind `review` + codex.__resetOffsets(); + + codex.ingestCodexOnce({}); + const after = lanes.getLane(lane.id); + assert.equal(after.stage, "review"); // declared-wins: recordDetection refused, so no advance + assert.equal(after.detected_stage, null); + assert.equal(after.stages.review.evidence, "declared by the session"); + lanes.deleteLane(lane.id); + }); + it("a second pass with no appended bytes changes nothing", () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); diff --git a/server/__tests__/codex-nudge.test.js b/server/__tests__/codex-nudge.test.js index 27745b06..1a29466f 100644 --- a/server/__tests__/codex-nudge.test.js +++ b/server/__tests__/codex-nudge.test.js @@ -1,7 +1,7 @@ /** - * @file Unit tests for the codex stage-declaration safety net: nudges a live - * codex pane to run `ccam stage` when detection has clearly moved past the - * declared stage and stayed there, without ever writing stage itself. + * @file Unit tests for the codex evidence safety net: nudges a live codex pane + * to run `ccam stage --evidence` when the lane has sat at an inferred ( + * evidence-less) stage past the delay, without ever writing stage itself. * @author Nguyễn Ngọc Trí Vĩ */ @@ -22,10 +22,15 @@ const lanes = require("../lib/lanes"); const tmux = require("../lib/tmux"); const nudge = require("../lib/codex-nudge"); -/** Backdate a lane's detected_at so it looks `minutes` old to the sweep. */ -function backdateDetection(laneId, minutes) { - const iso = new Date(Date.now() - minutes * 60_000).toISOString(); - db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(iso, laneId); +/** Backdate the current stage's enteredAt so it looks `minutes` old. */ +function backdateStage(laneId, minutes) { + const lane = lanes.getLane(laneId); + const stages = { ...lane.stages }; + stages[lane.stage] = { + ...stages[lane.stage], + enteredAt: new Date(Date.now() - minutes * 60_000).toISOString(), + }; + db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId); } describe("codex-nudge sweep", () => { @@ -45,24 +50,24 @@ describe("codex-nudge sweep", () => { tmux.__reset(); }); - function makeCodexLane({ cwd }) { + function makeCodexLane({ cwd, stage = "tests", evidence }) { const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` }); - lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" }); + lanes.setStage(lane.id, { stage, evidence }); return lane; } - it("nudges once a fresh detection has sat ahead of the declared stage past the delay", () => { + it("nudges once the lane has held an evidence-less stage past the delay", () => { const cwd = os.tmpdir(); const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` }); - backdateDetection(lane.id, 3); // older than the 2 min delay, well inside the 5 min TTL + backdateStage(lane.id, 3); // older than the 2 min delay assert.equal(nudge.sweepCodexNudges(), 1); const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l"); assert.equal(sendKeys.length, 1); - assert.match(sendKeys[0][4], /ccam stage tests --tool codex/); + assert.match(sendKeys[0][4], /ccam stage tests --tool codex --evidence/); - // A second pass for the same still-undeclared detection must not repeat it. + // A second pass for the same still-unevidenced stage must not repeat it. assert.equal(nudge.sweepCodexNudges(), 0); lanes.deleteLane(lane.id); }); @@ -70,15 +75,23 @@ describe("codex-nudge sweep", () => { it("never nudges before the delay has elapsed", () => { const cwd = os.tmpdir(); const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` }); - // detected_at is "now" — recordDetection just set it. + // enteredAt is "now" — setStage just wrote it. assert.equal(nudge.sweepCodexNudges(), 0); lanes.deleteLane(lane.id); }); - it("never nudges a stale detection (past the detection TTL)", () => { + it("never nudges a stage the session already backed with evidence", () => { const cwd = os.tmpdir(); - const lane = makeCodexLane({ cwd: `${cwd}/nudge-c` }); - backdateDetection(lane.id, 10); // past the default 5 min TTL + const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" }); + backdateStage(lane.id, 10); + assert.equal(nudge.sweepCodexNudges(), 0); + lanes.deleteLane(lane.id); + }); + + it("never nudges a lane that has not entered a pipeline node", () => { + const cwd = os.tmpdir(); + const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" }); + lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` }); assert.equal(nudge.sweepCodexNudges(), 0); lanes.deleteLane(lane.id); }); @@ -87,6 +100,8 @@ describe("codex-nudge sweep", () => { const cwd = os.tmpdir(); const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" }); lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` }); + lanes.setStage(lane.id, { stage: "tests" }); + backdateStage(lane.id, 3); assert.equal(nudge.sweepCodexNudges(), 0); lanes.deleteLane(lane.id); }); @@ -94,8 +109,8 @@ describe("codex-nudge sweep", () => { it("never nudges a lane with no live run", () => { const cwd = os.tmpdir(); const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" }); - lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" }); - backdateDetection(lane.id, 3); + lanes.setStage(lane.id, { stage: "tests" }); + backdateStage(lane.id, 3); assert.equal(nudge.sweepCodexNudges(), 0); lanes.deleteLane(lane.id); }); @@ -103,7 +118,7 @@ describe("codex-nudge sweep", () => { it("never types into a pane sitting at a bare shell prompt", () => { const cwd = os.tmpdir(); const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` }); - backdateDetection(lane.id, 3); + backdateStage(lane.id, 3); tmux.__setExecImpl((args) => { calls.push(args); if (args[0] === "display-message") return "bash"; @@ -113,15 +128,14 @@ describe("codex-nudge sweep", () => { lanes.deleteLane(lane.id); }); - it("nudges again once the target detected stage changes, after a prior nudge", () => { + it("nudges again once the lane moves to another evidence-less stage", () => { const cwd = os.tmpdir(); const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` }); - backdateDetection(lane.id, 3); + backdateStage(lane.id, 3); assert.equal(nudge.sweepCodexNudges(), 1); - // Detection advances to a later node; forward-only recordDetection accepts it. - lanes.recordDetection(lane.id, { nodeId: "review", signal: "gh pr create" }); - backdateDetection(lane.id, 3); + lanes.setStage(lane.id, { stage: "review" }); + backdateStage(lane.id, 3); assert.equal(nudge.sweepCodexNudges(), 1); lanes.deleteLane(lane.id); }); diff --git a/server/__tests__/lane-agents.test.js b/server/__tests__/lane-agents.test.js index 06cb7dbc..55eea09a 100644 --- a/server/__tests__/lane-agents.test.js +++ b/server/__tests__/lane-agents.test.js @@ -115,4 +115,30 @@ describe("installAgents", () => { await installAgents(lane); assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents"))); }); + + it("installs the AGENTS.md stage block for a codex lane, not .claude/agents/", async () => { + const cwd = makeRepoDir(); + const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" }); + + const result = await installAgents(lane); + assert.deepEqual(result.installed, ["AGENTS.md"]); + assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false); + const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"); + assert.match(doc, /ccam stage --tool codex/); + }); + + it("preserves the user's own AGENTS.md prose and replaces only the block", async () => { + const cwd = makeRepoDir(); + const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" }); + const file = path.join(cwd, "AGENTS.md"); + fs.writeFileSync(file, "# House rules\n\nNever force-push.\n"); + + await installAgents(lane); + await installAgents(lane); // reinstall must not duplicate the block + + const doc = fs.readFileSync(file, "utf8"); + assert.match(doc, /Never force-push\./); + assert.equal(doc.split("").length - 1, 1); + assert.equal(doc.split("").length - 1, 1); + }); }); diff --git a/server/lib/codex-ingest.js b/server/lib/codex-ingest.js index 5df86da7..da046d0c 100644 --- a/server/lib/codex-ingest.js +++ b/server/lib/codex-ingest.js @@ -14,7 +14,11 @@ * tool-call payload onto the same `{tool_name, tool_input}` shape a Claude hook * delivers, and feeds the EXISTING detect() + recordDetection() path — so a * codex lane paints the same amber "detected" badge under the same rules - * (forward-only, declared-wins) and never renders `done`. Like + * (forward-only, declared-wins) and never renders `done`. A written detection + * then ALSO advances the lane's declared `stage` to that node, without + * evidence: codex lanes are the one place where nothing else can move the + * pipeline map, so the node renders `passed-no-evidence` (amber) rather than + * `done`, and codex-nudge.js asks the session for the real evidence. Like * workflow-ingest.js, everything here is fail-safe: a malformed or partial * journal throws only locally and is skipped, and a poll pass never throws. * @@ -309,6 +313,18 @@ function ingestCodexOnce({ broadcast } = {}) { if (detection) { try { changed = lanesLib.recordDetection(lane.id, detection).written; + // Auto-advance: a codex session has no hook and rarely calls `ccam + // stage` on its own, so without this its card sits at `idle` forever + // while the amber badge is the only thing that ever moves. Gated on + // recordDetection having WRITTEN, so it inherits that function's + // forward-only + declared-wins + TTL guards for free. Deliberately + // passes NO evidence: the node renders `passed-no-evidence` (amber), + // never `done` — detection sees a tool call, never its outcome. The + // last node has no detect rules in any template, so this can never + // auto-declare a lane `done`. + if (changed) { + lanesLib.setStage(lane.id, { stage: detection.nodeId, keepDetection: true }); + } } catch { /* lane deleted mid-pass, or sqlite busy — an inference is never worth throwing for */ } diff --git a/server/lib/codex-nudge.js b/server/lib/codex-nudge.js index 9f25f2a4..dc159806 100644 --- a/server/lib/codex-nudge.js +++ b/server/lib/codex-nudge.js @@ -1,35 +1,34 @@ /** - * @file Safety-net reminder for codex lanes whose auto-detected stage has - * clearly moved past the last stage the session actually *declared* (see - * `server/lib/stage-detect.js` / `server/lib/codex-ingest.js`). A codex - * session only declares a stage when it calls `ccam stage` on its own — - * there is no hook or wrapper that does it for it — so a lane can sit with a - * stale declared stage indefinitely even while its pipeline map keeps - * painting fresh amber "detected" badges. + * @file Safety-net reminder for codex lanes sitting at a stage nobody has + * backed with evidence. `codex-ingest.js` auto-advances a codex lane's stage + * from its rollout journal — that keeps the pipeline map moving, but an + * inferred stage carries no evidence, so its node renders + * `passed-no-evidence` (amber) rather than `done`. Only the session's own + * `ccam stage --evidence "..."` call can supply that, and a codex + * session has no hook or wrapper that does it for it. * - * This module never writes `stage` or `detected_stage` itself — the - * declared-wins / inferred-never-`done` invariants in lanes.js are untouched. - * It only types a plain-text reminder into the lane's own live tmux pane + * This module never writes `stage`, `stages` or `detected_stage` itself. It + * only types a plain-text reminder into the lane's own live tmux pane * (`tmux.sendText`), the same way a human watching the dashboard would nudge - * the agent — so any stage change that results is still a REAL declaration - * the codex session made itself. + * the agent — so the evidence that results is still a REAL declaration the + * codex session made itself. * @author Nguyễn Ngọc Trí Vĩ */ const NUDGE_DELAY_MS = 120_000; // give the session 2 min to declare on its own first -// Per-lane memory of the last detected_stage already nudged for, so the 60s -// sweep doesn't retype the same reminder every pass. Cleared implicitly: once -// the session declares (setStage nulls detected_stage) or detection advances -// again, the value here no longer matches and a fresh nudge is due. +// Per-lane memory of the last stage already nudged for, so the 60s sweep +// doesn't retype the same reminder every pass. Cleared implicitly: once the +// session declares with evidence, or the lane moves to another node, the value +// here no longer matches and a fresh nudge is due. // ponytail: in-memory, resets on server restart — worst case one duplicate // nudge after a restart, not worth a DB column for. const nudgedFor = new Map(); /** - * One sweep pass: nudge every codex lane whose fresh detection sits ahead of - * its declared stage, has held for at least NUDGE_DELAY_MS, and hasn't - * already been nudged for that exact detected stage. + * One sweep pass: nudge every codex lane whose CURRENT stage has been held for + * at least NUDGE_DELAY_MS with no evidence recorded, and hasn't already been + * nudged for that exact stage. * @returns {number} How many lanes were nudged. */ function sweepCodexNudges() { @@ -47,14 +46,16 @@ function sweepCodexNudges() { let nudged = 0; for (const lane of codexLanes) { try { - if (!lane.detected_stage || lane.detected_stage === lane.stage) { + // No record under the current stage means the lane never entered a + // pipeline node (a fresh lane sits at `idle`) — nothing to ask about. + const record = (lane.stages || {})[lane.stage]; + if (!record || record.evidence) { nudgedFor.delete(lane.id); continue; } - if (lanes.detectionIsStale(lane.detected_at)) continue; - const detectedAt = Date.parse(lane.detected_at); - if (!Number.isFinite(detectedAt) || Date.now() - detectedAt < NUDGE_DELAY_MS) continue; - if (nudgedFor.get(lane.id) === lane.detected_stage) continue; + const enteredAt = Date.parse(record.enteredAt); + if (!Number.isFinite(enteredAt) || Date.now() - enteredAt < NUDGE_DELAY_MS) continue; + if (nudgedFor.get(lane.id) === lane.stage) continue; const runId = runIdForLane(lane.id); if (!tmux.hasSession(runId)) continue; @@ -64,9 +65,9 @@ function sweepCodexNudges() { tmux.sendText( runId, - `[ccam] You appear to have moved on to "${lane.detected_stage}" — run: ccam stage ${lane.detected_stage} --tool codex --evidence "..."` + `[ccam] Stage "${lane.stage}" was inferred and has no evidence — run: ccam stage ${lane.stage} --tool codex --evidence "..."` ); - nudgedFor.set(lane.id, lane.detected_stage); + nudgedFor.set(lane.id, lane.stage); nudged += 1; } catch { /* one lane's failure must not abort the sweep */ diff --git a/server/lib/lane-agents.js b/server/lib/lane-agents.js index 6515cb53..2fe22a17 100644 --- a/server/lib/lane-agents.js +++ b/server/lib/lane-agents.js @@ -1,5 +1,6 @@ /** - * @file Installs the ship-feature-lane pipeline's agent templates + * @file Installs a lane's driving instructions. For a `claude` lane that is + * the ship-feature-lane pipeline's agent templates * (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a * driving session can launch them by the BARE subagent_type name. Source is * the repo's own `agents/` — the same files the ccam plugin ships, where @@ -19,6 +20,52 @@ const TEMPLATE_DIR = path.join(__dirname, "..", "..", "agents"); const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"]; const EXCLUDE_LINE = ".claude/agents/"; +// Codex reads no `.claude/agents/`, so for a codex lane the useful thing to +// install is the one instruction it DOES read: AGENTS.md, the project doc it +// loads from the working directory. Marker-delimited so a reinstall replaces +// exactly this block and never the user's own prose, and appended rather than +// written over an existing file. +const STAGE_BLOCK_START = ""; +const STAGE_BLOCK_END = ""; +const STAGE_BLOCK = `${STAGE_BLOCK_START} +## Declare your lane stage as you work + +This directory is watched by CCAM. Its pipeline map advances on its own by +*inferring* your stage from your tool calls, but an inference carries no +evidence — the node stays amber, never green. Only your own declaration does: + +\`\`\`bash +ccam stage --tool codex --evidence "" +\`\`\` + +- Call it at every real transition — starting to plan, starting to implement, + running tests, opening the PR — not once at the end. +- \`--tool codex\` matters: one directory can back both a \`claude\` lane and a + \`codex\` lane, and without a tool the command fails rather than guess. Inside + a pane the dashboard started you need nothing — it carries \`CCAM_LANE_ID\`. +- \`ccam stage\` warns on stderr and names the valid nodes if a stage matches + none of this lane's pipeline template. +- If it reports that no lane owns this directory, skip the stage calls and + carry on. Stage reporting is reporting, never control flow. +- A one-line \`[ccam]\` reminder may be typed straight into this pane asking for + evidence on the current stage. That is the dashboard, not the user — treat it + as the same instruction as this section and make the real call. +${STAGE_BLOCK_END}`; + +/** Append the stage block to `/AGENTS.md`, or replace an existing one. */ +function installStageDoc(cwd) { + const file = path.join(cwd, "AGENTS.md"); + const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; + const start = existing.indexOf(STAGE_BLOCK_START); + const end = existing.indexOf(STAGE_BLOCK_END); + const next = + start !== -1 && end > start + ? existing.slice(0, start) + STAGE_BLOCK + existing.slice(end + STAGE_BLOCK_END.length) + : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${STAGE_BLOCK}\n`; + fs.writeFileSync(file, next); + return "AGENTS.md"; +} + /** The dir shared across every worktree of a repo — where info/exclude * lives (same as info/attributes; MERGE_HEAD/HEAD/the index are the only * per-worktree-private state, not this). */ @@ -33,7 +80,10 @@ async function commonGitDir(cwd) { * any existing copy — this is a reinstall, not a merge) and idempotently * git-exclude that directory in the shared common git dir. * - * @param {{cwd: string}} lane + * A `codex` lane gets the AGENTS.md stage block instead — Codex never reads + * `.claude/agents/`, and its own working copy is the only channel it does read. + * + * @param {{cwd: string, tool?: string}} lane * @returns {Promise<{installed: string[]}>} */ async function installAgents(lane) { @@ -43,6 +93,8 @@ async function installAgents(lane) { }); } + if (lane.tool === "codex") return { installed: [installStageDoc(lane.cwd)] }; + const dest = path.join(lane.cwd, ".claude", "agents"); fs.mkdirSync(dest, { recursive: true }); const installed = []; diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 247f226d..e7fcea1b 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -297,8 +297,15 @@ function reservedPorts(excludeLaneId = null) { * of where the agent now says it is has not been passed for this pass; it has * to be re-earned. Records under a key matching no node in the pipeline are * left untouched — their position is unknown, so they cannot be proven ahead. + * + * `keepDetection` is the ONE exception to that clearing, and exists for exactly + * one caller: codex-ingest.js, which advances a codex lane's stage FROM its own + * detection. There the detection is not stale evidence of something the + * declaration superseded — it is the reason for the declaration, and the card + * has to keep saying so, or an inferred stage becomes indistinguishable from + * one the agent actually claimed. */ -function setStage(id, { stage, status, evidence, note, result } = {}) { +function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) { const lane = getLane(id); if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" }); const next = stage || lane.stage; @@ -321,7 +328,9 @@ function setStage(id, { stage, status, evidence, note, result } = {}) { }; db.prepare( `UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${ - changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : "" + changed && !keepDetection + ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" + : "" } WHERE id = ?` ).run(