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:
@@ -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