@yunhozz/arc 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/MANIFEST.sha256 +88 -78
  2. package/claude/agents/arc-implementer.md +8 -23
  3. package/claude/agents/arc-planner.md +14 -23
  4. package/claude/agents/arc-qa.md +9 -19
  5. package/claude/agents/arc-reviewer.md +11 -20
  6. package/claude/agents/arc-scouter.md +10 -17
  7. package/claude/skill/SKILL.md +9 -9
  8. package/claude/skill/models.toml +11 -11
  9. package/claude/skill/references/arc-visual-companion.md +3 -3
  10. package/claude/skill/references/brainstorming.md +153 -62
  11. package/claude/skill/references/commit.md +6 -6
  12. package/claude/skill/references/coordination.md +20 -8
  13. package/claude/skill/references/git-recovery.md +2 -2
  14. package/claude/skill/references/handoff-prep.md +7 -5
  15. package/claude/skill/references/handoff.md +62 -9
  16. package/claude/skill/references/limits.md +5 -5
  17. package/claude/skill/references/planning.md +22 -16
  18. package/claude/skill/references/scripts/start-server.sh +7 -0
  19. package/claude/skill/references/superpowers-provenance.json +4 -4
  20. package/claude/skill/references/verification.md +6 -4
  21. package/claude/skill/runtime/bootstrap.mjs +2 -2
  22. package/claude/skill/runtime/cooperative-schema.mjs +32 -1
  23. package/claude/skill/runtime/cooperative.mjs +68 -13
  24. package/claude/skill/runtime/evaluate.mjs +85 -6
  25. package/claude/skill/runtime/git-evidence.mjs +98 -0
  26. package/claude/skill/runtime/operations.mjs +2 -2
  27. package/claude/skill/runtime/role-policy.json +6 -6
  28. package/claude/skill/runtime/store.mjs +10 -0
  29. package/claude/skill/runtime/target-preparation.mjs +110 -0
  30. package/claude/skill/runtime/verification.mjs +6 -4
  31. package/claude/skill/scripts/model_config.py +17 -5
  32. package/codex/agents/arc-implementer.toml +10 -25
  33. package/codex/agents/arc-planner.toml +12 -19
  34. package/codex/agents/arc-qa.toml +7 -15
  35. package/codex/agents/arc-reviewer.toml +9 -16
  36. package/codex/agents/arc-scouter.toml +8 -13
  37. package/codex/skill/SKILL.md +9 -9
  38. package/codex/skill/models.toml +13 -13
  39. package/codex/skill/references/arc-visual-companion.md +3 -3
  40. package/codex/skill/references/brainstorming.md +153 -62
  41. package/codex/skill/references/commit.md +6 -6
  42. package/codex/skill/references/coordination.md +20 -8
  43. package/codex/skill/references/git-recovery.md +2 -2
  44. package/codex/skill/references/handoff-prep.md +5 -5
  45. package/codex/skill/references/handoff-worktree.md +4 -4
  46. package/codex/skill/references/handoff.md +61 -8
  47. package/codex/skill/references/limits.md +5 -5
  48. package/codex/skill/references/planning.md +23 -17
  49. package/codex/skill/references/scripts/start-server.sh +7 -0
  50. package/codex/skill/references/superpowers-provenance.json +4 -4
  51. package/codex/skill/references/verification.md +6 -4
  52. package/codex/skill/runtime/bootstrap.mjs +2 -2
  53. package/codex/skill/runtime/cooperative-schema.mjs +32 -1
  54. package/codex/skill/runtime/cooperative.mjs +68 -13
  55. package/codex/skill/runtime/evaluate.mjs +85 -6
  56. package/codex/skill/runtime/git-evidence.mjs +98 -0
  57. package/codex/skill/runtime/operations.mjs +2 -2
  58. package/codex/skill/runtime/role-policy.json +6 -6
  59. package/codex/skill/runtime/store.mjs +10 -0
  60. package/codex/skill/runtime/target-preparation.mjs +110 -0
  61. package/codex/skill/runtime/verification.mjs +6 -4
  62. package/codex/skill/scripts/model_config.py +25 -6
  63. package/lib/installer/global-config.mjs +1 -1
  64. package/lib/installer/manifest.mjs +6 -2
  65. package/lib/installer/plan.mjs +8 -5
  66. package/lib/installer/preset.mjs +7 -4
  67. package/lib/installer/runtime-assets.mjs +1 -0
  68. package/lib/management/operations.mjs +1 -1
  69. package/lib/models/catalog-anthropic.mjs +46 -0
  70. package/lib/models/catalog.mjs +62 -0
  71. package/lib/models/codex-catalog.mjs +103 -0
  72. package/lib/models/config.mjs +19 -11
  73. package/lib/models/index.mjs +1 -1
  74. package/lib/policy/role-policy.json +6 -6
  75. package/lib/prompts/agent-fragments.json +254 -0
  76. package/lib/prompts/render.mjs +199 -0
  77. package/lib/runtime/bootstrap.mjs +2 -2
  78. package/lib/runtime/cooperative-schema.mjs +32 -1
  79. package/lib/runtime/cooperative.mjs +68 -13
  80. package/lib/runtime/evaluate.mjs +85 -6
  81. package/lib/runtime/git-evidence.mjs +98 -0
  82. package/lib/runtime/operations.mjs +2 -2
  83. package/lib/runtime/store.mjs +10 -0
  84. package/lib/runtime/target-preparation.mjs +110 -0
  85. package/lib/runtime/verification.mjs +6 -4
  86. package/lib/tui/app.mjs +15 -9
  87. package/lib/tui/custom-preset.mjs +122 -0
  88. package/lib/tui/render.mjs +3 -2
  89. package/lib/tui/setup.mjs +65 -8
  90. package/package.json +1 -1
@@ -114,6 +114,13 @@ umask 077
114
114
  SESSION_ID="$$-$(date +%s)"
115
115
 
116
116
  if [[ -n "$PROJECT_DIR" ]]; then
117
+ # Make the project dir absolute now: the script cds into its own directory
118
+ # before starting the server, which would re-root a relative path there.
119
+ # mkdir -p keeps the existing behavior of creating a missing project dir.
120
+ if ! PROJECT_DIR="$(mkdir -p -- "$PROJECT_DIR" && CDPATH='' cd -- "$PROJECT_DIR" && pwd)"; then
121
+ echo "{\"error\": \"Cannot create or enter --project-dir\"}"
122
+ exit 1
123
+ fi
117
124
  SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
118
125
  # Persist the bound port and key per project so a restart reuses them and an
119
126
  # already-open browser tab reconnects to the same URL with a valid cookie.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "repository": "https://github.com/obra/superpowers",
4
- "revision": "bb92a77741419a4ab5f06e711a283343f1ada0c3",
5
- "fetchedAt": "2026-10-17T07:50:01.077Z",
4
+ "revision": "22f0b463e7a5e579904dd6211a520321035526f7",
5
+ "fetchedAt": "2026-11-06T16:48:59.704Z",
6
6
  "skills": [
7
7
  "brainstorming",
8
8
  "writing-plans",
@@ -10,12 +10,12 @@
10
10
  ],
11
11
  "files": {
12
12
  "LICENSE": "a37e0e9697144819e1d965176ac4ae5bc3fa02d11e7812036bbcadf6dafe2400",
13
- "brainstorming/SKILL.md": "6ee10d1481b8a2580d5336ff66c5c08acc9d4b251b13bbc3bfd31be99c261d43",
13
+ "brainstorming/SKILL.md": "2c9ca71f94886f9ca9f4aef055f7cafb3e1a9bdf3aeebf8f4cb547c69405d207",
14
14
  "brainstorming/builder-check-prompt.md": "36d51a4723d6f0a3ce5811a0ea3b3a8424f07533947e1355f5d8cb8cce40c55e",
15
15
  "brainstorming/scripts/frame-template.html": "6a8a4e58bd6a44b904e2e3c57de774481d909204597e1498de53f1b2fecc4c4e",
16
16
  "brainstorming/scripts/helper.js": "43c6d69954a46ec34a2a262bcc62a9a7e83e839c739f199cb72646d397c686e3",
17
17
  "brainstorming/scripts/server.cjs": "763abf96510e0b892a1141397b31aa82ef900cf87ed34dde94bc8782baf4a7ca",
18
- "brainstorming/scripts/start-server.sh": "a4e5ae84275bcaacd2f84345afeabe59cf7b00ba080e123da7cc1fb226f12847",
18
+ "brainstorming/scripts/start-server.sh": "314dd68dc00e8dd94c7491aca8a5f004c7fddffc161e2b07482f29f48d13659a",
19
19
  "brainstorming/scripts/stop-server.sh": "0b5ccbbd57f62d3ed88993f7940b5ee0e5c0fc9b21c550c623da4f6292e47daf",
20
20
  "brainstorming/visual-companion.md": "6ffcb038b53254bd31debaa15e12dcd4ff24bd63d690e0b644ac6396701cf2e2",
21
21
  "writing-plans/SKILL.md": "a6c67c1900064347c2a329990dd3c555657c51c3ec53b259a08aa01a2c26139a",
@@ -10,14 +10,16 @@ Read before verification design, QA/Reviewer assignment, correction rechecks or
10
10
  <a id="visual-evidence-and-adjudication"></a>
11
11
  ## Visual evidence and adjudication
12
12
 
13
- Each criterion needs exact approved source/version/approval/access, IDs/properties/method and output-revision linkage. Implementer reporting does not replace fresh QA. Record pass/mismatch/unverified and connect to criterion verdicts; pass needs accessible supporting evidence. Missing linkage is unverified; visible difference is mismatch, separately, without inferring current branch UI. Inaccessible materials/missing interpretation approval hold comparison.
13
+ Each criterion needs the exact approved source, version, approval and access state; IDs, properties and comparison method; and a link between output and revision. Implementer reporting does not replace fresh QA. Record pass/mismatch/unverified and connect to criterion verdicts; pass needs accessible supporting evidence. Missing linkage is unverified; visible difference is mismatch, separately, without inferring current branch UI. Hold comparison when materials are inaccessible or interpretation approval is missing.
14
14
 
15
- Policy/generated/manifest checks prove structure, not role behavior, installed builds, Claude Code CLI compliance or fidelity. Report changes/exclusions/run-omitted checks/criterion evidence-limits/platform availability. Separate baseline failures; independent progress relaxes no checks.
15
+ Policy, generated-file and manifest checks prove structure, not role behavior, installed builds, Claude Code CLI compliance or fidelity. Report changes, exclusions, checks run and omitted, criterion evidence and limits, and platform availability. Report baseline failures separately; independent progress does not relax checks.
16
16
 
17
17
  <a id="risk-scaled-execution"></a>
18
18
  ## Risk-scaled execution
19
19
 
20
- Edits belong to Implementer except the [small direct path](handoff.md#orchestrator-direct-path-for-small-tasks). Orchestrator determines behavior/risk/minimum meaningful checks.
20
+ The [fast gate](handoff.md#orchestrator-direct-path-for-small-tasks) references this risk definition; refresh affected evidence when inputs change and preserve independent verification boundaries.
21
+
22
+ Edits belong to Implementer except the [small direct path](handoff.md#orchestrator-direct-path-for-small-tasks). Orchestrator determines behavior, risk and minimum meaningful checks.
21
23
 
22
24
  | Risk | Minimum flow |
23
25
  |---|---|
@@ -36,7 +38,7 @@ Ordinary/significant tasks need fresh QA before acceptance. Low risk requires na
36
38
 
37
39
  Record which risk/instruction/approved obligations each check satisfies. Do not automatically assign QA/Reviewer for clear low-risk scope with sufficient evidence and no requirement. Metadata counts alone prove no significance; one permission/loss rule may. Vague file deletion is not low-risk evidence; reclassify installer/manifest impacts. Confirm cleanup targets unused, required materials intact, ignore patterns scoped and diff approved before focused-check-only acceptance.
38
40
 
39
- Separate environment/code failure and report exact limits/gaps. Use Planner/Scouter only for unresolved planning/discovery. Reviewer covers substantial correctness/factual/security/data/interface risks. Orchestrator assigns support as needed. Claim only verified outputs. Read cited `references/test-driven-development.md`, preserving exceptions.
41
+ Distinguish environment failures from code failures and report exact limits and gaps. Use Planner/Scouter only for unresolved planning/discovery. Reviewer covers substantial correctness/factual/security/data/interface risks. Orchestrator assigns support as needed. Claim only verified outputs. Read cited `references/test-driven-development.md`, preserving exceptions.
40
42
 
41
43
  <a id="final-plan-reviewer-correction-loop"></a>
42
44
  ### Final plan Reviewer correction loop
@@ -6,7 +6,7 @@ import {observeCooperativeTarget,validateObservedTarget,validateWorkflowId,workf
6
6
  import {saveDurableState} from './store.mjs';
7
7
  export const COOPERATIVE_RUNTIME_MODULES=Object.freeze([
8
8
  'cli.mjs','command.mjs','bootstrap.mjs','cooperative-schema.mjs','cooperative.mjs','evaluate.mjs',
9
- 'scope.mjs','store.mjs','git-evidence.mjs','assignment.mjs','verification.mjs','acceptance.mjs','external.mjs','schema.mjs','operations.mjs','gate.mjs','adapters/codex.mjs','adapters/claude.mjs','capabilities.mjs','identity.mjs','git-query.mjs'
9
+ 'scope.mjs','store.mjs','git-evidence.mjs','assignment.mjs','verification.mjs','acceptance.mjs','external.mjs','schema.mjs','operations.mjs','gate.mjs','adapters/codex.mjs','adapters/claude.mjs','capabilities.mjs','identity.mjs','git-query.mjs','target-preparation.mjs'
10
10
  ]);
11
11
  export const cooperativeCoverage=()=>({enforcement:'cooperative',provenance:'operator-attested',nativeVerified:false});
12
12
  export function commandResult({workflowId,state,input,decision='allow',ruleId='cooperative.recorded',persisted=false,...extra}){
@@ -16,7 +16,7 @@ export async function cooperativeVersions(){
16
16
  const hash=bytes=>'sha256:'+crypto.createHash('sha256').update(bytes).digest('hex');
17
17
  let policy;try{policy=await fs.readFile(new URL('../policy/role-policy.json',import.meta.url));}catch(error){if(error.code!=='ENOENT')throw error;policy=await fs.readFile(new URL('./role-policy.json',import.meta.url));}
18
18
  const runtime=await Promise.all(COOPERATIVE_RUNTIME_MODULES.map(file=>fs.readFile(new URL(file,import.meta.url))));
19
- return {policy:'1',runtime:'cooperative-4',policyHash:hash(policy),runtimeHash:hash(Buffer.concat(runtime))};
19
+ return {policy:'1',runtime:'cooperative-6',policyHash:hash(policy),runtimeHash:hash(Buffer.concat(runtime))};
20
20
  }
21
21
  export async function initializeCooperativeWorkflow({cwd,workflowId,input,observe=observeCooperativeTarget,storage={}}){
22
22
  validateWorkflowId(workflowId);validateCooperativeCommand({command:'init',input});
@@ -2,6 +2,8 @@ import {ROLES} from './operations.mjs';
2
2
  import {assertSecretFree} from './external.mjs';
3
3
  import {object,text,validatePlannerLedger} from './schema.mjs';
4
4
  import {absolute,validateWorkflowId} from './scope.mjs';
5
+ import path from 'node:path';
6
+ import {validateTargetPreparationRecord} from './target-preparation.mjs';
5
7
  const fail=message=>{throw Error('Cooperative schema: '+message);};
6
8
  const hash=value=>typeof value==='string'&&/^sha256:[a-f0-9]{64}$/.test(value);
7
9
  const revision=value=>Number.isSafeInteger(value)&&value>=0;
@@ -9,6 +11,24 @@ const actor=value=>object(value)&&text(value.id)&&Object.keys(value).every(k=>['
9
11
  const statement=value=>object(value)&&text(value.messageRef)&&object(value.data);
10
12
  function fields(value,allowed){if(!object(value)||Object.keys(value).some(k=>!allowed.includes(k)))fail('unexpected input field');}
11
13
  function json(value){try{const encoded=JSON.stringify(value);if(encoded===undefined)fail('JSON input');}catch{fail('JSON input');}}
14
+ const exact=(value,keys)=>object(value)&&Object.keys(value).length===keys.length&&keys.every(key=>Object.hasOwn(value,key));
15
+ const oid=value=>typeof value==='string'&&/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
16
+ const time=value=>typeof value==='string'&&Number.isFinite(Date.parse(value))&&/(?:Z|[+-]\d\d:\d\d)$/.test(value);
17
+ const unique=value=>Array.isArray(value)&&new Set(value).size===value.length;
18
+ const gitPath=value=>text(value)&&!/[\\\x00\r\n:]/.test(value)&&!value.startsWith('/')&&!value.split('/').some(part=>!part||part==='.'||part==='..');
19
+ const ref=value=>text(value)&&/^refs\/[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(value)&&!value.includes('..')&&!value.includes('//')&&!value.endsWith('/')&&!value.endsWith('.')&&!value.endsWith('.lock');
20
+ const approval=value=>exact(value,['ref','version','hash','approvedAt'])&&text(value.ref)&&Number.isSafeInteger(value.version)&&value.version>0&&hash(value.hash)&&time(value.approvedAt);
21
+ const document=value=>exact(value,['version','hash','content'])&&Number.isSafeInteger(value.version)&&value.version>0&&hash(value.hash)&&typeof value.content==='string'&&Buffer.byteLength(value.content)<=1024*1024;
22
+ export function validatePlanAmendArguments(a){
23
+ if(!exact(a,['classification','reason','changedAt','affectedTaskIds','plan','history','approvals','writers','ownership','publication','remotes','recovery'])||!['contract','mechanical'].includes(a.classification)||!text(a.reason)||!time(a.changedAt)||!unique(a.affectedTaskIds)||!a.affectedTaskIds.length||!a.affectedTaskIds.every(text))fail('plan-amend request');
24
+ if(!exact(a.plan,['path','gitPath','old','new'])||!absolute(a.plan.path)||!gitPath(a.plan.gitPath)||!document(a.plan.old)||!document(a.plan.new)||a.plan.new.version<=a.plan.old.version)fail('plan-amend document');
25
+ if(!exact(a.history,['base','planCommit','head','branch'])||![a.history.base,a.history.planCommit,a.history.head].every(oid)||!text(a.history.branch)||/[\x00\r\n]/.test(a.history.branch))fail('plan-amend history');
26
+ if(!exact(a.approvals,['original','amendment'])||!approval(a.approvals.original)||!(a.classification==='mechanical'&&a.approvals.amendment===null||approval(a.approvals.amendment)))fail('plan-amend approvals');
27
+ if(!Array.isArray(a.writers)||!a.writers.length||a.writers.some(w=>!exact(w,['assignmentId','stoppedAt','evidenceRef'])||!text(w.assignmentId)||!time(w.stoppedAt)||!text(w.evidenceRef))||!unique(a.writers.map(w=>w.assignmentId)))fail('plan-amend writers');
28
+ if(!['exclusive','shared','unknown'].includes(a.ownership)||!['unpublished','published','unknown'].includes(a.publication)||!Array.isArray(a.remotes)||a.remotes.some(r=>!exact(r,['name','identity','complete','refs','observedAt','evidenceRef'])||!text(r.name)||!hash(r.identity)||r.complete!==true||!time(r.observedAt)||!text(r.evidenceRef)||!Array.isArray(r.refs)||r.refs.some(x=>!exact(x,['name','oid'])||!ref(x.name)||!oid(x.oid))||!unique(r.refs.map(x=>x.name)))||!unique(a.remotes.map(r=>r.name)))fail('plan-amend remote snapshot');
29
+ if(!exact(a.recovery,['ref','oid'])||!ref(a.recovery.ref)||!a.recovery.ref.startsWith('refs/arc/recovery/')||!oid(a.recovery.oid)||path.normalize(a.plan.path)!==a.plan.path)fail('plan-amend recovery');
30
+ return a;
31
+ }
12
32
  export function validateCooperativeCommand(command){
13
33
  assertSecretFree(command);fields(command,['command','input']);const {input,command:name}=command;
14
34
  if(!['init','record','status','reconcile','prepare','result'].includes(name))fail('command');
@@ -25,7 +45,7 @@ export function validateCooperativeCommand(command){
25
45
  if(name==='record'){
26
46
  fields(input.evidence,['kind','messageRef','data']);
27
47
  if(input.evidence?.data?.affectedTaskIds!==undefined&&(!Array.isArray(input.evidence.data.affectedTaskIds)||!input.evidence.data.affectedTaskIds.every(text)||new Set(input.evidence.data.affectedTaskIds).size!==input.evidence.data.affectedTaskIds.length))fail('affected task IDs');
28
- if(!statement(input.evidence)||!['note','approval','external-approval','document','scope','check','finding','review','acceptance-evidence','planner-stage','planner-evidence','planner-result','planner-review'].includes(input.evidence.kind))fail('evidence');
48
+ if(!statement(input.evidence)||!['note','approval','external-approval','document','scope','check','finding','review','acceptance-evidence','planner-stage','planner-evidence','planner-result','planner-review','target-preparation'].includes(input.evidence.kind))fail('evidence');
29
49
  }
30
50
  if(resultStatement){
31
51
  fields(input.result,['status','messageRef','data','notExecutedEvidence']);
@@ -33,6 +53,7 @@ export function validateCooperativeCommand(command){
33
53
  if(input.result.status==='cancelled'&&(!object(input.result.notExecutedEvidence)||!text(input.result.notExecutedEvidence.messageRef)))fail('cancel requires not-executed evidence');
34
54
  }
35
55
  if((name==='prepare'||name==='result'&&!resultStatement)&&!text(input.operation))fail('operation');
56
+ if(name==='prepare'&&input.operation==='plan-amend')validatePlanAmendArguments(input.arguments);
36
57
  return command;
37
58
  }
38
59
  export function validateCooperativeState(state){
@@ -44,13 +65,23 @@ export function validateCooperativeState(state){
44
65
  for(const key of ['actors','selections','assignments','identities','leases','scopes','approvals','documents','tasks','requests','intents','results','checks','findings','acceptances','commits','audit','unresolved','records','targetHistory'])if(!Array.isArray(state[key]))fail(key);
45
66
  if(state.tasks.some(t=>!object(t)||!text(t.id)||!Array.isArray(t.paths)||!Array.isArray(t.criteria)||!Array.isArray(t.requiredChecks)||!Array.isArray(t.dependencies))||new Set(state.tasks.map(t=>t.id)).size!==state.tasks.length)fail('task contracts');
46
67
  if(state.commitObservations!==undefined&&(!Array.isArray(state.commitObservations)||state.commitObservations.some(o=>!object(o)||o.source!=='local-git'||!text(o.intentId)||!revision(o.revision))))fail('commit observations');
68
+ if(state.planAmendObservations!==undefined&&(!Array.isArray(state.planAmendObservations)||state.planAmendObservations.some(o=>!object(o)||o.source!=='local-git'||!text(o.intentId)||!revision(o.revision))))fail('plan-amend observations');
69
+ if(state.planAmendments!==undefined&&(!Array.isArray(state.planAmendments)||state.planAmendments.some(a=>!object(a)||a.source!=='operator-attested'||a.observationSource!=='local-git'||!text(a.intentId)||!absolute(a.path)||!hash(a.oldHash)||!hash(a.newHash)||!oid(a.oldCommit)||!oid(a.newCommit)||!Array.isArray(a.mapping)||!a.mapping.length)))fail('plan-amend connections');
47
70
  if(!revision(state.scopeVersion)||!revision(state.targetVersion))fail('scope/target version');
48
71
  if(state.actors.some(a=>!actor(a.actor)||a.source!=='operator-attested')||state.selections.some(s=>s.source!=='operator-attested'||!text(s.messageRef)))fail('attestations');
49
72
  for(const key of ['scopes','approvals','documents','checks','findings','acceptances','commits'])if(state[key].some(entry=>!object(entry)||entry.source!=='operator-attested'))fail('attestation source');
50
73
  if(state.records.some(r=>!actor(r.actor)||r.source!=='operator-attested'||!text(r.kind)||!statement(r)))fail('records');
51
74
  if(state.requests.some(r=>!text(r.requestId)||!hash(r.requestFingerprint)||!object(r.decision))||new Set(state.requests.map(r=>r.requestId)).size!==state.requests.length)fail('request replay');
52
75
  if(state.intents.some(i=>!text(i.requestId)||!['prepared','succeeded','failed','unknown','mismatch','cancelled'].includes(i.status)||!hash(i.fingerprint))||new Set(state.intents.map(i=>i.requestId)).size!==state.intents.length)fail('intents');
76
+ for(const intent of state.intents.filter(i=>i.operation==='plan-amend')){validatePlanAmendArguments(intent.arguments);if(!object(intent.target)||!object(intent.versions)||!object(intent.observation)||intent.source!=='operator-attested'||intent.checkpoint!==undefined&&!object(intent.checkpoint))fail('plan-amend intent');}
53
77
  if(state.results.some(r=>!text(r.requestId)||!['succeeded','failed','unknown','mismatch','cancelled'].includes(r.status)||r.source!=='operator-attested'||!object(r.data)))fail('results');
54
78
  if(state.plannerLedger!==undefined){validatePlannerLedger(state.plannerLedger);if(state.plannerLedger.stages.some(stage=>stage.workflowId!==state.workflowId||!Array.isArray(stage.artifacts)))fail('Planner workflow binding or artifacts');}
79
+ for(const key of ['targetIntents','targetCheckpoints','targetTransitions','targetDocuments'])if(state[key]!==undefined&&!Array.isArray(state[key]))fail(key);
80
+ const preparationIntents=state.targetIntents??[],checkpoints=state.targetCheckpoints??[],transitions=state.targetTransitions??[],documents=state.targetDocuments??[];
81
+ if(new Set(preparationIntents.map(i=>i.id)).size!==preparationIntents.length)fail('target intent identity');
82
+ for(const intent of preparationIntents){try{validateTargetPreparationRecord('intent',intent);}catch{fail('target intent');}const approved=state.records.find(r=>r.kind==='approval'&&r.messageRef===intent.approvalRef&&r.source==='operator-attested'&&r.actor?.role==='orchestrator'&&r.scopeVersion===intent.scopeVersion&&r.targetVersion===intent.sourceTargetVersion)?.data?.targetPreparation;if(intent.source!=='operator-attested'||intent.workflowId!==state.workflowId||!revision(intent.scopeVersion)||!hash(intent.sourceFingerprint)||!object(intent.sourceSnapshot)||!approved||['depth','selectionRef','kind','candidate','base','workflowId','firstStageId','sourceTargetVersion'].some(k=>approved[k]!==intent[k])||JSON.stringify(approved.sourceTarget)!==JSON.stringify(intent.sourceTarget)||JSON.stringify(approved.documents)!==JSON.stringify(intent.documents))fail('target intent provenance');}
83
+ for(const checkpoint of checkpoints){try{validateTargetPreparationRecord('checkpoint',{type:'checkpoint',...checkpoint});}catch{fail('target checkpoint');}if(checkpoint.source!=='operator-attested'||!text(checkpoint.messageRef)||!preparationIntents.some(i=>i.id===checkpoint.intentId&&i.firstStageId===checkpoint.stageId)||!state.records.some(r=>r.kind==='target-preparation'&&r.messageRef===checkpoint.messageRef&&r.source==='operator-attested'))fail('target checkpoint provenance');}
84
+ for(const transition of transitions){const request=transition.validation?.request;try{validateTargetPreparationRecord('transition',request);}catch{fail('target transition');}if(!object(transition.target)||!text(transition.messageRef)||!Number.isSafeInteger(transition.targetVersion)||transition.targetVersion!==transition.sourceTargetVersion+1||request.intentId!==transition.intentId||request.checkpointRef!==transition.checkpointRef||!preparationIntents.some(i=>i.id===transition.intentId)||!checkpoints.some(c=>c.intentId===transition.intentId&&c.messageRef===transition.checkpointRef)||!state.records.some(r=>r.kind==='target-preparation'&&r.messageRef===transition.messageRef&&r.source==='operator-attested'))fail('target transition provenance');}
85
+ for(const document of documents){try{validateTargetPreparationRecord('destination',document);}catch{fail('target document');}if(document.sourceLink!=='operator-attested'||document.verification?.status!=='verified'||document.verification?.source!=='local-bytes'||!text(document.messageRef)||!transitions.some(t=>t.intentId===document.intentId&&t.messageRef===document.transitionRef&&t.targetVersion===document.targetVersion)||!state.records.some(r=>r.kind==='target-preparation'&&r.messageRef===document.messageRef&&r.source==='operator-attested'))fail('target document provenance');}
55
86
  assertSecretFree(state);json(state);return state;
56
87
  }
@@ -1,7 +1,7 @@
1
1
  import {EXTERNAL_ACTIONS,reconcileCooperativeExternal,cooperativeSupport} from './external.mjs';
2
2
  import {reconcileCooperativeCommit} from './acceptance.mjs';
3
3
  import {evaluateCooperativeVerification,invalidateAffectedEvidence,readCooperativeChanges} from './verification.mjs';
4
- import {readAssignmentGitEvidence,readCooperativeCommitGitEvidence} from './git-evidence.mjs';
4
+ import {readAssignmentGitEvidence,readCooperativeCommitGitEvidence,readPlanAmendGitEvidence} from './git-evidence.mjs';
5
5
  import {recordCooperativeAssignmentResult} from './assignment.mjs';
6
6
  import fs from 'node:fs/promises';
7
7
  import {constants} from 'node:fs';
@@ -9,13 +9,14 @@ import path from 'node:path';
9
9
  import {createHash} from 'node:crypto';
10
10
  import {validateCooperativeCommand,validateCooperativeState} from './cooperative-schema.mjs';
11
11
  import {observeCooperativeTarget,validateObservedTarget,validateWorkflowId,workflowStatePath,inspectPath,absolute,within} from './scope.mjs';
12
- import {fingerprintRequest,saveDurableState,applyCooperativeCommitTransition} from './store.mjs';
12
+ import {fingerprintRequest,saveDurableState,applyCooperativeCommitTransition,applyCooperativeTargetTransition} from './store.mjs';
13
13
  import {commandResult,cooperativeVersions} from './bootstrap.mjs';
14
- import {evaluateCooperative,reconcileCooperativeResult} from './evaluate.mjs';
14
+ import {evaluateCooperative,reconcileCooperativeResult,reconcileCooperativePlanAmend} from './evaluate.mjs';
15
+ import {evaluateTargetPreparation,readTargetPreparationEvidence} from './target-preparation.mjs';
15
16
  const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
16
17
  const sameCheckout=(a,b)=>['realpath','gitCommonDir','gitDir'].every(k=>a[k]===b[k]);
17
18
  /** Only trusted code may inject observation/storage. CLI JSON cannot set either. */
18
- export async function runCooperativeCommand({cwd,workflowId,command,input,observe=observeCooperativeTarget,storage={},readGit=readAssignmentGitEvidence,readChanges=readCooperativeChanges,readCommitGit=readCooperativeCommitGitEvidence}){
19
+ export async function runCooperativeCommand({cwd,workflowId,command,input,observe=observeCooperativeTarget,storage={},readGit=readAssignmentGitEvidence,readChanges=readCooperativeChanges,readCommitGit=readCooperativeCommitGitEvidence,readPlanAmendGit=readPlanAmendGitEvidence,clock=Date.now}){
19
20
  validateWorkflowId(workflowId);validateCooperativeCommand({command,input});
20
21
  if(command==='init')throw Error('Use initializeCooperativeWorkflow for init');
21
22
  const target=await validateObservedTarget(cwd,await observe(cwd)),statePath=await workflowStatePath(target,workflowId);
@@ -26,7 +27,7 @@ export async function runCooperativeCommand({cwd,workflowId,command,input,observ
26
27
  if(!sameCheckout(state.target,target))return result(undefined,{decision:'hold',ruleIds:['cooperative.target']});
27
28
  const changes=state.tasks.length?await readChanges({state}):undefined;
28
29
  const evaluation=evaluateCooperative({state,request:{command,input},evidence:{target,versions:await cooperativeVersions(),changes}});
29
- const unresolved=state.plannerLedger?.stages.some(s=>s.pending)||state.unresolved.some(entry=>!entry.resolvedBy)||state.intents.some(i=>['prepared','unknown','mismatch'].includes(i.status)&&!(['check','review'].includes(i.operation)&&i.stale));
30
+ const unresolved=state.plannerLedger?.stages.some(s=>s.pending)||state.unresolved.some(entry=>!entry.resolvedBy)||state.intents.some(i=>(['prepared','unknown','mismatch'].includes(i.status)||i.operation==='plan-amend'&&i.status==='failed')&&!(['check','review'].includes(i.operation)&&i.stale));
30
31
  return {...result(state,evaluation.decision==='allow'&&unresolved?{decision:'hold',ruleIds:['cooperative.unresolved']}:evaluation,{persisted:true}),state,support:cooperativeSupport(),...(changes?{changeFingerprints:changes}:{})};
31
32
  }
32
33
  let lock,plannerLock;
@@ -49,10 +50,43 @@ export async function runCooperativeCommand({cwd,workflowId,command,input,observ
49
50
  if(command==='prepare'&&['accept','commit'].includes(input.operation)||commitIntent){
50
51
  try{git=await readCommitGit({target:currentTarget,...(commitIntent&&input.result?.data?.commit?{commit:input.result.data.commit}:{})});}catch(error){gitError=error.message;}
51
52
  }
53
+ const planIntent=state.intents.find(i=>i.operation==='plan-amend'&&i.requestId===((command==='record'&&input.evidence.kind==='note'&&input.evidence.data?.type==='plan-amend-checkpoint')?input.evidence.data.intentId:['result','reconcile'].includes(command)?input.intentId:null));
54
+ const planArguments=command==='prepare'&&input.operation==='plan-amend'?input.arguments:planIntent?.arguments;
55
+ let now;
56
+ if(planArguments){
57
+ try{git=await readPlanAmendGit({target:currentTarget,arguments:planArguments,mapping:command==='reconcile'&&input.result?.status==='succeeded'?input.result.data?.mapping??[]:[]});}catch(error){gitError=error.message;}
58
+ }
52
59
  const changes=state.tasks.length?await readChanges({state}):undefined;
53
60
  const plannerArtifacts=plannerWork?await readPlannerArtifacts(state,command==='record'&&input.evidence.kind==='planner-evidence'?input.evidence:undefined):undefined;
54
61
  const plannerHistory=command==='record'&&input.evidence.kind==='planner-stage'?await readPlannerHistory(state,statePath,input.evidence.data.document?.path):{};
55
- let decision=evaluateCooperative({state,request:{command,input},evidence:{target:currentTarget,versions,git,changes,requestFingerprint,plannerArtifacts,...plannerHistory}});
62
+ if(planArguments&&!gitError){try{now=clock();if(!Number.isFinite(now))throw Error('Invalid observation clock');}catch(error){gitError=error.message;}}
63
+ let destinationReview=false;
64
+ let decision=evaluateCooperative({state,request:{command,input},evidence:{target:currentTarget,versions,git,changes,requestFingerprint,plannerArtifacts,now,...plannerHistory}});
65
+ if(command==='record'&&input.evidence.kind==='planner-review'){
66
+ const data=input.evidence.data,stage=state.plannerLedger?.stages.find(s=>s.id===data.stageId),intent=state.targetIntents?.find(i=>i.id===stage?.targetIntentId),transition=state.targetTransitions?.find(t=>t.intentId===intent?.id&&t.targetVersion===state.targetVersion),document=intent?.documents.find(d=>d.kind===stage?.document.kind);
67
+ if(transition&&document){
68
+ let observed;try{observed=await readTargetPreparationEvidence({state,targetIntent:intent,destination:{realpath:state.target.realpath,path:document.path},observe,readChanges});}catch{}
69
+ const valid=input.actor.role==='orchestrator'&&stage.scopeVersion===state.scopeVersion&&same(versions,state.versions)&&same(currentTarget,state.target)&&!stage.pending&&stage.result?.status==='succeeded'&&stage.result.ref===data.resultRef&&stage.result.chatDraft?.complete===true&&data.full===true&&data.complete===true&&observed?.destination?.document?.hash===data.hash&&Array.isArray(data.criteria)&&data.criteria.length===stage.criteria.length&&new Set(data.criteria.map(c=>c.id)).size===stage.criteria.length&&data.criteria.every(c=>stage.criteria.some(s=>s.id===c.id)&&c.status==='resolved'&&typeof c.judgmentRef==='string'&&c.judgmentRef);
70
+ destinationReview=!!valid;
71
+ decision=valid?{decision:'allow',ruleIds:['cooperative.target-preparation']}:{decision:'hold',ruleIds:['cooperative.target-preparation'],reason:'Current destination bytes and complete source-bound review required'};
72
+ }
73
+ }
74
+ if(command==='record'&&['document','approval'].includes(input.evidence.kind)&&state.targetIntents?.some(intent=>!state.targetTransitions?.some(t=>t.intentId===intent.id)&&intent.documents.some(doc=>doc.kind===input.evidence.data.kind&&(input.evidence.data.path===doc.path||input.evidence.data.path===path.join(intent.sourceTarget.realpath,doc.path)))))decision={decision:'hold',ruleIds:['cooperative.target-preparation'],reason:'Prepared target transition required before document records'};
75
+ if(command==='record'&&input.evidence.kind==='approval'){
76
+ const data=input.evidence.data,intent=state.targetIntents?.find(i=>state.targetTransitions?.some(t=>t.intentId===i.id&&t.targetVersion===state.targetVersion)&&i.documents.some(d=>d.kind===data.kind));
77
+ if(intent){
78
+ const document=intent.documents.find(d=>d.kind===data.kind),firstStage=state.plannerLedger?.stages.find(s=>s.id===intent.firstStageId);
79
+ const stage=firstStage?.document.kind===data.kind?firstStage:state.plannerLedger?.stages.find(s=>s.document.kind===data.kind&&s.document.path===path.join(state.target.realpath,document.path)&&s.targetVersion===state.targetVersion);
80
+ const review=state.records.findLast(r=>r.kind==='planner-review'&&r.scopeVersion===state.scopeVersion&&r.targetVersion===state.targetVersion&&r.data?.stageId===stage?.id&&r.data?.resultRef===stage?.result?.ref&&r.data?.full===true&&r.data?.complete===true&&r.data?.hash===data.contentHash);
81
+ let observed;try{observed=await readTargetPreparationEvidence({state,targetIntent:intent,destination:{realpath:state.target.realpath,path:document.path},observe,readChanges});}catch{}
82
+ if(stage?.scopeVersion!==state.scopeVersion||![document.path,path.join(state.target.realpath,document.path)].includes(data.path)||!review||observed?.destination?.document?.hash!==data.contentHash||!same(currentTarget,state.target)||!same(versions,state.versions)||input.actor.role!=='orchestrator')decision={decision:'hold',ruleIds:['cooperative.target-preparation'],reason:'Current reviewed destination bytes required for approval'};
83
+ }
84
+ }
85
+ if(command==='record'&&input.evidence.kind==='target-preparation'){
86
+ let observed;
87
+ try{const intent=state.targetIntents?.find(i=>i.id===input.evidence.data.intentId);observed=await readTargetPreparationEvidence({state,targetIntent:intent,destination:input.evidence.data.destination,observe,readChanges});}catch{}
88
+ decision=input.actor.role==='orchestrator'&&same(versions,state.versions)?evaluateTargetPreparation({state,request:input.evidence.data,evidence:{...observed,versions}}):{decision:'hold',ruleIds:['cooperative.target-preparation']};
89
+ }
56
90
  if(gitError)decision={decision:'hold',ruleIds:[input.operation==='assign-agent'?'cooperative.documents':'cooperative.git-evidence'],reason:gitError};
57
91
  if(command==='prepare'&&decision.decision==='allow'){
58
92
  if(decision.proposedTransition?.plannerLedger)state.plannerLedger=decision.proposedTransition.plannerLedger;
@@ -62,10 +96,10 @@ export async function runCooperativeCommand({cwd,workflowId,command,input,observ
62
96
  if(['reconcile','result'].includes(command)&&input.result){
63
97
  if(input.expectedRevision!==state.revision)state.unresolved.push({kind:'stale-result',requestId:input.requestId,intentId:input.intentId,expectedRevision:input.expectedRevision,revision:state.revision+1});
64
98
  let intent=state.intents.find(i=>i.requestId===input.intentId);
65
- decision=EXTERNAL_ACTIONS.includes(intent?.operation)?reconcileCooperativeExternal({state,intent,result:input.result,observations:{target:currentTarget}}):intent?.operation==='commit'?reconcileCooperativeCommit({state,intent,result:input.result,gitEvidence:git}):['check','review'].includes(intent?.operation)?evaluateCooperativeVerification({state,request:{...input,operation:'verification-result'},evidence:{changes}}):intent?.operation==='assign-agent'?recordCooperativeAssignmentResult({state,intent,result:input.result,observations:{target:currentTarget}}):reconcileCooperativeResult({state,intent,result:input.result,observations:{target:currentTarget}});
66
- if(['assign-agent','check','review','commit',...EXTERNAL_ACTIONS].includes(intent?.operation)&&!same(versions,state.versions))decision={...decision,decision:'hold',ruleIds:['cooperative.versions']};
99
+ decision=EXTERNAL_ACTIONS.includes(intent?.operation)?reconcileCooperativeExternal({state,intent,result:input.result,observations:{target:currentTarget}}):intent?.operation==='commit'?reconcileCooperativeCommit({state,intent,result:input.result,gitEvidence:git}):intent?.operation==='plan-amend'?reconcileCooperativePlanAmend({state,intent,result:input.result,observations:{target:currentTarget,git},now}):['check','review'].includes(intent?.operation)?evaluateCooperativeVerification({state,request:{...input,operation:'verification-result'},evidence:{changes}}):intent?.operation==='assign-agent'?recordCooperativeAssignmentResult({state,intent,result:input.result,observations:{target:currentTarget}}):reconcileCooperativeResult({state,intent,result:input.result,observations:{target:currentTarget}});
100
+ if(['assign-agent','check','review','commit','plan-amend',...EXTERNAL_ACTIONS].includes(intent?.operation)&&!same(versions,state.versions))decision={...decision,decision:'hold',ruleIds:['cooperative.versions']};
67
101
  if(input.expectedRevision!==state.revision)decision={...decision,decision:'hold',ruleIds:['cooperative.revision']};
68
- if((intent?.operation==='commit'||EXTERNAL_ACTIONS.includes(intent?.operation))&&input.actor.role!=='orchestrator')decision={...decision,decision:'hold',ruleIds:['cooperative.acceptance-actor']};
102
+ if((['commit','plan-amend'].includes(intent?.operation)||EXTERNAL_ACTIONS.includes(intent?.operation))&&input.actor.role!=='orchestrator')decision={...decision,decision:'hold',ruleIds:['cooperative.acceptance-actor']};
69
103
  if(['check','review'].includes(intent?.operation)&&!same(currentTarget,state.target))decision={...decision,decision:'hold',ruleIds:['cooperative.target']};
70
104
  if(intent?.operation==='commit'){
71
105
  const verified=decision.proposedTransition;
@@ -90,6 +124,20 @@ export async function runCooperativeCommand({cwd,workflowId,command,input,observ
90
124
  state.commitObservations??=[];state.commitObservations.push(observation);
91
125
  if(decision.decision!=='allow'&&verified?.result?.status==='succeeded')verified.result.status='mismatch';
92
126
  }
127
+ if(intent?.operation==='plan-amend'){
128
+ if(command!=='reconcile'||gitError||state.unresolved.some(u=>!u.resolvedBy&&!(u.intentId===input.intentId&&['target-changed','stale-result'].includes(u.kind))))decision={...decision,decision:'hold',ruleIds:['cooperative.plan-amend']};
129
+ if(decision.decision!=='allow'&&decision.proposedTransition?.result?.status==='succeeded')decision.proposedTransition.result.status='mismatch';
130
+ const observed={intentId:input.intentId,revision:state.revision+1,source:'local-git',target:currentTarget,observation:git??null,...(gitError?{error:gitError}:{})};
131
+ if(decision.decision==='allow'){
132
+ const previous=structuredClone(state.target),nextVersion=state.targetVersion+1,a=intent.arguments,transition=decision.proposedTransition;
133
+ state=invalidateAffectedEvidence({state,changedVersions:{targetVersion:nextVersion,documentVersion:a.plan.new.version},affectedTaskIds:a.affectedTaskIds});
134
+ state.targetHistory.push({target:previous,revision:state.revision+1,source:'local-observation',kind:'verified-plan-amend-parent',intentId:input.intentId});state.target=structuredClone(transition.target);state.targetVersion=nextVersion;
135
+ state.planAmendments??=[];state.planAmendments.push({intentId:input.intentId,path:a.plan.path,oldVersion:a.plan.old.version,newVersion:a.plan.new.version,oldHash:a.plan.old.hash,newHash:a.plan.new.hash,oldCommit:a.history.planCommit,newCommit:transition.mapping[0].new,mapping:structuredClone(transition.mapping),approvalRefs:structuredClone(a.approvals),source:'operator-attested',observationSource:'local-git'});
136
+ for(const u of state.unresolved)if(!u.resolvedBy&&u.intentId===input.intentId&&u.kind==='target-changed')u.resolvedBy=input.requestId;
137
+ intent=state.intents.find(i=>i.requestId===input.intentId);
138
+ }
139
+ state.planAmendObservations??=[];state.planAmendObservations.push(observed);
140
+ }
93
141
  const verification=decision.proposedTransition;
94
142
  if(verification?.check){
95
143
  if(decision.decision!=='allow'&&verification.check.status==='passed'){verification.check.status='unverified';verification.check.stale=true;verification.findings=[];verification.result.status='mismatch';}
@@ -100,17 +148,17 @@ export async function runCooperativeCommand({cwd,workflowId,command,input,observ
100
148
  if(decision.decision==='allow'&&input.expectedRevision===state.revision&&['succeeded','failed','cancelled'].includes(decision.proposedTransition?.result?.status))for(const entry of state.unresolved)if(entry.kind==='stale-result'&&entry.intentId===input.intentId&&!entry.resolvedBy){entry.resolvedBy=input.requestId;entry.resolutionEvidence={intentId:input.intentId,resultRef:input.result.messageRef,revision:state.revision};}
101
149
  const actualResult=decision.proposedTransition?.result??input.result;
102
150
  // Preserve actual statements even when runtime/HEAD changed, without rebinding target.
103
- if(!same(currentTarget,state.target)){state.targetHistory.push({target:currentTarget,revision:state.revision+1,source:'local-observation'});state.unresolved.push({kind:'target-changed',...(intent?.operation==='commit'?{intentId:input.intentId}:{}),revision:state.revision+1});}
151
+ if(!same(currentTarget,state.target)){state.targetHistory.push({target:currentTarget,revision:state.revision+1,source:'local-observation'});state.unresolved.push({kind:'target-changed',...(['commit','plan-amend'].includes(intent?.operation)?{intentId:input.intentId}:{}),revision:state.revision+1});}
104
152
  if(!same(versions,state.versions))state.unresolved.push({kind:'versions-changed',revision:state.revision+1});
105
153
  state.results.push({...actualResult,requestId:input.intentId,recordRequestId:input.requestId,actor:input.actor,source:'operator-attested'});
106
- if(intent&&(['assign-agent','check','review','commit',...EXTERNAL_ACTIONS].includes(intent.operation)?decision.proposedTransition?.result&&['prepared','unknown','mismatch'].includes(intent.status):true)){
154
+ if(intent&&(['assign-agent','check','review','commit','plan-amend',...EXTERNAL_ACTIONS].includes(intent.operation)?decision.proposedTransition?.result&&['prepared','unknown','mismatch'].includes(intent.status):true)){
107
155
  intent.status=actualResult.status;
108
156
  if(decision.decision==='allow'&&actualResult.status==='succeeded'&&intent.operation==='assign-agent')state.assignments.push({intentId:intent.requestId,taskId:intent.taskId,agentId:actualResult.data.agentId,status:'active',source:'operator-attested',identityVerified:false});
109
157
  }
110
158
  else state.unresolved.push({kind:'unmatched-result',requestId:input.intentId,revision:state.revision+1});
111
159
  }else if(command==='record'&&input.evidence.kind.startsWith('planner-')){
112
- if(decision.proposedTransition?.plannerLedger&&(decision.decision==='allow'||decision.proposedTransition.recordUncertainty)){
113
- state.plannerLedger=decision.proposedTransition.plannerLedger;
160
+ if(destinationReview||decision.proposedTransition?.plannerLedger&&(decision.decision==='allow'||decision.proposedTransition.recordUncertainty)){
161
+ if(!destinationReview)state.plannerLedger=decision.proposedTransition.plannerLedger;
114
162
  state.records.push({...input.evidence,actor:input.actor,source:'operator-attested',requestId:input.requestId,revision:state.revision+1,scopeVersion:state.scopeVersion,targetVersion:state.targetVersion});
115
163
  if(input.evidence.kind==='planner-result'){
116
164
  const intent=state.intents.find(i=>i.operation==='planner-call'&&i.requestId===input.evidence.data.callRequestId);
@@ -118,7 +166,12 @@ export async function runCooperativeCommand({cwd,workflowId,command,input,observ
118
166
  }
119
167
  }
120
168
  }else if(command==='record'&&decision.decision==='allow'){
169
+ if(input.evidence.kind==='target-preparation'&&input.evidence.data.type==='transition')state=applyCooperativeTargetTransition({state,decision,messageRef:input.evidence.messageRef});
170
+ if(input.evidence.kind==='target-preparation'&&decision.proposedTransition?.intent){state.targetIntents??=[];state.targetIntents.push(decision.proposedTransition.intent);}
171
+ if(input.evidence.kind==='target-preparation'&&decision.proposedTransition?.checkpoint){state.targetCheckpoints??=[];state.targetCheckpoints.push({...decision.proposedTransition.checkpoint,messageRef:input.evidence.messageRef,source:'operator-attested'});}
172
+ if(input.evidence.kind==='target-preparation'&&decision.proposedTransition?.destination){state.targetDocuments??=[];state.targetDocuments.push({...decision.proposedTransition.destination,messageRef:input.evidence.messageRef});}
121
173
  const entry={...input.evidence,actor:input.actor,source:'operator-attested',requestId:input.requestId,revision:state.revision+1,scopeVersion:state.scopeVersion,targetVersion:state.targetVersion};
174
+ if(entry.kind==='note'&&decision.proposedTransition?.checkpoint){const intent=state.intents.find(i=>i.operation==='plan-amend'&&i.requestId===decision.proposedTransition.checkpoint.intentId);if(!intent)throw Error('Checkpoint intent unavailable');intent.checkpoint=structuredClone(decision.proposedTransition.checkpoint);}
122
175
  if(entry.kind==='scope'){
123
176
  if(!Array.isArray(entry.data.paths)||entry.data.paths.some(p=>!absolute(p)||!within(state.target.realpath,p)))throw Error('Scope paths must stay inside workspace');
124
177
  state=invalidateAffectedEvidence({state,changedVersions:{scopeVersion:state.scopeVersion+1},affectedTaskIds:entry.data.affectedTaskIds});entry.scopeVersion=state.scopeVersion;state.scopes.push(entry);
@@ -143,6 +196,8 @@ export async function runCooperativeCommand({cwd,workflowId,command,input,observ
143
196
  if(ready){marker.resolvedBy=input.requestId;marker.resolutionEvidence={taskIds:ids,contracts:proof,scopeVersion:state.scopeVersion};}
144
197
  }
145
198
  }
199
+ // The observed identifiers contain no URLs and do not verify remote state.
200
+ if(planArguments&&Array.isArray(git?.configuredRemotes)&&git.configuredRemotes.every(r=>r&&typeof r.name==='string'&&/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(r.name)&&/^sha256:[a-f0-9]{64}$/.test(r.identity??'')))decision={...decision,observedRemoteIdentities:{source:'local-git',remotes:git.configuredRemotes.map(r=>({name:r.name,identity:r.identity}))}};
146
201
  // A held request is durable audit, never an executable intent.
147
202
  state.revision++;
148
203
  const output=result(state,decision,{persisted:true});
@@ -14,11 +14,74 @@ import {prepareCooperativeAssignment} from './assignment.mjs';
14
14
  * @property {object} coverage
15
15
  */
16
16
  import path from 'node:path';
17
- import {validateCooperativeState,validateCooperativeCommand} from './cooperative-schema.mjs';
17
+ import crypto from 'node:crypto';
18
+ import {fingerprintRequest} from './store.mjs';
19
+ import {validateCooperativeState,validateCooperativeCommand,validatePlanAmendArguments} from './cooperative-schema.mjs';
18
20
  import {validateWorkflowState, validateRequest, validateEvidence,validatePlannerStage} from './schema.mjs';
19
21
  import {ROLES, ROLE_OPERATIONS, GIT_OPERATIONS, EXTERNAL_OPERATIONS} from './operations.mjs';
20
22
  const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
21
23
  const within=(base,file)=>{const relative=path.relative(base,file);return relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative));};
24
+ const objectFields=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===keys.length&&keys.every(key=>Object.hasOwn(value,key));
25
+ const hashBytes=value=>'sha256:'+crypto.createHash('sha256').update(value).digest('hex');
26
+ const gitBlob=(value,length)=>crypto.createHash(length===64?'sha256':'sha1').update(`blob ${Buffer.byteLength(value)}\0`).update(value).digest('hex');
27
+ const planCoverage={enforcement:'cooperative',provenance:'operator-attested',nativeVerified:false,remoteVerified:false};
28
+ const planDecision=(state,reason,transition=null,allow=false)=>({expectedRevision:state?.revision??0,decision:allow?'allow':'hold',ruleIds:['cooperative.plan-amend'],reason,coverage:{...planCoverage},requiredEvidence:[],nextActions:[],proposedTransition:transition});
29
+ const unresolvedPlan=state=>state.intents.some(i=>i.operation==='plan-amend'&&['prepared','unknown','mismatch','failed'].includes(i.status));
30
+ const remoteIdentities=remotes=>remotes.map(r=>r.name+'\0'+r.identity).sort();
31
+ function remoteSnapshotValid(remotes,configured,at,protectedOids){
32
+ if(!Number.isFinite(at)||!Array.isArray(configured)||!Array.isArray(remotes)||configured.some(r=>!objectFields(r,['name','identity'])||typeof r.name!=='string'||!/^sha256:[a-f0-9]{64}$/.test(r.identity??''))||new Set(configured.map(r=>r.name)).size!==configured.length||!same(remoteIdentities(remotes),remoteIdentities(configured)))return false;
33
+ return remotes.every(r=>r.complete===true&&Number.isFinite(Date.parse(r.observedAt))&&at-Date.parse(r.observedAt)>=0&&at-Date.parse(r.observedAt)<=300000&&r.refs.every(ref=>ref.oid.length===protectedOids[0]?.length&&!protectedOids.includes(ref.oid)));
34
+ }
35
+ function planEntries(commit,length){
36
+ const validOid=value=>typeof value==='string'&&new RegExp(`^[a-f0-9]{${length}}$`).test(value);
37
+ return commit&&validOid(commit.oid)&&validOid(commit.parent)&&typeof commit.author==='string'&&typeof commit.authorDate==='string'&&typeof commit.message==='string'&&Array.isArray(commit.headerTemplate)&&commit.headerTemplate.every(header=>typeof header==='string')&&['tree','parent','committer'].every(name=>commit.headerTemplate.filter(header=>header===name).length===1)&&Array.isArray(commit.tree)&&new Set(commit.tree.map(row=>row.path)).size===commit.tree.length&&commit.tree.every(row=>objectFields(row,['path','mode','oid'])&&typeof row.path==='string'&&row.path.length>0&&!row.path.startsWith('/')&&!/[\\\x00\r\n:]/.test(row.path)&&!row.path.split('/').some(p=>!p||p==='.'||p==='..')&&['100644','100755','120000','160000'].includes(row.mode)&&validOid(row.oid));
38
+ }
39
+ function oldPlanObservation(a,g,target){
40
+ const h=g?.history,commits=h?.commits,length=target.head.length,oldBlob=gitBlob(a.plan.old.content,length);
41
+ if(!same(g?.target,target)||!g.index||!new RegExp(`^[a-f0-9]{${length}}$`).test(g.index.tree??'')||g.index.tree!==g.index.headTree||g.index.stagedPaths?.length!==0||g.worktree?.planClean!==true||!/^sha256:[a-f0-9]{64}$/.test(g.worktree?.nonPlanFingerprint??'')||g.plan?.path!==a.plan.path||g.plan.content!==a.plan.old.content||g.plan.hash!==a.plan.old.hash||g.plan.originalHash!==a.plan.old.hash||!h||h.base!==a.history.base||h.planCommit!==a.history.planCommit||h.head!==a.history.head||!Array.isArray(commits)||!commits.length||commits.length>256||commits.some(c=>!c)||commits[0].oid!==a.history.planCommit||commits.at(-1).oid!==a.history.head||new Set(commits.map(c=>c.oid)).size!==commits.length)return false;
42
+ const originalMode=commits[0].tree?.find(row=>row.path===a.plan.gitPath)?.mode;
43
+ return ['100644','100755'].includes(originalMode)&&g.plan.mode===originalMode&&commits.every((c,index)=>planEntries(c,length)&&c.parent===(index?commits[index-1].oid:a.history.base)&&c.tree.filter(row=>row.path===a.plan.gitPath).length===1&&c.tree.some(row=>row.path===a.plan.gitPath&&row.mode===originalMode&&row.oid===oldBlob));
44
+ }
45
+ function approvalRecord(state,a,approval){
46
+ const record=state.records.findLast(r=>r.kind==='approval'&&r.messageRef===approval.ref);
47
+ return record?.source==='operator-attested'&&record.actor?.role==='orchestrator'&&record.targetVersion===state.targetVersion&&record.scopeVersion===state.scopeVersion&&record.data?.kind==='plan'&&record.data.path===a.plan.path&&record.data.version===approval.version&&record.data.contentHash===approval.hash&&record.data.approvedAt===approval.approvedAt;
48
+ }
49
+ function planAuthorization(state,a,at){
50
+ if(!Number.isFinite(at)||a.ownership!=='exclusive'||a.publication!=='unpublished'||a.history.head!==state.target.head||a.history.branch!==state.target.branch||!state.target.branch||a.recovery.oid!==state.target.head||a.plan.path!==path.join(state.target.realpath,...a.plan.gitPath.split('/'))||a.plan.old.hash!==hashBytes(a.plan.old.content)||a.plan.new.hash!==hashBytes(a.plan.new.content)||a.plan.old.hash===a.plan.new.hash)return false;
51
+ const old=a.approvals.original,next=a.approvals.amendment,changed=Date.parse(a.changedAt);
52
+ if(old.version!==a.plan.old.version||old.hash!==a.plan.old.hash||Date.parse(old.approvedAt)>changed||changed>at||!approvalRecord(state,a,old))return false;
53
+ if(a.classification==='contract'&&!next||next&&(next.version!==a.plan.new.version||next.hash!==a.plan.new.hash||Date.parse(next.approvedAt)<changed||Date.parse(next.approvedAt)>at||!approvalRecord(state,a,next)))return false;
54
+ return a.writers.every(w=>Date.parse(w.stoppedAt)<=at&&state.records.some(r=>r.kind==='note'&&r.messageRef===w.evidenceRef&&r.source==='operator-attested'&&r.actor?.role==='orchestrator'&&r.targetVersion===state.targetVersion&&r.scopeVersion===state.scopeVersion&&r.data?.type==='writer-stopped'&&r.data.assignmentId===w.assignmentId&&r.data.stoppedAt===w.stoppedAt));
55
+ }
56
+ export function prepareCooperativePlanAmend({state,request,evidence}={}){
57
+ const a=request?.arguments,g=evidence?.git,at=evidence?.now;
58
+ try{validatePlanAmendArguments(a);}catch{return planDecision(state,'Exact plan-amend evidence required');}
59
+ if(request.actor?.role!=='orchestrator'||request.expectedRevision!==state.revision||state.unresolved.some(x=>!x.resolvedBy)||state.intents.some(i=>i.operation==='plan-amend'&&['prepared','unknown','mismatch','failed'].includes(i.status))||!planAuthorization(state,a,at)||!oldPlanObservation(a,g,state.target)||g.recoveryRef!==null||!remoteSnapshotValid(a.remotes,g.configuredRemotes,at,g.history.commits.map(c=>c.oid)))return planDecision(state,'Approval, stopped writers, clean index, local history and unpublished remote attestations required');
60
+ return planDecision(state,'Durable intent only; the operator performs any rewrite',{intent:{requestId:request.requestId,operation:'plan-amend',status:'prepared',arguments:structuredClone(a),target:structuredClone(state.target),versions:structuredClone(state.versions),scopeVersion:state.scopeVersion,targetVersion:state.targetVersion,observation:structuredClone(g),fingerprint:fingerprintRequest(request),source:'operator-attested'}},true);
61
+ }
62
+ export function checkpointCooperativePlanAmend({state,request,evidence}={}){
63
+ const d=request?.evidence?.data,intent=state.intents.find(i=>i.requestId===d?.intentId&&i.operation==='plan-amend'),a=intent?.arguments,g=evidence?.git;
64
+ if(request?.actor?.role!=='orchestrator'||!objectFields(d,['type','intentId'])||d.type!=='plan-amend-checkpoint'||!intent||intent.status!=='prepared'||intent.checkpoint||!same(intent.target,state.target)||!same(intent.versions,state.versions)||!planAuthorization(state,a,evidence?.now)||!oldPlanObservation(a,g,state.target)||!same(g.recoveryRef,{name:a.recovery.ref,oid:a.recovery.oid})||g.worktree.nonPlanFingerprint!==intent.observation.worktree.nonPlanFingerprint||g.index.tree!==intent.observation.index.tree||!same(g.history.commits,intent.observation.history.commits)||!remoteSnapshotValid(a.remotes,g.configuredRemotes,evidence.now,g.history.commits.map(c=>c.oid)))return planDecision(state,'Current prepared intent, recovery ref and pre-rewrite observation required');
65
+ return planDecision(state,'Pre-rewrite checkpoint recorded',{checkpoint:{intentId:intent.requestId,messageRef:request.evidence.messageRef,observedAt:new Date(evidence.now).toISOString(),observation:structuredClone(g)}},true);
66
+ }
67
+ export function reconcileCooperativePlanAmend({state,intent,result,observations,now}={}){
68
+ const transition={result:{...structuredClone(result),status:result?.status==='unknown'?'unknown':result?.status==='failed'?'failed':'mismatch',data:{...structuredClone(result?.data??{}),remoteVerified:false},source:'operator-attested'},observation:observations?.git??null};
69
+ const hold=reason=>planDecision(state,reason,transition);
70
+ if(!intent||intent.operation!=='plan-amend'||!['prepared','unknown','mismatch'].includes(intent.status)||!intent.checkpoint||!same(intent.target,state.target)||!same(intent.versions,state.versions)||intent.scopeVersion!==state.scopeVersion||intent.targetVersion!==state.targetVersion)return hold('Matching unresolved plan-amend intent and checkpoint required');
71
+ if(result?.status!=='succeeded')return hold('Rewrite did not establish a successful result');
72
+ const d=result.data,a=intent.arguments,g=observations?.git,target=observations?.target,mapping=d?.mapping,old=intent.observation.history.commits,next=g?.history?.newCommits;
73
+ try{validatePlanAmendArguments({...a,remotes:d?.remotes});}catch{return hold('Exact result and fresh remote attestations required');}
74
+ if(!objectFields(d,['head','mapping','remotes'])||!Array.isArray(mapping)||mapping.length!==old.length||!mapping.length||mapping.some(m=>!objectFields(m,['old','new']))||new Set(mapping.map(m=>m.old)).size!==mapping.length||new Set(mapping.map(m=>m.new)).size!==mapping.length||!Array.isArray(next)||next.length!==mapping.length||!target||target.realpath!==intent.target.realpath||target.gitCommonDir!==intent.target.gitCommonDir||target.gitDir!==intent.target.gitDir||target.branch!==intent.target.branch||target.head!==d.head||mapping.at(-1).new!==target.head||!same(g?.target,target)||g.index?.tree!==g.index?.headTree||g.index?.stagedPaths?.length!==0||g.worktree?.planClean!==true||g.worktree.nonPlanFingerprint!==intent.observation.worktree.nonPlanFingerprint||!same(g.recoveryRef,{name:a.recovery.ref,oid:a.recovery.oid})||g.plan?.path!==a.plan.path||g.plan.content!==a.plan.new.content||g.plan.hash!==a.plan.new.hash||g.plan.originalHash!==a.plan.old.hash||!same(g.history.commits,old)||!same(remoteIdentities(d.remotes),remoteIdentities(a.remotes))||!remoteSnapshotValid(d.remotes,g.configuredRemotes,now,[...old.map(c=>c.oid),...mapping.map(m=>m.new)]))return hold('Observed target, preservation, mapping, document or remote evidence differs');
75
+ const planOid=gitBlob(a.plan.new.content,target.head.length),originalMode=old[0].tree.find(row=>row.path===a.plan.gitPath)?.mode;if(g.plan.mode!==originalMode)return hold('Working plan mode differs');
76
+ for(let i=0;i<mapping.length;i++){
77
+ const before=old[i],after=next[i],m=mapping[i];
78
+ if(!planEntries(after,target.head.length)||m.old!==before.oid||m.new!==after.oid||after.parent!==(i?mapping[i-1].new:a.history.base)||after.author!==before.author||after.authorDate!==before.authorDate||after.message!==before.message||!same(after.headerTemplate,before.headerTemplate))return hold('Commit mapping or metadata differs');
79
+ const plan=after.tree.filter(row=>row.path===a.plan.gitPath),oldPlan=before.tree.find(row=>row.path===a.plan.gitPath);if(plan.length!==1||!oldPlan||plan[0].mode!==oldPlan.mode||plan[0].oid!==planOid)return hold('Unexpected plan bytes or mode in rewritten history');
80
+ const other=rows=>rows.filter(row=>row.path!==a.plan.gitPath).sort((x,y)=>x.path<y.path?-1:x.path>y.path?1:0);
81
+ if(!same(other([...before.tree]),other([...after.tree])))return hold('Non-plan tree changed');
82
+ }
83
+ transition.result.status='succeeded';transition.target=structuredClone(target);transition.mapping=structuredClone(mapping);return planDecision(state,'Locally observed same-intent amendment; remote claims remain operator-attested',transition,true);
84
+ }
22
85
  /** Pure policy decision. Evidence is supplied by a trusted adapter/provider, never tool arguments. */
23
86
  export function evaluate(policy, state, request, evidence){
24
87
  const result=(decision,id,reason,requiredEvidence=[])=>({decision,ruleIds:[id],reason,requiredEvidence,expectedRevision:state?.revision??0,proposedTransition:null,coverage:{platform:request?.platform??'unknown',enforcement:'decision-only'}});
@@ -113,6 +176,7 @@ export function evaluate(policy, state, request, evidence){
113
176
  if(d.kind!=='task'||!accepted||task.status!=='accepted'||request.paths.some(p=>!task.paths.some(base=>within(base,p))))return result('hold','git.orchestrator-accept-commit','accepted task verification required',['git-evidence']);
114
177
  }
115
178
  }
179
+ if(request.operation==='plan-amend')return result('hold','runtime.plan-amend','Cooperative plan-amend requires its durable intent and local observer',['plan-amend-intent']);
116
180
  if(EXTERNAL_OPERATIONS.includes(request.operation))return result('hold','external.explicit-user-authority','explicit external target approval must be verified',['external-approval']);
117
181
  if(request.operation==='transition')return result('hold','runtime.transition','result confirmation required',['observed-result']);
118
182
  return result('allow',roleRule,'assigned operation with verified provenance');
@@ -128,11 +192,15 @@ export function evaluateCooperative({policy,state,request,evidence}={}){
128
192
  if(!evidence||!same(evidence.target,state.target))return hold('cooperative.target','Observed target changed');
129
193
  if(!same(evidence.versions,state.versions))return hold('cooperative.versions','Policy or runtime changed');
130
194
  if(request.input&&request.input.expectedRevision!==state.revision)return hold('cooperative.revision','Stale revision');
195
+ const checkpoint=request.command==='record'&&request.input.evidence.kind==='note'&&request.input.evidence.data?.type==='plan-amend-checkpoint';
196
+ if(unresolvedPlan(state)&&((request.command==='prepare')||(request.command==='record'&&!checkpoint)))return hold('cooperative.plan-amend-unresolved','Resolve the same plan-amend intent before further work');
197
+ if(checkpoint)return checkpointCooperativePlanAmend({state,request:request.input,evidence});
131
198
  if(request.command==='record'&&request.input.evidence.kind==='external-approval'&&request.input.actor.role!=='orchestrator')return hold('cooperative.external-approval','Orchestrator records explicit external approval');
132
199
  if(request.command==='record'&&request.input.evidence.kind.startsWith('planner-')||request.command==='prepare'&&request.input.operation==='planner-call')return evaluateCooperativePlanner({state,request,evidence});
133
200
  if(request.command==='prepare'){
134
201
  if(unresolvedExternal(state))return hold('cooperative.external-unresolved','Investigate unresolved external effects before follow-up');
135
202
  if(EXTERNAL_ACTIONS.includes(request.input.operation))return evaluateCooperativeExternal({state,request:request.input,approvalEvidence:state.records.findLast(r=>r.kind==='external-approval'&&r.messageRef===request.input.arguments?.approvalRef)});
203
+ if(request.input.operation==='plan-amend')return prepareCooperativePlanAmend({state,request:request.input,evidence});
136
204
  if(request.input.operation==='accept')return evaluateCooperativeAcceptance({state,request:request.input,evidence});
137
205
  if(request.input.operation==='commit')return prepareCooperativeCommit({state,request:request.input,evidence,gitEvidence:evidence.git});
138
206
  return request.input.operation==='assign-agent'?prepareCooperativeAssignment({policy,state,request:request.input,observations:{target:evidence.target,git:evidence.git}}):evaluateCooperativeVerification({state,request:request.input,evidence});
@@ -206,11 +274,19 @@ export function evaluateCooperativePlanner({state,request,evidence}={}){
206
274
  const kind=input.evidence.kind,ref=input.evidence.messageRef;
207
275
  if(kind==='planner-stage'){
208
276
  if(ledger.stages.some(s=>s.id===data.id))return result('deny','Planner stage ID already exists');
209
- const contract={document:data.document,inputVersion:data.inputVersion,criteriaVersion:data.criteriaVersion,scopeVersion:data.scopeVersion};
277
+ const contract={document:data.document,inputVersion:data.inputVersion,criteriaVersion:data.criteriaVersion,scopeVersion:data.scopeVersion,...(data.targetIntentId?{targetIntentId:data.targetIntentId}:{})};
210
278
  const approval=state.records.find(r=>r.kind==='approval'&&r.messageRef===data.approvalRef&&r.source==='operator-attested'&&r.scopeVersion===state.scopeVersion&&r.targetVersion===state.targetVersion);
211
279
  const approved=approval?.data?.plannerStage;
212
- if(!approved||!same(contract.document,approved.document)||['inputVersion','criteriaVersion','scopeVersion'].some(k=>contract[k]!==approved[k])||contract.scopeVersion!==state.scopeVersion||!within(state.target.realpath,data.document?.path))return result('hold','Approved stage contract and current scope are required');
213
- if(data.document.kind==='plan'&&!approval.data.prerequisiteRefs?.some(ref=>state.records.some(r=>r.kind==='approval'&&r.messageRef===ref&&r.data.kind==='spec'&&r.source==='operator-attested')))return result('hold','Approved spec prerequisite is required for plan drafting');
280
+ if(!approved||!same(contract.document,approved.document)||['inputVersion','criteriaVersion','scopeVersion','targetIntentId'].some(k=>contract[k]!==approved[k])||contract.scopeVersion!==state.scopeVersion||!within(state.target.realpath,data.document?.path))return result('hold','Approved stage contract and current scope are required');
281
+ const activeIntent=state.targetIntents?.find(i=>!state.targetTransitions?.some(t=>t.intentId===i.id));
282
+ if(activeIntent&&data.targetIntentId!==activeIntent.id)return result('hold','First stage must bind the active target intent');
283
+ const preparedIntent=state.targetIntents?.find(i=>state.targetTransitions?.some(t=>t.intentId===i.id&&t.targetVersion===state.targetVersion));
284
+ if(preparedIntent&&data.document.kind==='plan'&&!preparedIntent.documents.some(d=>d.kind==='plan'&&path.join(state.target.realpath,d.path)===data.document.path))return result('hold','Plan path must match the prepared target intent');
285
+ if(data.targetIntentId){const intent=state.targetIntents?.find(i=>i.id===data.targetIntentId);if(!intent||intent.firstStageId!==data.id||intent.sourceTargetVersion!==state.targetVersion||intent.documents.some(d=>d.kind==='spec')&&data.document.kind!=='spec'||!intent.documents.some(d=>d.kind===data.document.kind&&path.join(state.target.realpath,d.path)===data.document.path))return result('hold','Current target intent and matching first document required');}
286
+ const targetIntent=state.targetIntents?.find(i=>i.id===data.targetIntentId);
287
+ const approvedOmission=targetIntent?.depth==='Bounded'&&targetIntent.firstStageId===data.id&&targetIntent.documents.length===1&&targetIntent.documents[0].kind==='plan';
288
+ const approvedSpec=approval.data.prerequisiteRefs?.some(ref=>state.records.some(r=>r.kind==='approval'&&r.messageRef===ref&&r.data.kind==='spec'&&r.source==='operator-attested'&&r.scopeVersion===state.scopeVersion&&r.targetVersion===state.targetVersion&&(!preparedIntent||state.targetDocuments?.some(d=>d.intentId===preparedIntent.id&&preparedIntent.documents.some(doc=>doc.kind==='spec'&&doc.path===d.path)&&d.approvalRef===ref&&d.scopeVersion===state.scopeVersion&&d.targetVersion===state.targetVersion&&[d.path,path.join(state.target.realpath,d.path)].includes(r.data.path)&&d.hash===r.data.contentHash))));
289
+ if(data.document.kind==='plan'&&!approvedOmission&&!approvedSpec)return result('hold','Approved spec prerequisite is required for plan drafting');
214
290
  const previous=[...ledger.stages,...(evidence.plannerHistory??[]).map(h=>h.stage)].filter(s=>s.document.path===data.document.path&&s.document.kind===data.document.kind&&s.document.phase===data.document.phase);
215
291
  if(evidence.plannerHistoryError)return result('hold','Planner history could not be verified: '+evidence.plannerHistoryError);
216
292
  if(previous.length){
@@ -233,8 +309,11 @@ export function evaluateCooperativePlanner({state,request,evidence}={}){
233
309
  if(kind==='planner-result'){
234
310
  const call=stage.calls.find(c=>c.requestId===data.callRequestId);
235
311
  if(!call||stage.pending!==call.requestId||!['reserved','unknown'].includes(call.status)||!['succeeded','failed','unknown'].includes(data.status))return result('hold','A pending Planner call and confirmed result identity are required');
236
- if(data.status==='succeeded'&&(!artifact(data.artifactRef)||artifact(data.artifactRef).purpose!=='draft'||!artifact(data.selfReviewRef)||artifact(data.selfReviewRef).purpose!=='self-review'))return result('hold','Verified full draft and self-review artifacts are required');
237
- call.status=data.status;call.resultRef=ref;stage.result={ref,requestId:call.requestId,status:data.status,artifactRef:data.artifactRef??null,selfReviewRef:data.selfReviewRef??null,source:'operator-attested'};stage.ready=false;
312
+ const chat=data.chatDraft,intent=state.targetIntents?.find(i=>i.id===stage.targetIntentId);
313
+ if(chat&&call.status!=='reserved')return result('hold','Unknown Planner call must be reconciled before checkpoint');
314
+ const chatComplete=!!(intent&&chat?.intentId===intent.id&&typeof chat.draftRef==='string'&&chat.draftRef&&typeof chat.selfReviewRef==='string'&&chat.selfReviewRef&&chat.complete===true);
315
+ if(data.status==='succeeded'&&!chatComplete&&(!artifact(data.artifactRef)||artifact(data.artifactRef).purpose!=='draft'||!artifact(data.selfReviewRef)||artifact(data.selfReviewRef).purpose!=='self-review'))return result('hold','Verified full draft and self-review artifacts are required');
316
+ call.status=data.status;call.resultRef=ref;stage.result={ref,requestId:call.requestId,status:data.status,artifactRef:data.artifactRef??null,selfReviewRef:data.selfReviewRef??null,...(chatComplete?{chatDraft:structuredClone(chat)}:{}),source:'operator-attested'};stage.ready=false;
238
317
  if(data.status!=='unknown')stage.pending=null;
239
318
  return transition(stage,data.status==='unknown'?'Planner execution remains unknown':'Planner result statement recorded',data.status==='unknown'?'hold':'allow',data.status==='unknown');
240
319
  }