feat(lanes): auto-advance a codex lane's stage from its rollout detection

A codex session emits no hooks and rarely calls `ccam stage` on its own, so
its card sat at `idle` forever while the amber "detected" badge was the only
thing that ever moved. `codex-ingest.js` now advances the declared `stage`
from its own written detection — the one place in CCAM where an inference
moves the blue `current` ring, and only for `codex` lanes.

It stays honest: gated on `recordDetection` having written (so forward-only,
declared-wins and the detection TTL are inherited unchanged), passes NO
evidence (the node renders `passed-no-evidence` amber, never `done`), and no
template's last node carries detect rules, so a lane can never be
auto-declared `done`. `setStage` grows a `keepDetection` option — its sole
caller — so the detection columns survive the advance and the card keeps
showing which command moved it instead of looking like an agent's own claim.

Two levers then get the evidence auto-advance cannot supply:

- `codex-nudge.js` now nudges on a stage held 2+ min with no evidence,
  instead of on a detection sitting ahead of the declared stage — after
  auto-advance the old condition can never fire, since the two now match.
- `ccam lanes agents install` on a codex lane writes the marker-delimited
  `<!-- ccam:stage:start -->` block into the lane's own AGENTS.md — the one
  instruction channel Codex reads — instead of copying agent templates into
  `.claude/agents/`, which it never loads. Appended, user prose preserved,
  reinstall replaces exactly that block.

Verified with a real `codex exec` run: stage moved to `watching-pr` with
`evidence: null` and the detection signal intact. 1364 server tests pass.
This commit is contained in:
2026-09-03 16:51:02 +07:00
parent 8a812a174b
commit e109d2804c
13 changed files with 318 additions and 106 deletions
+9 -8
View File
@@ -37,15 +37,16 @@ ccam stage <stage> --tool codex --evidence "<what is actually done>"
a step of the actual work. a step of the actual work.
The dashboard also *infers* your stage from your tool calls (it reads Codex's 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 own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane
only paints a dashed amber badge. It never counts as evidence and never renders that inference does move the pipeline map forward on its own. It still never
a node green. Only your own declaration does. 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 If you hold a stage for a while with no evidence recorded, the dashboard may
past your last declared stage, the dashboard may type a one-line `[ccam]` type a one-line `[ccam]` reminder straight into this pane asking you to run
reminder straight into this pane asking you to run `ccam stage`. That is the `ccam stage ... --evidence "..."`. That is the dashboard, not the user — treat
dashboard, not the user — treat it as the same instruction as this section and it as the same instruction as this section and just make the real call.
just make the real call.
## Where to work ## Where to work
- `server/` for API/routes/data processing. - `server/` for API/routes/data processing.
+28 -12
View File
@@ -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 next Codex release renames. `rolloutFiles()` is the single function to re-point
if that changes. 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 ### Codex evidence reliability (`server/lib/codex-nudge.js`)
depends on the session calling `ccam stage` itself, so two mechanisms make
that more likely without weakening declared-wins/inferred-never-`done`: 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 - **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex
pane's first prompt is pre-filled with a plain-text checklist of 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 — 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 `/ccam:ship-feature-lane` skill command, in plain text since Codex has no
slash commands. 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
`<!-- ccam:stage:start -->` section into `<lane cwd>/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): `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 when a codex lane's current stage has been held for at least two minutes with
declared `stage` for at least two minutes and hasn't already been nudged for `stages[stage].evidence` still null and hasn't already been nudged for that
that exact detected stage, it types a one-line `[ccam] ... run: ccam stage 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 "..."` 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 the pane isn't running an interactive program — `pty-run.js`'s
`SHELL_COMMANDS` — since Enter there would execute the reminder as a shell `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 command instead of submitting it as a chat turn). It never writes `stage`,
`detected_stage` — any resulting declaration is still one the codex session `stages` or `detected_stage` — any resulting evidence is still something the
made itself, through the real `ccam stage` call. codex session declared itself, through the real `ccam stage` call.
--- ---
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - 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). - 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 <stage> [--evidence "..."]`), not inferred by the dashboard. - A lane's stage is **declared** by the driving Claude session (via `ccam stage <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. - **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 <slug>` 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. - **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 <slug>` 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. - **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. - **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.
+12 -7
View File
@@ -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 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 Sessions and Analytics. Its **stage detection** works anyway, from Codex's own
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every 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 `DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection
free is a *declared* stage, since only the session itself can call `ccam stage`. also **advances the declared stage** — the one place an inference moves the blue
Two things help it get there: the run console pre-fills a codex pane's opening `current` ring, because a Codex session has no hook and nothing else would ever
prompt with a stage-declaration checklist built from its own pipeline nodes, move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
and a 60s sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line node stays amber (`passed-no-evidence`), never green, since only the session
reminder into a lane's pane if its detected stage sits fresh and undeclared for itself can say what it finished. Three things help it get there: the run console
more than two minutes. 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 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 events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
+57 -20
View File
@@ -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. **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 **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 Codex's own on-disk rollout journals rather than from hooks, and unlike a claude
detection" below. What a codex lane cannot get for free is a *declared* stage: lane a written detection there also **advances the lane's declared `stage`** —
the run console never pre-fills a Claude slash command into a codex pane (Codex 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 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 - **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's
first prompt with a plain-text list of `ccam stage <stage> --tool codex` first prompt with a plain-text list of `ccam stage <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 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 `/ccam:ship-feature-lane` skill command, just as plain text since Codex has
no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`. 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 `<!-- ccam:stage:start -->` section into
`<lane cwd>/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 (`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 current stage has been held for at least two minutes with no evidence
at least two minutes, and types a one-line `[ccam] ... run: ccam stage ...` recorded, and types a one-line `[ccam] ... run: ccam stage ... --evidence
reminder straight into that lane's live tmux pane (`tmux.sendText`, skipped "..."` reminder straight into that lane's live tmux pane (`tmux.sendText`,
if the pane is at a bare shell prompt). It never writes `stage` or skipped if the pane is at a bare shell prompt). It never writes `stage`,
`detected_stage` itself — declared-wins and inferred-never-`done` are `stages` or `detected_stage` itself — the evidence that results is a real
untouched — it only prods the session into making the real declaration. declaration the session made.
## Destructive lane actions ## Destructive lane actions
@@ -852,9 +861,25 @@ shape a Claude hook delivers:
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored | | anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
Everything past that point is shared: the same templates, the same 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: 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 - 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 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 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 `provisioning` or `failed`). It writes nothing else: not `stage`, not
`notes`, not `slot`/`ports`. `notes`, not `slot`/`ports`.
**A codex lane's *declared* stage is a separate matter.** CCAM cannot inject an **A codex lane's *evidence* is a separate matter.** Auto-advance moves the ring
instruction into a CLI it merely spawns, so a codex session declares a stage but can never supply evidence, so every node a codex lane passes through stays
only if something tells it to — an `AGENTS.md` in the repository (this repo has amber until the session itself runs `ccam stage <node> --evidence "..."`. CCAM
one) is the only lever. A codex lane in a repo without one shows detected stages cannot inject an instruction into a CLI it merely spawns, so an `AGENTS.md` in
and no declarations. Note that from inside a codex session `ccam stage` needs to the working copy is the only lever — `ccam lanes agents install` writes the
know which lane it is: a pane the dashboard started carries `CCAM_LANE_ID`, and marker-delimited block for you (see "Installing the QC/gate agents"). Note that
anywhere else `--tool codex` disambiguates (see "Which lane a CLI command from inside a codex session `ccam stage` needs to know which lane it is: a pane
addresses" below). the dashboard started carries `CCAM_LANE_ID`, and anywhere else `--tool codex`
disambiguates (see "Which lane a CLI command addresses" below).
### The evidence boundary ### 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 `<lane>/.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). Writes both agent definitions (from the repo's own `agents/`, the same files the plugin ships) into `<lane>/.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
`<lane>/AGENTS.md` — the project doc Codex *does* load from the working
directory — delimited by `<!-- ccam:stage:start -->` / `<!-- ccam:stage:end -->`
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. **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. **`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.
+3 -1
View File
@@ -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 `.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 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 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
`<!-- ccam:stage:start -->` 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 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 for plugin users — and must not be run alongside it. Events carry no id, so two
+33
View File
@@ -158,6 +158,39 @@ describe("codex ingest pass", () => {
lanes.deleteLane(lane.id); 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", () => { it("a second pass with no appended bytes changes nothing", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
+39 -25
View File
@@ -1,7 +1,7 @@
/** /**
* @file Unit tests for the codex stage-declaration safety net: nudges a live * @file Unit tests for the codex evidence safety net: nudges a live codex pane
* codex pane to run `ccam stage` when detection has clearly moved past the * to run `ccam stage --evidence` when the lane has sat at an inferred (
* declared stage and stayed there, without ever writing stage itself. * evidence-less) stage past the delay, without ever writing stage itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -22,10 +22,15 @@ const lanes = require("../lib/lanes");
const tmux = require("../lib/tmux"); const tmux = require("../lib/tmux");
const nudge = require("../lib/codex-nudge"); const nudge = require("../lib/codex-nudge");
/** Backdate a lane's detected_at so it looks `minutes` old to the sweep. */ /** Backdate the current stage's enteredAt so it looks `minutes` old. */
function backdateDetection(laneId, minutes) { function backdateStage(laneId, minutes) {
const iso = new Date(Date.now() - minutes * 60_000).toISOString(); const lane = lanes.getLane(laneId);
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(iso, 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", () => { describe("codex-nudge sweep", () => {
@@ -45,24 +50,24 @@ describe("codex-nudge sweep", () => {
tmux.__reset(); tmux.__reset();
}); });
function makeCodexLane({ cwd }) { function makeCodexLane({ cwd, stage = "tests", evidence }) {
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` }); 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; 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 cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` }); 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); assert.equal(nudge.sweepCodexNudges(), 1);
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l"); const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
assert.equal(sendKeys.length, 1); 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); assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id); lanes.deleteLane(lane.id);
}); });
@@ -70,15 +75,23 @@ describe("codex-nudge sweep", () => {
it("never nudges before the delay has elapsed", () => { it("never nudges before the delay has elapsed", () => {
const cwd = os.tmpdir(); const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` }); 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); assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id); 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 cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c` }); const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" });
backdateDetection(lane.id, 10); // past the default 5 min TTL 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); assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id); lanes.deleteLane(lane.id);
}); });
@@ -87,6 +100,8 @@ describe("codex-nudge sweep", () => {
const cwd = os.tmpdir(); const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" }); const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` }); 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); assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id); lanes.deleteLane(lane.id);
}); });
@@ -94,8 +109,8 @@ describe("codex-nudge sweep", () => {
it("never nudges a lane with no live run", () => { it("never nudges a lane with no live run", () => {
const cwd = os.tmpdir(); const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" }); const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" });
lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" }); lanes.setStage(lane.id, { stage: "tests" });
backdateDetection(lane.id, 3); backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 0); assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id); lanes.deleteLane(lane.id);
}); });
@@ -103,7 +118,7 @@ describe("codex-nudge sweep", () => {
it("never types into a pane sitting at a bare shell prompt", () => { it("never types into a pane sitting at a bare shell prompt", () => {
const cwd = os.tmpdir(); const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` }); const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` });
backdateDetection(lane.id, 3); backdateStage(lane.id, 3);
tmux.__setExecImpl((args) => { tmux.__setExecImpl((args) => {
calls.push(args); calls.push(args);
if (args[0] === "display-message") return "bash"; if (args[0] === "display-message") return "bash";
@@ -113,15 +128,14 @@ describe("codex-nudge sweep", () => {
lanes.deleteLane(lane.id); 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 cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` }); const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
backdateDetection(lane.id, 3); backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1); assert.equal(nudge.sweepCodexNudges(), 1);
// Detection advances to a later node; forward-only recordDetection accepts it. lanes.setStage(lane.id, { stage: "review" });
lanes.recordDetection(lane.id, { nodeId: "review", signal: "gh pr create" }); backdateStage(lane.id, 3);
backdateDetection(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1); assert.equal(nudge.sweepCodexNudges(), 1);
lanes.deleteLane(lane.id); lanes.deleteLane(lane.id);
}); });
+26
View File
@@ -115,4 +115,30 @@ describe("installAgents", () => {
await installAgents(lane); await installAgents(lane);
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents"))); 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 <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("<!-- ccam:stage:start -->").length - 1, 1);
assert.equal(doc.split("<!-- ccam:stage:end -->").length - 1, 1);
});
}); });
+17 -1
View File
@@ -14,7 +14,11 @@
* tool-call payload onto the same `{tool_name, tool_input}` shape a Claude hook * tool-call payload onto the same `{tool_name, tool_input}` shape a Claude hook
* delivers, and feeds the EXISTING detect() + recordDetection() path — so a * delivers, and feeds the EXISTING detect() + recordDetection() path — so a
* codex lane paints the same amber "detected" badge under the same rules * 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 * 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. * journal throws only locally and is skipped, and a poll pass never throws.
* *
@@ -309,6 +313,18 @@ function ingestCodexOnce({ broadcast } = {}) {
if (detection) { if (detection) {
try { try {
changed = lanesLib.recordDetection(lane.id, detection).written; 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 { } catch {
/* lane deleted mid-pass, or sqlite busy — an inference is never worth throwing for */ /* lane deleted mid-pass, or sqlite busy — an inference is never worth throwing for */
} }
+27 -26
View File
@@ -1,35 +1,34 @@
/** /**
* @file Safety-net reminder for codex lanes whose auto-detected stage has * @file Safety-net reminder for codex lanes sitting at a stage nobody has
* clearly moved past the last stage the session actually *declared* (see * backed with evidence. `codex-ingest.js` auto-advances a codex lane's stage
* `server/lib/stage-detect.js` / `server/lib/codex-ingest.js`). A codex * from its rollout journal — that keeps the pipeline map moving, but an
* session only declares a stage when it calls `ccam stage` on its own — * inferred stage carries no evidence, so its node renders
* there is no hook or wrapper that does it for it — so a lane can sit with a * `passed-no-evidence` (amber) rather than `done`. Only the session's own
* stale declared stage indefinitely even while its pipeline map keeps * `ccam stage <node> --evidence "..."` call can supply that, and a codex
* painting fresh amber "detected" badges. * session has no hook or wrapper that does it for it.
* *
* This module never writes `stage` or `detected_stage` itself — the * This module never writes `stage`, `stages` or `detected_stage` itself. It
* declared-wins / inferred-never-`done` invariants in lanes.js are untouched. * only types a plain-text reminder into the lane's own live tmux pane
* 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 * (`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 agent — so the evidence that results is still a REAL declaration the
* the codex session made itself. * codex session made itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const NUDGE_DELAY_MS = 120_000; // give the session 2 min to declare on its own first 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 // Per-lane memory of the last stage already nudged for, so the 60s sweep
// sweep doesn't retype the same reminder every pass. Cleared implicitly: once // doesn't retype the same reminder every pass. Cleared implicitly: once the
// the session declares (setStage nulls detected_stage) or detection advances // session declares with evidence, or the lane moves to another node, the value
// again, the value here no longer matches and a fresh nudge is due. // here no longer matches and a fresh nudge is due.
// ponytail: in-memory, resets on server restart — worst case one duplicate // ponytail: in-memory, resets on server restart — worst case one duplicate
// nudge after a restart, not worth a DB column for. // nudge after a restart, not worth a DB column for.
const nudgedFor = new Map(); const nudgedFor = new Map();
/** /**
* One sweep pass: nudge every codex lane whose fresh detection sits ahead of * One sweep pass: nudge every codex lane whose CURRENT stage has been held for
* its declared stage, has held for at least NUDGE_DELAY_MS, and hasn't * at least NUDGE_DELAY_MS with no evidence recorded, and hasn't already been
* already been nudged for that exact detected stage. * nudged for that exact stage.
* @returns {number} How many lanes were nudged. * @returns {number} How many lanes were nudged.
*/ */
function sweepCodexNudges() { function sweepCodexNudges() {
@@ -47,14 +46,16 @@ function sweepCodexNudges() {
let nudged = 0; let nudged = 0;
for (const lane of codexLanes) { for (const lane of codexLanes) {
try { 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); nudgedFor.delete(lane.id);
continue; continue;
} }
if (lanes.detectionIsStale(lane.detected_at)) continue; const enteredAt = Date.parse(record.enteredAt);
const detectedAt = Date.parse(lane.detected_at); if (!Number.isFinite(enteredAt) || Date.now() - enteredAt < NUDGE_DELAY_MS) continue;
if (!Number.isFinite(detectedAt) || Date.now() - detectedAt < NUDGE_DELAY_MS) continue; if (nudgedFor.get(lane.id) === lane.stage) continue;
if (nudgedFor.get(lane.id) === lane.detected_stage) continue;
const runId = runIdForLane(lane.id); const runId = runIdForLane(lane.id);
if (!tmux.hasSession(runId)) continue; if (!tmux.hasSession(runId)) continue;
@@ -64,9 +65,9 @@ function sweepCodexNudges() {
tmux.sendText( tmux.sendText(
runId, 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; nudged += 1;
} catch { } catch {
/* one lane's failure must not abort the sweep */ /* one lane's failure must not abort the sweep */
+54 -2
View File
@@ -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 * (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 * 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 * 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 TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
const EXCLUDE_LINE = ".claude/agents/"; 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 = "<!-- ccam:stage:start -->";
const STAGE_BLOCK_END = "<!-- ccam:stage: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 <stage> --tool codex --evidence "<what is actually done>"
\`\`\`
- 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 `<cwd>/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 /** 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 * lives (same as info/attributes; MERGE_HEAD/HEAD/the index are the only
* per-worktree-private state, not this). */ * 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 * any existing copy — this is a reinstall, not a merge) and idempotently
* git-exclude that directory in the shared common git dir. * 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[]}>} * @returns {Promise<{installed: string[]}>}
*/ */
async function installAgents(lane) { 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"); const dest = path.join(lane.cwd, ".claude", "agents");
fs.mkdirSync(dest, { recursive: true }); fs.mkdirSync(dest, { recursive: true });
const installed = []; const installed = [];
+11 -2
View File
@@ -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 * 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 * 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. * 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); const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" }); if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage; const next = stage || lane.stage;
@@ -321,7 +328,9 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
}; };
db.prepare( db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${ `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 = ?` WHERE id = ?`
).run( ).run(