diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dee162..e8e9e0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ All notable changes to codexclaw are documented here. The format follows ## [Unreleased] +### Changed + +- Formal P now requires an architect proposal, a main-owned executable plan and + reflection by the same architect before independent audit. C0/C1 fast paths and + explicit user limits keep their existing precedence; this adds guidance, not a + runtime gate. + +### Fixed + +- Native V2 spawn hooks preserve canonical Fernet-shaped task messages without + appending plaintext skill affordances or leaf guards. Structural validation + rejects malformed lookalikes; omission notices identify the guards that could + not be attached. Ciphertext recognition does not authenticate the message. + +- Subagent waiting guidance instructed coordinators to retire workers after + about three empty waits, even when analysis could still be progressing. + `waiting.md` now requires evidence of progress or stagnation, preserves + unavailable observations, and uses non-interrupting checkpoints. Delegation + and DISPATCH-RETIRE-01 clarify actual shutdown checks and managed recovery; + cancellation or exhausted limits grant no retry or replacement. Progress + assessment remains agent-followed (#178). +- Managed dispatch now accepts explicit `task_failed` reports for confirmed + stagnation or unusable final output. A recorded stopped child, task evidence + and reconciliation are required before the existing bounded handoff can run. + Provider errors keep their existing classification; cancellation and permission + denials cannot be overridden by a task-failure label (#178). + ## [0.2.28] - 2026-09-14 ### Fixed diff --git a/README.ko.md b/README.ko.md index 35736a36..c86353c2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@

CI - 3,150 tests passing + 3,177 tests passing 29 skills 28 hooks Documentation diff --git a/README.md b/README.md index 2dcee674..7b670e9c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

CI - 3,150 tests passing + 3,177 tests passing 29 skills 28 hooks Documentation diff --git a/README.zh.md b/README.zh.md index f0a07a0b..0d455ff6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,7 +13,7 @@

CI - 3,150 tests passing + 3,177 tests passing 29 skills 28 hooks Documentation diff --git a/devlog/_fin/260916_selective_execution/000_plan.md b/devlog/_fin/260916_selective_execution/000_plan.md new file mode 100644 index 00000000..aab377e8 --- /dev/null +++ b/devlog/_fin/260916_selective_execution/000_plan.md @@ -0,0 +1,50 @@ +# Architect consultation with existing delegation policy + +Formal-P architect consultation needs a concrete proposal, a main-owned executable +plan and reflection by the same architect before independent review. Consolidate +the architect and recovery fixes from #177 and #179 while preserving CXC's +existing delegation-selection policy. Neither executor-first nor main-direct +implementation becomes a new default. + +## Scope + +Class: C3 compact policy/hint integration. No new delegation-selection rule, +runtime gate, dependency or model setting. Preserve reviewed ciphertext handling +and task failure recovery. Main applies the bounded correction; independent +review checks the changed instructions and emitted output. + +- Restore `plugins/codexclaw/skills/dev/SKILL.md` exactly to upstream 03541398. +- Remove added implementation-ownership sections from P plan output, B guidance + and structure mirrors. Existing DISPATCH-ECONOMY-01 remains the selection owner. +- Keep formal-P consultation records and P/A hook reminders. CLI P entry repeats + only the architect sequence; B returns to its original instructions/output. +- Keep ciphertext preservation from #177 and retirement/task-failure recovery + from #179. Their existing managed-dispatch bounds and stop precedence remain. +- Update hook/CLI assertions to cover architect output, unchanged phase state and + absence of the added implementation-default instructions. +- Align PR/issue descriptions, local source, installed files and reapply patches. + +## Acceptance + +1. No new executor-first or main-direct default remains in active policy. +2. The canonical dev skill equals upstream bytes; existing delegation criteria + and build-phase instructions remain unchanged. +3. Formal P requires architect proposal, main plan and same-architect reflection + before independent audit, subject to existing fast paths and explicit limits. +4. #179's evidence-based retirement and reconciled task-failure recovery remain. +5. Source/dist, affected tests, build, full suite, gate/inventory, independent + review and installed CLI/hook checks agree. +6. One ordinary PR targets dev. #177 and #179 are superseded without deleting their + branches; #178 stays open until upstream integration. +7. Local and installed corrections have drift checks, backups and verified patches. + No upstream merge, release or model-setting change is authorized here. + +## Review context + +The initial architect proposal and reflection established the retained +consultation/transport/recovery boundaries. A later clarification withdraws the +added implementation-default policy and restores the upstream selection rules. +This correction adds no new architecture decision; it removes the unsupported +policy extension. The earlier reviews do not certify the corrected diff: a fresh +independent review and checks cover delivery. Private receipts retain both +revisions without presenting the withdrawn plan as current behavior. diff --git a/devlog/_fin/260916_selective_execution/010_implementation.md b/devlog/_fin/260916_selective_execution/010_implementation.md new file mode 100644 index 00000000..cb5ada6f --- /dev/null +++ b/devlog/_fin/260916_selective_execution/010_implementation.md @@ -0,0 +1,50 @@ +# Architect and recovery consolidation + +The final change strengthens formal-P architect consultation while keeping CXC's +existing delegation-selection rules. It preserves the transport and recovery +fixes from #177 and #179 without introducing an executor-first or main-direct +implementation default. + +## Changes and provenance + +Credits: thisisjun786, original contributions in #177 and #179. + +The canonical dev skill is restored byte-for-byte to upstream 03541398. Added +implementation ownership sections and P/B ownership hints are removed. Formal P +retains proposal, main plan and same-architect reflection before independent audit; +the CLI repeats that sequence only at P entry. Existing fast paths, explicit +limits, DISPATCH-ECONOMY-01 and B guidance keep their original roles. + +The complete subagent-config component and the waiting/delegation recovery +references retain the reviewed combined implementation. Recovery requires the +registered stopped child and reconciliation; cancellation and permission failures +still stop. Ciphertext shape recognition preserves opaque native messages and +reports omitted prompt instructions without claiming authentication. + +## Verification + +Fresh checks on the corrected revision: + +- Independent review: PASS, no blockers. Baseline comparisons confirm the original + dev skill, B guidance and DISPATCH-ECONOMY-01; recovery/ciphertext files are unchanged. +- Phase hook/CLI suite: 185 pass, zero failures. +- `npm run build`: exit 0, 181 compiled files. +- `TMPDIR= CODEXCLAW_SKIP_REPOMAP_SMOKE=1 npm test`: + exit 0, 3177 total, 3103 pass, 74 conditional skips, zero failures. +- `npm run gate`, `inventory.mjs --check --tests 3177` and `git diff --check`: pass. +- Compiled CLI/hook QA: five real invocations each on source and installation + check architect output at P and unchanged B instructions/phase behavior. +- Installed recovery QA: 35 CLI invocations pass, including stopped-child + recovery, live/unknown reconciliation, evidence limits and stop precedence. +- Nine changed installed files pass pre-write drift checks. The installed dev + skill matches upstream bytes. Patch reverse/reapply matches before/after copies. + +These checks use isolated fixtures and spawn no live provider or worker. +Agent-followed policy remains a semantic-review obligation. Raw logs, review +receipts and patch proofs remain in the private correction backup. + +## Delivery + +#180 supersedes closed #177 and #179. Original branches are preserved. #178 tracks +the retained recovery work and stays open until upstream integration. Source and +installation updates are separate from upstream merge or release. diff --git a/docs-site/src/content/docs/guides/subagents.md b/docs-site/src/content/docs/guides/subagents.md index 482f1c2f..7cfe92ce 100644 --- a/docs-site/src/content/docs/guides/subagents.md +++ b/docs-site/src/content/docs/guides/subagents.md @@ -15,6 +15,11 @@ Four roles cover the common subagent workflow: - **executor** — focused implementation. - **architect** — read-only design proposals and checks of main-owned executable plans. +Formal P keeps the required design sequence: architect proposal, main's executable +plan, then reflection by the same architect before independent review. The existing +C0/C1 fast path and explicit user limits still apply. See the +[workflow guide](/codexclaw/guides/pabcd/) for phase requirements. + ## Native architect setup Architect uses `agent_type: "architect"`, its own model/effort/prompt settings and @@ -56,14 +61,15 @@ surfaces, but it does not choose skills. Dispatchers explicitly name each requir skill with preferred `[$cxc-](skill://)` links or the plugin-native `$codexclaw:cxc-` fallback. When the spawn message is plaintext, the hook normalizes known broken/bare mentions and inlines recognized SKILL.md bodies on V2-shaped spawns. -Native ChatGPT-backend V2 gives the hook ciphertext, so both operations are no-ops there; -when no body can be inlined, it appends a plaintext `[CXC-SKILL-AFFORDANCE]` block telling -the child to self-load any `$cxc-` / `$codexclaw:cxc-` mention from -`//SKILL.md`; fork inheritance remains a secondary channel. Its other -reliable native V2 channels are the leaf guard and omitted configured -`model`/`reasoning_effort` injection for non-full-history spawns. It never invents role -baselines or inferred surface skills. Role config, resolver, and spawn-wrapper are all -shipped (L9). +Native ChatGPT-backend V2 can give the hook ciphertext. Recognized canonical +Fernet-shaped messages are preserved byte-for-byte: no skill text, affordance or +leaf guard is appended to them. The hook reports the omitted prompt guards; +structural checks identify an opaque frame but do not authenticate or decrypt it. +For plaintext messages where no skill body can be inlined, the hook appends a +`[CXC-SKILL-AFFORDANCE]` telling the child to self-load named skills. Configured +omitted `model`/`reasoning_effort` injection remains available for non-full-history +spawns. It never invents role baselines or inferred surface skills. Role config, +resolver, and spawn-wrapper are all shipped (L9). ## First fallback @@ -82,9 +88,11 @@ cxc subagents set executor --fallback-model cursor/grok-4.6 --fallback-effort hi cxc subagents set executor --clear-fallback ``` -The same flags apply to explorer and reviewer. When both attempts fail, the main -agent takes over remaining work. An independent review requirement remains -outstanding; main-agent work does not satisfy it. +The same flags apply to explorer, reviewer and architect. Only a returned +`main-direct` action permits main to reclaim remaining work from a managed dispatch. +A `ready` action requires a fresh claim; `stop` or `reconcile` permits neither a +replacement nor direct takeover. Independent review remains outstanding when +required; main-agent work does not satisfy it. ### Execution and evidence @@ -106,6 +114,15 @@ authentication and cancellation failures stop. Ambiguous creation or ongoing wor must be reconciled before replacement; stopped executors require a change review and cleanup evidence. Unknown error prose does not trigger blind rotation. +Confirmed stagnation or unusable final output uses a separate `outcome:task_failed` +report with `taskFailure: {kind: "stagnation" | "unusable_output", evidence: "..."}`. +Include the recorded child ID, `executionState:stopped`, and `reconciliation` +describing termination and partial-work inspection. Both evidence strings must be +non-empty and at most 2000 characters. This report has no provider `error`; it uses +the same bounded handoff and returned actions as provider recovery. Cancellation, +exhausted limits and a wait timeout alone are not task failures. Validate final +work before reporting `outcome:complete`, which cannot be reopened for recovery. + OCX retains its own retries and global/per-model fallback. The two-attempt limit applies to CXC-issued native attempts, not every downstream provider request. Requested and observed models are recorded separately; an unobserved actual model diff --git a/plugins/codexclaw/components/pabcd-state/dist/hook.js b/plugins/codexclaw/components/pabcd-state/dist/hook.js index 3b1a36bd..2f863ff1 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/hook.js +++ b/plugins/codexclaw/components/pabcd-state/dist/hook.js @@ -303,6 +303,7 @@ const PHASE_DIRECTIVES = { "[codexclaw: PLAN]", "Apply this pointer and its owners within exact user limits and permissions. No-delegation means no dispatch.", "Load $codexclaw:cxc-pabcd for P and C2+ plan-output; $codexclaw:cxc-dev selects class and relevant surfaces. No implementation yet.", + "Formal P, including C2 compact and plan-only P: obtain the configured read-only architect proposal BEFORE the executable plan, send that concrete plan to the SAME architect for reflection BEFORE A, and record the consultation per $codexclaw:cxc-pabcd phase-plan/plan-output. The C0/C1 fast path needs none.", "Plan-only ends with the plan. Forbidden checks: NOT RUN; naming an artifact grants no write permission.", ].join("\n"), A: [ @@ -310,6 +311,7 @@ const PHASE_DIRECTIVES = { "Apply this pointer and its owners within exact user limits and permissions. No-delegation means no dispatch.", "Load $codexclaw:cxc-dev-code-reviewer for review and $codexclaw:cxc-dev for relevant surfaces; authorized PABCD A uses $codexclaw:cxc-pabcd's audit owner. Do not build yet.", "Authorized dispatch follows the owner's named-skill, same-reviewer and verdict contracts; main synthesizes. Report unmet independent review; inline review is not its proof. Do not bypass gates.", + "An amendment changing a module-responsibility, data-structure, interface or execution-flow decision needs reflection from the SAME architect before A completes ($codexclaw:cxc-pabcd phase-audit); text/test clarification alone does not. The reviewer stays independent.", ].join("\n"), B: [ "[codexclaw: BUILD]", diff --git a/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js b/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js index 28e28528..74ae0c7f 100644 --- a/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js +++ b/plugins/codexclaw/components/pabcd-state/dist/orchestrate-cli.js @@ -453,6 +453,16 @@ function hasPabcdCloseRow( ); } +/** + * CLI entry can reach P without a UserPromptSubmit turn. Repeat the formal-P + * architect consultation pointer there. Advice only; no new phase gate. + */ +function withArchitectHint(phase , output ) { + return phase === "P" + ? `${output} [formal P: architect proposal -> main executable plan -> same-architect reflection before A (cxc-pabcd phase-plan)]` + : output; +} + export function runOrchestrateCli(args , commitHooks = {}, nativeEnv = {}) { if ("help" in args) return { code: 0, output: renderOrchestrateHelp() }; @@ -653,7 +663,7 @@ export function runOrchestrateCli(args scanEvidence: { scanRounds: state.interview?.scanRounds ?? 0, highContradictionCount: gate.highContradictionCount }, ...(args.attest?.did ? { evidence: args.attest.did } : {}), }); - return { code: 0, output: `orchestrate P: I → P (agent override, session ${sessionId})` }; + return { code: 0, output: withArchitectHint("P", `orchestrate P: I → P (agent override, session ${sessionId})`) }; } else { // Not ready and no override: advise-block with gate warnings. return { @@ -1118,7 +1128,7 @@ export function runOrchestrateCli(args reason: "cli", ...(args.attest?.did ? { evidence: args.attest.did } : {}), }); - return { code: 0, output: `orchestrate ${args.verb}: current=${state.phase} -> ${result.state.phase} (${state.phase} → ${result.state.phase}, session ${sessionId})` }; + return { code: 0, output: withArchitectHint(result.state.phase, `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 — diff --git a/plugins/codexclaw/components/pabcd-state/src/hook.ts b/plugins/codexclaw/components/pabcd-state/src/hook.ts index 153d5d98..a0a6d3e5 100644 --- a/plugins/codexclaw/components/pabcd-state/src/hook.ts +++ b/plugins/codexclaw/components/pabcd-state/src/hook.ts @@ -303,6 +303,7 @@ const PHASE_DIRECTIVES: Partial> = { "[codexclaw: PLAN]", "Apply this pointer and its owners within exact user limits and permissions. No-delegation means no dispatch.", "Load $codexclaw:cxc-pabcd for P and C2+ plan-output; $codexclaw:cxc-dev selects class and relevant surfaces. No implementation yet.", + "Formal P, including C2 compact and plan-only P: obtain the configured read-only architect proposal BEFORE the executable plan, send that concrete plan to the SAME architect for reflection BEFORE A, and record the consultation per $codexclaw:cxc-pabcd phase-plan/plan-output. The C0/C1 fast path needs none.", "Plan-only ends with the plan. Forbidden checks: NOT RUN; naming an artifact grants no write permission.", ].join("\n"), A: [ @@ -310,6 +311,7 @@ const PHASE_DIRECTIVES: Partial> = { "Apply this pointer and its owners within exact user limits and permissions. No-delegation means no dispatch.", "Load $codexclaw:cxc-dev-code-reviewer for review and $codexclaw:cxc-dev for relevant surfaces; authorized PABCD A uses $codexclaw:cxc-pabcd's audit owner. Do not build yet.", "Authorized dispatch follows the owner's named-skill, same-reviewer and verdict contracts; main synthesizes. Report unmet independent review; inline review is not its proof. Do not bypass gates.", + "An amendment changing a module-responsibility, data-structure, interface or execution-flow decision needs reflection from the SAME architect before A completes ($codexclaw:cxc-pabcd phase-audit); text/test clarification alone does not. The reviewer stays independent.", ].join("\n"), B: [ "[codexclaw: BUILD]", diff --git a/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts b/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts index 43800ea3..0eea24af 100644 --- a/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts +++ b/plugins/codexclaw/components/pabcd-state/src/orchestrate-cli.ts @@ -453,6 +453,16 @@ function hasPabcdCloseRow( ); } +/** + * CLI entry can reach P without a UserPromptSubmit turn. Repeat the formal-P + * architect consultation pointer there. Advice only; no new phase gate. + */ +function withArchitectHint(phase: Phase, output: string): string { + return phase === "P" + ? `${output} [formal P: architect proposal -> main executable plan -> same-architect reflection before A (cxc-pabcd phase-plan)]` + : output; +} + export function runOrchestrateCli(args: OrchestrateCliArgs | OrchestrateCliHelpArgs, commitHooks: OrchestrateCommitHooks = {}, nativeEnv: NodeJS.ProcessEnv = {}): CliResult { if ("help" in args) return { code: 0, output: renderOrchestrateHelp() }; @@ -653,7 +663,7 @@ export function runOrchestrateCli(args: OrchestrateCliArgs | OrchestrateCliHelpA scanEvidence: { scanRounds: state.interview?.scanRounds ?? 0, highContradictionCount: gate.highContradictionCount }, ...(args.attest?.did ? { evidence: args.attest.did } : {}), }); - return { code: 0, output: `orchestrate P: I → P (agent override, session ${sessionId})` }; + return { code: 0, output: withArchitectHint("P", `orchestrate P: I → P (agent override, session ${sessionId})`) }; } else { // Not ready and no override: advise-block with gate warnings. return { @@ -1118,7 +1128,7 @@ export function runOrchestrateCli(args: OrchestrateCliArgs | OrchestrateCliHelpA reason: "cli", ...(args.attest?.did ? { evidence: args.attest.did } : {}), }); - return { code: 0, output: `orchestrate ${args.verb}: current=${state.phase} -> ${result.state.phase} (${state.phase} → ${result.state.phase}, session ${sessionId})` }; + return { code: 0, output: withArchitectHint(result.state.phase, `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 — diff --git a/plugins/codexclaw/components/pabcd-state/test/hook.test.ts b/plugins/codexclaw/components/pabcd-state/test/hook.test.ts index 211d7451..dd977045 100644 --- a/plugins/codexclaw/components/pabcd-state/test/hook.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/hook.test.ts @@ -114,6 +114,9 @@ test("wp3: phase pointers retain owners and active work-phase boundaries", () => assert.match(interviewDirective(), /\$codexclaw:cxc-interview/); assert.match(interviewDirective(), /Mind dispatch/i); assert.match(phaseDirective("P"), /No implementation yet/); + for (const phase of ["P", "B"] as const) { + assert.doesNotMatch(phaseDirective(phase), /implementation ownership|main implements by default|defaults to the configured executor/i); + } assert.match(phaseDirective("A"), /cxc-dev-code-reviewer/); assert.match(phaseDirective("C"), /C-RENDER-GROUNDING-01/); const bound = phaseDirective("B", { activeWorkPhase: { id: "wp3", title: "minimal hooks" } }); @@ -121,6 +124,53 @@ test("wp3: phase pointers retain owners and active work-phase boundaries", () => assert.match(bound, /other work-phases are OUT OF SCOPE until D closes/); }); +test("260914: P and A directives carry the architect consultation contract", () => { + const p = phaseDirective("P"); + // Formal-P sequence: read-only architect proposal before the executable plan, + // the concrete plan back to the SAME architect for reflection before A, and a + // recorded consultation — inside the existing user-limit wording. + assert.match(p, /read-only architect proposal BEFORE the executable plan/); + assert.match(p, /SAME architect for reflection BEFORE A/); + assert.match(p, /phase-plan\/plan-output/); + assert.match(p, /C0\/C1 fast path needs none/); + assert.match(p, /No-delegation means no dispatch/); + + const a = phaseDirective("A"); + // A's reminder is the amendment recheck, distinct from P's initial sequence: + // only design-decision changes go back to the same architect. + assert.match(a, /module-responsibility, data-structure, interface or execution-flow/); + assert.match(a, /SAME architect before A completes/); + assert.match(a, /text\/test clarification alone does not/); + assert.match(a, /reviewer stays independent/i); + assert.doesNotMatch(a, /architect proposal BEFORE/i); + + // No other phase carries an initial-consultation hint. + for (const phase of ["B", "C", "D"] as const) { + assert.doesNotMatch(phaseDirective(phase), /architect/i, `${phase} directive`); + } + assert.doesNotMatch(interviewDirective(), /architect/i); +}); + +test("260914: hook P output carries the architect sequence; A output carries the amendment recheck", () => { + const cwd = freshCwd(); + try { + const pOut = handleUserPromptSubmit(ups("orchestrate P", cwd, "arch-seq", "t1")); + assert.notEqual(pOut, ""); + const pCtx = JSON.parse(pOut.trimEnd()).hookSpecificOutput.additionalContext; + assert.match(pCtx, /read-only architect proposal BEFORE the executable plan/); + assert.match(pCtx, /SAME architect for reflection BEFORE A/); + + const aOut = handleUserPromptSubmit(ups("orchestrate a", cwd, "arch-seq", "t2")); + assert.notEqual(aOut, ""); + const aCtx = JSON.parse(aOut.trimEnd()).hookSpecificOutput.additionalContext; + assert.match(aCtx, /SAME architect before A completes/); + assert.match(aCtx, /text\/test clarification alone does not/); + assert.doesNotMatch(aCtx, /architect proposal BEFORE/i); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + const WP3_ORIGINAL_C2_PROMPT = "README 계약에 맞게 기존 내부 메모 생성/목록 기능을 완성해줘. 네트워크 서버나 공개 API는 아니고 src/route.mjs와 src/service.mjs의 기존 빈 구현을 채우는 작업이야. src/store.mjs와 test/notes.test.mjs는 수정하지 마. 기존 번호 문서에 결과를 기록하고 node --test test/notes.test.mjs로 실제 검증해줘. 새 의존성/추상화/파일, goal/FSM 변경, 커밋, 서브에이전트 파견은 하지 마."; test("wp3: original Korean C2 still reaches scoped CHECK without entering C", () => { 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 c3364881..adcb0a74 100644 --- a/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts +++ b/plugins/codexclaw/components/pabcd-state/test/orchestrate-cli.test.ts @@ -2683,3 +2683,85 @@ test("wp7 preservation: CLI D-close keeps dependsOn and outcome", () => { assert.equal(saved.workPhases[1].status, "in_progress"); assert.deepEqual(taskFields(saved), expectedTaskFields); }); + +test("260914: P entry echoes architect consultation; other verbs do not", () => { + const cwd = freshCwd(); // not a repo: captureSourceIdentity is "unavailable", so B>C is not delta-gated + try { + const id = "wp3-hint"; + seedSession(cwd, id, "IDLE"); + + // IDLE -> P carries the pointer. + const toP = runOrchestrateCli({ verb: "P", attest: null, session: id, cwd, json: false }); + assert.equal(toP.code, 0, toP.output); + assert.equal(readState(cwd, id).phase, "P"); + assert.doesNotMatch(toP.output, /implementation ownership|main implements by default/); + assert.match(toP.output, /architect proposal -> main executable plan -> same-architect reflection before A/); + + // status is read-only and must not echo the pointer while parked at P. + const statusAtP = runOrchestrateCli({ verb: "status", attest: null, session: id, cwd, json: false }); + assert.equal(statusAtP.code, 0, statusAtP.output); + assert.equal(readState(cwd, id).phase, "P"); + assert.doesNotMatch(statusAtP.output, /implementation ownership/); + assert.doesNotMatch(statusAtP.output, /architect/i); + + // P -> A does not carry the pointer. + const planUnit = seedPlanUnit(cwd); + const toA = runOrchestrateCli({ verb: "A", attest: { from: "P", to: "A", did: "audited", planUnit }, session: id, cwd, json: false }); + assert.equal(toA.code, 0, toA.output); + assert.equal(readState(cwd, id).phase, "A"); + assert.doesNotMatch(toA.output, /implementation ownership/); + assert.doesNotMatch(toA.output, /architect/i); + + // A -> B keeps the original output without an ownership or architect hint. + const toB = runOrchestrateCli({ + verb: "B", + attest: { from: "A", to: "B", did: "audit folded back", auditOutput: "reviewer: GO; refs verified", auditVerdict: "pass" }, + session: id, cwd, json: false, + }); + assert.equal(toB.code, 0, toB.output); + assert.equal(readState(cwd, id).phase, "B"); + assert.doesNotMatch(toB.output, /implementation ownership|main implements by default/); + assert.doesNotMatch(toB.output, /architect/i); + + // status at B stays clean too. + const statusAtB = runOrchestrateCli({ verb: "status", attest: null, session: id, cwd, json: false }); + assert.equal(statusAtB.code, 0, statusAtB.output); + assert.equal(readState(cwd, id).phase, "B"); + assert.doesNotMatch(statusAtB.output, /implementation ownership/); + assert.doesNotMatch(statusAtB.output, /architect/i); + + // B -> C does not carry the pointer. + const toC = runOrchestrateCli({ verb: "C", attest: { from: "B", to: "C", did: "implemented the slice" }, session: id, cwd, json: false }); + assert.equal(toC.code, 0, toC.output); + assert.equal(readState(cwd, id).phase, "C"); + assert.doesNotMatch(toC.output, /implementation ownership/); + assert.doesNotMatch(toC.output, /architect/i); + + // C -> D closes to IDLE and does not carry the pointer. + const toD = runOrchestrateCli({ verb: "D", attest: { from: "C", to: "D", did: "checks passed", checkOutput: "tests 1 pass 1", exitCode: 0 }, session: id, cwd, json: false }); + assert.equal(toD.code, 0, toD.output); + assert.equal(readState(cwd, id).phase, "IDLE"); + assert.doesNotMatch(toD.output, /implementation ownership/); + assert.doesNotMatch(toD.output, /architect/i); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); + +test("260914: I->P agent override echoes architect consultation", () => { + const cwd = freshCwd(); + try { + // Same unready-interview fixture as the override tests above (line ~615). + seedSession(cwd, "s1", "I"); + const r = runOrchestrateCli({ + verb: "P", + attest: { from: "I", to: "P", did: "interview done", override: true }, + session: "s1", + cwd, + json: false, + }); + assert.equal(r.code, 0, r.output); + assert.equal(readState(cwd, "s1").phase, "P"); + assert.match(r.output, /agent override/); + assert.doesNotMatch(r.output, /implementation ownership|main implements by default/); + assert.match(r.output, /architect proposal -> main executable plan -> same-architect reflection before A/); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); diff --git a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js index 76d2fbda..4a224ce2 100644 --- a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js +++ b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { runDispatch } from "./fallback-dispatch.js"; import { readConfig, ROLES } from "./store.js"; -export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId}; creation is not completion. Report failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`; +export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId} only after validating final work; native completion alone is not task success and terminal reports cannot reopen. Report provider failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). For confirmed stagnation or unusable final output, report outcome:"task_failed" with taskFailure:{kind:"stagnation"|"unusable_output",evidence:}, the same IDs, executionState:"stopped", and reconciliation:. Task evidence and reconciliation must each be non-empty text of at most 2000 characters; taskFailure permits only kind and evidence. Omit error on task reports; supplied stop errors still stop, unknown errors reconcile, and next-eligible provider errors must use outcome:"failed". Never relabel cancellation, exhausted bounds, a bare wait timeout or supported disagreement as task failure. Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`; export function sessionFallbackNotice(cwd ) { const roles = readConfig(cwd).roles; diff --git a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js index 3a26c444..8659668d 100644 --- a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js +++ b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js @@ -8,6 +8,9 @@ import { renameWithRetry } from "./atomic-write.js"; import { decodeDispatchFailure } from "./fallback-errors.js"; +/** Confirmed task failure is main's judgment with bounded observational evidence, never a provider code. */ + + @@ -57,6 +60,13 @@ function smallText(value , field ) { if (typeof value !== "string" || !value.trim() || value.length > 2000) throw new Error(`invalid ${field}`); return value.trim(); } +const TASK_FAILURE_KINDS = new Set(["stagnation", "unusable_output"]); +function taskFailure(raw ) { + const t = record(raw); + for (const key of Object.keys(t)) if (key !== "kind" && key !== "evidence") throw new Error("invalid taskFailure key"); + if (!TASK_FAILURE_KINDS.has(t.kind )) throw new Error("invalid taskFailure kind"); + return { kind: t.kind , evidence: smallText(t.evidence, "taskFailure evidence") }; +} function directory(cwd , sessionId ) { let dir = cwd; for (const part of [".codexclaw", "dispatches", sessionId]) { @@ -89,6 +99,8 @@ function readState(path , sessionId , dispatchId ) for (const field of ["agentId", "observedModel", "code", "reconciliation", "toolUseId"]) { if (a[field] !== null && typeof a[field] !== "string") throw new Error(`invalid attempt ${field}`); } + // Version-1 records predate taskFailure; absent normalizes to null, malformed fails closed. + a.taskFailure = a.taskFailure === undefined || a.taskFailure === null ? null : taskFailure(a.taskFailure); } return d ; } @@ -100,7 +112,7 @@ function saveState(path , state ) { } finally { rmSync(temp, { force: true }); } } function attempt(c ) { - return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null }; + return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, taskFailure: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null }; } function result(d , action , reason ) { const a = d.attempts.at(-1) ; @@ -165,6 +177,7 @@ function report(d , b ) { if (!a.agentId || b.agentId !== a.agentId) throw new Error("complete requires the recorded agentId"); a.status = "complete"; d.status = "complete"; return result(d); } + if (b.outcome === "task_failed") return taskFailed(d, b); if (b.outcome !== "failed" && b.outcome !== "unavailable") throw new Error("invalid report outcome"); const failure = b.outcome === "unavailable" ? null : decodeDispatchFailure(b.error); a.code = failure?.code ?? null; @@ -182,12 +195,42 @@ function report(d , b ) { a.status = "failed"; d.status = "main-direct"; return result(d); } if (b.executionState === "stopped" && !a.agentId) throw new Error("record created agent before stopped handoff"); + return handoff(d); +} + +/** Bounded rotation shared by provider and task failures: next candidate, else main reclaims. */ +function handoff(d ) { + const a = d.attempts.at(-1) ; a.status = "failed"; if (d.attempts.length === d.candidates.length) { d.status = "main-direct"; return result(d); } d.attempts.push(attempt(d.candidates[d.attempts.length])); return result(d); } +/** A supplied provider error keeps its own decoding; without one the task-failure path runs. */ +function taskFailed(d , b ) { + const a = d.attempts.at(-1) ; + if (b.error !== undefined) { + const failure = decodeDispatchFailure(b.error); + a.code = failure.code; + if (failure.action === "stop") { d.status = "stopped"; return result(d, "stop", "failure does not permit model fallback"); } + if (failure.action === "unknown") { a.status = "reconcile"; return result(d, "reconcile", "error is unclassified; obtain structured OCX evidence, do not guess a code"); } + throw new Error("fallback-eligible provider error must report outcome failed, not task_failed"); + } + const failure = taskFailure(b.taskFailure); + if (b.executionState === "not_created") throw new Error("task failure requires a recorded stopped child"); + if (b.executionState !== "stopped") { + a.status = "reconcile"; return result(d, "reconcile", "confirm the child is stopped before task handoff"); + } + if (!a.agentId) throw new Error("record created agent before stopped handoff"); + if (b.agentId !== a.agentId) throw new Error("recorded child must be stopped and identified"); + a.reconciliation = smallText(b.reconciliation, "reconciliation evidence"); + // An accepted task failure supersedes any provider code parked by an earlier reconciled report. + a.code = null; + a.taskFailure = failure; + return handoff(d); +} + /** Marker resolution never creates a dispatch. The hook uses it to avoid primary effort reinjection. */ export function managedSpawn(cwd , sessionId , message ) { cwd = dispatchRoot(cwd); diff --git a/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js b/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js index 5c661cff..0b9daaaa 100644 --- a/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js +++ b/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js @@ -23,12 +23,12 @@ * recognized cxc mention's SKILL.md body to the message. Atomic overflow rule: * if the normalized message plus ALL candidate bodies would exceed * MAX_NORMALIZE_LENGTH, no bodies are appended (never truncated/partial). - * ENCRYPTION LIMIT (live-proven 260710, devlog 260710_v1_v2_parity/080): on - * native ChatGPT-backend V2 sessions the hook receives `message` as backend - * ciphertext, so normalization/inlining are silent no-ops there — the - * hook-borne channels that survive encryption are the plaintext-prepended - * leaf guard and the plaintext model/reasoning_effort fields. Inlining works - * on plaintext surfaces (v1, non-encrypted provider/proxy paths). + * ENCRYPTION LIMIT: native V2 messages can be backend ciphertext. Preserve + * those bytes: plaintext guards, skill bodies or prompt overrides in that + * encrypted slot make the backend reject the child's task. Metadata-based + * recursion denial and separate model/effort fields still apply. Message + * augmentation is available only on plaintext surfaces; disclose the gap + * to the caller rather than claiming those instructions reached the child. * * SAFETY: `updatedInput` is a FULL REPLACEMENT of tool_input (registry.rs:122), * honored only on permissionDecision "allow" (output_parser.rs:162). We echo the @@ -534,6 +534,41 @@ export function isV2SpawnInput(toolInput ) { return "task_name" in toolInput || "fork_turns" in toolInput; } +/** + * Structural recognition of a native Fernet task envelope. The wire form is + * base64url(version || timestamp || IV || ciphertext || HMAC) = 57 + 16n bytes + * with n >= 1, version 0x80. This is a SHAPE check, never authentication: the + * hook holds no key and cannot verify the HMAC, so it accepts both the + * canonical padded and the wholly unpadded base64url encodings of a well-formed + * frame. A strict decode/re-encode comparison rejects bad alphabet, embedded + * whitespace, impossible encoded lengths, partial or excess padding, and + * nonzero unused pad bits — every rejected input is ordinary plaintext and + * keeps its guards. No `gAAAA` prefix, timestamp, or MAC requirement: prefix + * resemblance alone must never strip plaintext attachment. + */ +function isFernetTokenShape(token ) { + const firstPad = token.indexOf("="); + const core = firstPad === -1 ? token : token.slice(0, firstPad); + // Padding is legal only as a trailing run. + if (firstPad !== -1 && !/^=+$/.test(token.slice(firstPad))) return false; + const rem = core.length % 4; + if (firstPad === -1) { + // Entirely unpadded: a %4==1 core is an impossible base64 length. + if (rem === 1) return false; + } else { + // Canonical padding only: exactly the count that rounds the core to a + // 4-char block. rem 0 or 1 can never take padding. + if (rem < 2 || token.length - core.length !== 4 - rem) return false; + } + if (!/^[A-Za-z0-9_-]+$/.test(core)) return false; + const decoded = Buffer.from(core, "base64url"); + // Re-encode catches nonzero unused pad bits and any lenient-decode drift. + if (decoded.toString("base64url") !== core) return false; + // version(1) + timestamp(8) + IV(16) + ciphertext(16n, n>=1) + HMAC(32). + if (decoded.length < 73 || (decoded.length - 57) % 16 !== 0) return false; + return decoded[0] === 0x80; +} + /** * Hook-facing spawn tool names across surfaces: plain/V1 canonicalizes to * `spawn_agent`; native V2 rides the `collaboration` namespace and reaches hooks @@ -848,6 +883,7 @@ export function runSpawnAttachHook(raw ) { // Keep the native one-of shape. Attachment-only requests still need routing. const message = validItems ? outgoing : toolInput.message; if (typeof message !== "string" || (!validItems && message.trim().length === 0)) return ""; + const encryptedV2Message = v2Spawn && isFernetTokenShape(message); const cwd = typeof obj.cwd === "string" && obj.cwd.length > 0 ? obj.cwd : process.cwd(); const dispatchScan = validItems ? textItems.map(item => scanInlineSkillBlocks(item.text).scanSource).join("\n\n") @@ -913,11 +949,9 @@ export function runSpawnAttachHook(raw ) { // (WP2 live bug: doc-quoted markers poisoned raw includes()). const markerScanSource = scanInlineSkillBlocks(inlinedMessage).scanSource; - // WP2 cr3 — V2 affordance: when inlining attached nothing (encrypted native - // path, or no plaintext mentions), append the plaintext self-load instruction - // so the child can resolve mentions itself. Marker-deduped; size-guarded; - // never on v1 (upstream parses mentions there). Zero-mention plaintext V2 - // also gets it — deliberate small overhead (090_plan). + // V2 plaintext without inlined bodies gets a self-load instruction. + // Marker-deduped and size-guarded; the ciphertext boundary below discards + // all generated text for encrypted messages and discloses that omission. let affordanceMessage = inlinedMessage; if ( v2Spawn && @@ -1010,8 +1044,11 @@ export function runSpawnAttachHook(raw ) { } } } - const promptChanged = injectedPrompt !== null; + const promptChanged = !encryptedV2Message && injectedPrompt !== null; if (trustPrefix) evidenceExemptMessage = `${trustPrefix}${evidenceExemptMessage}`; + // The native backend treats this whole value as ciphertext. Keep D1 and + // routing above, but never put our plaintext inside its encrypted slot. + if (encryptedV2Message) evidenceExemptMessage = message; const updatedItems = mappedItems ? [...mappedItems] : null; if (updatedItems) { if (firstText < 0) updatedItems.unshift({ type: "text", text: evidenceExemptMessage }); @@ -1042,7 +1079,10 @@ export function runSpawnAttachHook(raw ) { const fallbackNotice = !managed && readConfig(cwd).roles[role].fallback ? `[codexclaw] This direct spawn is not managed by first-fallback tracking. For subsequent tasks: ${DISPATCH_GUIDANCE}` : null; - if (!managed && !fallbackNotice && !messageChanged && injectedModel === null && injectedEffort === null) return ""; + const additionalContext = [fallbackNotice, encryptedV2Message + ? `[codexclaw] Native V2 task ciphertext was preserved. Hook-added skill text, scope instructions and prompt overrides were not attached; native recursion checks and separate routing fields still apply.${resolution.trustWarning ? ` ${resolution.trustWarning}` : ""}` + : null].filter(Boolean).join("\n"); + if (!managed && !additionalContext && !messageChanged && injectedModel === null && injectedEffort === null) return ""; // Full replacement preserves whichever native input form the caller chose. const updatedInput = updatedItems @@ -1064,7 +1104,7 @@ export function runSpawnAttachHook(raw ) { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput, - ...(fallbackNotice ? { additionalContext: fallbackNotice } : {}), + ...(additionalContext ? { additionalContext } : {}), }, })}\n`; } catch { diff --git a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts index 7b3d691e..6073da82 100644 --- a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts +++ b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { runDispatch } from "./fallback-dispatch.ts"; import { readConfig, ROLES } from "./store.ts"; -export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId}; creation is not completion. Report failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`; +export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId} only after validating final work; native completion alone is not task success and terminal reports cannot reopen. Report provider failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). For confirmed stagnation or unusable final output, report outcome:"task_failed" with taskFailure:{kind:"stagnation"|"unusable_output",evidence:}, the same IDs, executionState:"stopped", and reconciliation:. Task evidence and reconciliation must each be non-empty text of at most 2000 characters; taskFailure permits only kind and evidence. Omit error on task reports; supplied stop errors still stop, unknown errors reconcile, and next-eligible provider errors must use outcome:"failed". Never relabel cancellation, exhausted bounds, a bare wait timeout or supported disagreement as task failure. Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`; export function sessionFallbackNotice(cwd: string): string { const roles = readConfig(cwd).roles; diff --git a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts index aa8f5dd1..8f03f3e4 100644 --- a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts +++ b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts @@ -8,6 +8,8 @@ import { renameWithRetry } from "./atomic-write.ts"; import { decodeDispatchFailure } from "./fallback-errors.ts"; export interface Candidate { model: string | null; effort: EffortName | null; } +/** Confirmed task failure is main's judgment with bounded observational evidence, never a provider code. */ +interface TaskFailure { kind: "stagnation" | "unusable_output"; evidence: string; } interface Attempt { id: string; candidate: Candidate; @@ -15,6 +17,7 @@ interface Attempt { agentId: string | null; observedModel: string | null; code: string | null; + taskFailure: TaskFailure | null; status: "ready" | "claimed" | "running" | "reconcile" | "failed" | "complete"; reconciliation: string | null; spawnIssued: boolean; @@ -57,6 +60,13 @@ function smallText(value: unknown, field: string): string { if (typeof value !== "string" || !value.trim() || value.length > 2000) throw new Error(`invalid ${field}`); return value.trim(); } +const TASK_FAILURE_KINDS = new Set(["stagnation", "unusable_output"]); +function taskFailure(raw: unknown): TaskFailure { + const t = record(raw); + for (const key of Object.keys(t)) if (key !== "kind" && key !== "evidence") throw new Error("invalid taskFailure key"); + if (!TASK_FAILURE_KINDS.has(t.kind as string)) throw new Error("invalid taskFailure kind"); + return { kind: t.kind as TaskFailure["kind"], evidence: smallText(t.evidence, "taskFailure evidence") }; +} function directory(cwd: string, sessionId: string): string { let dir = cwd; for (const part of [".codexclaw", "dispatches", sessionId]) { @@ -89,6 +99,8 @@ function readState(path: string, sessionId: string, dispatchId: string): Dispatc for (const field of ["agentId", "observedModel", "code", "reconciliation", "toolUseId"]) { if (a[field] !== null && typeof a[field] !== "string") throw new Error(`invalid attempt ${field}`); } + // Version-1 records predate taskFailure; absent normalizes to null, malformed fails closed. + a.taskFailure = a.taskFailure === undefined || a.taskFailure === null ? null : taskFailure(a.taskFailure); } return d as unknown as Dispatch; } @@ -100,7 +112,7 @@ function saveState(path: string, state: Dispatch): void { } finally { rmSync(temp, { force: true }); } } function attempt(c: Candidate): Attempt { - return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null }; + return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, taskFailure: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null }; } function result(d: Dispatch, action?: DispatchResult["action"], reason?: string): DispatchResult { const a = d.attempts.at(-1)!; @@ -165,6 +177,7 @@ function report(d: Dispatch, b: Record): DispatchResult { if (!a.agentId || b.agentId !== a.agentId) throw new Error("complete requires the recorded agentId"); a.status = "complete"; d.status = "complete"; return result(d); } + if (b.outcome === "task_failed") return taskFailed(d, b); if (b.outcome !== "failed" && b.outcome !== "unavailable") throw new Error("invalid report outcome"); const failure = b.outcome === "unavailable" ? null : decodeDispatchFailure(b.error); a.code = failure?.code ?? null; @@ -182,12 +195,42 @@ function report(d: Dispatch, b: Record): DispatchResult { a.status = "failed"; d.status = "main-direct"; return result(d); } if (b.executionState === "stopped" && !a.agentId) throw new Error("record created agent before stopped handoff"); + return handoff(d); +} + +/** Bounded rotation shared by provider and task failures: next candidate, else main reclaims. */ +function handoff(d: Dispatch): DispatchResult { + const a = d.attempts.at(-1)!; a.status = "failed"; if (d.attempts.length === d.candidates.length) { d.status = "main-direct"; return result(d); } d.attempts.push(attempt(d.candidates[d.attempts.length])); return result(d); } +/** A supplied provider error keeps its own decoding; without one the task-failure path runs. */ +function taskFailed(d: Dispatch, b: Record): DispatchResult { + const a = d.attempts.at(-1)!; + if (b.error !== undefined) { + const failure = decodeDispatchFailure(b.error); + a.code = failure.code; + if (failure.action === "stop") { d.status = "stopped"; return result(d, "stop", "failure does not permit model fallback"); } + if (failure.action === "unknown") { a.status = "reconcile"; return result(d, "reconcile", "error is unclassified; obtain structured OCX evidence, do not guess a code"); } + throw new Error("fallback-eligible provider error must report outcome failed, not task_failed"); + } + const failure = taskFailure(b.taskFailure); + if (b.executionState === "not_created") throw new Error("task failure requires a recorded stopped child"); + if (b.executionState !== "stopped") { + a.status = "reconcile"; return result(d, "reconcile", "confirm the child is stopped before task handoff"); + } + if (!a.agentId) throw new Error("record created agent before stopped handoff"); + if (b.agentId !== a.agentId) throw new Error("recorded child must be stopped and identified"); + a.reconciliation = smallText(b.reconciliation, "reconciliation evidence"); + // An accepted task failure supersedes any provider code parked by an earlier reconciled report. + a.code = null; + a.taskFailure = failure; + return handoff(d); +} + /** Marker resolution never creates a dispatch. The hook uses it to avoid primary effort reinjection. */ export function managedSpawn(cwd: string, sessionId: string, message: string): { candidate: Candidate; role: RoleName } | null { cwd = dispatchRoot(cwd); diff --git a/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts b/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts index 1f1d734a..23980a3e 100644 --- a/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts +++ b/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts @@ -23,12 +23,12 @@ * recognized cxc mention's SKILL.md body to the message. Atomic overflow rule: * if the normalized message plus ALL candidate bodies would exceed * MAX_NORMALIZE_LENGTH, no bodies are appended (never truncated/partial). - * ENCRYPTION LIMIT (live-proven 260710, devlog 260710_v1_v2_parity/080): on - * native ChatGPT-backend V2 sessions the hook receives `message` as backend - * ciphertext, so normalization/inlining are silent no-ops there — the - * hook-borne channels that survive encryption are the plaintext-prepended - * leaf guard and the plaintext model/reasoning_effort fields. Inlining works - * on plaintext surfaces (v1, non-encrypted provider/proxy paths). + * ENCRYPTION LIMIT: native V2 messages can be backend ciphertext. Preserve + * those bytes: plaintext guards, skill bodies or prompt overrides in that + * encrypted slot make the backend reject the child's task. Metadata-based + * recursion denial and separate model/effort fields still apply. Message + * augmentation is available only on plaintext surfaces; disclose the gap + * to the caller rather than claiming those instructions reached the child. * * SAFETY: `updatedInput` is a FULL REPLACEMENT of tool_input (registry.rs:122), * honored only on permissionDecision "allow" (output_parser.rs:162). We echo the @@ -534,6 +534,41 @@ export function isV2SpawnInput(toolInput: Record): boolean { return "task_name" in toolInput || "fork_turns" in toolInput; } +/** + * Structural recognition of a native Fernet task envelope. The wire form is + * base64url(version || timestamp || IV || ciphertext || HMAC) = 57 + 16n bytes + * with n >= 1, version 0x80. This is a SHAPE check, never authentication: the + * hook holds no key and cannot verify the HMAC, so it accepts both the + * canonical padded and the wholly unpadded base64url encodings of a well-formed + * frame. A strict decode/re-encode comparison rejects bad alphabet, embedded + * whitespace, impossible encoded lengths, partial or excess padding, and + * nonzero unused pad bits — every rejected input is ordinary plaintext and + * keeps its guards. No `gAAAA` prefix, timestamp, or MAC requirement: prefix + * resemblance alone must never strip plaintext attachment. + */ +function isFernetTokenShape(token: string): boolean { + const firstPad = token.indexOf("="); + const core = firstPad === -1 ? token : token.slice(0, firstPad); + // Padding is legal only as a trailing run. + if (firstPad !== -1 && !/^=+$/.test(token.slice(firstPad))) return false; + const rem = core.length % 4; + if (firstPad === -1) { + // Entirely unpadded: a %4==1 core is an impossible base64 length. + if (rem === 1) return false; + } else { + // Canonical padding only: exactly the count that rounds the core to a + // 4-char block. rem 0 or 1 can never take padding. + if (rem < 2 || token.length - core.length !== 4 - rem) return false; + } + if (!/^[A-Za-z0-9_-]+$/.test(core)) return false; + const decoded = Buffer.from(core, "base64url"); + // Re-encode catches nonzero unused pad bits and any lenient-decode drift. + if (decoded.toString("base64url") !== core) return false; + // version(1) + timestamp(8) + IV(16) + ciphertext(16n, n>=1) + HMAC(32). + if (decoded.length < 73 || (decoded.length - 57) % 16 !== 0) return false; + return decoded[0] === 0x80; +} + /** * Hook-facing spawn tool names across surfaces: plain/V1 canonicalizes to * `spawn_agent`; native V2 rides the `collaboration` namespace and reaches hooks @@ -848,6 +883,7 @@ export function runSpawnAttachHook(raw: string): string { // Keep the native one-of shape. Attachment-only requests still need routing. const message = validItems ? outgoing : toolInput.message; if (typeof message !== "string" || (!validItems && message.trim().length === 0)) return ""; + const encryptedV2Message = v2Spawn && isFernetTokenShape(message); const cwd = typeof obj.cwd === "string" && obj.cwd.length > 0 ? obj.cwd : process.cwd(); const dispatchScan = validItems ? textItems.map(item => scanInlineSkillBlocks(item.text).scanSource).join("\n\n") @@ -913,11 +949,9 @@ export function runSpawnAttachHook(raw: string): string { // (WP2 live bug: doc-quoted markers poisoned raw includes()). const markerScanSource = scanInlineSkillBlocks(inlinedMessage).scanSource; - // WP2 cr3 — V2 affordance: when inlining attached nothing (encrypted native - // path, or no plaintext mentions), append the plaintext self-load instruction - // so the child can resolve mentions itself. Marker-deduped; size-guarded; - // never on v1 (upstream parses mentions there). Zero-mention plaintext V2 - // also gets it — deliberate small overhead (090_plan). + // V2 plaintext without inlined bodies gets a self-load instruction. + // Marker-deduped and size-guarded; the ciphertext boundary below discards + // all generated text for encrypted messages and discloses that omission. let affordanceMessage = inlinedMessage; if ( v2Spawn && @@ -1010,8 +1044,11 @@ export function runSpawnAttachHook(raw: string): string { } } } - const promptChanged = injectedPrompt !== null; + const promptChanged = !encryptedV2Message && injectedPrompt !== null; if (trustPrefix) evidenceExemptMessage = `${trustPrefix}${evidenceExemptMessage}`; + // The native backend treats this whole value as ciphertext. Keep D1 and + // routing above, but never put our plaintext inside its encrypted slot. + if (encryptedV2Message) evidenceExemptMessage = message; const updatedItems = mappedItems ? [...mappedItems] : null; if (updatedItems) { if (firstText < 0) updatedItems.unshift({ type: "text", text: evidenceExemptMessage }); @@ -1042,7 +1079,10 @@ export function runSpawnAttachHook(raw: string): string { const fallbackNotice = !managed && readConfig(cwd).roles[role].fallback ? `[codexclaw] This direct spawn is not managed by first-fallback tracking. For subsequent tasks: ${DISPATCH_GUIDANCE}` : null; - if (!managed && !fallbackNotice && !messageChanged && injectedModel === null && injectedEffort === null) return ""; + const additionalContext = [fallbackNotice, encryptedV2Message + ? `[codexclaw] Native V2 task ciphertext was preserved. Hook-added skill text, scope instructions and prompt overrides were not attached; native recursion checks and separate routing fields still apply.${resolution.trustWarning ? ` ${resolution.trustWarning}` : ""}` + : null].filter(Boolean).join("\n"); + if (!managed && !additionalContext && !messageChanged && injectedModel === null && injectedEffort === null) return ""; // Full replacement preserves whichever native input form the caller chose. const updatedInput: Record = updatedItems @@ -1064,7 +1104,7 @@ export function runSpawnAttachHook(raw: string): string { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput, - ...(fallbackNotice ? { additionalContext: fallbackNotice } : {}), + ...(additionalContext ? { additionalContext } : {}), }, })}\n`; } catch { diff --git a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts index 498dc502..d2d8ae80 100644 --- a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts +++ b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -43,6 +43,36 @@ test("real CLI refuses corrupt state and invalid JSON rather than resetting it", const out = run(JSON.stringify({ ...base, action: "status" })); assert.equal(out.status, 1); assert.match(out.stdout, /invalid dispatch identity/); }); +test("real CLI recovers confirmed task failures across separate processes", () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-dispatch-cli-task-")); + const { CODEX_THREAD_ID: _nativeSession, ...inherited } = process.env; + const env = { ...inherited, CODEXCLAW_HOME: join(cwd, "global") }; + setRole(cwd, "executor", { mode: "model", model: "xai/grok-4.6", fallback: { model: "cursor/grok-4.6", effort: "low" } }, "project", env); + const call = (input: unknown) => { + const child = spawnSync(process.execPath, [cli], { cwd, env, input: JSON.stringify(input), encoding: "utf8" }); + assert.equal(child.status, 0, child.stdout + child.stderr); + return child.stdout ? JSON.parse(child.stdout) : null; + }; + const base = { sessionId: "fixture", dispatchId: "task" }; + const first = call({ ...base, action: "start", role: "executor" }); + call({ ...base, action: "claim", attemptId: first.attemptId }); + call({ ...base, action: "report", attemptId: first.attemptId, outcome: "created", agentId: "child-a" }); + const second = call({ ...base, action: "report", attemptId: first.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; diff inspected", taskFailure: { kind: "unusable_output", evidence: "final message unrelated to the packet" } }); + assert.equal(second.action, "ready"); + assert.equal(second.attempts[0].taskFailure?.kind, "unusable_output"); + assert.equal(second.attempts[0].code, null); + const claim = call({ ...base, action: "claim", attemptId: second.attemptId }); + assert.equal(claim.candidate.model, "cursor/grok-4.6"); + call({ ...base, action: "report", attemptId: second.attemptId, outcome: "created", agentId: "child-b" }); + const end = call({ ...base, action: "report", attemptId: second.attemptId, outcome: "task_failed", agentId: "child-b", executionState: "stopped", reconciliation: "second child stopped; partial work preserved", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } }); + assert.equal(end.action, "main-direct"); + const status = call({ ...base, action: "status" }); + assert.equal(status.action, "main-direct"); + assert.equal(status.attempts[1].taskFailure?.kind, "stagnation"); + const raw = JSON.parse(readFileSync(join(cwd, ".codexclaw", "dispatches", "fixture", "task.json"), "utf8")); + assert.equal(raw.attempts[0].taskFailure.kind, "unusable_output"); +}); + test("malformed startup payload is silent, malformed dispatch input is visible", () => { const hook = spawnSync(process.execPath, [cli, "hook", "session-start"], { input: "", encoding: "utf8" }); assert.equal(hook.status, 0); assert.equal(hook.stdout, ""); diff --git a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts index e41bc7ad..ee202170 100644 --- a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts +++ b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, mkdirSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runDispatch, managedSpawn } from "../src/fallback-dispatch.ts"; @@ -170,3 +170,177 @@ test("candidate snapshot remains stable if role settings change after start", () const next = call({ action: "report", attemptId: start.attemptId, outcome: "failed", error: "rate_limit_exceeded", executionState: "not_created", reconciliation: "native creation returned no child" }); assert.equal(call({ action: "claim", attemptId: next.attemptId }).candidate?.model, "cursor/grok-4.6"); }); + +test("confirmed task failure recovers through the next candidate, then main-direct", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const next = call({ + action: "report", attemptId: start.attemptId, outcome: "task_failed", + agentId: "child-a", executionState: "stopped", + reconciliation: "child stopped; inspected diff and preserved edits", + taskFailure: { kind: "unusable_output", evidence: "final message contained no findings or diff for the packet" }, + }); + assert.equal(next.action, "ready"); + assert.equal(next.attempts[0].taskFailure?.kind, "unusable_output"); + assert.equal(next.attempts[0].code, null); + const claim = call({ action: "claim", attemptId: next.attemptId }); + assert.equal(claim.candidate?.model, "cursor/grok-4.6"); + call({ action: "report", attemptId: next.attemptId, outcome: "created", agentId: "child-b" }); + const end = call({ + action: "report", attemptId: next.attemptId, outcome: "task_failed", + agentId: "child-b", executionState: "stopped", + reconciliation: "second child stopped; partial work inspected", + taskFailure: { kind: "stagnation", evidence: "no new edits or output at the stated review point" }, + }); + assert.equal(end.action, "main-direct"); + assert.equal(end.attempts.length, 2); + assert.equal(end.attempts[1].taskFailure?.kind, "stagnation"); +}); + +for (const role of ROLES) test(`${role}: task failure on the last candidate returns main-direct`, () => { + const { call, start } = fixture(role); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; partial edits inspected", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } }); + const second = call({ action: "claim", attemptId: next.attemptId }); + call({ action: "report", attemptId: second.attemptId, outcome: "created", agentId: "child-b" }); + const end = call({ action: "report", attemptId: second.attemptId, outcome: "task_failed", agentId: "child-b", executionState: "stopped", reconciliation: "second child stopped; output unusable", taskFailure: { kind: "unusable_output", evidence: "final message unrelated to the packet" } }); + assert.equal(end.action, "main-direct"); + assert.equal(end.attempts.length, 2); + assert.equal(end.independentReviewRequired, role === "reviewer"); +}); + +test("dispatch state without taskFailure stays readable and recovers; malformed metadata fails closed", () => { + const { call, start, cwd, base } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const path = join(cwd, ".codexclaw", "dispatches", base.sessionId, base.dispatchId + ".json"); + const legacy = JSON.parse(readFileSync(path, "utf8")); + delete legacy.attempts[0].taskFailure; + writeFileSync(path, JSON.stringify(legacy)); + assert.equal(call({ action: "status" }).attempts[0].taskFailure, null); + const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } }); + assert.equal(next.action, "ready"); + const corrupt = JSON.parse(readFileSync(path, "utf8")); + corrupt.attempts[0].taskFailure = { kind: "timeout", evidence: "x" }; + writeFileSync(path, JSON.stringify(corrupt)); + assert.throws(() => call({ action: "status" }), /taskFailure kind/); +}); + +test("task failure needs a recorded stopped child before any handoff", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const taskFailure = { kind: "unusable_output", evidence: "final message unusable" }; + for (const executionState of ["running", "unknown"]) { + const out = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState, reconciliation: "still checking", taskFailure }); + assert.equal(out.action, "reconcile"); + assert.equal(out.attempts.length, 1); + } + assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", executionState: "not_created", reconciliation: "x", taskFailure }), /recorded stopped child/); + assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-b", executionState: "stopped", reconciliation: "x", taskFailure }), /stopped and identified/); + assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", taskFailure }), /reconciliation/); + const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure }); + assert.equal(next.action, "ready"); +}); + +test("task failure without a recorded child is rejected", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "ghost", executionState: "stopped", reconciliation: "claimed child stopped", taskFailure: { kind: "stagnation", evidence: "no output" } }), /record created agent/); +}); + +test("invalid taskFailure payloads are rejected without state advancement", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const report = { action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected" }; + assert.throws(() => call({ ...report }), /JSON object/); + assert.throws(() => call({ ...report, taskFailure: "stagnation" }), /JSON object/); + assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: "x", extra: 1 } }), /taskFailure key/); + assert.throws(() => call({ ...report, taskFailure: { kind: "timeout", evidence: "x" } }), /taskFailure kind/); + assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: " " } }), /evidence/); + assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: 42 } }), /evidence/); + assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: "x".repeat(2001) } }), /evidence/); + const status = call({ action: "status" }); + assert.equal(status.attempts.length, 1); + assert.equal(status.attempts[0].status, "running"); + assert.equal(status.attempts[0].taskFailure, null); +}); + +test("provider error decoding wins over the task_failed label", () => { + // A decoded stop wins before the child-state gate, even while the child is live. + for (const [error, code, executionState] of [ + [{ code: "permission_denied" }, "permission_denied", "running"], + ["client_cancelled", "client_cancelled", "unknown"], + [{ code: "cyber_policy" }, "cyber_policy", "running"], + ] as const) { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const stop = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", error, agentId: "child-a", executionState, reconciliation: "still live", taskFailure: { kind: "stagnation", evidence: "x" } }); + assert.equal(stop.action, "stop"); + assert.equal(stop.attempts[0].code, code); + const again = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }); + assert.equal(again.action, "stop"); + assert.equal(again.attempts.length, 1); + } + + const unknown = fixture(); + unknown.call({ action: "claim", attemptId: unknown.start.attemptId }); + unknown.call({ action: "report", attemptId: unknown.start.attemptId, outcome: "created", agentId: "child-a" }); + const rec = unknown.call({ action: "report", attemptId: unknown.start.attemptId, outcome: "task_failed", error: "vague prose", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }); + assert.equal(rec.action, "reconcile"); + assert.equal(rec.attempts.length, 1); + + const mixed = fixture(); + mixed.call({ action: "claim", attemptId: mixed.start.attemptId }); + mixed.call({ action: "report", attemptId: mixed.start.attemptId, outcome: "created", agentId: "child-a" }); + assert.throws(() => mixed.call({ action: "report", attemptId: mixed.start.attemptId, outcome: "task_failed", error: "insufficient_quota", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }), /outcome failed/); + const next = mixed.call({ action: "report", attemptId: mixed.start.attemptId, outcome: "failed", error: "insufficient_quota", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected" }); + assert.equal(next.action, "ready"); + assert.equal(next.attempts[0].code, "insufficient_quota"); +}); + +test("a plain failed report with task metadata never implies task recovery", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const out = call({ action: "report", attemptId: start.attemptId, outcome: "failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "unusable_output", evidence: "no usable output" } }); + assert.equal(out.action, "reconcile"); + assert.equal(out.attempts.length, 1); + assert.equal(out.attempts[0].taskFailure, null); +}); + +test("terminal dispatch states never reopen for a task report", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + call({ action: "report", attemptId: start.attemptId, outcome: "complete", agentId: "child-a" }); + const out = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }); + assert.equal(out.action, "complete"); + assert.equal(out.attempts.length, 1); +}); + +test("task failure before claim or on a stale attempt is rejected", () => { + const { call, start } = fixture(); + assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }), /claim the attempt/); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } }); + assert.equal(next.action, "ready"); + assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }), /stale/); +}); +test("a reconciled provider report followed by task failure clears the stale code", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" }); + const rec = call({ action: "report", attemptId: start.attemptId, outcome: "failed", error: "insufficient_quota", agentId: "child-a", executionState: "running" }); + assert.equal(rec.action, "reconcile"); + assert.equal(rec.attempts[0].code, "insufficient_quota"); + const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "unusable_output", evidence: "final message unusable" } }); + assert.equal(next.action, "ready"); + assert.equal(next.attempts[0].code, null); + assert.equal(next.attempts[0].taskFailure?.kind, "unusable_output"); +}); diff --git a/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts b/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts index 93e5668d..0affce69 100644 --- a/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts +++ b/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts @@ -971,7 +971,10 @@ test("same-intent v1/v2 spawns produce surface-appropriate effective payloads", runSpawnAttachHook(spawnPayloadAt(cwd, { message: intent, agent_type: "explorer" })), ); const v2 = updatedInputOf( - runSpawnAttachHook(spawnPayloadAt(cwd, { task_name: "map_fe", fork_turns: "none", message: intent })), + // Explicit agent_type: the normalized mention link carries the checkout path, + // and a "delegation-review"-style path would otherwise hit legacy review-keyword + // inference. v1 already pins explorer; keep the surfaces comparable. + runSpawnAttachHook(spawnPayloadAt(cwd, { task_name: "map_fe", fork_turns: "none", message: intent, agent_type: "explorer" })), ); // Same model + effort routing on both surfaces. assert.equal(v1.model, v2.model); @@ -995,19 +998,161 @@ test("v2 affordance: appended only when inlining attached nothing", () => { ); assert.ok((inlined.message as string).includes(`${INLINE_SKILL_OPEN}dev">`)); assert.ok(!(inlined.message as string).includes(affordanceOpening)); - // No mentions (ciphertext-like opaque text) -> affordance appended after the task text. + // Plaintext without mentions still gets the self-load affordance. const opaque = updatedInputOf( - runSpawnAttachHook(spawnPayload({ task_name: "t", fork_turns: "none", message: "gAAAAABopaquetoken" })), + runSpawnAttachHook(spawnPayload({ task_name: "t", fork_turns: "none", message: "Inspect the catalog module." })), ); assert.ok((opaque.message as string).includes(SKILL_AFFORDANCE_MARKER)); assert.ok((opaque.message as string).startsWith(`${LEAF_GUARD_BLOCK}\n\n`), "guard stays first"); assert.ok( - (opaque.message as string).indexOf("gAAAAABopaquetoken") < + (opaque.message as string).indexOf("Inspect the catalog module.") < (opaque.message as string).indexOf(SKILL_AFFORDANCE_MARKER), "affordance rides after the task text", ); }); +// Public Fernet generate.json "hello" vector: a real envelope wire form, no key. +const FERNET_VECTOR = "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA=="; + +// Synthetic Fernet-shaped frame: version(1) || timestamp(8) || IV(16) || +// ciphertext(ciphertextBytes) || HMAC(32). Structural fixture only — the bytes +// are not authenticated and carry no key material. +function fernetFrame(options: { version?: number; timestamp?: Uint8Array; ciphertextBytes?: number } = {}) { + return Buffer.concat([ + Buffer.from([options.version ?? 0x80]), + options.timestamp ?? Buffer.alloc(8, 0), + Buffer.alloc(16, 0x11), + Buffer.alloc(options.ciphertextBytes ?? 16, 0x22), + Buffer.alloc(32, 0x33), + ]); +} + +// Canonical padded or wholly unpadded base64url wire form of a frame. +function fernetToken(frame: Uint8Array, padded = true): string { + const unpadded = Buffer.from(frame).toString("base64url"); + return padded ? `${unpadded}${"=".repeat((4 - (unpadded.length % 4)) % 4)}` : unpadded; +} + +const CIPHERTEXT_NOTICE = /ciphertext was preserved/; + +test("native V2 ciphertext survives routing and prompt overrides byte-for-byte", () => { + const message = FERNET_VECTOR; + const cwd = workspaceWithConfig({ + architect: { mode: "model", model: "architect-fixture", effort: "high", promptOverride: "Architect-only instructions" }, + }); + for (const tool_name of ["spawn_agent", "collaborationspawn_agent"]) { + const input = { task_name: "design", agent_type: "architect", fork_turns: "none", message }; + const payload = { ...JSON.parse(spawnPayloadAt(cwd, input)), tool_name }; + const output = runSpawnAttachHook(JSON.stringify(payload)); + const updated = updatedInputOf(output); + assert.deepEqual(updated, { ...input, model: "architect-fixture", reasoning_effort: "high" }); + assert.match(JSON.parse(output).hookSpecificOutput.additionalContext, /prompt overrides were not attached/); + } +}); + +test("native V2 ciphertext preserves explicit settings and full-history fork restrictions", () => { + const message = FERNET_VECTOR; + const cwd = workspaceWithConfig({ + architect: { mode: "model", model: "configured-fixture", effort: "high", promptOverride: "Architect-only instructions" }, + }); + for (const fields of [ + { fork_turns: "none", model: "caller-fixture", reasoning_effort: "low" }, + { fork_turns: "all" }, + ]) { + const input = { task_name: "design", agent_type: "architect", message, ...fields }; + const out = runSpawnAttachHook(spawnPayloadAt(cwd, input)); + assert.deepEqual(out ? updatedInputOf(out) : input, input); + } +}); + +test("native V2 ciphertext cannot bypass the existing recursion denial", () => { + const message = FERNET_VECTOR; + const result = JSON.parse(runSpawnAttachHook(subagentSpawnPayload({ task_name: "nested", message }))); + assert.equal(result.hookSpecificOutput.permissionDecision, "deny"); + assert.equal(result.hookSpecificOutput.updatedInput, undefined); +}); + +test("valid Fernet frames stay byte-identical across padding forms and block counts", () => { + const variants: [string, string][] = [ + ["reference vector (canonical padded)", FERNET_VECTOR], + ["reference vector (unpadded)", FERNET_VECTOR.slice(0, -2)], + ]; + // n=3 frames fill a whole base64 group, so their padded and unpadded wire + // forms coincide; both entries still exercise the accepted shape. + for (const blocks of [1, 2, 3]) { + const frame = fernetFrame({ ciphertextBytes: 16 * blocks }); + variants.push([`n=${blocks} padded`, fernetToken(frame)], [`n=${blocks} unpadded`, fernetToken(frame, false)]); + } + // A timestamp whose bytes do not encode the old gAAAA prefix must still be + // recognized: the classifier checks structure, not the prefix. + const noPrefix = fernetToken(fernetFrame({ timestamp: Buffer.alloc(8, 0xff) })); + assert.ok(!noPrefix.startsWith("gAAAA"), "fixture must actually lack the legacy prefix"); + variants.push(["non-gAAAA timestamp", noPrefix]); + for (const [label, message] of variants) { + const out = runSpawnAttachHook(spawnPayload({ task_name: "t", fork_turns: "none", message })); + const parsed = JSON.parse(out); + assert.equal(parsed.hookSpecificOutput.permissionDecision, "allow", label); + assert.equal(parsed.hookSpecificOutput.updatedInput.message, message, `${label}: byte-identical`); + assert.match(parsed.hookSpecificOutput.additionalContext ?? "", CIPHERTEXT_NOTICE, label); + } +}); + +test("malformed V2 ciphertext lookalikes keep the guard, affordance and configured prompt", () => { + const cwd = workspaceWithConfig({ + architect: { mode: "model", model: "architect-fixture", effort: "high", promptOverride: "Architect-only instructions" }, + }); + const core = FERNET_VECTOR.slice(0, -2); + const malformed: [string, string][] = [ + ["short gAAAA prefix", "gAAAAx"], + ["old e2e fixture", "gAAAAABopaque-payload"], + ["impossible base64 length", "gAAAA"], + ["old invalid fixture", `gAAAAAB${"aB9_".repeat(30)}==`], + ["wrong version byte", fernetToken(fernetFrame({ version: 0x81 }))], + ["truncated frame", FERNET_VECTOR.slice(0, 80)], + ["empty ciphertext", fernetToken(fernetFrame({ ciphertextBytes: 0 }))], + ["non-16-byte-block ciphertext", fernetToken(fernetFrame({ ciphertextBytes: 24 }))], + ["embedded whitespace", `${core.slice(0, 40)} ${core.slice(40)}==`], + ["standard-alphabet characters", FERNET_VECTOR.replaceAll("_", "/")], + ["partial padding", `${core}=`], + ["excess padding", `${FERNET_VECTOR}=`], + ["trailing newline", `${FERNET_VECTOR}\n`], + ["padding with carriage return", `${core}=\r`], + ["mid-string padding", `${core.slice(0, 20)}=${core.slice(20)}==`], + ["nonzero unused pad bits", `${core.slice(0, -1)}B==`], + ]; + for (const [label, message] of malformed) { + const out = runSpawnAttachHook(spawnPayloadAt(cwd, { task_name: "t", fork_turns: "none", agent_type: "architect", message })); + const parsed = JSON.parse(out); + assert.equal(parsed.hookSpecificOutput.permissionDecision, "allow", label); + const ui = parsed.hookSpecificOutput.updatedInput; + assert.ok((ui.message as string).startsWith(`${LEAF_GUARD_BLOCK}\n\n`), `${label}: guard attached`); + assert.ok((ui.message as string).includes("Architect-only instructions"), `${label}: prompt override attached`); + assert.ok((ui.message as string).includes(SKILL_AFFORDANCE_MARKER), `${label}: plaintext affordance attached`); + // Plaintext attachment already trims trailing whitespace; only ciphertext + // has the byte-for-byte preservation contract. + assert.ok((ui.message as string).includes(message.trimEnd()), `${label}: task text preserved`); + assert.doesNotMatch(parsed.hookSpecificOutput.additionalContext ?? "", CIPHERTEXT_NOTICE, label); + } +}); + +test("v1 items carrying a Fernet-shaped token stay on the normal attachment path", () => { + const out = runSpawnAttachHook(spawnPayload({ + agent_type: "explorer", + items: [ + { type: "text", text: FERNET_VECTOR }, + { type: "attachment", ref: "fixture-1" }, + ], + })); + const parsed = JSON.parse(out); + const ui = parsed.hookSpecificOutput.updatedInput; + assert.ok(Array.isArray(ui.items)); + const [first, second] = ui.items as Record[]; + assert.ok((first.text as string).startsWith(`${V1_SCOPE_BLOCK}\n\n`)); + assert.ok((first.text as string).includes(FERNET_VECTOR)); + assert.deepEqual(second, { type: "attachment", ref: "fixture-1" }); + assert.doesNotMatch(parsed.hookSpecificOutput.additionalContext ?? "", CIPHERTEXT_NOTICE); +}); + test("v1 spawns never get the affordance (upstream parses mentions there)", () => { const ui = updatedInputOf(runSpawnAttachHook(spawnPayload({ message: "no mentions here", agent_type: "explorer" }))); assert.ok(!(ui.message as string).includes(SKILL_AFFORDANCE_MARKER)); @@ -1174,4 +1319,3 @@ test("explicit executor and reviewer roles take precedence over message keywords assert.equal(inferRole("executor", "review the implementation"), "executor"); assert.equal(inferRole("reviewer", "inspect correctness"), "reviewer"); }); - diff --git a/plugins/codexclaw/skills/loop/references/waiting.md b/plugins/codexclaw/skills/loop/references/waiting.md index 534827cb..3afbfe3d 100644 --- a/plugins/codexclaw/skills/loop/references/waiting.md +++ b/plugins/codexclaw/skills/loop/references/waiting.md @@ -28,8 +28,67 @@ external processes inside a loop: See `cxc-pabcd` `references/delegation.md`. - Never end the turn just because a wait timed out — re-wait or poll, and keep the user informed each cycle. -- If a reviewer/worker has produced nothing after ~3 wait cycles, treat it as - a failed dispatch (DISPATCH-RETIRE-01) rather than waiting silently forever. - That retirement CONSUMES the DISPATCH-RETIRE-01 same-agent retry: go straight - to a fresh spawn with the failure folded into the new packet — the silent - agent does not get a second retry. + +## Progress, stagnation, failure, unobservable (LOOP-WAIT-EVIDENCE-01, DEFAULT) + +Wait count and elapsed time are not the retirement signal; evidence is. Before +retiring a dispatched agent, refresh task-scoped observations (VCS diff, owned +processes, recent output) and classify what you actually see: + +- **Progress** — new evidence advancing the packet: edits, findings, reads, + command events, delivered artifacts. A read-only reviewer produces findings, + not edits; never require a file change from one. Liveness alone — identical + heartbeats, repeated no-op reads or messages — is not semantic progress and + does not postpone reassessment forever. On V2, a wait reporting updates is + not the answer: obtain the separately delivered final message first. +- **Suspected stagnation** — comparable observations show no advancement. + Where supported, send one non-interrupting checkpoint asking for findings, + remaining work and the next artifact; a queued-but-unread checkpoint is not + proof of a stall. Compare new evidence with the prior observation at one + stated, task-appropriate next review point. That point fixes when you look + again; it is not a new cancellation budget, and repeated no-op activity does + not reset it. +- **Confirmed failure** — an actual terminal error, final output demonstrably + nonsensical or unusable for the task packet, or stagnation evidenced at the + stated review point. Record concrete output evidence for an output-failure + judgment; interim updates and supported disagreement alone are not failures. + A wait timeout alone is a normal outcome, and a healthy long command may emit + sparse output — inspect command state before treating silence as failure. + Missing edits alone do not prove a stall. +- **Unobservable** — available observations cannot establish progress or + failure; for example, child state is inaccessible and the only signals are + a clean tree and a checkpoint that may still be queued. Report the observation + gap and seek direction within authorized limits; never manufacture a failure + or an OCX error code for a stall. + +Explicit cancellation, actual terminal failures and stated user/host resource +limits outrank progress evidence; report cancellations and exhausted bounds as +what they are, separate from provider errors, and preserve the original error. + +## Retirement and handoff + +Retire on confirmed failure or an explicit cancellation/bound, not on a wait +count. Record the pre-stop reason and last meaningful activity; after the stop +call, verify the actual terminal state, owned processes and partial edits — a +returned *previous* status of `running` is not proof of termination. If +termination is unknown, start no overlapping writer. A finished child may still +hold a queued checkpoint response; reconcile its real status, and never treat a +checkpoint request as permission to duplicate its work. + +Recovery from confirmed failure follows the bounded lifecycle +(DISPATCH-RETIRE-01): at most one retry on the same handle, then a fresh spawn +with the failure folded into the new packet. When the configured +first-fallback protocol manages the dispatch its result owns the next step +instead — `ready` means claim the next attempt, `main-direct` means main +reclaims the work, and `reconcile`/`stop` authorize neither a replacement +spawn nor direct execution. Cancellation or an exhausted bound grants no +continuation: stop within authority and report the cancellation or bound, +never as a provider failure. + +For managed stagnation or unusable final output, report `outcome:task_failed` +with the matching `taskFailure.kind`, concrete `taskFailure.evidence`, the +recorded child ID, `executionState:stopped` and termination/partial-work +`reconciliation`. Follow the [report contract](../../pabcd/references/delegation.md#configured-first-fallback). +Provider errors use `outcome:failed`; do not invent a provider code for a task +failure or label cancellation or exhausted bounds as stagnation. Validate the +final work before reporting `outcome:complete`, which closes the dispatch. diff --git a/plugins/codexclaw/skills/pabcd/SKILL.md b/plugins/codexclaw/skills/pabcd/SKILL.md index 49675ddb..34e94918 100644 --- a/plugins/codexclaw/skills/pabcd/SKILL.md +++ b/plugins/codexclaw/skills/pabcd/SKILL.md @@ -69,7 +69,7 @@ link is a conditional routing edge, not a command to preload the entire graph. | Phase / trigger | Mandatory owner before work | |---|---| | I | cxc-interview; no active host goal | -| P, including plan-only | [Plan phase](references/phase-plan.md); C2+ plans also read [Plan output](references/plan-output.md) | +| P, including plan-only | [Plan phase](references/phase-plan.md): architect proposal → main executable plan → same-architect reflection; C2+ plans also read [Plan output](references/plan-output.md) for consultation evidence | | A, if authorized | [Audit phase](references/phase-audit.md) | | C | [Check phase](references/phase-check.md) | | P/A specifying render or conditional-path verification | [Check phase](references/phase-check.md), to define reachable activation and observable evidence | diff --git a/plugins/codexclaw/skills/pabcd/references/delegation.md b/plugins/codexclaw/skills/pabcd/references/delegation.md index 2d4e76cc..44d68959 100644 --- a/plugins/codexclaw/skills/pabcd/references/delegation.md +++ b/plugins/codexclaw/skills/pabcd/references/delegation.md @@ -97,6 +97,12 @@ reuse the same reviewer throughout the A loop. Before waiting on dispatched work, read the mode-neutral [Waiting on work](../../loop/references/waiting.md) rules in either HITL or HOTL. This route does not authorize an otherwise forbidden dispatch, wait, or mode transition. +A wait timeout is an observation outcome, not a verdict: classify progress, +suspected stagnation, confirmed failure and unavailable observation per that +reference before any retirement. A suspected-stall checkpoint uses +non-interrupting delivery where the family supports it — V1 `send_input` +without `interrupt`, V2 `send_message` — and a queued message is context the +child may not have read yet, never proof of a stall. ### Detect the family first (DISPATCH-SCHEMA-DETECT-01, STRICT) @@ -130,7 +136,7 @@ thread surface: a separate Codex task is not a bigger subagent. See |---|---| | spawn | `spawn_agent({ message \| items, model?, reasoning_effort?, fork_context? })` | | handle | returns `{ agent_id, nickname }`; address by `agent_id` | -| wait | `wait_agent({ targets[], timeout_ms })` returns final status that **may carry the final message**; a timeout is a normal outcome | +| wait | `wait_agent({ targets[], timeout_ms })` returns final status that **may carry the final message**; a timeout is a normal outcome, not failure evidence | | follow-up | `send_input({ target, message \| items, interrupt? })` | | stop | `close_agent({ target })`, returning the previous status | | restore | `resume_agent({ id })` | @@ -239,13 +245,21 @@ named decision revisions within ONE plan; a separate new plan starts a fresh con Do not promise cost savings from reuse. Use the host's supported follow-up and wait operations; an empty timed wait alone is not evidence of a failed call. -On an actual failed call, preserve the failure evidence and apply the existing -retirement rule: at most one retry on the same handle, then a fresh context carrying -the failure and plan. If a second distinct context also fails, main reclaims the -planning work under the existing lifecycle rule, but the missing architect consultation -remains unmet. Report the gap and stop dependent completion; main self-check does not -replace it. Do not silently switch models, register roles, or bypass host restrictions. -Explicit user limits still govern dispatch and completion scope. +On an actual failed call, preserve the failure evidence. With +[configured first fallback](#configured-first-fallback), the returned action governs +recovery: `ready` requires a new claim, only `main-direct` permits reclaim, and +`reconcile`/`stop` permit neither reclaim nor replacement. The unmanaged retry rule +below does not authorize extra calls on this path. + +Without managed dispatch, apply the existing retirement rule: at most one retry +on the same handle, then a fresh context carrying the failure and plan. If a second +distinct context also fails, main reclaims the planning work. Confirm prior work +has stopped and inspect partial results before retry, replacement or reclaim. + +In either path, a missing architect consultation remains unmet. Report the gap +and stop dependent completion; main self-check does not replace it. Do not silently +switch models, register roles, or bypass host restrictions. Explicit user limits +still govern dispatch and completion scope. ## Speculative dispatch (DISPATCH-SPECULATE-01, HEURISTIC) @@ -269,20 +283,41 @@ protocol. A PreToolUse reminder after a direct call cannot retroactively manage candidate's model/effort (null inherits the original session). Preserve the role. 3. Every report includes `sessionId`, `dispatchId`, and the current `attemptId`. Report `outcome:created` and the actual `agentId`, then use native wait. Report - `outcome:complete` with that ID on successful completion. Do not confuse a - successful spawn with successful work. -4. On failure report `outcome:failed`, the original `error`, and `executionState`: + `outcome:complete` with that ID only after validating the final work. A native + completed status does not prove the task succeeded; terminal reports cannot be reopened. +4. On provider failure report `outcome:failed`, the original `error`, and `executionState`: `not_created`, `stopped`, `unknown`, or `running`. Known no-child failures need concrete `reconciliation` evidence. A stopped child requires its recorded `agentId` and evidence that work/processes stopped and changes were inspected; - pass only remaining work to the replacement. Unknown outcomes never authorize + a stop call returning previous status `running` is not that evidence — verify + the current terminal state and owned processes first. Pass only remaining work + to the replacement. Unknown outcomes never authorize another child. If native spawn is absent, report `outcome:unavailable` with confirmed `not_created` and capability evidence, never a policy denial. + For confirmed stagnation or unusable final output, use `outcome:task_failed` + with `taskFailure: {kind: "stagnation" | "unusable_output", evidence: "..."}`. + This requires a recorded child, `executionState:stopped`, matching `agentId` + and `reconciliation`; running or unknown work must be reconciled first. + Task evidence explains the failure; reconciliation explains termination and + partial-work inspection. Both are non-empty text of at most 2000 characters. + No other task kinds or taskFailure keys are accepted. Never label cancellation, + exhausted bounds, a wait timeout alone or a supported disagreement as task failure. 5. `ready` means claim the next attempt. `main-direct` means main reclaims the remaining work; `independentReviewRequired` stays true for reviewer tasks. Main implementation is never independent review. `stop` or `reconcile` means no model switch or direct-execution permission. Inspect the reason and state. +A task-failure report has no provider `error`; for example: + +```json +{"action":"report","outcome":"task_failed","sessionId":"","dispatchId":"","attemptId":"","agentId":"","executionState":"stopped","taskFailure":{"kind":"unusable_output","evidence":"Final answer addresses a different task; the required result is absent."},"reconciliation":"Verified terminal child, no owned processes, and inspected partial edits."} +``` + +A supplied provider error retains precedence: stop errors stop and unknown errors +reconcile; next-eligible provider errors must use `outcome:failed` instead of a mixed +report. Accepted task failures record `taskFailure` and clear the attempt's provider +`code`. These observations are main's assertions, not authenticated native receipts. + Use `action:status` to recover after interruption. It never reissues an executable spawn. A claimed attempt with a lost response must be reconciled, not claimed again. Do not remove locks to make a retry work. If a lock survives a crashed diff --git a/plugins/codexclaw/skills/pabcd/references/phase-plan.md b/plugins/codexclaw/skills/pabcd/references/phase-plan.md index 28120fea..e84fcc2f 100644 --- a/plugins/codexclaw/skills/pabcd/references/phase-plan.md +++ b/plugins/codexclaw/skills/pabcd/references/phase-plan.md @@ -2,9 +2,12 @@ ## Architect consultation for formal P -Every formal P plan follows this sequence, including plan-only work that enters P. -The dev-owned C0/C1 fast path remains unchanged. Explicit user limits (such as -no delegation) take precedence; record any resulting consultation gap honestly. +Every formal P plan follows this sequence, including C2 compact plans and plan-only +P work; it is not limited to large or full plans. Applicability follows the planning +work, not whether the agent happened to write the required plan header. Explanation +or read-only assessment alone does not enter P. The dev-owned C0/C1 fast path needs +neither consultation nor a consultation record. Explicit user limits (such as no +delegation) take precedence; record any resulting consultation gap honestly. 1. Main gathers requirements and source evidence, identifies scope and existing owners. 2. Dispatch a read-only `architect` with `dev` and `dev-architecture` attached. @@ -17,7 +20,9 @@ no delegation) take precedence; record any resulting consultation gap honestly. 4. Send that concrete plan to the SAME architect for a reflection check. Require `ALIGNED` or `MISALIGNED`, a decision-ID-to-plan mapping and exact gaps. Resolve material gaps with recorded main dispositions before independent A audit. A missing - proposal or reflection check is not completed consultation. + proposal or reflection check is not completed consultation. Keep the actual + handle, proposal, main dispositions and reflection tied to the submitted plan + revision in the [Plan output consultation record](plan-output.md#architect-consultation). 5. Dispatch the independent A reviewer. Architect reflection never replaces A. Use the [delegation owner](delegation.md#architect-context-and-routing) for supported diff --git a/plugins/codexclaw/skills/pabcd/references/plan-output.md b/plugins/codexclaw/skills/pabcd/references/plan-output.md index d856c0ab..a0e5fe79 100644 --- a/plugins/codexclaw/skills/pabcd/references/plan-output.md +++ b/plugins/codexclaw/skills/pabcd/references/plan-output.md @@ -15,7 +15,7 @@ not silently remove a field. | Stop condition | When the authorized work ends; plan-only stops after returning the requested plan. | | Memory artifact | Where the plan/evidence is recorded. For a no-file request, name this returned plan rather than creating a file. Authorized persistent execution still follows the implementation-unit record rules. | | Expected terminal outcomes | What success, unresolved requirements or blocked execution would mean for this scope. Report outcomes are not new FSM phases or host goal statuses. | -| Escalation condition | What requires main/user direction. If delegation is planned, state both directions: main reclaims a slice after two distinct agents fail its packet (DISPATCH-RETIRE-01); pushing a slice to a worker requires a P-phase amendment, never a mid-B improvisation. | +| Escalation condition | What requires main/user direction. Delegation plans follow DISPATCH-RETIRE-01: without managed dispatch, main reclaims after two distinct agents fail the same packet, once prior work is stopped. With [configured first fallback](delegation.md#configured-first-fallback), the returned action governs recovery: only `main-direct` permits reclaim; `reconcile`/`stop` permit neither reclaim nor replacement. Pushing a slice to a worker requires a P-phase amendment, never a mid-B improvisation. | HOTL goal plans also state the cxc-loop resource bounds. Follow the live host goal-tool contract; do not invent a token or time budget that the user did not set. @@ -26,6 +26,27 @@ exit code or treat the plan as implementation proof. Likewise, naming a memory artifact or escalation path does not authorize a file write, host goal, dispatch, phase transition or external action. +## Architect consultation + +Formal P follows the [Plan phase owner](phase-plan.md#architect-consultation-for-formal-p), +including C2 compact and plan-only P plans. Keep a compact record in the existing +plan, using summaries and evidence references rather than copying conversation +transcripts: + +- The actual returned architect handle and proposal reference, with its design + decision IDs. +- Main's acceptance, rejection or amendment of each decision, with a short reason. +- The concrete executable-plan path/revision sent to that same architect. +- The reflection response reference, `ALIGNED` or `MISALIGNED`, and any remaining + gaps with main's dispositions. Resolve material gaps before independent A audit. + +An explicit user limit such as no delegation is a disclosed consultation gap. +A failed call or unavailable required role remains unmet consultation; recording +the reason does not make it complete. Follow the existing +[routing and failure owner](delegation.md#architect-context-and-routing). +C0/C1 fast-path work requires neither consultation nor this record. Main owns +the executable plan and final decisions; architect reflection is not A review. + ## Reader summary A C2+ unit's `000_plan.md` opens with a reader-facing summary per diff --git a/plugins/codexclaw/skills/search/SKILL.md b/plugins/codexclaw/skills/search/SKILL.md index b400133e..0489c71b 100644 --- a/plugins/codexclaw/skills/search/SKILL.md +++ b/plugins/codexclaw/skills/search/SKILL.md @@ -145,11 +145,13 @@ The shared payload form is a **link-form mention in the spawn message**. On V1 t child's first turn parses the mention and injects the full SKILL.md body. When a V2-shaped spawn message reaches the codexclaw hook as plaintext (non-encrypted provider/proxy paths), the hook recognizes the same mention and inlines the full body. -Native ChatGPT-backend V2 sends the hook ciphertext, so mention normalization and body -inlining are no-ops there; when no body can be inlined, a plaintext -`[CXC-SKILL-AFFORDANCE]` block tells the child to self-load any `$cxc-` / -`$codexclaw:cxc-` mention from `//SKILL.md`; fork inheritance -remains a secondary channel. If the path is not link-safe, use the plugin-native +Plaintext V2 without an inlined body receives a `[CXC-SKILL-AFFORDANCE]` block +asking the child to self-load mentions from `//SKILL.md`. +Native ChatGPT-backend V2 sends ciphertext with a Fernet envelope. The hook +preserves structurally recognized ciphertext byte-for-byte and tells the caller +that hook-added skill text, scope instructions and prompt overrides were omitted; +it cannot attach the plaintext affordance to encrypted task bytes. Native recursion +checks and separate model/effort routing still apply. If the path is not link-safe, use the plugin-native `$codexclaw:cxc-search` fallback instead: ```text diff --git a/plugins/codexclaw/test/hook-e2e.test.mjs b/plugins/codexclaw/test/hook-e2e.test.mjs index 9cb8b11e..ac4ac328 100644 --- a/plugins/codexclaw/test/hook-e2e.test.mjs +++ b/plugins/codexclaw/test/hook-e2e.test.mjs @@ -802,9 +802,13 @@ test("260710: spawn hook e2e - native collaboration name drives the V2 path", () } }); -// WP2 cr3: an opaque (ciphertext-like) V2 message that inlines nothing gains the -// plaintext self-load affordance block, after the task text, under the guard. -test("260710 WP2: spawn hook e2e - opaque V2 message gains the skill affordance", () => { +// The shipped entrypoint preserves native ciphertext and augments plaintext. +for (const [name, message, encrypted] of [ + // Public Fernet generate.json vector; no live task or key material. + ["ciphertext stays byte-identical", "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==", true], + ["plaintext gains the skill affordance", "Inspect the catalog module.", false], + ["short ciphertext lookalike gains the skill affordance", "gAAAAx", false], +]) test(`spawn hook e2e - V2 ${name}`, () => { const { hookEvent, distAbs } = readHookCommand("./hooks/pre-tool-use-attaching-skills.json"); const ep = snapshotEntrypoint(distAbs); assert.ok(ep, "subagent-config dist entrypoint must settle"); @@ -813,13 +817,21 @@ test("260710 WP2: spawn hook e2e - opaque V2 message gains the skill affordance" const res = runHook(ep, hookEvent, { hook_event_name: "PreToolUse", session_id: "s1", cwd, tool_name: "collaborationspawn_agent", - tool_input: { task_name: "t", fork_turns: "none", message: "gAAAAABopaque-payload" }, + tool_input: { task_name: "t", fork_turns: "none", message }, }, { CXC_SKILLS_DIR: join(pluginRoot, "skills") }); assert.equal(res.status, 0, res.stderr); - const ui = JSON.parse(res.stdout).hookSpecificOutput.updatedInput; + const output = JSON.parse(res.stdout).hookSpecificOutput; + const ui = output.updatedInput; + if (encrypted) { + assert.equal(ui.message, message); + assert.equal(ui.task_name, "t"); + assert.equal(ui.fork_turns, "none"); + assert.match(output.additionalContext, /prompt overrides were not attached/); + return; + } assert.ok(ui.message.startsWith("[CXC-LEAF-GUARD]")); assert.match(ui.message, /\[CXC-SKILL-AFFORDANCE\]/); - assert.ok(ui.message.indexOf("gAAAAABopaque-payload") < ui.message.indexOf("[CXC-SKILL-AFFORDANCE]")); + assert.ok(ui.message.indexOf(message) < ui.message.indexOf("[CXC-SKILL-AFFORDANCE]")); assert.match(ui.message, /skills\/\/SKILL\.md/); } finally { rmSync(cwd, { recursive: true, force: true }); @@ -884,7 +896,7 @@ test("260713: spawn hook e2e - snapshot override composes mention repair with th const v2Guard = runHook(ep, hookEvent, { hook_event_name: "PreToolUse", session_id: "s1", cwd: configuredCwd, tool_name: "spawn_agent", - tool_input: { task_name: "child_task", fork_turns: "none", message: "$cxc-dev map the codebase" }, + tool_input: { task_name: "child_task", agent_type: "explorer", fork_turns: "none", message: "$cxc-dev map the codebase" }, }, skillsEnv); assert.equal(v2Guard.status, 0, v2Guard.stderr); const v2Ui = JSON.parse(v2Guard.stdout).hookSpecificOutput.updatedInput; diff --git a/structure/10_subagent_skill_routing.md b/structure/10_subagent_skill_routing.md index ed1bff17..071ac933 100644 --- a/structure/10_subagent_skill_routing.md +++ b/structure/10_subagent_skill_routing.md @@ -6,7 +6,7 @@ aliases: [L14 Design, subagent skill routing, cxc skill attachment] # L14 — Subagent Skill Routing + Loop/Goal Handoff (Design SOT) -Status: DESIGN + SHIPPED — **spawn surface status 260710: V1 is codexclaw's default; the model catalog pins sol/terra to V2 and luna to V1, while `features.multi_agent_v2` selects V2 only for fallback models. The surface pins on the first turn.** V2 requires task_name+message and rejects `items`; production builders emit `fork_turns:"none"` plus resolvable message mentions. Historical shape below documents the v1-era evolution. (E5 dispatch builder shipped in L15; lazygap_impl 020 added `INTENT_ROLE`/`routeDispatch` and the E3 spawn PreToolUse hook. When the spawn message is plaintext, the hook normalizes recognized mentions and inlines full SKILL.md bodies on V2-shaped spawns. Native ChatGPT-backend V2 presents ciphertext, so the hook appends a plaintext `[CXC-SKILL-AFFORDANCE]` self-load instruction when it cannot inline a body; the leaf guard and model+effort injection remain reliable. It never invents role baselines or missing surfaces. The old `CODEXCLAW_SPAWN_ATTACH=v1` opt-in is gone; V1 `items` remains a manual, strongest channel.) · 2026-07-10 +Status: DESIGN + SHIPPED — **spawn surface status 260710: V1 is codexclaw's default; the model catalog pins sol/terra to V2 and luna to V1, while `features.multi_agent_v2` selects V2 only for fallback models. The surface pins on the first turn.** V2 requires task_name+message and rejects `items`; production builders emit `fork_turns:"none"` plus resolvable message mentions. Historical shape below documents the v1-era evolution. (E5 dispatch builder shipped in L15; lazygap_impl 020 added `INTENT_ROLE`/`routeDispatch` and the E3 spawn PreToolUse hook. When the spawn message is plaintext, the hook normalizes recognized mentions and inlines full SKILL.md bodies on V2-shaped spawns. Native ChatGPT-backend V2 presents ciphertext; the hook checks the Fernet envelope structure and preserves recognized ciphertext byte-for-byte, disclosing omitted hook text to the caller. Only plaintext V2 receives the leaf guard and self-load affordance; metadata-based recursion denial and separate model+effort routing remain active. It never invents role baselines or missing surfaces. The old `CODEXCLAW_SPAWN_ATTACH=v1` opt-in is gone; V1 `items` remains a manual, strongest channel.) · 2026-07-10 > This is the design source of truth for the L14 hardening track. The defect > diagnosis with file:line evidence lives in @@ -97,12 +97,14 @@ are therefore the production shared channel, but their delivery differs: V1 turn spawn message into `UserInput::Text` and parses link/plugin mentions natively; V2 sends `InterAgentCommunication`, which upstream excludes from skill collection. The codexclaw spawn hook compensates only when the V2 message reaches it as plaintext by inlining full -SKILL.md bodies for recognized cxc mentions. Native ChatGPT-backend V2 presents encrypted -ciphertext, so normalization and inlining are safe no-ops there. When no body can be -inlined, the hook appends a plaintext `[CXC-SKILL-AFFORDANCE]` block telling the child to -self-load any `$cxc-` / `$codexclaw:cxc-` mention by reading -`//SKILL.md`; fork inheritance remains a secondary channel. The native -V2 hook also reliably prepends the leaf guard and injects configured model/effort fields. +SKILL.md bodies for recognized cxc mentions. Plaintext V2 without an inlined body +receives a `[CXC-SKILL-AFFORDANCE]` block asking the child to read +`//SKILL.md`. Native ChatGPT-backend V2 presents ciphertext; +the hook recognizes canonical base64url Fernet envelope structure, preserves those +bytes, and discloses that hook-added skill text, scope instructions and prompt +overrides were omitted. This is structural recognition, not authentication. +Malformed prefix-like plaintext still receives normal attachment. Metadata-based +recursion denial and separate configured model/effort routing remain active. ### L15.2 follow-up (SHIPPED as WP2, E3 — mention normalization) When `message` is plaintext, the spawn PreToolUse hook scans it for known cxc mentions, diff --git a/structure/20_pabcd_dispatch_doctrine.md b/structure/20_pabcd_dispatch_doctrine.md index ef76fcbb..fa707bb4 100644 --- a/structure/20_pabcd_dispatch_doctrine.md +++ b/structure/20_pabcd_dispatch_doctrine.md @@ -139,9 +139,13 @@ codexclaw translation: lane first — it is almost always a read-only packet on a `worker`. - **Architect consultation in formal P.** Main evidence -> architect proposal -> main executable plan -> same architect reflection -> independent A reviewer. Main retains - every final decision. Recheck only named module/data/interface/flow decision changes; + every final decision. Formal P includes C2 compact and plan-only P plans; C0/C1 + fast-path work needs neither consultation nor a consultation record. Plan output + records the actual handle, proposal, main dispositions and reflection against the + submitted plan revision. P hook/CLI hints expose the sequence before planning. + Recheck only named module/data/interface/flow decision changes; reuse context within one plan and start fresh for a new plan. Existing installed - owners: `pabcd/references/phase-plan.md`, `phase-audit.md` and `delegation.md`. + owners: `pabcd/references/phase-plan.md`, `plan-output.md`, `phase-audit.md` and `delegation.md`. This is E7 guidance, not a new phase or runtime consultation gate. Missing consultation is not complete; architect reflection never substitutes for independent A review. - **Audit (A) is never skipped.** Before B, the main session must dispatch an independent @@ -179,17 +183,26 @@ codexclaw translation: `agents.max_threads` (default 6) and V2 `max_concurrent_threads_per_session` (default 4, including the root). - **DISPATCH-RETIRE-01 (fresh-spawn fallback).** This is the exception to the reuse - default above: an agent id that failed (error, timeout, unresponsive, nonsense - output) is retired, not nursed. At most ONE retry against the same task_name; then + default above: an agent id that failed is retired, not nursed — where failure + means an actual terminal error, nonsense output, or stagnation evidenced per + the waiting reference's evidence rule + (`plugins/codexclaw/skills/loop/references/waiting.md`). A bare wait timeout is + a normal outcome, not a failure. Explicit cancellation or an exhausted + user/host bound stops within authority and is reported as such; it is not a + failure and grants no retry, replacement, or reclaim. At most ONE retry against the + same task_name; then abandon it (V2 has only `interrupt_agent`; V1 has `close_agent` and `resume_agent`) and fresh-spawn with the failure summary folded into the new TASK packet. Repeated `followup_task`/`send_message` against a broken agent is a broken-resume loop — the dispatch analogue of LOOP-REPAIR-01's doom loop. When the fresh spawn — a SECOND distinct agent — also fails the SAME task packet, stop blaming agents: two independent failures on one packet are evidence the packet - itself failed the DISPATCH-ECONOMY-01 specifiability bar. The main session + itself failed the DISPATCH-ECONOMY-01 specifiability bar. Absent a managed + dispatch, the main session reclaims that slice and does the work directly instead of dispatching a third - copy (packet-failure reclaim, 260711 fork-debate verdict #4). Lineage: + copy (packet-failure reclaim, 260711 fork-debate verdict #4); under configured + first fallback the protocol result owns that decision — `main-direct` permits + reclaim, `reconcile`/`stop` permit neither reclaim nor a replacement spawn. Lineage: `../jawcode/devlog/_plan/260616_actor_fresh_fallback/_fin/00_moc.md` (implementation-verified). Both rules are E7 doctrine (agent-followed); no hook observes agent lifecycles. @@ -267,11 +280,15 @@ channel (`buildSpawnItems`/`SpawnPayload.items`). Prefer `[$cxc-](skill:// plugin-native `$codexclaw:cxc-` when a link is unsafe. The WP2 E3 spawn PreToolUse hook normalizes known broken/bare cxc mentions and inlines recognized skill bodies on V2-shaped spawns only when `message` reaches it as plaintext. Native -ChatGPT-backend V2 presents ciphertext, so both operations are no-ops there. When no body -can be inlined, the hook appends a plaintext `[CXC-SKILL-AFFORDANCE]` block telling the +ChatGPT-backend V2 can present ciphertext. The hook preserves that message byte-for-byte: +putting plaintext inside the encrypted slot makes the backend reject the child task. +It reports omitted hook-added instructions to the caller; this is not skill delivery. +Metadata-based recursion denial and separate model/effort routing still apply. +On plaintext V2, when no body can be inlined, the hook appends a +`[CXC-SKILL-AFFORDANCE]` block telling the child to self-load any `$cxc-` / `$codexclaw:cxc-` mention from `//SKILL.md`; fork inheritance remains a secondary channel. The native -V2 hook also carries the leaf guard and configured model/effort injection; it does not add +V2 plaintext hook also carries the leaf guard; it does not add role baselines or infer surface skills. Dispatchers remain responsible for naming every required skill (DISPATCH-TASK-01). diff --git a/structure/INDEX.md b/structure/INDEX.md index 0b175db8..49beaa98 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -199,7 +199,7 @@ The manifest wires 22 hook JSON files; `plugin.json` `hooks` and `hooks/*.json` | `PreToolUse` `^request_user_input$` | `hooks/pre-tool-use-guarding-interview-in-goal.json` | same pabcd-state CLI | denies user-input/interview tool use while native goal mode is active or unreadable | | `PostToolUse` `^request_user_input$` | `hooks/post-tool-use-capturing-interview-answers.json` | same pabcd-state CLI | captures interview question/answer events to the ledger; in an interactive I-phase also reinjects the Mind-rescan directive as `additionalContext` (L18); never blocks | | `SubagentStop` `^worker$` | `hooks/subagent-stop-verifying-evidence.json` | same pabcd-state CLI | verifies worker evidence expectations on subagent stop | -| `PreToolUse` spawn/collaboration variants | `hooks/pre-tool-use-attaching-skills.json` | `node "${PLUGIN_ROOT}/components/subagent-config/dist/spawn-attach-hook.js" hook pre-tool-use` | normalizes cxc mentions and inlines recognized skill bodies on plaintext V2-shaped spawns; when no body can be inlined (including encrypted native V2), appends a plaintext `[CXC-SKILL-AFFORDANCE]` child self-load instruction; also applies model+effort routing plus leaf guards on both surfaces and never adds missing skills | +| `PreToolUse` spawn/collaboration variants | `hooks/pre-tool-use-attaching-skills.json` | `node "${PLUGIN_ROOT}/components/subagent-config/dist/spawn-attach-hook.js" hook pre-tool-use` | normalizes mentions, inlines recognized skills and adds scope instructions on plaintext; plaintext V2 without inlined bodies gets a self-load affordance. Native V2 ciphertext stays byte-identical and omitted hook text is disclosed to the caller. Metadata-based recursion denial and separate model+effort routing remain active | | `PostCompact` | `hooks/post-compact-resetting-reinject-cursor.json` | `node "${PLUGIN_ROOT}/components/pabcd-state/dist/cli.js" hook post-compact` | resets reinjection cursor/stage context after compaction | | `PreToolUse` `^(apply_patch|Write|Edit)$` | `hooks/pre-tool-use-linting-apply-patch.json` | `node "${PLUGIN_ROOT}/components/pabcd-state/dist/cli.js" hook pre-tool-use-edit` | combined edit path: comment lint (deny-capable) then IDLE-edit arming advisory | | `PostToolUse` `^(view_image|browser:control-in-app-browser|chrome:control-chrome|computer-use:computer-use|apply_patch)$` | `hooks/post-tool-use-tracking-render-observations.json` | `node "${PLUGIN_ROOT}/components/pabcd-state/dist/cli.js" hook post-tool-use-render-observation` | tracks render/visual observation events for QA evidence | @@ -265,6 +265,7 @@ though they have no package-local `test` script. This asymmetry is intentional, | `cxc loop` | `components/pabcd-state/dist/cli.js loop` | initializes, shows, or validates the project-local goalplan substrate | | `cxc goalplan` | `components/pabcd-state/dist/cli.js goalplan` | deprecated alias for `cxc loop` | | `cxc subagents` | `components/subagent-config/dist/cli.js` (list/get/set) | reads/writes the per-role `.codexclaw/subagents.json` model+effort+prompt config | +| `cxc subagents dispatch` | `components/subagent-config/dist/fallback-dispatch-cli.js` | records main-owned claims and outcomes; provider failures and explicit `task_failed` evidence use bounded handoff after child reconciliation; never invokes a model itself | | `cxc provider` | `components/provider-bridge/dist/cli.js` (detect) | read-only ocx provider detect/status; never mutates provider state | | `cxc serve` | `components/messenger-bridge/dist/cli.js serve` | runs the loopback bridge server for the GUI, JSON API, and messenger adapters | | `cxc service` | `components/messenger-bridge/dist/cli.js service` | installs, uninstalls, or reports the macOS launchd daemon for `cxc serve` | @@ -323,7 +324,7 @@ Subagent role TOMLs live under `plugins/codexclaw/agents/`: `explorer`, `reviewe | `executor` | `worker` | scoped yes | bounded implementation inside an assigned write scope | | `architect` | `architect` | no | design proposals and executable-plan reflection; main owns decisions | -The subagent config component selects per-role model, effort and prompt overrides; default mode inherits the main Codex model. Architect attaches the existing `dev` and `dev-architecture` skills. Formal P and changed-decision rechecks follow `skills/pabcd/references/phase-plan.md`, `phase-audit.md` and `delegation.md`; these are agent-followed instructions, not new runtime enforcement. +The subagent config component selects per-role model, effort and prompt overrides; default mode inherits the main Codex model. Architect attaches the existing `dev` and `dev-architecture` skills. Formal P (including compact C2 and plan-only P plans) and changed-decision rechecks follow `skills/pabcd/references/phase-plan.md`, `phase-audit.md` and `delegation.md`; `plan-output.md` records the actual consultation and same-architect reflection against the executable-plan revision. P hook/CLI hints expose that sequence. These are agent-followed instructions, not new runtime enforcement. ---