@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
@@ -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
  }
@@ -3,6 +3,7 @@ import {inspectPath} from './scope.mjs';
3
3
  import crypto from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import {spawnSync} from 'node:child_process';
6
+ import {constants} from 'node:fs';
6
7
 
7
8
  const oid=value=>typeof value==='string'&&/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
8
9
  const hash=bytes=>'sha256:'+crypto.createHash('sha256').update(bytes).digest('hex');
@@ -142,6 +143,103 @@ export async function readCooperativeCommitGitEvidence({target,commit,gitRunner=
142
143
  return {...evidence,commit:{commit,parent:metadata[1],tree:metadata[2],paths:committedPaths,head:target.head}};
143
144
  }
144
145
 
146
+ // Capture every working-tree entry, including ignored and empty directories.
147
+ // Never follow a symlink or include Git's own mutable workflow directory.
148
+ async function preservationFingerprint(root,planPath){
149
+ const rows=[];let total=0;
150
+ async function visit(dir,relative=''){
151
+ const before=await fs.lstat(dir);if(!before.isDirectory()||before.isSymbolicLink())throw Error('Unsafe worktree directory');
152
+ const names=(await fs.readdir(dir,{encoding:'buffer'})).map(text).sort((a,b)=>Buffer.compare(Buffer.from(a),Buffer.from(b)));
153
+ for(const name of names){
154
+ if(!relative&&name==='.git')continue;
155
+ const rel=relative?relative+'/'+name:name;if(!gitPath(rel))throw Error('Unsafe worktree path');if(rel===planPath)continue;
156
+ const filename=path.join(dir,name),stat=await fs.lstat(filename);if(++total>50000)throw Error('Worktree snapshot too large');
157
+ const mode=stat.mode&0o7777;
158
+ if(stat.isDirectory()&&!stat.isSymbolicLink()){rows.push({path:rel,type:'directory',mode});await visit(filename,rel);}
159
+ else if(stat.isSymbolicLink())rows.push({path:rel,type:'symlink',mode,target:await fs.readlink(filename)});
160
+ else if(stat.isFile()){
161
+ if(stat.size>64*1024*1024)throw Error('Worktree file too large');
162
+ const handle=await fs.open(filename,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);let bytes;
163
+ try{const first=await handle.stat();if(!first.isFile()||first.dev!==stat.dev||first.ino!==stat.ino)throw Error('Worktree file changed');bytes=await handle.readFile();const last=await handle.stat(),named=await fs.lstat(filename);if(first.dev!==last.dev||first.ino!==last.ino||first.size!==last.size||first.mtimeMs!==last.mtimeMs||last.dev!==named.dev||last.ino!==named.ino||last.mode!==named.mode)throw Error('Worktree file changed');}finally{await handle.close();}
164
+ rows.push({path:rel,type:'file',mode,hash:hash(bytes)});
165
+ }else throw Error('Unsupported worktree entry');
166
+ }
167
+ const after=await fs.lstat(dir);if(before.dev!==after.dev||before.ino!==after.ino||before.mtimeMs!==after.mtimeMs||before.mode!==after.mode)throw Error('Worktree directory changed');
168
+ }
169
+ await visit(root);return hash(Buffer.from(JSON.stringify(rows)));
170
+ }
171
+ async function readPlanBytes(filename){
172
+ await inspectPath(filename);const handle=await fs.open(filename,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
173
+ try{const before=await handle.stat();if(!before.isFile()||before.size>1024*1024)throw Error('Invalid plan file');const bytes=await handle.readFile(),after=await handle.stat(),named=await fs.lstat(filename);if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size||before.mtimeMs!==after.mtimeMs||after.dev!==named.dev||after.ino!==named.ino||after.mode!==named.mode)throw Error('Plan file changed');await inspectPath(filename);return {bytes,mode:before.mode&0o111?'100755':'100644'};}finally{await handle.close();}
174
+ }
175
+ // This value is used only as input to an opaque fingerprint and is never
176
+ // persisted or returned. URL credentials and fragment are discarded; a query
177
+ // can affect routing, so its presence requires operator investigation.
178
+ function sanitizedFetchUrl(value){
179
+ if(typeof value!=='string'||!value||/[\x00-\x1f\x7f]/.test(value))throw Error('Invalid remote identity');
180
+ if(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)){
181
+ let parsed;try{parsed=new URL(value);}catch{throw Error('Invalid remote identity');}
182
+ if(!['http:','https:','ssh:','git:','file:'].includes(parsed.protocol)||parsed.protocol!=='file:'&&!parsed.hostname||parsed.search)throw Error('Invalid remote identity');
183
+ parsed.username='';parsed.password='';parsed.hash='';return parsed.toString();
184
+ }
185
+ if(!/^[A-Za-z]:[\\/]/.test(value)){
186
+ const scp=/^(?:[^@/:\s]+@)?(\[[0-9A-Fa-f:.]+\]|[^@/:\s]+):(.+)$/.exec(value);
187
+ if(scp)return `scp:${scp[1].toLowerCase()}:${scp[2]}`;
188
+ }
189
+ return `path:${value}`;
190
+ }
191
+
192
+ /** Local, read-only plan-amend observation. Git arguments and object IDs are
193
+ * checked before transport; operator snapshots never enter the Git runner. */
194
+ export async function readPlanAmendGitEvidence({target,arguments:a,mapping=[],gitRunner=defaultRunner}={}){
195
+ if(!target||!a?.plan||!a?.history||!a?.recovery||!gitPath(a.plan.gitPath)||a.plan.path!==path.join(target.realpath,...a.plan.gitPath.split('/'))||![a.history.base,a.history.planCommit,a.history.head].every(oid)||!/^refs\/arc\/recovery\/[A-Za-z0-9._/-]+$/.test(a.recovery.ref)||a.recovery.ref.includes('..')||a.recovery.ref.includes('//')||!Array.isArray(mapping)||mapping.length>256||mapping.some(m=>!m||!oid(m.old)||!oid(m.new)))throw Error('Invalid plan-amend observation request');
196
+ const run=async args=>{const r=await gitRunner({args,cwd:target.realpath});if(!r||r.status!==0||r.error||!(Buffer.isBuffer(r.stdout)||typeof r.stdout==='string'))throw Error('Plan-amend Git query failed: '+args[0]);return Buffer.from(r.stdout);};
197
+ const line=async args=>{const v=text(await run(args));if(!v.endsWith('\n')||v.slice(0,-1).includes('\n'))throw Error('Invalid Git scalar');return v.slice(0,-1);};
198
+ const before=await readGitEvidence({target,gitRunner});
199
+ const headTree=await line(['rev-parse','--verify',`${target.head}^{tree}`]);if(!oid(headTree))throw Error('Invalid HEAD tree');
200
+ const observeRemotes=async()=>{
201
+ const remoteOutput=text(await run(['remote']));if(remoteOutput&&!remoteOutput.endsWith('\n'))throw Error('Incomplete remote list');
202
+ const names=remoteOutput?remoteOutput.slice(0,-1).split('\n'):[];if(!unique(names)||names.some(name=>!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)))throw Error('Invalid configured remote');
203
+ const remotes=[];for(const name of names.sort()){
204
+ const output=text(await run(['remote','get-url','--all',name]));if(!output||!output.endsWith('\n'))throw Error('Invalid remote identity');
205
+ const urls=output.slice(0,-1).split('\n').map(sanitizedFetchUrl),identities=[...new Set(urls)].sort();if(!identities.length)throw Error('Invalid remote identity');
206
+ remotes.push({name,identity:hash(Buffer.from(JSON.stringify(identities)))});
207
+ }return remotes;
208
+ };
209
+ const observeRecoveryRef=async()=>{
210
+ const result=await gitRunner({args:['show-ref','--verify','--quiet',a.recovery.ref],cwd:target.realpath});
211
+ if(result?.status===0&&!result.error){const value=await line(['rev-parse','--verify',a.recovery.ref]);if(!oid(value))throw Error('Invalid recovery ref');return {name:a.recovery.ref,oid:value};}
212
+ if(result?.status!==1||result.error)throw Error('Recovery ref observation failed');return null;
213
+ };
214
+ const configuredRemotes=await observeRemotes();
215
+ const list=text(await run(['rev-list','--reverse','--topo-order',`${a.history.base}..${a.history.head}`]));
216
+ if(!list.endsWith('\n'))throw Error('Empty or incomplete history');const ids=list.slice(0,-1).split('\n');if(ids.length>256||!ids.every(oid)||!unique(ids))throw Error('Invalid history range');
217
+ const commit=async id=>{
218
+ const raw=await run(['cat-file','commit',id]),separator=raw.indexOf(Buffer.from('\n\n'));if(separator<0)throw Error('Malformed commit');
219
+ const headers=[];for(const header of text(raw.subarray(0,separator)).split('\n')){
220
+ if(header.startsWith(' ')){const previous=headers.at(-1);if(!previous||['tree','parent','committer'].includes(previous.name))throw Error('Malformed commit header');previous.raw+='\n'+header;continue;}
221
+ const parsed=/^([^\s]+) (.*)$/.exec(header);if(!parsed)throw Error('Malformed commit header');headers.push({name:parsed[1],raw:header,value:parsed[2]});
222
+ }
223
+ const fields=name=>headers.filter(header=>header.name===name),trees=fields('tree'),parents=fields('parent'),authors=fields('author'),committers=fields('committer');
224
+ const match=/^author (.*) (\d+) ([+-]\d{4})$/.exec(authors[0]?.raw??'');if(trees.length!==1||!oid(trees[0].value)||parents.length!==1||!oid(parents[0].value)||authors.length!==1||committers.length!==1||!match)throw Error('Expected linear commit metadata');
225
+ const headerTemplate=headers.map(header=>['tree','parent','committer'].includes(header.name)?header.name:header.raw);
226
+ const bytes=await run(['ls-tree','-r','-z','--full-tree',id]);const entries=text(bytes);if(entries&&!entries.endsWith('\0'))throw Error('Incomplete tree');
227
+ const treeEntries=(entries?entries.slice(0,-1).split('\0'):[]).map(row=>{const m=/^(100644|100755|120000|160000) (blob|commit) ([a-f0-9]+)\t([\s\S]+)$/.exec(row);if(!m||!oid(m[3])||!gitPath(m[4]))throw Error('Invalid tree entry');return {path:m[4],mode:m[1],oid:m[3]};});
228
+ if(!unique(treeEntries.map(x=>x.path)))throw Error('Duplicate tree path');
229
+ return {oid:id,parent:parents[0].value,author:match[1],authorDate:match[2]+' '+match[3],message:text(raw.subarray(separator+2)),tree:treeEntries,headerTemplate};
230
+ };
231
+ const commits=[];for(const id of ids)commits.push(await commit(id));
232
+ const newCommits=[];for(const item of mapping)newCommits.push(await commit(item.new));
233
+ const original=await run(['show',`${a.history.planCommit}:${a.plan.gitPath}`]);
234
+ const current=await readPlanBytes(a.plan.path);
235
+ const firstFingerprint=await preservationFingerprint(target.realpath,a.plan.gitPath);
236
+ const recoveryRef=await observeRecoveryRef();
237
+ const after=await readGitEvidence({target,gitRunner}),secondFingerprint=await preservationFingerprint(target.realpath,a.plan.gitPath);
238
+ const latest=await readPlanBytes(a.plan.path),finalRemotes=await observeRemotes(),finalRecoveryRef=await observeRecoveryRef();
239
+ if(!equal(before,after)||firstFingerprint!==secondFingerprint||!latest.bytes.equals(current.bytes)||latest.mode!==current.mode||!equal(configuredRemotes,finalRemotes)||!equal(recoveryRef,finalRecoveryRef))throw Error('Git or worktree changed during observation');
240
+ return {target,index:{tree:before.indexTree,headTree,stagedPaths:before.stagedPaths},worktree:{planClean:!before.workingPaths.includes(a.plan.gitPath)&&!before.untrackedPaths.includes(a.plan.gitPath),nonPlanFingerprint:firstFingerprint},configuredRemotes,history:{base:a.history.base,planCommit:a.history.planCommit,head:a.history.head,commits,newCommits},plan:{path:a.plan.path,content:text(current.bytes),hash:hash(current.bytes),originalHash:hash(original),mode:current.mode},recoveryRef};
241
+ }
242
+
145
243
  const validGitPaths=values=>Array.isArray(values)&&values.every(gitPath)&&unique(values);
146
244
  const within=(base,file)=>{const relative=path.relative(base,file);return relative===''||!path.isAbsolute(relative)&&relative!=='..'&&!relative.startsWith('..'+path.sep);};
147
245
  /** Provenance-neutral Git checks shared by trusted and cooperative callers.
@@ -2,5 +2,5 @@
2
2
  export const ROLES = Object.freeze(['orchestrator','scouter','planner','implementer','qa','reviewer']);
3
3
  export const GIT_OPERATIONS = Object.freeze(['stage','commit','push','merge','rebase','reset','branch-create','branch-switch','worktree-create','worktree-remove']);
4
4
  export const EXTERNAL_OPERATIONS = Object.freeze(['push','merge','deploy','publish','external-message']);
5
- export const OPERATIONS = Object.freeze(['read','draft','write','check','qa','review','temporary-write','operational-query','install','prepare-target','request-approval','assign-agent','accept','transition','scope-expansion','indirect-bypass',...new Set([...GIT_OPERATIONS,...EXTERNAL_OPERATIONS])]);
6
- export const ROLE_OPERATIONS = Object.freeze({orchestrator:['read','write','check','prepare-target','request-approval','assign-agent','accept','transition','install',...GIT_OPERATIONS,...EXTERNAL_OPERATIONS],scouter:['read'],planner:['read','draft'],implementer:['read','write','check'],qa:['read','check','temporary-write'],reviewer:['read','review']});
5
+ export const OPERATIONS = Object.freeze(['read','draft','write','check','qa','review','temporary-write','operational-query','install','prepare-target','request-approval','assign-agent','accept','transition','plan-amend','scope-expansion','indirect-bypass',...new Set([...GIT_OPERATIONS,...EXTERNAL_OPERATIONS])]);
6
+ export const ROLE_OPERATIONS = Object.freeze({orchestrator:['read','write','check','prepare-target','request-approval','assign-agent','accept','transition','plan-amend','install',...GIT_OPERATIONS,...EXTERNAL_OPERATIONS],scouter:['read'],planner:['read','draft'],implementer:['read','write','check'],qa:['read','check','temporary-write'],reviewer:['read','review']});
@@ -1,5 +1,6 @@
1
1
  import {verifiedCooperativeReconciliation} from './acceptance.mjs';
2
2
  import {verifiedReconciliation} from './gate.mjs';
3
+ import {applyTargetPreparationTransition} from './target-preparation.mjs';
3
4
  import fs from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import crypto from 'node:crypto';
@@ -82,3 +83,12 @@ export function applyCooperativeCommitTransition({state,decision}){
82
83
  next.target=structuredClone(transition.target);next.commits.push(structuredClone(transition.commit));next.tasks.find(t=>t.id===transition.commit.taskId).status='committed';
83
84
  return next;
84
85
  }
86
+
87
+ /** Target preparation changes identity only after re-evaluating its captured observation. */
88
+ export function applyCooperativeTargetTransition({state,decision,messageRef}){
89
+ const next=applyTargetPreparationTransition({state,decision});
90
+ if(next.targetVersion!==state.targetVersion+1)throw Error('Verified target preparation transition required');
91
+ next.targetHistory.push({target:structuredClone(state.target),revision:state.revision+1,source:'local-observation',kind:'target-preparation-source'});
92
+ next.targetTransitions.at(-1).messageRef=messageRef;
93
+ return next;
94
+ }