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:
@@ -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
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user