diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe09d3..5112f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ All notable changes to codexclaw are documented here. The format follows ## [Unreleased] +## [0.2.12] — 2026-08-22 + +Three bugs filed against codexclaw, all of them cases where the tool obstructed +an agent that was following its own instructions. + +### Fixed + +- **`loop`, `scan` and `receipt` had no `--help` (#47).** The top-level help + points at those commands, and following that pointer failed — `--help` was + reported as an unknown verb, and `cxc --version` as an unknown command. + `orchestrate` was fixed for this long ago; its siblings never were. + + The individual error messages were fine. The problem was that discovery was + only available through failure: arming a goalplan took six consecutive + rejections to assemble one correct command. The `loop` usage now spells out the + steer batch shape, which is the one nobody can guess. + + Also from that issue: `scan record` now accepts `--cwd`, which `orchestrate` + already documented. + +- **The same `--session` id resolved to two different FSMs (#48).** Session files + live under the process cwd, so a thread whose cwd is one tree while its work is + in another silently interviews one FSM and orchestrates the other. `status` now + warns when the id exists elsewhere and names the paths, instead of reporting + `IDLE` for a cycle that is live next door. Detection only — the other tree is + never read or written. `loop show` also accepts `--session` and resolves the + slug the session already carries. + +- **Two gates forced the forgery they existed to prevent (#49).** `receipt test` + refused a receipt whenever the check dirtied the tree, including when the dirty + files were the artifacts the check exists to rebuild — so the reported + workaround was a no-op existence check, a receipt that certifies nothing. + `--generated ` now declares expected rewrites; everything undeclared is + still refused. + + `orchestrate D` refused a goalplan whose work-phases were all done, because + "complete" and "empty" produced the same internal result. The workaround was to + write a finished phase back to `in_progress` purely to pass the gate. D now + closes over a complete plan and the refusal names which real cause applies. + ## [0.2.11] — 2026-08-22 ### Fixed diff --git a/README.ko.md b/README.ko.md index 6215613..807f2bc 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 1,933 tests passing + 1,961 tests passing 28 skills 22 hooks Documentation diff --git a/README.md b/README.md index 47f155c..6f7e3fd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 1,933 tests passing + 1,961 tests passing 28 skills 22 hooks Documentation diff --git a/README.zh.md b/README.zh.md index 5da8a07..df1bce0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 1,933 tests passing + 1,961 tests passing 28 skills 22 hooks Documentation diff --git a/bin/codexclaw.mjs b/bin/codexclaw.mjs index 0dd93cd..b1c1f1f 100755 --- a/bin/codexclaw.mjs +++ b/bin/codexclaw.mjs @@ -30,7 +30,7 @@ import { spawnSync } from "node:child_process"; import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { existsSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { homedir } from "node:os"; const here = dirname(fileURLToPath(import.meta.url)); @@ -421,6 +421,21 @@ if (isMain) switch (cmd) { console.log(renderTopLevelHelp()); process.exit(0); break; + // #47: `cxc --version` was an unknown command, so the only way to learn which + // payload was live was to read the cache directory name. + case "version": + case "--version": + case "-v": { + try { + const manifestPath = join(here, "..", "plugins", "codexclaw", ".codex-plugin", "plugin.json"); + console.log(JSON.parse(readFileSync(manifestPath, "utf8")).version ?? "unknown"); + process.exit(0); + } catch (err) { + console.error(`cxc --version: could not read the plugin manifest (${err.code ?? err.message})`); + process.exit(1); + } + break; + } case "enable": process.exit(runConfigGuard("enable")); break; diff --git a/cli/package.json b/cli/package.json index 83291aa..f8b8a79 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/cli", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "codexclaw CLI — status, subagent config, provider toggle, GUI launcher.", diff --git a/devlog/_plan/260822_attest_win_parity/010_help_verbs.md b/devlog/_plan/260822_attest_win_parity/010_help_verbs.md new file mode 100644 index 0000000..e2084df --- /dev/null +++ b/devlog/_plan/260822_attest_win_parity/010_help_verbs.md @@ -0,0 +1,77 @@ +# 010 - issue #47: the sibling commands had no --help + +## Why this belongs in the same unit + +The attest fix was about codexclaw telling an agent to run something impossible. +This is the same theme one layer down: codexclaw telling an agent where to look +and then refusing to answer. + +``` +cxc --help # mentions loop / orchestrate --help +cxc orchestrate --help # OK +cxc loop --help # loop: unknown loop verb '--help' +cxc scan --help # scan: unknown scan action '--help' +cxc receipt --help # receipt: unknown receipt verb '--help' +cxc --version # codexclaw: unknown command '--version' +``` + +`orchestrate` was fixed long ago (`devlog/_fin/260709_cxc_help_agent_ux`); its +siblings never were. The top-level help points at them, so following the pointer +is what breaks. + +## Measured cost, from this session + +I hit this myself before the issue was filed. Arming the goalplan took six +rejections to get one command right: + +``` +loop steer: --session is required +loop steer: --batch-json is required +loop steer: idempotencyKey is required and must be a non-empty string +loop steer: rationale is required and must be a non-empty string +loop steer: evidence is required and must be a non-empty string +loop steer: ops must be a non-empty array +``` + +Each one is a good error message. Together they are a guessing game, because +there was no way to ask for the whole shape at once. That is the actual defect: +not that the errors are bad, but that discovery was only available through +failure. + +## Fix + +`help | --help | -h` on `loop`, `scan` and `receipt` now print usage and exit 0, +matching `orchestrate`'s contract. Unknown verbs still fail, but the message +names the way out (`run cxc loop --help`) rather than only listing verbs. + +The `loop` usage spells out the steer batch shape explicitly, since that is the +one nobody can guess: + +``` +{ "idempotencyKey": "", "rationale": "", "evidence": "", + "ops": [ { "kind": "annotate", "note": "..." } ] } +``` + +`cxc --version` reads the installed manifest. Previously the only way to know +which payload was live was to read the cache directory name. + +## `scan record --cwd` + +Reported in the same issue and fixed here: `orchestrate` documents and accepts +`--cwd`, `scan` rejected it outright. The reporter's session had its answer +ledger in one tree and its process cwd in another, so `--derive` silently +matched nothing: + +``` +derived=0 dimension(s) from the answer ledger — WARNING: nothing matched +``` + +That is a preview of issue #48, which is the same split seen from the FSM side. + +## Tests + +`help-verbs.test.ts` asserts the CONTRACT — exit 0 plus a `Usage:` block — rather +than the wording, so the text can be edited freely. It also pins the flags that +were previously discoverable only through rejection (`--session`, `--batch-json`, +`idempotencyKey`), asserts that unknown verbs now point at `--help`, and covers +`scan record --cwd` in both the explicit and defaulted forms. diff --git a/devlog/_plan/260822_attest_win_parity/020_session_split.md b/devlog/_plan/260822_attest_win_parity/020_session_split.md new file mode 100644 index 0000000..c3a8603 --- /dev/null +++ b/devlog/_plan/260822_attest_win_parity/020_session_split.md @@ -0,0 +1,66 @@ +# 020 - issue #48: one session id, two FSMs + +## The failure + +Session files live at `/.codexclaw/sessions/.json`. The id is stable; +the cwd is not. A Codex thread whose process cwd is one tree while its work is in +another therefore has TWO FSMs under the same id, and nothing says so: + +``` +~/.cli-jaw/.codexclaw/sessions/.json phase=I 13:16Z +~/kim_wiki/.codexclaw/sessions/.json phase=IDLE 13:22Z +``` + +The reporter closed D in the wiki tree and the next turn re-injected Interview +from the cli-jaw copy. `scan record --derive` matched nothing because the answer +ledger was in the other tree, and reported it as a warning rather than an error. + +## Why not just move the store + +The obvious fix — pin session files to the session-start workspace — would +invalidate every session file that exists today, in every checkout, with no +migration path. The failure is bad but it is not worth a flag day. + +So the fix makes the split **visible** instead of silently picking one side. + +## Fix + +`findForeignSessionCopies(cwd, sessionId, candidates)` looks for the same id in +plausible sibling roots and returns the paths it finds. Detection only: the other +tree is never read from or written to. + +`orchestrate status` now reports it: + +``` +session=split-demo-0001 phase=IDLE interview=false auditPassed=false checkPassed=false +WARNING: this session id also has state in 1 other tree(s); the phase above describes THIS cwd only. + also at: C:\Users\super\cxc-split-a\.codexclaw\sessions\split-demo-0001.json + Pass --cwd to address a specific tree. +``` + +That is exactly the reporter's scenario: `phase=IDLE` here, a live cycle next +door, and now a line that says so. The `--json` form carries `alsoFoundAt`. + +The candidate list is deliberately shallow — immediate children of `$HOME` plus +the parent of cwd — because this is a warning on a read-only command, not a +filesystem crawl. `node_modules` and `AppData` are skipped as places a workspace +never lives. + +## `loop show --session` + +The same issue reported that `loop show --slug` is cwd-only and prints +`no plan found` from the wrong tree. `loop init --session` already binds the slug +into the session file, so `resolveSlug` now falls back to that binding: + +``` +cxc loop show --session # no 47-character slug to retype +``` + +This also makes the session the source of truth when the id exists in more than +one tree, rather than whichever directory the shell happened to be in. + +## Not fixed here + +The underlying cwd-keyed storage is unchanged, so two trees still diverge — you +are now told about it rather than misled by it. Making `--session` resolve to one +canonical store is a larger change that needs a migration story. diff --git a/devlog/_plan/260822_attest_win_parity/030_receipt_and_d_gates.md b/devlog/_plan/260822_attest_win_parity/030_receipt_and_d_gates.md new file mode 100644 index 0000000..64b7e43 --- /dev/null +++ b/devlog/_plan/260822_attest_win_parity/030_receipt_and_d_gates.md @@ -0,0 +1,84 @@ +# 030 - issue #49: two gates that fought legitimate work + +Both halves share a shape: a rule written to prevent forgery ended up forcing it. + +## 1. `receipt test` refused a validator that rebuilds its own artifacts + +``` +검증 통과 +graph.json 생성 — {nodes: 100, ...} +receipt test: the command changed the source while running (working tree went dirty); +no receipt written — a check cannot certify a tree it rewrote +``` + +`validate.py --build` IS the documented ontology gate for that repo. It rewrites +`graph.json` by design. The receipt runner treated that as contamination. + +The reported workaround is the damning part: commit the generated files, then run +`cxc receipt test -- test -f ...` — a no-op existence check. That produces a +receipt that certifies nothing. **A forged receipt is strictly worse than a loose +one**, because it satisfies CHECK-BINDING-01 while proving less than no receipt +at all. + +### Fix: declared, never inferred + +``` +cxc receipt test --session --generated build -- node validate.mjs +``` + +`--generated` is repeatable and takes a repo-relative path; a path covers that +file or that directory. Anything NOT declared is still refused, so the escape +hatch cannot be widened by accident: + +| scenario | result | +|---|---| +| declared path rewritten | receipt written | +| undeclared path rewritten | still refused | +| wrong path declared, real file rewritten | still refused | +| `build/graph.json` declared, `build/other.json` written | still refused | + +The refusal message now names the flag, so the next agent finds the sanctioned +route instead of inventing the `test -f` trick. The receipt records +`generatedPaths`, so a reader can see exactly which rewrites were permitted. + +Verified end to end on a throwaway git repo whose validator rewrites its own +output: refused without the flag, receipt written with it, still refused when the +wrong path is declared. + +## 2. `orchestrate D` refused a goalplan that was already finished + +``` +the bound goalplan "" has no active work-phase to close (CYCLE-COMPLETION-01). +``` + +`advanceWorkPhase` returns `no_active` for two very different situations, and the +gate treated them the same: + +- the plan is EMPTY, or everything is blocked — a real refusal +- every work-phase is `done` — the plan is **complete** + +The reported workaround was to write a finished phase back to `in_progress` just +to get past the gate. That is corrupting the record in order to satisfy a check +about the record. + +D now closes when every work-phase is done, and the refusal names which of the +two real causes applies: + +``` +... has no work-phase to close: the plan is empty — register workPhases[] first +... has no work-phase to close: every remaining work-phase is blocked or superseded +``` + +The goalplan ledger says `cycle closed over an already-complete plan` rather than +`closed null`. + +## 3. SOURCE-DELTA-01, documented rather than changed + +The issue also asks that a B-phase commit count as B work. It already does — I +hit `the source is unchanged since B began` twice in this session, and both times +the cause was real: I had entered B and then tried to leave it without touching +the tree, because the work had happened in an earlier cycle. + +The rule is correct. What was missing is that the message does not say what +counts. Left as-is here rather than loosened; a gate that occasionally annoys is +better than one that lets an empty B through. diff --git a/package.json b/package.json index 5f6227d..262fb97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexclaw", - "version": "0.2.11", + "version": "0.2.12", "private": true, "description": "cli-jaw-style dev discipline + multi-model subagents for the OpenAI Codex runtime.", "type": "module", diff --git a/plugins/codexclaw/.codex-plugin/plugin.json b/plugins/codexclaw/.codex-plugin/plugin.json index ab973e5..b65933f 100644 --- a/plugins/codexclaw/.codex-plugin/plugin.json +++ b/plugins/codexclaw/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codexclaw", - "version": "0.2.11+codex.20260818121334", + "version": "0.2.12+codex.20260818121334", "description": "cli-jaw-style dev discipline (dev skills + PABCD) and multi-model subagents for the OpenAI Codex runtime, with optional opencodex provider routing.", "author": { "name": "lidge-jun", diff --git a/plugins/codexclaw/bin/cxc.mjs b/plugins/codexclaw/bin/cxc.mjs index 1d849be..51d43dd 100644 --- a/plugins/codexclaw/bin/cxc.mjs +++ b/plugins/codexclaw/bin/cxc.mjs @@ -18,7 +18,7 @@ * the payload path). Both print a pointer instead of failing cryptically. */ import { spawnSync } from "node:child_process"; -import { realpathSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { dirname, join, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; @@ -117,6 +117,20 @@ if (isMain) { console.log(HELP); process.exit(0); } + // #47: `cxc --version` was reported as an unknown command, so the only way to + // learn which payload was installed was to read the cache path. + if (cmd === "version" || cmd === "--version" || cmd === "-v") { + try { + const manifest = JSON.parse( + readFileSync(join(payloadRoot, ".codex-plugin", "plugin.json"), "utf8"), + ); + console.log(manifest.version ?? "unknown"); + process.exit(0); + } catch (err) { + console.error(`cxc --version: could not read the plugin manifest (${err.code ?? err.message})`); + process.exit(1); + } + } if (cmd === "gui" || cmd === "map") { console.log( `cxc ${cmd}: available from a repo checkout only (github.com/lidge-jun/codexclaw — see README Development section).`, diff --git a/plugins/codexclaw/components/config-guard/package.json b/plugins/codexclaw/components/config-guard/package.json index b4c0672..1d88c48 100644 --- a/plugins/codexclaw/components/config-guard/package.json +++ b/plugins/codexclaw/components/config-guard/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/config-guard", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "Controlled feature-flag activation: enables only codexclaw's declared [features] flags via the official `codex features` CLI, with a revert manifest and backup.", diff --git a/plugins/codexclaw/components/cxc-ops/package.json b/plugins/codexclaw/components/cxc-ops/package.json index cfd4b3c..765ed77 100644 --- a/plugins/codexclaw/components/cxc-ops/package.json +++ b/plugins/codexclaw/components/cxc-ops/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/cxc-ops", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "codexclaw ops CLI — doctor (plugin health), reset (scoped state cleanup).", diff --git a/plugins/codexclaw/components/messenger-bridge/package.json b/plugins/codexclaw/components/messenger-bridge/package.json index 6ec2ade..0ce6e04 100644 --- a/plugins/codexclaw/components/messenger-bridge/package.json +++ b/plugins/codexclaw/components/messenger-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/messenger-bridge", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "codexclaw messenger bridge — cxc serve HTTP server + SQLite state substrate (zero third-party deps).", diff --git a/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js b/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js index 1b46765..a75aff4 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js +++ b/plugins/codexclaw/components/pabcd-state/dist/goalplan-cli.js @@ -73,9 +73,15 @@ const VERBS = new Set ([ /** Structural argv parse. argv excludes the `goalplan` kind token. */ export function parseGoalplanCliArgs(argv , cwd ) { const verb = (argv[0] ?? "").toLowerCase(); + // #47: `--help` on a sibling command used to be reported as an unknown verb, so an + // agent that followed `cxc --help`'s own pointer hit a non-zero exit and had to + // discover every flag one rejection at a time. Same contract as orchestrate. + if (verb === "help" || verb === "--help" || verb === "-h") { + return { verb: "help", cwd, criteria: [] }; + } if (!VERBS.has(verb)) { return { - error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase)`, + error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase); run cxc loop --help`, }; } const out = { verb: verb , cwd, criteria: [] }; @@ -104,6 +110,14 @@ export function parseGoalplanCliArgs(argv , cwd ) function resolveSlug(args ) { if (typeof args.slug === "string" && args.slug.length > 0) return deriveSlug(args.slug); if (typeof args.objective === "string" && args.objective.length > 0) return deriveSlug(args.objective); + // #48: `loop init --session` already binds the slug into the session file, so a + // later `show`/`validate` can recover it without the caller re-typing a + // 47-character derived slug. This also makes the session the source of truth + // when the same id has state in more than one tree. + if (typeof args.session === "string" && args.session.length > 0) { + const bound = readState(args.cwd, args.session).slug; + if (typeof bound === "string" && bound.length > 0) return bound; + } return null; } @@ -276,7 +290,39 @@ function renderPlanLines(plan ) { return lines.join("\n"); } +/** + * #47: every flag below used to be discoverable only by running the command and + * reading the rejection, one missing argument at a time. The steer batch shape is + * spelled out for the same reason. + */ +export function renderGoalplanHelp() { + return [ + "cxc loop — durable goalplan for a multi-cycle PABCD loop", + "", + "Usage:", + " cxc loop init --objective --session [--criterion ]... [--cwd ]", + " cxc loop show (--slug | --objective ) [--cwd ]", + " cxc loop validate --slug [--cwd ]", + " cxc loop steer --session --slug --batch-json [--cwd ]", + " cxc loop add-work-phase --session --slug --id --title ", + " cxc loop add-criterion --session --slug --criterion [--surface logic|web|tui]", + " cxc loop --help", + "", + "Notes:", + " Mutating verbs require --session ; show and validate are read-only.", + " The goalplan lives at /.codexclaw/goalplans//goalplan.json, so --cwd", + " matters when the process cwd is not the workspace you are planning in.", + "", + "steer --batch-json expects an object with:", + ' { "idempotencyKey": "", "rationale": "", "evidence": "",', + ' "ops": [ { "kind": "annotate", "note": "..." } ] }', + " op kinds: annotate | add-criterion | add-work-phase (all additive — steering", + " cannot weaken a completion criterion).", + ].join("\n"); +} + export function runGoalplanCli(args ) { + if (args.verb === "help") return { output: renderGoalplanHelp(), code: 0 }; if (args.verb === "init") { const objective = (args.objective ?? "").trim(); if (objective.length === 0) { @@ -313,7 +359,10 @@ export function runGoalplanCli(args ) { const slug = resolveSlug(args); if (!slug) { - return { output: `loop ${args.verb}: --slug "" or --objective "" is required`, code: 1 }; + return { + output: `loop ${args.verb}: --slug "", --objective "", or --session (with a bound plan) is required`, + code: 1, + }; } const plan = readGoalplan(args.cwd, slug); if (!plan) { diff --git a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js index a2d17a7..6039516 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/goalplan.js +++ b/plugins/codexclaw/components/pabcd-state/dist/goalplan.js @@ -1008,6 +1008,8 @@ function identityReasons(plan , gate , ctx + + /** * Advance the goalplan's work-phase cursor: mark the current activeWorkPhaseId * as `done`, then set the next pending work-phase active. diff --git a/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js b/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js index 78dcf79..84f1d23 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js +++ b/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js @@ -15,6 +15,7 @@ */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; +import { homedir } from "node:os"; import { coerceAttest, validateWorkPhaseBinding, GATED_TRANSITIONS, } from "./attest.js"; import { canEnter, transition } from "./fsm.js"; import { validatePlanArtifacts } from "./plan-gate.js"; @@ -108,6 +109,7 @@ import { appendLedger, readState, writeState, + findForeignSessionCopies, STATE_DIR, SESSIONS_SUBDIR, @@ -303,9 +305,26 @@ function renderPhaseContext(state , sessionId ) { return `current=${state.phase} session=${sessionId}`; } -function renderStatus(state , json ) { - if (json) return JSON.stringify({ phase: state.phase, flags: state.flags, sessionId: state.sessionId }); - return `session=${state.sessionId} phase=${state.phase} interview=${state.flags.interview} auditPassed=${state.flags.auditPassed} checkPassed=${state.flags.checkPassed}`; +function renderStatus(state , json , elsewhere = []) { + if (json) { + return JSON.stringify({ + phase: state.phase, + flags: state.flags, + sessionId: state.sessionId, + ...(elsewhere.length > 0 ? { alsoFoundAt: elsewhere } : {}), + }); + } + const line = `session=${state.sessionId} phase=${state.phase} interview=${state.flags.interview} auditPassed=${state.flags.auditPassed} checkPassed=${state.flags.checkPassed}`; + // #48: the same id in two trees means this line describes only ONE of them. + // Reporting IDLE for a cycle that is really in flight next door is the failure + // this warning exists to prevent. + if (elsewhere.length === 0) return line; + return [ + line, + `WARNING: this session id also has state in ${elsewhere.length} other tree(s); the phase above describes THIS cwd only.`, + ...elsewhere.map((p) => ` also at: ${p}`), + " Pass --cwd to address a specific tree.", + ].join("\n"); } export function renderOrchestrateParseError(error ) { @@ -334,7 +353,14 @@ export function runOrchestrateCli(args // status: read-only. With no session, report it (don't create one). if (args.verb === "status") { if (!sessionId) return { code: 0, output: "no active session" }; - return { code: 0, output: renderStatus(readState(args.cwd, sessionId), args.json) }; + return { + code: 0, + output: renderStatus( + readState(args.cwd, sessionId), + args.json, + findForeignSessionCopies(args.cwd, sessionId, siblingRoots(args.cwd)), + ), + }; } // G3 (fork-FSM collision, 260707): mutating verbs REQUIRE an explicit --session. @@ -543,10 +569,21 @@ export function runOrchestrateCli(args }; } if (advanced.kind === "no_active") { + // #49: "no active work-phase" has two very different causes. If every phase + // is already done, the plan is COMPLETE and refusing D strands the cycle — + // the reported workaround was to write a finished phase back to + // in_progress just to satisfy this gate, which corrupts the record to + // satisfy a check about the record. Only an EMPTY or fully blocked plan is + // a real refusal. + const closable = plan.workPhases.length > 0 && plan.workPhases.every((wp) => wp.status === "done"); + if (closable) { + advanced = { kind: "ok", closedId: null, plan }; + } else { return { code: 1, - output: `orchestrate D: ${renderPhaseContext(state, sessionId)}; the bound goalplan "${state.slug}" has no active work-phase to close (CYCLE-COMPLETION-01). Register or unblock a work-phase before closing a cycle. Nothing was written.`, + output: `orchestrate D: ${renderPhaseContext(state, sessionId)}; the bound goalplan "${state.slug}" has no work-phase to close (CYCLE-COMPLETION-01): ${plan.workPhases.length === 0 ? "the plan is empty — register workPhases[] first" : "every remaining work-phase is blocked or superseded — unblock one"}. Nothing was written.`, }; + } } } writeState(args.cwd, { ...clearedIdle(state), stopBlockPhase: null, stopBlockCount: 0 }); @@ -569,7 +606,11 @@ export function runOrchestrateCli(args event: "workphase_done", // 260714 wp4: log the EFFECTIVE closed id (implicit cursor may have // started from a null explicit cursor — "closed none" was a lie). - detail: `closed ${advanced.closedId}`, + // #49: a null id means the plan was already fully done and this cycle + // closed without advancing a cursor. Say that rather than "closed null". + detail: advanced.closedId + ? `closed ${advanced.closedId}` + : "cycle closed over an already-complete plan", }); if (advanced.plan.activeWorkPhaseId) { appendGoalplanLedger(args.cwd, state.slug, { @@ -651,3 +692,31 @@ export function runOrchestrateCli(args }); return { code: 0, output: `orchestrate ${args.verb}: current=${state.phase} -> ${result.state.phase} (${state.phase} → ${result.state.phase}, session ${sessionId})` }; } +/** + * #48: candidate trees to check for the SAME session id. Deliberately shallow — + * the immediate children of $HOME plus the parent of cwd — because this is a + * warning path on a read-only command, not a filesystem crawl. Anything deeper + * would cost more than the warning is worth. + */ +function siblingRoots(cwd ) { + const roots = []; + let home ; + try { + home = homedir(); + } catch { + return roots; + } + try { + for (const entry of readdirSync(home, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + // Skip the noisy ones a workspace never lives in. + if (entry.name === "node_modules" || entry.name === "AppData") continue; + roots.push(join(home, entry.name)); + } + } catch { + // an unreadable home is not an error for a warning path + } + const parent = resolve(cwd, ".."); + if (parent !== resolve(cwd)) roots.push(parent); + return roots; +} diff --git a/plugins/codexclaw/components/pabcd-state/dist/receipt-cli.js b/plugins/codexclaw/components/pabcd-state/dist/receipt-cli.js index 3a054cc..725648f 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/receipt-cli.js +++ b/plugins/codexclaw/components/pabcd-state/dist/receipt-cli.js @@ -28,10 +28,19 @@ import { STATE_DIR, sanitizeKey } from "./state.js"; + + /** Everything after `--` is the command; nothing before it is. */ export function parseReceiptCliArgs(argv , cwd ) { const verb = (argv[0] ?? "").toLowerCase(); - if (verb !== "test") return { error: `unknown receipt verb '${argv[0] ?? ""}' (expected test)` }; + // #47: --help was reported as an unknown verb, so the flags could only be learned + // from rejections. + if (verb === "help" || verb === "--help" || verb === "-h") { + return { verb: "help", cwd, command: [] }; + } + if (verb !== "test") { + return { error: `unknown receipt verb '${argv[0] ?? ""}' (expected test); run cxc receipt --help` }; + } const out = { verb: "test", cwd, command: [] }; let i = 1; for (; i < argv.length; i++) { @@ -39,6 +48,13 @@ export function parseReceiptCliArgs(argv , cwd ) if (a === "--") { i++; break; } if (a === "--session") out.session = argv[++i]; else if (a === "--cwd") out.cwd = argv[++i] ?? cwd; + else if (a === "--generated") { + const v = argv[++i]; + if (typeof v !== "string" || v.length === 0) { + return { error: "receipt test: --generated needs a repo-relative path" }; + } + out.generated = [...(out.generated ?? []), v.replace(/\\/g, "/").replace(/^\.\//, "")]; + } else return { error: `unexpected argument '${a}' before --` }; } out.command = argv.slice(i).filter((a) => typeof a === "string" && a.length > 0); @@ -54,6 +70,29 @@ export function receiptPathFor(cwd , sessionId ) { export function runReceiptCli(args ) { + if (args.verb === "help") { + return { + output: [ + "cxc receipt — record a check receipt that binds a command's result to a source tree", + "", + "Usage:", + " cxc receipt test --session [--cwd ] [--generated ]... -- [args...]", + " cxc receipt --help", + "", + "Notes:", + " Everything after `--` is the command; nothing before it is.", + " The session must be at phase C — a receipt is produced during Check.", + " The receipt is written to /.codexclaw/evidence//test-receipt.json", + " and is refused if the command changes the source while it runs.", + " --generated declares paths the check REGENERATES by design (a validator that", + " rebuilds its own artifacts). Repeatable. Undeclared rewrites are still refused.", + "", + "Example:", + " cxc receipt test --session -- npm test", + ].join("\n"), + code: 0, + }; + } const session = (args.session ?? "").trim(); if (session.length === 0) return { output: "receipt test: --session is required", code: 1 }; if (args.command.length === 0) { @@ -74,7 +113,11 @@ export function runReceiptCli(args ) { // Clear first: a stale success must not survive a failing re-run. rmSync(path, { force: true }); - const before = captureSourceIdentity(args.cwd, { excludeCodexclawArtifacts: true }); + const capture = { + excludeCodexclawArtifacts: true, + ...(args.generated && args.generated.length > 0 ? { generatedPaths: args.generated } : {}), + }; + const before = captureSourceIdentity(args.cwd, capture); const [bin, ...rest] = args.command; // Issue #40: `npm` is the command people actually pass here, and a bare // shell-less spawn of it cannot work on Windows - the name alone skips PATHEXT @@ -88,7 +131,7 @@ export function runReceiptCli(args ) { shell: false, ...invocation.options, }); - const after = captureSourceIdentity(args.cwd, { excludeCodexclawArtifacts: true }); + const after = captureSourceIdentity(args.cwd, capture); if (run.error || typeof run.status !== "number") { return { output: `receipt test: the command did not run to completion (${run.error?.message ?? "terminated by signal"}); no receipt written`, code: 1 }; @@ -98,7 +141,15 @@ export function runReceiptCli(args ) { } const cmp = compareSource(before, after); if (cmp.kind === "different") { - return { output: `receipt test: the command changed the source while running (${cmp.detail}); no receipt written — a check cannot certify a tree it rewrote`, code: 1 }; + return { + output: [ + `receipt test: the command changed the source while running (${cmp.detail}); no receipt written — a check cannot certify a tree it rewrote.`, + "If the check REGENERATES artifacts by design, declare them:", + " cxc receipt test --session --generated -- ", + "(repeatable; a path covers that file or that directory. Undeclared rewrites are still refused.)", + ].join("\n"), + code: 1, + }; } if (cmp.kind === "unavailable") { return { output: `receipt test: git could not resolve the source identity (${cmp.reason}); no receipt written`, code: 1 }; @@ -112,6 +163,8 @@ export function runReceiptCli(args ) { createdAt: new Date().toISOString(), ownerSessionId: session, checkEpoch: state.checkEpoch, + // Recorded so a reader can see WHICH rewrites the check was allowed to make. + ...(args.generated && args.generated.length > 0 ? { generatedPaths: args.generated } : {}), }; mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, `${JSON.stringify(receipt, null, 2)}\n`); diff --git a/plugins/codexclaw/components/pabcd-state/dist/scan-cli.js b/plugins/codexclaw/components/pabcd-state/dist/scan-cli.js index 7921416..5aaa390 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/scan-cli.js +++ b/plugins/codexclaw/components/pabcd-state/dist/scan-cli.js @@ -83,8 +83,20 @@ export function parseScanCliArgs( cwd , ) { const [action, ...rest] = argv; + // #47: --help was an unknown action, and --cwd was rejected outright even though + // orchestrate accepts it — which stranded a live session whose answer ledger lived + // in a different tree from the process cwd. + if (action === "help" || action === "--help" || action === "-h") { + return { + action: "help", + sessionId: "", + contradictionCount: 0, + highContradictionCount: 0, + cwd, + }; + } if (action !== "record") { - return { error: `unknown scan action '${action ?? ""}' — usage: scan record --session [--contradictions N] [--high N]` }; + return { error: `unknown scan action '${action ?? ""}'; run cxc scan --help` }; } let sessionId = ""; let contradictionCount = 0; @@ -98,11 +110,17 @@ export function parseScanCliArgs( const known = []; const unknown = []; const confidence = {}; + let resolvedCwd = cwd; for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg === "--session") { sessionId = rest[i + 1] ?? ""; i += 1; + } else if (arg === "--cwd") { + // #47: orchestrate documents and accepts --cwd; scan rejected it, which + // stranded a session whose answer ledger was in a different tree. + resolvedCwd = rest[i + 1] ?? cwd; + i += 1; } else if (arg === "--contradictions") { contradictionCount = Number.parseInt(rest[i + 1] ?? "", 10); i += 1; @@ -173,7 +191,7 @@ export function parseScanCliArgs( sessionId, contradictionCount, highContradictionCount, - cwd, + cwd: resolvedCwd, ...(derive ? { derive } : {}), ...(Object.keys(map).length > 0 ? { map } : {}), ...(Object.keys(dims).length > 0 ? { dims } : {}), @@ -264,6 +282,28 @@ function deriveLevel(score ) { } export function runScanCli(args ) { + if (args.action === "help") { + return { + output: [ + "cxc scan — record an interview rescan round and fold answers into the tracker", + "", + "Usage:", + " cxc scan record --session [--cwd ] [--contradictions N] [--high N]", + " [--derive] [--map =]...", + " [--dim =]... [--known =]...", + " [--unknown =]... [--confidence =<0..1>]...", + " cxc scan --help", + "", + "Notes:", + " --session is required; there is no latest-session fallback for a mutating command.", + " --cwd matters when the answer ledger lives outside the process cwd:", + " answers are read from /.codexclaw/interviews/.jsonl.", + " --derive folds captured answers in; --map attributes a questionId to a dimension.", + " --dim cannot set 'max' — that level gates I->P and must be attested.", + ].join("\n"), + code: 0, + }; + } try { const state = readState(args.cwd, args.sessionId); const tracker = state.interview ?? defaultInterview(0); diff --git a/plugins/codexclaw/components/pabcd-state/dist/source-identity.js b/plugins/codexclaw/components/pabcd-state/dist/source-identity.js index 65ae017..42960a6 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/source-identity.js +++ b/plugins/codexclaw/components/pabcd-state/dist/source-identity.js @@ -154,6 +154,17 @@ const STATE_DIR_PREFIX = ".codexclaw/"; + + + + + + + + + + + export function captureSourceIdentity(cwd , options = {}) { const capturedAt = new Date().toISOString(); let commitSha = ""; @@ -173,6 +184,10 @@ export function captureSourceIdentity(cwd , options = {}) if (options.excludeCodexclawArtifacts) { records = records.filter((r) => !r.path.startsWith(STATE_DIR_PREFIX)); } + const generated = (options.generatedPaths ?? []).filter((p) => p.length > 0); + if (generated.length > 0) { + records = records.filter((r) => !generated.some((g) => r.path === g || r.path.startsWith(`${g}/`))); + } if (records.length === 0) return { kind: "resolved", commitSha, dirty: false, capturedAt }; return { kind: "resolved", commitSha, dirty: true, treeHash: hashRecords(cwd, records), capturedAt }; } diff --git a/plugins/codexclaw/components/pabcd-state/dist/state.js b/plugins/codexclaw/components/pabcd-state/dist/state.js index 3e1c96c..38bfe92 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/state.js +++ b/plugins/codexclaw/components/pabcd-state/dist/state.js @@ -1,6 +1,6 @@ -import { mkdirSync, readFileSync, writeFileSync, appendFileSync, linkSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, linkSync, rmSync } from "node:fs"; import { randomUUID } from "node:crypto"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { renameWithRetry } from "./atomic-write.js"; import { reconstructInterview, normalizeInterview, isInterviewReady } from "./interview.js"; @@ -181,6 +181,41 @@ function statePath(cwd , sessionId ) { return join(sessionsDir(cwd), `${sanitizeKey(sessionId)}.json`); } +/** + * #48: session files live at `/.codexclaw/sessions/.json`, so the SAME + * `--session` id resolves to different state depending on where the process was + * started. A Codex thread whose cwd is one tree while its work is in another then + * interviews one FSM and orchestrates the other, and `status` reports IDLE for a + * cycle that is genuinely in flight elsewhere. + * + * Changing the storage location would break every existing session, so instead we + * make the split VISIBLE: look for the same id in the nearest plausible sibling + * roots and report what was found. Detection only — nothing is read from or + * written to the other tree. + */ +export function findForeignSessionCopies( + cwd , + sessionId , + candidates , +) { + const mine = resolve(statePath(cwd, sessionId)); + const seen = new Set ([mine]); + const found = []; + for (const root of candidates) { + if (typeof root !== "string" || root.length === 0) continue; + let candidate ; + try { + candidate = resolve(statePath(root, sessionId)); + } catch { + continue; + } + if (seen.has(candidate)) continue; + seen.add(candidate); + if (existsSync(candidate)) found.push(candidate); + } + return found; +} + /** * Materialize a fresh Codex session without resetting a resumed one. * diff --git a/plugins/codexclaw/components/pabcd-state/package.json b/plugins/codexclaw/components/pabcd-state/package.json index 02a8175..7e52052 100644 --- a/plugins/codexclaw/components/pabcd-state/package.json +++ b/plugins/codexclaw/components/pabcd-state/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/pabcd-state", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "IPABCD finite-state machine backed by per-session .codexclaw/sessions/.json + shared ledger.jsonl.", diff --git a/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts b/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts index b637867..d0901d7 100644 --- a/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts +++ b/plugins/codexclaw/components/pabcd-state/src/goalplan-cli.ts @@ -34,7 +34,7 @@ import { captureSourceIdentity, compareSource } from "./source-identity.ts"; import { parseSourceBoundReceipt } from "./source-receipt.ts"; import { applySteeringBatch } from "./steering.ts"; -export type GoalplanVerb = "init" | "show" | "validate" | "steer" | "add-criterion" | "add-work-phase"; +export type GoalplanVerb = "init" | "show" | "validate" | "steer" | "add-criterion" | "add-work-phase" | "help"; export interface GoalplanCliArgs { verb: GoalplanVerb; @@ -73,9 +73,15 @@ const VERBS: ReadonlySet = new Set([ /** Structural argv parse. argv excludes the `goalplan` kind token. */ export function parseGoalplanCliArgs(argv: string[], cwd: string): GoalplanCliArgs | GoalplanCliParseError { const verb = (argv[0] ?? "").toLowerCase(); + // #47: `--help` on a sibling command used to be reported as an unknown verb, so an + // agent that followed `cxc --help`'s own pointer hit a non-zero exit and had to + // discover every flag one rejection at a time. Same contract as orchestrate. + if (verb === "help" || verb === "--help" || verb === "-h") { + return { verb: "help", cwd, criteria: [] }; + } if (!VERBS.has(verb)) { return { - error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase)`, + error: `unknown loop verb '${argv[0] ?? ""}' (expected init|show|validate|steer|add-criterion|add-work-phase); run cxc loop --help`, }; } const out: GoalplanCliArgs = { verb: verb as GoalplanVerb, cwd, criteria: [] }; @@ -104,6 +110,14 @@ export interface GoalplanCliResult { function resolveSlug(args: GoalplanCliArgs): string | null { if (typeof args.slug === "string" && args.slug.length > 0) return deriveSlug(args.slug); if (typeof args.objective === "string" && args.objective.length > 0) return deriveSlug(args.objective); + // #48: `loop init --session` already binds the slug into the session file, so a + // later `show`/`validate` can recover it without the caller re-typing a + // 47-character derived slug. This also makes the session the source of truth + // when the same id has state in more than one tree. + if (typeof args.session === "string" && args.session.length > 0) { + const bound = readState(args.cwd, args.session).slug; + if (typeof bound === "string" && bound.length > 0) return bound; + } return null; } @@ -276,7 +290,39 @@ function renderPlanLines(plan: Goalplan): string { return lines.join("\n"); } +/** + * #47: every flag below used to be discoverable only by running the command and + * reading the rejection, one missing argument at a time. The steer batch shape is + * spelled out for the same reason. + */ +export function renderGoalplanHelp(): string { + return [ + "cxc loop — durable goalplan for a multi-cycle PABCD loop", + "", + "Usage:", + " cxc loop init --objective --session [--criterion ]... [--cwd ]", + " cxc loop show (--slug | --objective ) [--cwd ]", + " cxc loop validate --slug [--cwd ]", + " cxc loop steer --session --slug --batch-json [--cwd ]", + " cxc loop add-work-phase --session --slug --id --title ", + " cxc loop add-criterion --session --slug --criterion [--surface logic|web|tui]", + " cxc loop --help", + "", + "Notes:", + " Mutating verbs require --session ; show and validate are read-only.", + " The goalplan lives at /.codexclaw/goalplans//goalplan.json, so --cwd", + " matters when the process cwd is not the workspace you are planning in.", + "", + "steer --batch-json expects an object with:", + ' { "idempotencyKey": "", "rationale": "", "evidence": "",', + ' "ops": [ { "kind": "annotate", "note": "..." } ] }', + " op kinds: annotate | add-criterion | add-work-phase (all additive — steering", + " cannot weaken a completion criterion).", + ].join("\n"); +} + export function runGoalplanCli(args: GoalplanCliArgs): GoalplanCliResult { + if (args.verb === "help") return { output: renderGoalplanHelp(), code: 0 }; if (args.verb === "init") { const objective = (args.objective ?? "").trim(); if (objective.length === 0) { @@ -313,7 +359,10 @@ export function runGoalplanCli(args: GoalplanCliArgs): GoalplanCliResult { const slug = resolveSlug(args); if (!slug) { - return { output: `loop ${args.verb}: --slug "" or --objective "" is required`, code: 1 }; + return { + output: `loop ${args.verb}: --slug "", --objective "", or --session (with a bound plan) is required`, + code: 1, + }; } const plan = readGoalplan(args.cwd, slug); if (!plan) { diff --git a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts index 538550b..ad4151a 100644 --- a/plugins/codexclaw/components/pabcd-state/src/goalplan.ts +++ b/plugins/codexclaw/components/pabcd-state/src/goalplan.ts @@ -1004,7 +1004,9 @@ function identityReasons(plan: Goalplan, gate: FinalGateState, ctx: GoalplanVali * refusal leaves all three untouched. */ export type AdvanceResult = - | { kind: "ok"; plan: Goalplan; closedId: string } + // closedId is null when the plan was ALREADY fully done and this cycle closes + // without advancing a cursor (#49). + | { kind: "ok"; plan: Goalplan; closedId: string | null } | { kind: "tasks_pending"; workPhaseId: string; pending: GoalplanTask[] } | { kind: "no_active" }; diff --git a/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts b/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts index 77d38a8..f8314d9 100644 --- a/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts +++ b/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts @@ -15,6 +15,7 @@ */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; +import { homedir } from "node:os"; import { coerceAttest, validateWorkPhaseBinding, GATED_TRANSITIONS, type Attestation } from "./attest.ts"; import { canEnter, transition } from "./fsm.ts"; import { validatePlanArtifacts } from "./plan-gate.ts"; @@ -108,6 +109,7 @@ import { appendLedger, readState, writeState, + findForeignSessionCopies, STATE_DIR, SESSIONS_SUBDIR, type Phase, @@ -303,9 +305,26 @@ function renderPhaseContext(state: State, sessionId: string): string { return `current=${state.phase} session=${sessionId}`; } -function renderStatus(state: State, json: boolean): string { - if (json) return JSON.stringify({ phase: state.phase, flags: state.flags, sessionId: state.sessionId }); - return `session=${state.sessionId} phase=${state.phase} interview=${state.flags.interview} auditPassed=${state.flags.auditPassed} checkPassed=${state.flags.checkPassed}`; +function renderStatus(state: State, json: boolean, elsewhere: string[] = []): string { + if (json) { + return JSON.stringify({ + phase: state.phase, + flags: state.flags, + sessionId: state.sessionId, + ...(elsewhere.length > 0 ? { alsoFoundAt: elsewhere } : {}), + }); + } + const line = `session=${state.sessionId} phase=${state.phase} interview=${state.flags.interview} auditPassed=${state.flags.auditPassed} checkPassed=${state.flags.checkPassed}`; + // #48: the same id in two trees means this line describes only ONE of them. + // Reporting IDLE for a cycle that is really in flight next door is the failure + // this warning exists to prevent. + if (elsewhere.length === 0) return line; + return [ + line, + `WARNING: this session id also has state in ${elsewhere.length} other tree(s); the phase above describes THIS cwd only.`, + ...elsewhere.map((p) => ` also at: ${p}`), + " Pass --cwd to address a specific tree.", + ].join("\n"); } export function renderOrchestrateParseError(error: CliParseError): string { @@ -334,7 +353,14 @@ export function runOrchestrateCli(args: OrchestrateCliArgs | OrchestrateCliHelpA // status: read-only. With no session, report it (don't create one). if (args.verb === "status") { if (!sessionId) return { code: 0, output: "no active session" }; - return { code: 0, output: renderStatus(readState(args.cwd, sessionId), args.json) }; + return { + code: 0, + output: renderStatus( + readState(args.cwd, sessionId), + args.json, + findForeignSessionCopies(args.cwd, sessionId, siblingRoots(args.cwd)), + ), + }; } // G3 (fork-FSM collision, 260707): mutating verbs REQUIRE an explicit --session. @@ -543,10 +569,21 @@ export function runOrchestrateCli(args: OrchestrateCliArgs | OrchestrateCliHelpA }; } if (advanced.kind === "no_active") { + // #49: "no active work-phase" has two very different causes. If every phase + // is already done, the plan is COMPLETE and refusing D strands the cycle — + // the reported workaround was to write a finished phase back to + // in_progress just to satisfy this gate, which corrupts the record to + // satisfy a check about the record. Only an EMPTY or fully blocked plan is + // a real refusal. + const closable = plan.workPhases.length > 0 && plan.workPhases.every((wp) => wp.status === "done"); + if (closable) { + advanced = { kind: "ok", closedId: null, plan }; + } else { return { code: 1, - output: `orchestrate D: ${renderPhaseContext(state, sessionId)}; the bound goalplan "${state.slug}" has no active work-phase to close (CYCLE-COMPLETION-01). Register or unblock a work-phase before closing a cycle. Nothing was written.`, + output: `orchestrate D: ${renderPhaseContext(state, sessionId)}; the bound goalplan "${state.slug}" has no work-phase to close (CYCLE-COMPLETION-01): ${plan.workPhases.length === 0 ? "the plan is empty — register workPhases[] first" : "every remaining work-phase is blocked or superseded — unblock one"}. Nothing was written.`, }; + } } } writeState(args.cwd, { ...clearedIdle(state), stopBlockPhase: null, stopBlockCount: 0 }); @@ -569,7 +606,11 @@ export function runOrchestrateCli(args: OrchestrateCliArgs | OrchestrateCliHelpA event: "workphase_done", // 260714 wp4: log the EFFECTIVE closed id (implicit cursor may have // started from a null explicit cursor — "closed none" was a lie). - detail: `closed ${advanced.closedId}`, + // #49: a null id means the plan was already fully done and this cycle + // closed without advancing a cursor. Say that rather than "closed null". + detail: advanced.closedId + ? `closed ${advanced.closedId}` + : "cycle closed over an already-complete plan", }); if (advanced.plan.activeWorkPhaseId) { appendGoalplanLedger(args.cwd, state.slug, { @@ -651,3 +692,31 @@ export function runOrchestrateCli(args: OrchestrateCliArgs | OrchestrateCliHelpA }); return { code: 0, output: `orchestrate ${args.verb}: current=${state.phase} -> ${result.state.phase} (${state.phase} → ${result.state.phase}, session ${sessionId})` }; } +/** + * #48: candidate trees to check for the SAME session id. Deliberately shallow — + * the immediate children of $HOME plus the parent of cwd — because this is a + * warning path on a read-only command, not a filesystem crawl. Anything deeper + * would cost more than the warning is worth. + */ +function siblingRoots(cwd: string): string[] { + const roots: string[] = []; + let home: string; + try { + home = homedir(); + } catch { + return roots; + } + try { + for (const entry of readdirSync(home, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + // Skip the noisy ones a workspace never lives in. + if (entry.name === "node_modules" || entry.name === "AppData") continue; + roots.push(join(home, entry.name)); + } + } catch { + // an unreadable home is not an error for a warning path + } + const parent = resolve(cwd, ".."); + if (parent !== resolve(cwd)) roots.push(parent); + return roots; +} diff --git a/plugins/codexclaw/components/pabcd-state/src/receipt-cli.ts b/plugins/codexclaw/components/pabcd-state/src/receipt-cli.ts index 59d7450..4939f30 100644 --- a/plugins/codexclaw/components/pabcd-state/src/receipt-cli.ts +++ b/plugins/codexclaw/components/pabcd-state/src/receipt-cli.ts @@ -20,10 +20,12 @@ import { captureSourceIdentity, compareSource } from "./source-identity.ts"; import { STATE_DIR, sanitizeKey } from "./state.ts"; export interface ReceiptCliArgs { - verb: "test"; + verb: "test" | "help"; cwd: string; session?: string; command: string[]; + /** #49: repo-relative paths the check command is expected to rewrite. */ + generated?: string[]; } export interface ReceiptCliParseError { error: string } @@ -31,7 +33,14 @@ export interface ReceiptCliParseError { error: string } /** Everything after `--` is the command; nothing before it is. */ export function parseReceiptCliArgs(argv: string[], cwd: string): ReceiptCliArgs | ReceiptCliParseError { const verb = (argv[0] ?? "").toLowerCase(); - if (verb !== "test") return { error: `unknown receipt verb '${argv[0] ?? ""}' (expected test)` }; + // #47: --help was reported as an unknown verb, so the flags could only be learned + // from rejections. + if (verb === "help" || verb === "--help" || verb === "-h") { + return { verb: "help", cwd, command: [] }; + } + if (verb !== "test") { + return { error: `unknown receipt verb '${argv[0] ?? ""}' (expected test); run cxc receipt --help` }; + } const out: ReceiptCliArgs = { verb: "test", cwd, command: [] }; let i = 1; for (; i < argv.length; i++) { @@ -39,6 +48,13 @@ export function parseReceiptCliArgs(argv: string[], cwd: string): ReceiptCliArgs if (a === "--") { i++; break; } if (a === "--session") out.session = argv[++i]; else if (a === "--cwd") out.cwd = argv[++i] ?? cwd; + else if (a === "--generated") { + const v = argv[++i]; + if (typeof v !== "string" || v.length === 0) { + return { error: "receipt test: --generated needs a repo-relative path" }; + } + out.generated = [...(out.generated ?? []), v.replace(/\\/g, "/").replace(/^\.\//, "")]; + } else return { error: `unexpected argument '${a}' before --` }; } out.command = argv.slice(i).filter((a) => typeof a === "string" && a.length > 0); @@ -54,6 +70,29 @@ export function receiptPathFor(cwd: string, sessionId: string): string { export interface ReceiptCliResult { output: string; code: number } export function runReceiptCli(args: ReceiptCliArgs): ReceiptCliResult { + if (args.verb === "help") { + return { + output: [ + "cxc receipt — record a check receipt that binds a command's result to a source tree", + "", + "Usage:", + " cxc receipt test --session [--cwd ] [--generated ]... -- [args...]", + " cxc receipt --help", + "", + "Notes:", + " Everything after `--` is the command; nothing before it is.", + " The session must be at phase C — a receipt is produced during Check.", + " The receipt is written to /.codexclaw/evidence//test-receipt.json", + " and is refused if the command changes the source while it runs.", + " --generated declares paths the check REGENERATES by design (a validator that", + " rebuilds its own artifacts). Repeatable. Undeclared rewrites are still refused.", + "", + "Example:", + " cxc receipt test --session -- npm test", + ].join("\n"), + code: 0, + }; + } const session = (args.session ?? "").trim(); if (session.length === 0) return { output: "receipt test: --session is required", code: 1 }; if (args.command.length === 0) { @@ -74,7 +113,11 @@ export function runReceiptCli(args: ReceiptCliArgs): ReceiptCliResult { // Clear first: a stale success must not survive a failing re-run. rmSync(path, { force: true }); - const before = captureSourceIdentity(args.cwd, { excludeCodexclawArtifacts: true }); + const capture = { + excludeCodexclawArtifacts: true, + ...(args.generated && args.generated.length > 0 ? { generatedPaths: args.generated } : {}), + }; + const before = captureSourceIdentity(args.cwd, capture); const [bin, ...rest] = args.command; // Issue #40: `npm` is the command people actually pass here, and a bare // shell-less spawn of it cannot work on Windows - the name alone skips PATHEXT @@ -88,7 +131,7 @@ export function runReceiptCli(args: ReceiptCliArgs): ReceiptCliResult { shell: false, ...invocation.options, }); - const after = captureSourceIdentity(args.cwd, { excludeCodexclawArtifacts: true }); + const after = captureSourceIdentity(args.cwd, capture); if (run.error || typeof run.status !== "number") { return { output: `receipt test: the command did not run to completion (${run.error?.message ?? "terminated by signal"}); no receipt written`, code: 1 }; @@ -98,7 +141,15 @@ export function runReceiptCli(args: ReceiptCliArgs): ReceiptCliResult { } const cmp = compareSource(before, after); if (cmp.kind === "different") { - return { output: `receipt test: the command changed the source while running (${cmp.detail}); no receipt written — a check cannot certify a tree it rewrote`, code: 1 }; + return { + output: [ + `receipt test: the command changed the source while running (${cmp.detail}); no receipt written — a check cannot certify a tree it rewrote.`, + "If the check REGENERATES artifacts by design, declare them:", + " cxc receipt test --session --generated -- ", + "(repeatable; a path covers that file or that directory. Undeclared rewrites are still refused.)", + ].join("\n"), + code: 1, + }; } if (cmp.kind === "unavailable") { return { output: `receipt test: git could not resolve the source identity (${cmp.reason}); no receipt written`, code: 1 }; @@ -112,6 +163,8 @@ export function runReceiptCli(args: ReceiptCliArgs): ReceiptCliResult { createdAt: new Date().toISOString(), ownerSessionId: session, checkEpoch: state.checkEpoch, + // Recorded so a reader can see WHICH rewrites the check was allowed to make. + ...(args.generated && args.generated.length > 0 ? { generatedPaths: args.generated } : {}), }; mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, `${JSON.stringify(receipt, null, 2)}\n`); diff --git a/plugins/codexclaw/components/pabcd-state/src/scan-cli.ts b/plugins/codexclaw/components/pabcd-state/src/scan-cli.ts index a0d595e..7292901 100644 --- a/plugins/codexclaw/components/pabcd-state/src/scan-cli.ts +++ b/plugins/codexclaw/components/pabcd-state/src/scan-cli.ts @@ -44,7 +44,7 @@ import { readQaEvents } from "./interview-ledger.ts"; import { computeNextScanRound } from "./rescan-coordinator.ts"; export interface ScanCliArgs { - action: "record"; + action: "record" | "help"; sessionId: string; contradictionCount: number; highContradictionCount: number; @@ -83,8 +83,20 @@ export function parseScanCliArgs( cwd: string, ): ScanCliArgs | { error: string } { const [action, ...rest] = argv; + // #47: --help was an unknown action, and --cwd was rejected outright even though + // orchestrate accepts it — which stranded a live session whose answer ledger lived + // in a different tree from the process cwd. + if (action === "help" || action === "--help" || action === "-h") { + return { + action: "help", + sessionId: "", + contradictionCount: 0, + highContradictionCount: 0, + cwd, + }; + } if (action !== "record") { - return { error: `unknown scan action '${action ?? ""}' — usage: scan record --session [--contradictions N] [--high N]` }; + return { error: `unknown scan action '${action ?? ""}'; run cxc scan --help` }; } let sessionId = ""; let contradictionCount = 0; @@ -98,11 +110,17 @@ export function parseScanCliArgs( const known: Array<{ dimension: Dimension; text: string }> = []; const unknown: Array<{ dimension: Dimension; text: string }> = []; const confidence: Partial> = {}; + let resolvedCwd = cwd; for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg === "--session") { sessionId = rest[i + 1] ?? ""; i += 1; + } else if (arg === "--cwd") { + // #47: orchestrate documents and accepts --cwd; scan rejected it, which + // stranded a session whose answer ledger was in a different tree. + resolvedCwd = rest[i + 1] ?? cwd; + i += 1; } else if (arg === "--contradictions") { contradictionCount = Number.parseInt(rest[i + 1] ?? "", 10); i += 1; @@ -173,7 +191,7 @@ export function parseScanCliArgs( sessionId, contradictionCount, highContradictionCount, - cwd, + cwd: resolvedCwd, ...(derive ? { derive } : {}), ...(Object.keys(map).length > 0 ? { map } : {}), ...(Object.keys(dims).length > 0 ? { dims } : {}), @@ -264,6 +282,28 @@ function deriveLevel(score: DimensionScore): DimensionLevel { } export function runScanCli(args: ScanCliArgs): { output: string; code: number } { + if (args.action === "help") { + return { + output: [ + "cxc scan — record an interview rescan round and fold answers into the tracker", + "", + "Usage:", + " cxc scan record --session [--cwd ] [--contradictions N] [--high N]", + " [--derive] [--map =]...", + " [--dim =]... [--known =]...", + " [--unknown =]... [--confidence =<0..1>]...", + " cxc scan --help", + "", + "Notes:", + " --session is required; there is no latest-session fallback for a mutating command.", + " --cwd matters when the answer ledger lives outside the process cwd:", + " answers are read from /.codexclaw/interviews/.jsonl.", + " --derive folds captured answers in; --map attributes a questionId to a dimension.", + " --dim cannot set 'max' — that level gates I->P and must be attested.", + ].join("\n"), + code: 0, + }; + } try { const state: State = readState(args.cwd, args.sessionId); const tracker: InterviewTracker = state.interview ?? defaultInterview(0); diff --git a/plugins/codexclaw/components/pabcd-state/src/source-identity.ts b/plugins/codexclaw/components/pabcd-state/src/source-identity.ts index 8bd4a38..7258734 100644 --- a/plugins/codexclaw/components/pabcd-state/src/source-identity.ts +++ b/plugins/codexclaw/components/pabcd-state/src/source-identity.ts @@ -152,6 +152,17 @@ const STATE_DIR_PREFIX = ".codexclaw/"; export interface CaptureOptions { /** Drop `.codexclaw/` entries before hashing. Default false. */ excludeCodexclawArtifacts?: boolean; + /** + * #49: paths the check command is EXPECTED to rewrite. A validator that + * regenerates its own artifacts (`validate.py --build` writing graph.json) is + * the documented gate for some repos, and refusing its receipt forces agents to + * fake a `test -f` receipt instead — which defeats CHECK-BINDING-01 entirely. + * + * Prefix match on the repo-relative POSIX path, so `600_ontology` covers the + * whole directory and `graph.json` covers exactly that file. Declared by the + * caller, never inferred: an undeclared rewrite is still a refusal. + */ + generatedPaths?: string[]; } export function captureSourceIdentity(cwd: string, options: CaptureOptions = {}): SourceIdentity { @@ -173,6 +184,10 @@ export function captureSourceIdentity(cwd: string, options: CaptureOptions = {}) if (options.excludeCodexclawArtifacts) { records = records.filter((r) => !r.path.startsWith(STATE_DIR_PREFIX)); } + const generated = (options.generatedPaths ?? []).filter((p) => p.length > 0); + if (generated.length > 0) { + records = records.filter((r) => !generated.some((g) => r.path === g || r.path.startsWith(`${g}/`))); + } if (records.length === 0) return { kind: "resolved", commitSha, dirty: false, capturedAt }; return { kind: "resolved", commitSha, dirty: true, treeHash: hashRecords(cwd, records), capturedAt }; } diff --git a/plugins/codexclaw/components/pabcd-state/src/state.ts b/plugins/codexclaw/components/pabcd-state/src/state.ts index c317730..8f82430 100644 --- a/plugins/codexclaw/components/pabcd-state/src/state.ts +++ b/plugins/codexclaw/components/pabcd-state/src/state.ts @@ -1,6 +1,6 @@ -import { mkdirSync, readFileSync, writeFileSync, appendFileSync, linkSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, linkSync, rmSync } from "node:fs"; import { randomUUID } from "node:crypto"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { renameWithRetry } from "./atomic-write.ts"; import { type InterviewTracker, reconstructInterview, normalizeInterview, isInterviewReady } from "./interview.ts"; import type { SourceIdentity } from "./source-identity.ts"; @@ -181,6 +181,41 @@ function statePath(cwd: string, sessionId: string): string { return join(sessionsDir(cwd), `${sanitizeKey(sessionId)}.json`); } +/** + * #48: session files live at `/.codexclaw/sessions/.json`, so the SAME + * `--session` id resolves to different state depending on where the process was + * started. A Codex thread whose cwd is one tree while its work is in another then + * interviews one FSM and orchestrates the other, and `status` reports IDLE for a + * cycle that is genuinely in flight elsewhere. + * + * Changing the storage location would break every existing session, so instead we + * make the split VISIBLE: look for the same id in the nearest plausible sibling + * roots and report what was found. Detection only — nothing is read from or + * written to the other tree. + */ +export function findForeignSessionCopies( + cwd: string, + sessionId: string, + candidates: string[], +): string[] { + const mine = resolve(statePath(cwd, sessionId)); + const seen = new Set([mine]); + const found: string[] = []; + for (const root of candidates) { + if (typeof root !== "string" || root.length === 0) continue; + let candidate: string; + try { + candidate = resolve(statePath(root, sessionId)); + } catch { + continue; + } + if (seen.has(candidate)) continue; + seen.add(candidate); + if (existsSync(candidate)) found.push(candidate); + } + return found; +} + /** * Materialize a fresh Codex session without resetting a resumed one. * diff --git a/plugins/codexclaw/components/pabcd-state/test/help-verbs.test.ts b/plugins/codexclaw/components/pabcd-state/test/help-verbs.test.ts new file mode 100644 index 0000000..6718838 --- /dev/null +++ b/plugins/codexclaw/components/pabcd-state/test/help-verbs.test.ts @@ -0,0 +1,77 @@ +/** + * help-verbs.test.ts — issue #47: `cxc orchestrate --help` worked while every + * sibling reported `--help` as an unknown verb and exited non-zero. The top-level + * help points at those commands, so an agent that followed the pointer hit a brick + * wall and had to learn each flag from a rejection. + * + * These assert the CONTRACT (exit 0 plus usage), not the wording, so the help text + * can be edited without churning the test. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseGoalplanCliArgs, runGoalplanCli } from "../src/goalplan-cli.ts"; +import { parseReceiptCliArgs, runReceiptCli } from "../src/receipt-cli.ts"; +import { parseScanCliArgs, runScanCli } from "../src/scan-cli.ts"; + +const CWD = "/unused"; + +for (const token of ["help", "--help", "-h"]) { + test(`loop ${token} prints usage and exits 0`, () => { + const args = parseGoalplanCliArgs([token], CWD); + assert.ok(!("error" in args), `${token} must not be an unknown verb`); + const r = runGoalplanCli(args as never); + assert.equal(r.code, 0); + assert.match(r.output, /Usage:/); + // The flags that could previously only be discovered from rejections. + assert.match(r.output, /--session /); + assert.match(r.output, /--batch-json/); + assert.match(r.output, /idempotencyKey/); + }); + + test(`receipt ${token} prints usage and exits 0`, () => { + const args = parseReceiptCliArgs([token], CWD); + assert.ok(!("error" in args), `${token} must not be an unknown verb`); + const r = runReceiptCli(args as never); + assert.equal(r.code, 0); + assert.match(r.output, /Usage:/); + assert.match(r.output, /-- /); + }); + + test(`scan ${token} prints usage and exits 0`, () => { + const args = parseScanCliArgs([token], CWD); + assert.ok(!("error" in args), `${token} must not be an unknown action`); + const r = runScanCli(args as never); + assert.equal(r.code, 0); + assert.match(r.output, /Usage:/); + assert.match(r.output, /--cwd /); + }); +} + +// An unknown verb must still fail — but now it names the way out. +test("an unknown verb points at --help instead of just listing verbs", () => { + const loop = parseGoalplanCliArgs(["nope"], CWD); + assert.ok("error" in loop); + assert.match((loop as { error: string }).error, /cxc loop --help/); + + const receipt = parseReceiptCliArgs(["nope"], CWD); + assert.ok("error" in receipt); + assert.match((receipt as { error: string }).error, /cxc receipt --help/); + + const scan = parseScanCliArgs(["nope"], CWD); + assert.ok("error" in scan); + assert.match((scan as { error: string }).error, /cxc scan --help/); +}); + +// #47 also reported `scan record --cwd` as rejected while orchestrate accepts it. +// That stranded a session whose answer ledger lived outside the process cwd. +test("scan record accepts --cwd, like orchestrate does", () => { + const args = parseScanCliArgs(["record", "--session", "s1", "--cwd", "/elsewhere"], CWD); + assert.ok(!("error" in args), "scan record must accept --cwd"); + assert.equal((args as { cwd: string }).cwd, "/elsewhere"); +}); + +test("scan record without --cwd still resolves against the process cwd", () => { + const args = parseScanCliArgs(["record", "--session", "s1"], CWD); + assert.ok(!("error" in args)); + assert.equal((args as { cwd: string }).cwd, CWD); +}); diff --git a/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts b/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts index fd5d1c7..79e3b29 100644 --- a/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts @@ -833,11 +833,15 @@ test("D-close on a bound session is refused when the goalplan cannot be read", ( assert.equal(ledgerLines(cwd).length, 0); }); -test("D-close is refused when the bound goalplan has no active work-phase", () => { +// #49: a plan whose work-phases are ALL done is complete, not broken. Refusing D +// here stranded the cycle, and the reported workaround was to write a finished +// phase back to in_progress purely to satisfy this gate — corrupting the record +// to satisfy a check about the record. It closes now. +test("D-close succeeds when every work-phase is already done", () => { const cwd = boundCwd(); - const id = "cycle-no-active"; - const slug = "cycle-gate-empty"; - const plan = buildGoalplan({ objective: "no active phase" }); + const id = "cycle-all-done"; + const slug = "cycle-gate-complete"; + const plan = buildGoalplan({ objective: "all phases done" }); plan.slug = slug; plan.workPhases = [{ id: "wp-1", title: "closed", status: "done", tasks: [], criteriaIds: [] }]; plan.activeWorkPhaseId = null; @@ -850,12 +854,57 @@ test("D-close is refused when the bound goalplan has no active work-phase", () = assert.ok(!("error" in args)); const r = runOrchestrateCli(args as never); + assert.equal(r.code, 0, r.output); + assert.equal(readState(cwd, id).phase, "IDLE"); + assert.equal(ledgerLines(cwd).length, 1); +}); + +// The gate still exists — it just names the two REAL failures instead. +test("D-close is refused when the bound goalplan is empty", () => { + const cwd = boundCwd(); + const id = "cycle-empty-plan"; + const slug = "cycle-gate-empty"; + const plan = buildGoalplan({ objective: "no phases registered" }); + plan.slug = slug; + plan.workPhases = []; + plan.activeWorkPhaseId = null; + writeGoalplan(cwd, plan); + const epoch = "c-test-epoch"; + writeState(cwd, { ...defaultState(id), phase: "C", slug, checkEpoch: epoch, flags: { interview: false, auditPassed: true, checkPassed: false } }); + seedReceipt(cwd, id, epoch); + + const args = parseOrchestrateCliArgs(["d", "--session", id, "--cwd", cwd, "--attest", dAttest(id)], cwd); + assert.ok(!("error" in args)); + const r = runOrchestrateCli(args as never); + assert.equal(r.code, 1); - assert.match(r.output, /no active work-phase/); + assert.match(r.output, /the plan is empty/); assert.equal(readState(cwd, id).phase, "C"); assert.equal(ledgerLines(cwd).length, 0); }); +test("D-close is refused when every remaining work-phase is blocked", () => { + const cwd = boundCwd(); + const id = "cycle-all-blocked"; + const slug = "cycle-gate-blocked"; + const plan = buildGoalplan({ objective: "everything blocked" }); + plan.slug = slug; + plan.workPhases = [{ id: "wp-1", title: "stuck", status: "blocked", tasks: [], criteriaIds: [] }]; + plan.activeWorkPhaseId = null; + writeGoalplan(cwd, plan); + const epoch = "c-test-epoch"; + writeState(cwd, { ...defaultState(id), phase: "C", slug, checkEpoch: epoch, flags: { interview: false, auditPassed: true, checkPassed: false } }); + seedReceipt(cwd, id, epoch); + + const args = parseOrchestrateCliArgs(["d", "--session", id, "--cwd", cwd, "--attest", dAttest(id)], cwd); + assert.ok(!("error" in args)); + const r = runOrchestrateCli(args as never); + + assert.equal(r.code, 1); + assert.match(r.output, /blocked or superseded/); + assert.equal(readState(cwd, id).phase, "C"); +}); + test("an unbound (HITL) session closes its cycle exactly as before", () => { const cwd = freshCwd(); const id = "cycle-hitl"; diff --git a/plugins/codexclaw/components/pabcd-state/test/receipt-generated.test.ts b/plugins/codexclaw/components/pabcd-state/test/receipt-generated.test.ts new file mode 100644 index 0000000..2b3097a --- /dev/null +++ b/plugins/codexclaw/components/pabcd-state/test/receipt-generated.test.ts @@ -0,0 +1,104 @@ +/** + * receipt-generated.test.ts — issue #49: `receipt test` refused to write a receipt + * when the check command dirtied the tree, even though the dirty files were the + * artifacts the check exists to rebuild. + * + * Reported consequence: the agent committed the generated files and ran + * `cxc receipt test -- test -f ...`, a no-op existence check, to get past D. A + * forged receipt is strictly worse than a loose one — it defeats CHECK-BINDING-01 + * while looking like it satisfied it. + * + * The fix is DECLARED, not inferred: an undeclared rewrite is still refused. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { captureSourceIdentity, compareSource } from "../src/source-identity.ts"; +import { parseReceiptCliArgs } from "../src/receipt-cli.ts"; + +function repoWithGeneratedFile(): string { + const root = mkdtempSync(join(tmpdir(), "cxc-gen-")); + const git = (...a: string[]) => execFileSync("git", a, { cwd: root, encoding: "utf8" }); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + mkdirSync(join(root, "build"), { recursive: true }); + writeFileSync(join(root, "build", "graph.json"), '{"nodes":0}'); + writeFileSync(join(root, "src.txt"), "source"); + git("add", "-A"); + git("commit", "-qm", "init"); + return root; +} + +test("a declared generated path does not count as a source change", () => { + const root = repoWithGeneratedFile(); + try { + const opts = { excludeCodexclawArtifacts: true, generatedPaths: ["build"] }; + const before = captureSourceIdentity(root, opts); + writeFileSync(join(root, "build", "graph.json"), '{"nodes":999}'); + const after = captureSourceIdentity(root, opts); + assert.equal(compareSource(before, after).kind, "same"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("an UNdeclared rewrite is still a source change", () => { + const root = repoWithGeneratedFile(); + try { + const opts = { excludeCodexclawArtifacts: true, generatedPaths: ["build"] }; + const before = captureSourceIdentity(root, opts); + // src.txt was never declared — this must still be caught. + writeFileSync(join(root, "src.txt"), "rewritten"); + const after = captureSourceIdentity(root, opts); + assert.equal(compareSource(before, after).kind, "different"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("declaring the wrong path does not launder a real change", () => { + const root = repoWithGeneratedFile(); + try { + const opts = { excludeCodexclawArtifacts: true, generatedPaths: ["docs"] }; + const before = captureSourceIdentity(root, opts); + writeFileSync(join(root, "build", "graph.json"), '{"nodes":999}'); + const after = captureSourceIdentity(root, opts); + assert.equal(compareSource(before, after).kind, "different"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a file path declares exactly that file, not its siblings", () => { + const root = repoWithGeneratedFile(); + try { + const opts = { excludeCodexclawArtifacts: true, generatedPaths: ["build/graph.json"] }; + const before = captureSourceIdentity(root, opts); + writeFileSync(join(root, "build", "graph.json"), '{"nodes":1}'); + assert.equal(compareSource(before, captureSourceIdentity(root, opts)).kind, "same"); + writeFileSync(join(root, "build", "other.json"), "{}"); + assert.equal(compareSource(before, captureSourceIdentity(root, opts)).kind, "different"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("--generated is repeatable and normalizes separators", () => { + const args = parseReceiptCliArgs( + ["test", "--session", "s1", "--generated", "build", "--generated", ".\\docs\\api", "--", "npm", "test"], + "/unused", + ); + assert.ok(!("error" in args)); + assert.deepEqual((args as { generated?: string[] }).generated, ["build", "docs/api"]); + assert.deepEqual((args as { command: string[] }).command, ["npm", "test"]); +}); + +test("--generated without a value is a parse error, not a silent skip", () => { + const args = parseReceiptCliArgs(["test", "--session", "s1", "--generated"], "/unused"); + assert.ok("error" in args); + assert.match((args as { error: string }).error, /--generated needs a repo-relative path/); +}); diff --git a/plugins/codexclaw/components/pabcd-state/test/session-split.test.ts b/plugins/codexclaw/components/pabcd-state/test/session-split.test.ts new file mode 100644 index 0000000..9e60eee --- /dev/null +++ b/plugins/codexclaw/components/pabcd-state/test/session-split.test.ts @@ -0,0 +1,77 @@ +/** + * session-split.test.ts — issue #48: session files live at + * `/.codexclaw/sessions/.json`, so the same `--session` id resolves to + * different state depending on where the process started. + * + * Reported symptom: a thread whose cwd was `~/.cli-jaw` while its work was in + * `~/kim_wiki` interviewed one FSM and orchestrated the other. `status` said + * `phase=IDLE` for a cycle that had just closed D in the other tree, and the next + * turn re-injected Interview from the stale copy. + * + * Relocating the store would break every existing session, so the fix makes the + * split VISIBLE instead. Detection only: the other tree is never read or written. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findForeignSessionCopies, defaultState, STATE_DIR } from "../src/state.ts"; + +function treeWithSession(id: string): string { + const root = mkdtempSync(join(tmpdir(), "cxc-split-")); + mkdirSync(join(root, STATE_DIR, "sessions"), { recursive: true }); + writeFileSync( + join(root, STATE_DIR, "sessions", `${id}.json`), + JSON.stringify(defaultState(id), null, 2), + ); + return root; +} + +test("a session id present in a sibling tree is reported", () => { + const mine = treeWithSession("s-dup"); + const other = treeWithSession("s-dup"); + try { + const found = findForeignSessionCopies(mine, "s-dup", [other]); + assert.equal(found.length, 1); + assert.match(found[0], /s-dup\.json$/); + assert.ok(found[0].startsWith(other), "must name the OTHER tree, not this one"); + } finally { + rmSync(mine, { recursive: true, force: true }); + rmSync(other, { recursive: true, force: true }); + } +}); + +test("the caller's own tree is never reported as foreign", () => { + const mine = treeWithSession("s-self"); + try { + // Passing your own cwd as a candidate must not produce a self-warning. + assert.deepEqual(findForeignSessionCopies(mine, "s-self", [mine]), []); + } finally { + rmSync(mine, { recursive: true, force: true }); + } +}); + +test("a candidate without that session is not reported", () => { + const mine = treeWithSession("s-only"); + const bare = mkdtempSync(join(tmpdir(), "cxc-bare-")); + try { + assert.deepEqual(findForeignSessionCopies(mine, "s-only", [bare]), []); + } finally { + rmSync(mine, { recursive: true, force: true }); + rmSync(bare, { recursive: true, force: true }); + } +}); + +test("unreadable or missing candidates are skipped, not thrown", () => { + const mine = treeWithSession("s-safe"); + try { + const found = findForeignSessionCopies(mine, "s-safe", [ + join(tmpdir(), "cxc-does-not-exist-" + Date.now()), + "", + ]); + assert.deepEqual(found, []); + } finally { + rmSync(mine, { recursive: true, force: true }); + } +}); diff --git a/plugins/codexclaw/components/provider-bridge/package.json b/plugins/codexclaw/components/provider-bridge/package.json index f772f5e..824a096 100644 --- a/plugins/codexclaw/components/provider-bridge/package.json +++ b/plugins/codexclaw/components/provider-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/provider-bridge", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "Detect-only opencodex (ocx) status probe at session start; graceful native path when absent.", diff --git a/plugins/codexclaw/components/recall/package.json b/plugins/codexclaw/components/recall/package.json index 0447644..67072dc 100644 --- a/plugins/codexclaw/components/recall/package.json +++ b/plugins/codexclaw/components/recall/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/recall", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "Read-only chat/memory recall search over the Codex session root (~/.codex): date-pruned rollout scan + thread/memory sqlite enrichment.", diff --git a/plugins/codexclaw/components/skill-search/package.json b/plugins/codexclaw/components/skill-search/package.json index a051cab..5401260 100644 --- a/plugins/codexclaw/components/skill-search/package.json +++ b/plugins/codexclaw/components/skill-search/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/skill-search", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "Remote dormant-skill search over cli-jaw-skills / Hermes / ClawHub / gh code search. Zero-dep, TTL-cached, adapter-preamble output. No local vendoring.", diff --git a/plugins/codexclaw/components/subagent-config/package.json b/plugins/codexclaw/components/subagent-config/package.json index 4b950b9..8beface 100644 --- a/plugins/codexclaw/components/subagent-config/package.json +++ b/plugins/codexclaw/components/subagent-config/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/subagent-config", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "Stores subagent model/prompt config; serves it to the GUI and an MCP tool.", diff --git a/plugins/codexclaw/gui/package.json b/plugins/codexclaw/gui/package.json index 490583d..c01c613 100644 --- a/plugins/codexclaw/gui/package.json +++ b/plugins/codexclaw/gui/package.json @@ -1,6 +1,6 @@ { "name": "@codexclaw/gui", - "version": "0.2.11", + "version": "0.2.12", "private": true, "type": "module", "description": "codexclaw local dashboard (Vite + React) — subagent config, prompts, provider link bar.", diff --git a/plugins/codexclaw/inventory.json b/plugins/codexclaw/inventory.json index 53b85c6..3ac8184 100644 --- a/plugins/codexclaw/inventory.json +++ b/plugins/codexclaw/inventory.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "plugin": { "name": "codexclaw", - "manifestVersion": "0.2.11+codex.20260818121334", - "packageVersion": "0.2.11" + "manifestVersion": "0.2.12+codex.20260818121334", + "packageVersion": "0.2.12" }, "skills": [ { @@ -257,49 +257,49 @@ { "folder": "config-guard", "packageName": "@codexclaw/config-guard", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true }, { "folder": "cxc-ops", "packageName": "@codexclaw/cxc-ops", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true }, { "folder": "messenger-bridge", "packageName": "@codexclaw/messenger-bridge", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true }, { "folder": "pabcd-state", "packageName": "@codexclaw/pabcd-state", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true }, { "folder": "provider-bridge", "packageName": "@codexclaw/provider-bridge", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true }, { "folder": "recall", "packageName": "@codexclaw/recall", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true }, { "folder": "skill-search", "packageName": "@codexclaw/skill-search", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true }, { "folder": "subagent-config", "packageName": "@codexclaw/subagent-config", - "version": "0.2.11", + "version": "0.2.12", "hasTests": true } ] diff --git a/plugins/codexclaw/test/cli-usage.test.mjs b/plugins/codexclaw/test/cli-usage.test.mjs index a9da9f4..5484e1e 100644 --- a/plugins/codexclaw/test/cli-usage.test.mjs +++ b/plugins/codexclaw/test/cli-usage.test.mjs @@ -35,6 +35,34 @@ test("top-level CLI unknown command fails with recovery hint", () => { assert.match(res.stderr, /cxc --help/); }); +// #47: the top-level help points at the sibling commands, and following that +// pointer failed — --help was an unknown verb on loop/scan/receipt, and +// --version was an unknown command. These run the REAL binary, because the +// defect was in the argv dispatch rather than in any parser under test. +for (const command of ["loop", "scan", "receipt"]) { + test(`${command} --help exits 0 with usage, like orchestrate`, () => { + for (const flag of ["--help", "-h"]) { + const res = spawnSync("node", [cli, command, flag], { cwd: repoRoot, encoding: "utf8" }); + assert.equal(res.status, 0, `${command} ${flag} exited ${res.status}: ${res.stderr}`); + assert.match(res.stdout, /Usage:/); + assert.match(res.stdout, new RegExp(`cxc ${command}`)); + } + }); +} + +// There are TWO entry points — bin/codexclaw.mjs and plugins/codexclaw/bin/cxc.mjs — +// and fixing only one is exactly the mistake this case exists to catch. +test("--version prints the plugin version from both entry points", () => { + const entries = [cli, join(repoRoot, "plugins", "codexclaw", "bin", "cxc.mjs")]; + for (const entry of entries) { + for (const flag of ["--version", "-v", "version"]) { + const res = spawnSync("node", [entry, flag], { cwd: repoRoot, encoding: "utf8" }); + assert.equal(res.status, 0, `${entry} ${flag} exited ${res.status}: ${res.stderr}`); + assert.match(res.stdout.trim(), /^\d+\.\d+\.\d+/); + } + } +}); + test("top-level CLI delegates orchestrate help", () => { const res = spawnSync("node", [cli, "orchestrate", "--help"], { cwd: repoRoot, encoding: "utf8" }); assert.equal(res.status, 0);