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.
|
||||
|
||||
The dashboard also *infers* your stage from your tool calls (it reads Codex's
|
||||
own rollout journals — see `docs/LANES.md#stage-detection`), but an inference
|
||||
only paints a dashed amber badge. It never counts as evidence and never renders
|
||||
a node green. Only your own declaration does.
|
||||
own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane
|
||||
that inference does move the pipeline map forward on its own. It still never
|
||||
counts as **evidence**: a node it moved through stays amber and never renders
|
||||
green. Only your own declaration does that — which is why the call above is
|
||||
worth making even when the map already looks right.
|
||||
|
||||
If you go a while without declaring while that inference has clearly moved
|
||||
past your last declared stage, the dashboard may type a one-line `[ccam]`
|
||||
reminder straight into this pane asking you to run `ccam stage`. That is the
|
||||
dashboard, not the user — treat it as the same instruction as this section and
|
||||
just make the real call.
|
||||
If you hold a stage for a while with no evidence recorded, the dashboard may
|
||||
type a one-line `[ccam]` reminder straight into this pane asking you to run
|
||||
`ccam stage ... --evidence "..."`. That is the dashboard, not the user — treat
|
||||
it as the same instruction as this section and just make the real call.
|
||||
|
||||
## Where to work
|
||||
- `server/` for API/routes/data processing.
|
||||
|
||||
+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
|
||||
if that changes.
|
||||
|
||||
### Codex declared-stage reliability (`server/lib/codex-nudge.js`)
|
||||
A written detection then **also advances the lane's declared `stage`** —
|
||||
`setStage(lane.id, { stage: nodeId, keepDetection: true })`, the only place in
|
||||
CCAM where an inference moves the blue `current` ring. It is gated on
|
||||
`recordDetection` having written, so it inherits forward-only/declared-wins/TTL
|
||||
unchanged; it passes no `evidence`, so the node renders `passed-no-evidence`
|
||||
(amber) and never `done`; and `keepDetection` is the sole exception to
|
||||
`setStage`'s clearing of the detection columns, so the card still shows which
|
||||
command moved it rather than looking like an agent's own claim. No template's
|
||||
last node carries `detect` rules, so a lane can never be auto-declared `done`.
|
||||
|
||||
Detection alone only paints the amber badge; a codex lane's *declared* stage
|
||||
depends on the session calling `ccam stage` itself, so two mechanisms make
|
||||
that more likely without weakening declared-wins/inferred-never-`done`:
|
||||
### Codex evidence reliability (`server/lib/codex-nudge.js`)
|
||||
|
||||
Auto-advance moves the ring but can never supply evidence; only the session's
|
||||
own `ccam stage --evidence` call can, so three mechanisms make that more likely
|
||||
without weakening declared-wins/inferred-never-`done`:
|
||||
|
||||
- **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex
|
||||
pane's first prompt is pre-filled with a plain-text checklist of
|
||||
@@ -1581,17 +1591,23 @@ that more likely without weakening declared-wins/inferred-never-`done`:
|
||||
— the same up-front nudge a Claude lane gets from its pre-filled
|
||||
`/ccam:ship-feature-lane` skill command, in plain text since Codex has no
|
||||
slash commands.
|
||||
- **Stale-detection nudge** (`sweepCodexNudges`, an unref'd 60 s interval from
|
||||
- **`AGENTS.md` stage block** (`server/lib/lane-agents.js`): on a `codex` lane,
|
||||
`ccam lanes agents install` writes a marker-delimited
|
||||
`<!-- 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):
|
||||
when a codex lane's fresh (non-stale) `detected_stage` has sat ahead of its
|
||||
declared `stage` for at least two minutes and hasn't already been nudged for
|
||||
that exact detected stage, it types a one-line `[ccam] ... run: ccam stage
|
||||
...` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if
|
||||
when a codex lane's current stage has been held for at least two minutes with
|
||||
`stages[stage].evidence` still null and hasn't already been nudged for that
|
||||
exact stage, it types a one-line `[ccam] ... run: ccam stage ... --evidence
|
||||
"..."` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if
|
||||
the pane isn't running an interactive program — `pty-run.js`'s
|
||||
`SHELL_COMMANDS` — since Enter there would execute the reminder as a shell
|
||||
command instead of submitting it as a chat turn). It never writes `stage` or
|
||||
`detected_stage` — any resulting declaration is still one the codex session
|
||||
made itself, through the real `ccam stage` call.
|
||||
command instead of submitting it as a chat turn). It never writes `stage`,
|
||||
`stages` or `detected_stage` — any resulting evidence is still something the
|
||||
codex session declared itself, through the real `ccam stage` call.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: 13b2bc4 (2026-09-03)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
_Last synced: 8a812a1 (2026-09-03) + codex auto-advance._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -23,7 +23,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- Sessions are bound to lanes by longest path-boundary prefix match on their `cwd` (set in hook data from `claude` CLI).
|
||||
- A lane's stage is **declared** by the driving Claude session (via `ccam stage <stage> [--evidence "..."]`), not inferred by the dashboard.
|
||||
- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/ccam.js`) answers in order: `--lane`/leading id → `CCAM_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work.
|
||||
- **The console never writes a lane's stage.** Declared stages come from `ccam stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
||||
- **The console never writes a lane's stage.** Declared stages come from `ccam stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **One exception, `codex` lanes only:** `codex-ingest.js` advances `stage` from its own written detection (`setStage(..., { keepDetection: true })`) — a codex session has no hook and rarely calls `ccam stage`, so nothing else would ever move its map. It passes NO evidence, so the node renders `passed-no-evidence` (amber) and never `done`, and `keepDetection` preserves the detection columns so the card still shows the command that moved it. Do not extend this to `claude` lanes or to the console. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
||||
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `ccam feature activate <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.
|
||||
- **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
|
||||
Sessions and Analytics. Its **stage detection** works anyway, from Codex's own
|
||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000) — what a Codex lane cannot get for
|
||||
free is a *declared* stage, since only the session itself can call `ccam stage`.
|
||||
Two things help it get there: the run console pre-fills a codex pane's opening
|
||||
prompt with a stage-declaration checklist built from its own pipeline nodes,
|
||||
and a 60s sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line
|
||||
reminder into a lane's pane if its detected stage sits fresh and undeclared for
|
||||
more than two minutes.
|
||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection
|
||||
also **advances the declared stage** — the one place an inference moves the blue
|
||||
`current` ring, because a Codex session has no hook and nothing else would ever
|
||||
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
|
||||
node stays amber (`passed-no-evidence`), never green, since only the session
|
||||
itself can say what it finished. Three things help it get there: the run console
|
||||
pre-fills a codex pane's opening prompt with a stage-declaration checklist built
|
||||
from its own pipeline nodes, `ccam lanes agents install` writes the same
|
||||
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
|
||||
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
|
||||
a lane's pane if its current stage sits without evidence for more than two
|
||||
minutes.
|
||||
|
||||
The declared stage comes from `ccam stage`. The inferred stage comes from tool
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
|
||||
|
||||
+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.
|
||||
|
||||
**Stage detection is not in that list.** It does work for a codex lane, from
|
||||
Codex's own on-disk rollout journals rather than from hooks — see "Stage
|
||||
detection" below. What a codex lane cannot get for free is a *declared* stage:
|
||||
the run console never pre-fills a Claude slash command into a codex pane (Codex
|
||||
Codex's own on-disk rollout journals rather than from hooks, and unlike a claude
|
||||
lane a written detection there also **advances the lane's declared `stage`** —
|
||||
see "Stage detection" below. What a codex lane cannot get for free is
|
||||
*evidence*: an auto-advanced node renders `passed-no-evidence` (amber), never
|
||||
`done`, because only the session itself can say what it actually finished. The
|
||||
run console never pre-fills a Claude slash command into a codex pane (Codex
|
||||
would read `/ccam:ship-feature-lane` as prose), so a codex session declares
|
||||
stages only when something tells it to. Two things do:
|
||||
evidence only when something tells it to. Three things do:
|
||||
|
||||
- **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's
|
||||
first prompt with a plain-text list of `ccam stage <stage> --tool codex`
|
||||
@@ -86,14 +89,20 @@ stages only when something tells it to. Two things do:
|
||||
the same up-front priming a Claude lane gets from its pre-filled
|
||||
`/ccam:ship-feature-lane` skill command, just as plain text since Codex has
|
||||
no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`.
|
||||
- **A stale-detection nudge.** `server/lib/codex-nudge.js` sweeps every 60s
|
||||
- **An `AGENTS.md` block.** `ccam lanes agents install` on a codex lane writes
|
||||
a marker-delimited `<!-- 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
|
||||
fresh, non-stale `detected_stage` has sat ahead of its declared `stage` for
|
||||
at least two minutes, and types a one-line `[ccam] ... run: ccam stage ...`
|
||||
reminder straight into that lane's live tmux pane (`tmux.sendText`, skipped
|
||||
if the pane is at a bare shell prompt). It never writes `stage` or
|
||||
`detected_stage` itself — declared-wins and inferred-never-`done` are
|
||||
untouched — it only prods the session into making the real declaration.
|
||||
current stage has been held for at least two minutes with no evidence
|
||||
recorded, and types a one-line `[ccam] ... run: ccam stage ... --evidence
|
||||
"..."` reminder straight into that lane's live tmux pane (`tmux.sendText`,
|
||||
skipped if the pane is at a bare shell prompt). It never writes `stage`,
|
||||
`stages` or `detected_stage` itself — the evidence that results is a real
|
||||
declaration the session made.
|
||||
|
||||
## Destructive lane actions
|
||||
|
||||
@@ -852,9 +861,25 @@ shape a Claude hook delivers:
|
||||
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
|
||||
|
||||
Everything past that point is shared: the same templates, the same
|
||||
forward-only/declared-wins rules, the same amber-never-green boundary. Two
|
||||
forward-only/declared-wins rules, the same amber-never-green boundary. Three
|
||||
codex-only details:
|
||||
|
||||
- **A written detection also advances the lane's declared `stage`.** This is
|
||||
the one place in CCAM where an inference moves the blue `current` ring, and
|
||||
it exists because nothing else can: a codex session has no hook and rarely
|
||||
calls `ccam stage` on its own, so without it the card sits at `idle` forever
|
||||
while the amber badge is the only thing that ever moves. It is gated on
|
||||
`recordDetection` having *written*, so it inherits that function's
|
||||
forward-only, declared-wins and TTL guards unchanged — a detection behind the
|
||||
declared stage still advances nothing. The `setStage` call passes **no
|
||||
evidence**, so every node it moves through renders `passed-no-evidence`
|
||||
(amber), never `done`: a detection sees a tool call, never its outcome. And
|
||||
it passes `keepDetection: true`, the sole exception to `setStage`'s clearing
|
||||
of the detection columns — here the detection is the *reason* for the
|
||||
declaration, so the card keeps showing which command caused it rather than
|
||||
looking like a claim the agent made. The last node of every template carries
|
||||
no `detect` rules, so this can never auto-declare a lane `done`.
|
||||
|
||||
- Only bytes appended since the last pass are read. On the first sight of a
|
||||
journal only its recent tail is read, and nothing at all when that file is
|
||||
already idle — replaying a thread that finished days ago would paint stages
|
||||
@@ -869,14 +894,15 @@ codex-only details:
|
||||
`provisioning` or `failed`). It writes nothing else: not `stage`, not
|
||||
`notes`, not `slot`/`ports`.
|
||||
|
||||
**A codex lane's *declared* stage is a separate matter.** CCAM cannot inject an
|
||||
instruction into a CLI it merely spawns, so a codex session declares a stage
|
||||
only if something tells it to — an `AGENTS.md` in the repository (this repo has
|
||||
one) is the only lever. A codex lane in a repo without one shows detected stages
|
||||
and no declarations. Note that from inside a codex session `ccam stage` needs to
|
||||
know which lane it is: a pane the dashboard started carries `CCAM_LANE_ID`, and
|
||||
anywhere else `--tool codex` disambiguates (see "Which lane a CLI command
|
||||
addresses" below).
|
||||
**A codex lane's *evidence* is a separate matter.** Auto-advance moves the ring
|
||||
but can never supply evidence, so every node a codex lane passes through stays
|
||||
amber until the session itself runs `ccam stage <node> --evidence "..."`. CCAM
|
||||
cannot inject an instruction into a CLI it merely spawns, so an `AGENTS.md` in
|
||||
the working copy is the only lever — `ccam lanes agents install` writes the
|
||||
marker-delimited block for you (see "Installing the QC/gate agents"). Note that
|
||||
from inside a codex session `ccam stage` needs to know which lane it is: a pane
|
||||
the dashboard started carries `CCAM_LANE_ID`, and anywhere else `--tool codex`
|
||||
disambiguates (see "Which lane a CLI command addresses" below).
|
||||
|
||||
### The evidence boundary
|
||||
|
||||
@@ -1413,6 +1439,17 @@ ccam lanes agents install
|
||||
|
||||
Writes both agent definitions (from the repo's own `agents/`, the same files the plugin ships) into `<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.
|
||||
|
||||
**`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
|
||||
bare `subagent_type: qc-local` in the pipeline skill resolve there. One source
|
||||
file, two ways to reach it; the skill names the bare form first and falls back
|
||||
to the `ccam:` form.
|
||||
to the `ccam:` form. On a `codex` lane the same command installs the
|
||||
`<!-- 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
|
||||
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);
|
||||
});
|
||||
|
||||
it("advances the declared stage from the detection, without evidence", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
writeRollout("rollout-2026-08-27T10-00-10-adv.jsonl", cwd);
|
||||
codex.__resetOffsets();
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
// The ring moves — a codex session has nothing else that would move it…
|
||||
assert.equal(after.stage, "tests");
|
||||
// …but the node stays amber: an inference sees the tool call, not its result.
|
||||
assert.equal(after.stages.tests.evidence, null);
|
||||
// …and the badge survives, so the card still says WHY it moved.
|
||||
assert.equal(after.detected_stage, "tests");
|
||||
assert.match(after.detected_signal, /test:server/);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never drags an auto-advanced stage backwards", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
lanes.setStage(lane.id, { stage: "review", evidence: "declared by the session" });
|
||||
writeRollout("rollout-2026-08-27T10-00-11-back.jsonl", cwd); // detects `tests`, behind `review`
|
||||
codex.__resetOffsets();
|
||||
|
||||
codex.ingestCodexOnce({});
|
||||
const after = lanes.getLane(lane.id);
|
||||
assert.equal(after.stage, "review"); // declared-wins: recordDetection refused, so no advance
|
||||
assert.equal(after.detected_stage, null);
|
||||
assert.equal(after.stages.review.evidence, "declared by the session");
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("a second pass with no appended bytes changes nothing", () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Unit tests for the codex stage-declaration safety net: nudges a live
|
||||
* codex pane to run `ccam stage` when detection has clearly moved past the
|
||||
* declared stage and stayed there, without ever writing stage itself.
|
||||
* @file Unit tests for the codex evidence safety net: nudges a live codex pane
|
||||
* to run `ccam stage --evidence` when the lane has sat at an inferred (
|
||||
* evidence-less) stage past the delay, without ever writing stage itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,10 +22,15 @@ const lanes = require("../lib/lanes");
|
||||
const tmux = require("../lib/tmux");
|
||||
const nudge = require("../lib/codex-nudge");
|
||||
|
||||
/** Backdate a lane's detected_at so it looks `minutes` old to the sweep. */
|
||||
function backdateDetection(laneId, minutes) {
|
||||
const iso = new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(iso, laneId);
|
||||
/** Backdate the current stage's enteredAt so it looks `minutes` old. */
|
||||
function backdateStage(laneId, minutes) {
|
||||
const lane = lanes.getLane(laneId);
|
||||
const stages = { ...lane.stages };
|
||||
stages[lane.stage] = {
|
||||
...stages[lane.stage],
|
||||
enteredAt: new Date(Date.now() - minutes * 60_000).toISOString(),
|
||||
};
|
||||
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId);
|
||||
}
|
||||
|
||||
describe("codex-nudge sweep", () => {
|
||||
@@ -45,24 +50,24 @@ describe("codex-nudge sweep", () => {
|
||||
tmux.__reset();
|
||||
});
|
||||
|
||||
function makeCodexLane({ cwd }) {
|
||||
function makeCodexLane({ cwd, stage = "tests", evidence }) {
|
||||
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
|
||||
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
|
||||
lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" });
|
||||
lanes.setStage(lane.id, { stage, evidence });
|
||||
return lane;
|
||||
}
|
||||
|
||||
it("nudges once a fresh detection has sat ahead of the declared stage past the delay", () => {
|
||||
it("nudges once the lane has held an evidence-less stage past the delay", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` });
|
||||
backdateDetection(lane.id, 3); // older than the 2 min delay, well inside the 5 min TTL
|
||||
backdateStage(lane.id, 3); // older than the 2 min delay
|
||||
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
|
||||
assert.equal(sendKeys.length, 1);
|
||||
assert.match(sendKeys[0][4], /ccam stage tests --tool codex/);
|
||||
assert.match(sendKeys[0][4], /ccam stage tests --tool codex --evidence/);
|
||||
|
||||
// A second pass for the same still-undeclared detection must not repeat it.
|
||||
// A second pass for the same still-unevidenced stage must not repeat it.
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
@@ -70,15 +75,23 @@ describe("codex-nudge sweep", () => {
|
||||
it("never nudges before the delay has elapsed", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` });
|
||||
// detected_at is "now" — recordDetection just set it.
|
||||
// enteredAt is "now" — setStage just wrote it.
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a stale detection (past the detection TTL)", () => {
|
||||
it("never nudges a stage the session already backed with evidence", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c` });
|
||||
backdateDetection(lane.id, 10); // past the default 5 min TTL
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" });
|
||||
backdateStage(lane.id, 10);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("never nudges a lane that has not entered a pipeline node", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" });
|
||||
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
@@ -87,6 +100,8 @@ describe("codex-nudge sweep", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
|
||||
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
|
||||
lanes.setStage(lane.id, { stage: "tests" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
@@ -94,8 +109,8 @@ describe("codex-nudge sweep", () => {
|
||||
it("never nudges a lane with no live run", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" });
|
||||
lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" });
|
||||
backdateDetection(lane.id, 3);
|
||||
lanes.setStage(lane.id, { stage: "tests" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 0);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
@@ -103,7 +118,7 @@ describe("codex-nudge sweep", () => {
|
||||
it("never types into a pane sitting at a bare shell prompt", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` });
|
||||
backdateDetection(lane.id, 3);
|
||||
backdateStage(lane.id, 3);
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "bash";
|
||||
@@ -113,15 +128,14 @@ describe("codex-nudge sweep", () => {
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
it("nudges again once the target detected stage changes, after a prior nudge", () => {
|
||||
it("nudges again once the lane moves to another evidence-less stage", () => {
|
||||
const cwd = os.tmpdir();
|
||||
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
|
||||
backdateDetection(lane.id, 3);
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
|
||||
// Detection advances to a later node; forward-only recordDetection accepts it.
|
||||
lanes.recordDetection(lane.id, { nodeId: "review", signal: "gh pr create" });
|
||||
backdateDetection(lane.id, 3);
|
||||
lanes.setStage(lane.id, { stage: "review" });
|
||||
backdateStage(lane.id, 3);
|
||||
assert.equal(nudge.sweepCodexNudges(), 1);
|
||||
lanes.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
@@ -115,4 +115,30 @@ describe("installAgents", () => {
|
||||
await installAgents(lane);
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
|
||||
});
|
||||
|
||||
it("installs the AGENTS.md stage block for a codex lane, not .claude/agents/", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
|
||||
|
||||
const result = await installAgents(lane);
|
||||
assert.deepEqual(result.installed, ["AGENTS.md"]);
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||
const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
|
||||
assert.match(doc, /ccam stage <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
|
||||
* delivers, and feeds the EXISTING detect() + recordDetection() path — so a
|
||||
* codex lane paints the same amber "detected" badge under the same rules
|
||||
* (forward-only, declared-wins) and never renders `done`. Like
|
||||
* (forward-only, declared-wins) and never renders `done`. A written detection
|
||||
* then ALSO advances the lane's declared `stage` to that node, without
|
||||
* evidence: codex lanes are the one place where nothing else can move the
|
||||
* pipeline map, so the node renders `passed-no-evidence` (amber) rather than
|
||||
* `done`, and codex-nudge.js asks the session for the real evidence. Like
|
||||
* workflow-ingest.js, everything here is fail-safe: a malformed or partial
|
||||
* journal throws only locally and is skipped, and a poll pass never throws.
|
||||
*
|
||||
@@ -309,6 +313,18 @@ function ingestCodexOnce({ broadcast } = {}) {
|
||||
if (detection) {
|
||||
try {
|
||||
changed = lanesLib.recordDetection(lane.id, detection).written;
|
||||
// Auto-advance: a codex session has no hook and rarely calls `ccam
|
||||
// stage` on its own, so without this its card sits at `idle` forever
|
||||
// while the amber badge is the only thing that ever moves. Gated on
|
||||
// recordDetection having WRITTEN, so it inherits that function's
|
||||
// forward-only + declared-wins + TTL guards for free. Deliberately
|
||||
// passes NO evidence: the node renders `passed-no-evidence` (amber),
|
||||
// never `done` — detection sees a tool call, never its outcome. The
|
||||
// last node has no detect rules in any template, so this can never
|
||||
// auto-declare a lane `done`.
|
||||
if (changed) {
|
||||
lanesLib.setStage(lane.id, { stage: detection.nodeId, keepDetection: true });
|
||||
}
|
||||
} catch {
|
||||
/* lane deleted mid-pass, or sqlite busy — an inference is never worth throwing for */
|
||||
}
|
||||
|
||||
+27
-26
@@ -1,35 +1,34 @@
|
||||
/**
|
||||
* @file Safety-net reminder for codex lanes whose auto-detected stage has
|
||||
* clearly moved past the last stage the session actually *declared* (see
|
||||
* `server/lib/stage-detect.js` / `server/lib/codex-ingest.js`). A codex
|
||||
* session only declares a stage when it calls `ccam stage` on its own —
|
||||
* there is no hook or wrapper that does it for it — so a lane can sit with a
|
||||
* stale declared stage indefinitely even while its pipeline map keeps
|
||||
* painting fresh amber "detected" badges.
|
||||
* @file Safety-net reminder for codex lanes sitting at a stage nobody has
|
||||
* backed with evidence. `codex-ingest.js` auto-advances a codex lane's stage
|
||||
* from its rollout journal — that keeps the pipeline map moving, but an
|
||||
* inferred stage carries no evidence, so its node renders
|
||||
* `passed-no-evidence` (amber) rather than `done`. Only the session's own
|
||||
* `ccam stage <node> --evidence "..."` call can supply that, and a codex
|
||||
* session has no hook or wrapper that does it for it.
|
||||
*
|
||||
* This module never writes `stage` or `detected_stage` itself — the
|
||||
* declared-wins / inferred-never-`done` invariants in lanes.js are untouched.
|
||||
* It only types a plain-text reminder into the lane's own live tmux pane
|
||||
* This module never writes `stage`, `stages` or `detected_stage` itself. It
|
||||
* only types a plain-text reminder into the lane's own live tmux pane
|
||||
* (`tmux.sendText`), the same way a human watching the dashboard would nudge
|
||||
* the agent — so any stage change that results is still a REAL declaration
|
||||
* the codex session made itself.
|
||||
* the agent — so the evidence that results is still a REAL declaration the
|
||||
* codex session made itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const NUDGE_DELAY_MS = 120_000; // give the session 2 min to declare on its own first
|
||||
|
||||
// Per-lane memory of the last detected_stage already nudged for, so the 60s
|
||||
// sweep doesn't retype the same reminder every pass. Cleared implicitly: once
|
||||
// the session declares (setStage nulls detected_stage) or detection advances
|
||||
// again, the value here no longer matches and a fresh nudge is due.
|
||||
// Per-lane memory of the last stage already nudged for, so the 60s sweep
|
||||
// doesn't retype the same reminder every pass. Cleared implicitly: once the
|
||||
// session declares with evidence, or the lane moves to another node, the value
|
||||
// here no longer matches and a fresh nudge is due.
|
||||
// ponytail: in-memory, resets on server restart — worst case one duplicate
|
||||
// nudge after a restart, not worth a DB column for.
|
||||
const nudgedFor = new Map();
|
||||
|
||||
/**
|
||||
* One sweep pass: nudge every codex lane whose fresh detection sits ahead of
|
||||
* its declared stage, has held for at least NUDGE_DELAY_MS, and hasn't
|
||||
* already been nudged for that exact detected stage.
|
||||
* One sweep pass: nudge every codex lane whose CURRENT stage has been held for
|
||||
* at least NUDGE_DELAY_MS with no evidence recorded, and hasn't already been
|
||||
* nudged for that exact stage.
|
||||
* @returns {number} How many lanes were nudged.
|
||||
*/
|
||||
function sweepCodexNudges() {
|
||||
@@ -47,14 +46,16 @@ function sweepCodexNudges() {
|
||||
let nudged = 0;
|
||||
for (const lane of codexLanes) {
|
||||
try {
|
||||
if (!lane.detected_stage || lane.detected_stage === lane.stage) {
|
||||
// No record under the current stage means the lane never entered a
|
||||
// pipeline node (a fresh lane sits at `idle`) — nothing to ask about.
|
||||
const record = (lane.stages || {})[lane.stage];
|
||||
if (!record || record.evidence) {
|
||||
nudgedFor.delete(lane.id);
|
||||
continue;
|
||||
}
|
||||
if (lanes.detectionIsStale(lane.detected_at)) continue;
|
||||
const detectedAt = Date.parse(lane.detected_at);
|
||||
if (!Number.isFinite(detectedAt) || Date.now() - detectedAt < NUDGE_DELAY_MS) continue;
|
||||
if (nudgedFor.get(lane.id) === lane.detected_stage) continue;
|
||||
const enteredAt = Date.parse(record.enteredAt);
|
||||
if (!Number.isFinite(enteredAt) || Date.now() - enteredAt < NUDGE_DELAY_MS) continue;
|
||||
if (nudgedFor.get(lane.id) === lane.stage) continue;
|
||||
|
||||
const runId = runIdForLane(lane.id);
|
||||
if (!tmux.hasSession(runId)) continue;
|
||||
@@ -64,9 +65,9 @@ function sweepCodexNudges() {
|
||||
|
||||
tmux.sendText(
|
||||
runId,
|
||||
`[ccam] You appear to have moved on to "${lane.detected_stage}" — run: ccam stage ${lane.detected_stage} --tool codex --evidence "..."`
|
||||
`[ccam] Stage "${lane.stage}" was inferred and has no evidence — run: ccam stage ${lane.stage} --tool codex --evidence "..."`
|
||||
);
|
||||
nudgedFor.set(lane.id, lane.detected_stage);
|
||||
nudgedFor.set(lane.id, lane.stage);
|
||||
nudged += 1;
|
||||
} catch {
|
||||
/* one lane's failure must not abort the sweep */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* @file Installs the ship-feature-lane pipeline's agent templates
|
||||
* @file Installs a lane's driving instructions. For a `claude` lane that is
|
||||
* the ship-feature-lane pipeline's agent templates
|
||||
* (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
|
||||
* driving session can launch them by the BARE subagent_type name. Source is
|
||||
* the repo's own `agents/` — the same files the ccam plugin ships, where
|
||||
@@ -19,6 +20,52 @@ const TEMPLATE_DIR = path.join(__dirname, "..", "..", "agents");
|
||||
const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
|
||||
const EXCLUDE_LINE = ".claude/agents/";
|
||||
|
||||
// Codex reads no `.claude/agents/`, so for a codex lane the useful thing to
|
||||
// install is the one instruction it DOES read: AGENTS.md, the project doc it
|
||||
// loads from the working directory. Marker-delimited so a reinstall replaces
|
||||
// exactly this block and never the user's own prose, and appended rather than
|
||||
// written over an existing file.
|
||||
const STAGE_BLOCK_START = "<!-- 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
|
||||
* lives (same as info/attributes; MERGE_HEAD/HEAD/the index are the only
|
||||
* per-worktree-private state, not this). */
|
||||
@@ -33,7 +80,10 @@ async function commonGitDir(cwd) {
|
||||
* any existing copy — this is a reinstall, not a merge) and idempotently
|
||||
* git-exclude that directory in the shared common git dir.
|
||||
*
|
||||
* @param {{cwd: string}} lane
|
||||
* A `codex` lane gets the AGENTS.md stage block instead — Codex never reads
|
||||
* `.claude/agents/`, and its own working copy is the only channel it does read.
|
||||
*
|
||||
* @param {{cwd: string, tool?: string}} lane
|
||||
* @returns {Promise<{installed: string[]}>}
|
||||
*/
|
||||
async function installAgents(lane) {
|
||||
@@ -43,6 +93,8 @@ async function installAgents(lane) {
|
||||
});
|
||||
}
|
||||
|
||||
if (lane.tool === "codex") return { installed: [installStageDoc(lane.cwd)] };
|
||||
|
||||
const dest = path.join(lane.cwd, ".claude", "agents");
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
const installed = [];
|
||||
|
||||
+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
|
||||
* to be re-earned. Records under a key matching no node in the pipeline are
|
||||
* left untouched — their position is unknown, so they cannot be proven ahead.
|
||||
*
|
||||
* `keepDetection` is the ONE exception to that clearing, and exists for exactly
|
||||
* one caller: codex-ingest.js, which advances a codex lane's stage FROM its own
|
||||
* detection. There the detection is not stale evidence of something the
|
||||
* declaration superseded — it is the reason for the declaration, and the card
|
||||
* has to keep saying so, or an inferred stage becomes indistinguishable from
|
||||
* one the agent actually claimed.
|
||||
*/
|
||||
function setStage(id, { stage, status, evidence, note, result } = {}) {
|
||||
function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) {
|
||||
const lane = getLane(id);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
|
||||
const next = stage || lane.stage;
|
||||
@@ -321,7 +328,9 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
|
||||
};
|
||||
db.prepare(
|
||||
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${
|
||||
changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : ""
|
||||
changed && !keepDetection
|
||||
? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL"
|
||||
: ""
|
||||
}
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
|
||||
Reference in New Issue
Block a user