@yunhozz/arc 0.1.9 → 0.1.10

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 (88) hide show
  1. package/MANIFEST.sha256 +85 -60
  2. package/README.md +1 -1
  3. package/claude/agents/{arc-coder.md → arc-implementer.md} +7 -7
  4. package/claude/agents/arc-planner.md +4 -4
  5. package/claude/agents/arc-qa.md +4 -4
  6. package/claude/agents/arc-reviewer.md +5 -5
  7. package/claude/agents/arc-scouter.md +3 -3
  8. package/claude/skill/SKILL.md +7 -7
  9. package/claude/skill/models.toml +3 -3
  10. package/claude/skill/references/commit.md +22 -7
  11. package/claude/skill/references/coordination.md +25 -18
  12. package/claude/skill/references/git-recovery.md +1 -1
  13. package/claude/skill/references/handoff-prep.md +25 -2
  14. package/claude/skill/references/handoff.md +106 -24
  15. package/claude/skill/references/planning.md +73 -22
  16. package/claude/skill/references/verification.md +155 -17
  17. package/claude/skill/runtime/acceptance.mjs +68 -0
  18. package/claude/skill/runtime/assignment.mjs +42 -0
  19. package/claude/skill/runtime/bootstrap.mjs +35 -0
  20. package/claude/skill/runtime/cooperative-schema.mjs +55 -0
  21. package/claude/skill/runtime/cooperative.mjs +136 -0
  22. package/claude/skill/runtime/evaluate.mjs +40 -4
  23. package/claude/skill/runtime/external.mjs +51 -0
  24. package/claude/skill/runtime/gate.mjs +7 -7
  25. package/claude/skill/runtime/git-evidence.mjs +46 -0
  26. package/claude/skill/runtime/operations.mjs +2 -2
  27. package/claude/skill/runtime/role-policy.json +64 -19
  28. package/claude/skill/runtime/schema.mjs +1 -0
  29. package/claude/skill/runtime/scope.mjs +36 -0
  30. package/claude/skill/runtime/store.mjs +25 -4
  31. package/claude/skill/runtime/verification.mjs +123 -0
  32. package/claude/skill/scripts/model_config.py +2 -2
  33. package/codex/agents/{arc-coder.toml → arc-implementer.toml} +8 -8
  34. package/codex/agents/arc-planner.toml +6 -6
  35. package/codex/agents/arc-qa.toml +6 -6
  36. package/codex/agents/arc-reviewer.toml +6 -6
  37. package/codex/agents/arc-scouter.toml +5 -5
  38. package/codex/skill/SKILL.md +9 -9
  39. package/codex/skill/models.toml +3 -3
  40. package/codex/skill/references/commit.md +23 -8
  41. package/codex/skill/references/coordination.md +25 -18
  42. package/codex/skill/references/git-recovery.md +2 -2
  43. package/codex/skill/references/handoff-prep.md +25 -2
  44. package/codex/skill/references/handoff-worktree.md +4 -4
  45. package/codex/skill/references/handoff.md +116 -34
  46. package/codex/skill/references/planning.md +76 -23
  47. package/codex/skill/references/verification.md +160 -22
  48. package/codex/skill/runtime/acceptance.mjs +68 -0
  49. package/codex/skill/runtime/assignment.mjs +42 -0
  50. package/codex/skill/runtime/bootstrap.mjs +35 -0
  51. package/codex/skill/runtime/cooperative-schema.mjs +55 -0
  52. package/codex/skill/runtime/cooperative.mjs +136 -0
  53. package/codex/skill/runtime/evaluate.mjs +40 -4
  54. package/codex/skill/runtime/external.mjs +51 -0
  55. package/codex/skill/runtime/gate.mjs +7 -7
  56. package/codex/skill/runtime/git-evidence.mjs +46 -0
  57. package/codex/skill/runtime/operations.mjs +2 -2
  58. package/codex/skill/runtime/role-policy.json +64 -19
  59. package/codex/skill/runtime/schema.mjs +1 -0
  60. package/codex/skill/runtime/scope.mjs +36 -0
  61. package/codex/skill/runtime/store.mjs +25 -4
  62. package/codex/skill/runtime/verification.mjs +123 -0
  63. package/codex/skill/scripts/model_config.py +3 -3
  64. package/lib/cli/root.mjs +1 -0
  65. package/lib/cli/workflow.mjs +21 -0
  66. package/lib/installer/manifest.mjs +1 -1
  67. package/lib/installer/plan.mjs +27 -13
  68. package/lib/installer/targets.mjs +1 -1
  69. package/lib/models/config.mjs +1 -1
  70. package/lib/models/profiles.mjs +1 -1
  71. package/lib/policy/placement.mjs +6 -6
  72. package/lib/policy/render.mjs +3 -3
  73. package/lib/policy/role-policy.json +64 -19
  74. package/lib/runtime/acceptance.mjs +68 -0
  75. package/lib/runtime/assignment.mjs +42 -0
  76. package/lib/runtime/bootstrap.mjs +35 -0
  77. package/lib/runtime/cooperative-schema.mjs +55 -0
  78. package/lib/runtime/cooperative.mjs +136 -0
  79. package/lib/runtime/evaluate.mjs +40 -4
  80. package/lib/runtime/external.mjs +51 -0
  81. package/lib/runtime/gate.mjs +7 -7
  82. package/lib/runtime/git-evidence.mjs +46 -0
  83. package/lib/runtime/operations.mjs +2 -2
  84. package/lib/runtime/schema.mjs +1 -0
  85. package/lib/runtime/scope.mjs +36 -0
  86. package/lib/runtime/store.mjs +25 -4
  87. package/lib/runtime/verification.mjs +123 -0
  88. package/package.json +1 -1
@@ -0,0 +1,136 @@
1
+ import {EXTERNAL_ACTIONS,reconcileCooperativeExternal,cooperativeSupport} from './external.mjs';
2
+ import {reconcileCooperativeCommit} from './acceptance.mjs';
3
+ import {evaluateCooperativeVerification,invalidateAffectedEvidence,readCooperativeChanges} from './verification.mjs';
4
+ import {readAssignmentGitEvidence,readCooperativeCommitGitEvidence} from './git-evidence.mjs';
5
+ import {recordCooperativeAssignmentResult} from './assignment.mjs';
6
+ import fs from 'node:fs/promises';
7
+ import {validateCooperativeCommand,validateCooperativeState} from './cooperative-schema.mjs';
8
+ import {observeCooperativeTarget,validateObservedTarget,validateWorkflowId,workflowStatePath,inspectPath,absolute,within} from './scope.mjs';
9
+ import {fingerprintRequest,saveDurableState,applyCooperativeCommitTransition} from './store.mjs';
10
+ import {commandResult,cooperativeVersions} from './bootstrap.mjs';
11
+ import {evaluateCooperative,reconcileCooperativeResult} from './evaluate.mjs';
12
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
13
+ const sameCheckout=(a,b)=>['realpath','gitCommonDir','gitDir'].every(k=>a[k]===b[k]);
14
+ /** Only trusted code may inject observation/storage. CLI JSON cannot set either. */
15
+ export async function runCooperativeCommand({cwd,workflowId,command,input,observe=observeCooperativeTarget,storage={},readGit=readAssignmentGitEvidence,readChanges=readCooperativeChanges,readCommitGit=readCooperativeCommitGitEvidence}){
16
+ validateWorkflowId(workflowId);validateCooperativeCommand({command,input});
17
+ if(command==='init')throw Error('Use initializeCooperativeWorkflow for init');
18
+ const target=await validateObservedTarget(cwd,await observe(cwd)),statePath=await workflowStatePath(target,workflowId);
19
+ const read=async()=>{await inspectPath(statePath);const state=validateCooperativeState(JSON.parse(await fs.readFile(statePath,'utf8')));if(state.workflowId!==workflowId)throw Error('Workflow identity mismatch');return state;};
20
+ const result=(state,decision,extra={})=>commandResult({workflowId,state,input,...decision,...extra});
21
+ if(command==='status'){
22
+ const state=await read();
23
+ if(!sameCheckout(state.target,target))return result(undefined,{decision:'hold',ruleIds:['cooperative.target']});
24
+ const changes=state.tasks.length?await readChanges({state}):undefined;
25
+ const evaluation=evaluateCooperative({state,request:{command,input},evidence:{target,versions:await cooperativeVersions(),changes}});
26
+ const unresolved=state.unresolved.some(entry=>!entry.resolvedBy)||state.intents.some(i=>['prepared','unknown','mismatch'].includes(i.status)&&!(['check','review'].includes(i.operation)&&i.stale));
27
+ return {...result(state,evaluation.decision==='allow'&&unresolved?{decision:'hold',ruleIds:['cooperative.unresolved']}:evaluation,{persisted:true}),state,support:cooperativeSupport(),...(changes?{changeFingerprints:changes}:{})};
28
+ }
29
+ let lock;
30
+ try{
31
+ lock=await fs.open(statePath+'.lock','wx',0o600);let state=await read();
32
+ if(!sameCheckout(state.target,target))return result(state,{decision:'hold',ruleIds:['cooperative.target']});
33
+ const requestFingerprint=fingerprintRequest({command,input});const prior=state.requests.find(r=>r.requestId===input.requestId);
34
+ if(prior){if(prior.requestFingerprint!==requestFingerprint)throw Error('Request fingerprint conflict');return result(state,{decision:'hold',ruleIds:['cooperative.replay']},{replayed:true});}
35
+ if(!['reconcile','result'].includes(command)&&input.expectedRevision!==state.revision)return result(state,{decision:'hold',ruleIds:[['prepare','result'].includes(command)?'cooperative.unsupported':'cooperative.revision']});
36
+ const currentTarget=await validateObservedTarget(cwd,await observe(cwd));
37
+ if(!sameCheckout(state.target,currentTarget))return result(state,{decision:'hold',ruleIds:['cooperative.target']});
38
+ const versions=await cooperativeVersions();
39
+ let git,gitError;
40
+ if(command==='prepare'&&input.operation==='assign-agent'&&input.arguments&&same(currentTarget,state.target)){
41
+ try{git=await readGit({target:currentTarget,documents:input.arguments?.documents??[]});}catch(error){gitError=error.message;}
42
+ }
43
+ const commitIntent=['result','reconcile'].includes(command)?state.intents.find(i=>i.requestId===input.intentId&&i.operation==='commit'):undefined;
44
+ if(command==='prepare'&&['accept','commit'].includes(input.operation)||commitIntent){
45
+ try{git=await readCommitGit({target:currentTarget,...(commitIntent&&input.result?.data?.commit?{commit:input.result.data.commit}:{})});}catch(error){gitError=error.message;}
46
+ }
47
+ const changes=state.tasks.length?await readChanges({state}):undefined;
48
+ let decision=evaluateCooperative({state,request:{command,input},evidence:{target:currentTarget,versions,git,changes}});
49
+ if(gitError)decision={decision:'hold',ruleIds:[input.operation==='assign-agent'?'cooperative.documents':'cooperative.git-evidence'],reason:gitError};
50
+ if(command==='prepare'&&decision.decision==='allow'){
51
+ if(decision.proposedTransition?.intent)state.intents.push(decision.proposedTransition.intent);
52
+ if(decision.proposedTransition?.acceptance){state.acceptances.push(decision.proposedTransition.acceptance);state.tasks.find(t=>t.id===input.taskId).status='accepted';}
53
+ }
54
+ if(['reconcile','result'].includes(command)&&input.result){
55
+ if(input.expectedRevision!==state.revision)state.unresolved.push({kind:'stale-result',requestId:input.requestId,intentId:input.intentId,expectedRevision:input.expectedRevision,revision:state.revision+1});
56
+ let intent=state.intents.find(i=>i.requestId===input.intentId);
57
+ 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}});
58
+ if(['assign-agent','check','review','commit',...EXTERNAL_ACTIONS].includes(intent?.operation)&&!same(versions,state.versions))decision={...decision,decision:'hold',ruleIds:['cooperative.versions']};
59
+ if(input.expectedRevision!==state.revision)decision={...decision,decision:'hold',ruleIds:['cooperative.revision']};
60
+ if((intent?.operation==='commit'||EXTERNAL_ACTIONS.includes(intent?.operation))&&input.actor.role!=='orchestrator')decision={...decision,decision:'hold',ruleIds:['cooperative.acceptance-actor']};
61
+ if(['check','review'].includes(intent?.operation)&&!same(currentTarget,state.target))decision={...decision,decision:'hold',ruleIds:['cooperative.target']};
62
+ if(intent?.operation==='commit'){
63
+ const verified=decision.proposedTransition;
64
+ // Failed reconciliation records what actually happened without changing target.
65
+ const observation={intentId:input.intentId,revision:state.revision+1,observation:verified?.observation??git??null,source:'local-git',...(gitError?{error:gitError}:{})};
66
+ if(decision.decision==='allow'){
67
+ state=applyCooperativeCommitTransition({state,decision});
68
+ intent=state.intents.find(i=>i.requestId===input.intentId);
69
+ const committedChanges=await readChanges({state}),commit=state.commits.at(-1);commit.postCommitFingerprint=committedChanges.tasks[intent.taskId];
70
+ if(committedChanges.taskSnapshots){
71
+ commit.snapshot=structuredClone(committedChanges.taskSnapshots[intent.taskId]);
72
+ const changedPaths=commit.paths.map(p=>state.target.realpath+'/'+p);
73
+ for(const prior of state.commits.slice(0,-1)){
74
+ const snapshot=prior.snapshotConnections?.at(-1)?.snapshot??prior.snapshot,current=committedChanges.taskSnapshots[prior.taskId];
75
+ if(!snapshot||!current||!changedPaths.some(p=>snapshot.paths.some(base=>within(base,p))))continue;
76
+ const projected=structuredClone(snapshot);for(const key of ['index','rows'])projected[key]=[...snapshot[key].filter(row=>!changedPaths.includes(row.path)),...current[key].filter(row=>changedPaths.includes(row.path))].sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
77
+ prior.snapshotConnections??=[];prior.snapshotConnections.push({commit:commit.commit,requestId:input.requestId,snapshot:projected,fingerprint:fingerprintRequest(projected)});prior.postCommitFingerprint=fingerprintRequest(projected);
78
+ }
79
+ }
80
+ for(const entry of state.unresolved)if(entry.kind==='target-changed'&&entry.intentId===input.intentId)entry.resolvedBy=input.requestId;
81
+ }
82
+ state.commitObservations??=[];state.commitObservations.push(observation);
83
+ if(decision.decision!=='allow'&&verified?.result?.status==='succeeded')verified.result.status='mismatch';
84
+ }
85
+ const verification=decision.proposedTransition;
86
+ if(verification?.check){
87
+ if(decision.decision!=='allow'&&verification.check.status==='passed'){verification.check.status='unverified';verification.check.stale=true;verification.findings=[];verification.result.status='mismatch';}
88
+ state.checks.push(verification.check);for(const finding of verification.findings){const index=state.findings.findIndex(f=>f.id===finding.id);if(index<0)state.findings.push(finding);else state.findings[index]=finding;}
89
+ }
90
+ if(intent?.operation==='assign-agent'&&decision.decision!=='allow'&&decision.proposedTransition?.result&&['succeeded','failed'].includes(decision.proposedTransition.result.status))decision.proposedTransition.result.status='mismatch';
91
+ if(EXTERNAL_ACTIONS.includes(intent?.operation)&&decision.decision!=='allow'&&decision.proposedTransition?.result&&(decision.proposedTransition.result.status==='succeeded'||decision.ruleIds[0]!=='cooperative.external'))decision.proposedTransition.result.status='mismatch';
92
+ 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};}
93
+ const actualResult=decision.proposedTransition?.result??input.result;
94
+ // Preserve actual statements even when runtime/HEAD changed, without rebinding target.
95
+ 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});}
96
+ if(!same(versions,state.versions))state.unresolved.push({kind:'versions-changed',revision:state.revision+1});
97
+ state.results.push({...actualResult,requestId:input.intentId,recordRequestId:input.requestId,actor:input.actor,source:'operator-attested'});
98
+ if(intent&&(['assign-agent','check','review','commit',...EXTERNAL_ACTIONS].includes(intent.operation)?decision.proposedTransition?.result&&['prepared','unknown','mismatch'].includes(intent.status):true)){
99
+ intent.status=actualResult.status;
100
+ 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});
101
+ }
102
+ else state.unresolved.push({kind:'unmatched-result',requestId:input.intentId,revision:state.revision+1});
103
+ }else if(command==='record'&&decision.decision==='allow'){
104
+ const entry={...input.evidence,actor:input.actor,source:'operator-attested',requestId:input.requestId,revision:state.revision+1,scopeVersion:state.scopeVersion,targetVersion:state.targetVersion};
105
+ if(entry.kind==='scope'){
106
+ 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');
107
+ state=invalidateAffectedEvidence({state,changedVersions:{scopeVersion:state.scopeVersion+1},affectedTaskIds:entry.data.affectedTaskIds});entry.scopeVersion=state.scopeVersion;state.scopes.push(entry);
108
+ state.unresolved.push({kind:'scope-changed',revision:state.revision+1,scopeVersion:state.scopeVersion,affectedTaskIds:state.tasks.filter(t=>t.invalidatedBy?.scopeVersion===state.scopeVersion).map(t=>t.id)});
109
+ }
110
+ if(['document','approval'].includes(entry.kind)&&state.tasks.length)state=invalidateAffectedEvidence({state,changedVersions:{[entry.kind+'Version']:entry.revision},affectedTaskIds:entry.data.affectedTaskIds});
111
+ if(decision.proposedTransition?.task){const task=decision.proposedTransition.task,index=state.tasks.findIndex(t=>t.id===task.id);if(index>=0){state=invalidateAffectedEvidence({state,changedVersions:{taskVersion:entry.revision},affectedTaskIds:[task.id]});state.tasks[index]=task;}else state.tasks.push(task);}
112
+ if(decision.proposedTransition?.finding){const finding=decision.proposedTransition.finding,index=state.findings.findIndex(f=>f.id===finding.id);if(index<0)state.findings.push(finding);else state.findings[index]=finding;}
113
+ state.records.push(entry);
114
+ if(decision.proposedTransition?.task)for(const marker of state.unresolved.filter(u=>u.kind==='scope-changed'&&!u.resolvedBy&&u.scopeVersion<=state.scopeVersion)){
115
+ const ids=marker.affectedTaskIds?.length?marker.affectedTaskIds:state.tasks.map(t=>t.id),proof=[];let ready=ids.length>0;
116
+ for(const id of ids){
117
+ const task=state.tasks.find(t=>t.id===id),approval=state.records.findLast(r=>r.kind==='approval'&&(!r.data.affectedTaskIds||r.data.affectedTaskIds.includes(id)));
118
+ if(!task||task.invalidated||task.scopeVersion!==state.scopeVersion||!approval||approval.revision<=marker.revision||approval.scopeVersion!==state.scopeVersion||approval.targetVersion!==state.targetVersion||task.approvalRef!==approval.messageRef||task.approvalVersion!==approval.data.approvalVersion||task.documentVersion!==approval.data.documentVersion){ready=false;break;}
119
+ const documents=approval.data.documents;
120
+ if(!Array.isArray(documents)||documents.length!==2||!['spec','plan'].every(kind=>documents.some(d=>d.kind===kind))){ready=false;break;}
121
+ try{const observed=await readGit({target:currentTarget,documents});if(!same(observed.target,currentTarget)||!observed.docsCommit||!same(observed.docsCommit.documents,documents)||documents.some(d=>d.commit!==observed.docsCommit.commit||d.approvalRef!==approval.messageRef||!observed.documents?.some(o=>o.path===d.path&&o.gitPath===d.gitPath&&o.observedHash===d.contentHash&&o.currentHash===d.contentHash))){ready=false;break;}}
122
+ catch{ready=false;break;}
123
+ const scope=state.scopes.find(s=>s.scopeVersion===state.scopeVersion);if(!scope||task.paths.some(p=>!scope.data.paths.some(base=>within(base,p)))){ready=false;break;}
124
+ proof.push({taskId:id,taskRef:task.messageRef,approvalRef:approval.messageRef,documents:structuredClone(documents)});
125
+ }
126
+ if(ready){marker.resolvedBy=input.requestId;marker.resolutionEvidence={taskIds:ids,contracts:proof,scopeVersion:state.scopeVersion};}
127
+ }
128
+ }
129
+ // A held request is durable audit, never an executable intent.
130
+ state.revision++;
131
+ const output=result(state,decision,{persisted:true});
132
+ state.requests.push({requestId:input.requestId,requestFingerprint,request:structuredClone({command,input}),decision:output});
133
+ state.audit.push({command,requestId:input.requestId,revision:state.revision,decision:output.decision,ruleIds:output.ruleIds,source:'operator-attested',actor:input.actor,...(input.intentId?{intentId:input.intentId,resolution:state.results.at(-1)?.status}:{}),...(input.operation?{operation:input.operation}:{})});
134
+ validateCooperativeState(state);await inspectPath(statePath);await saveDurableState({statePath,state,storage});return output;
135
+ }finally{if(lock){await lock.close();await fs.unlink(statePath+'.lock');}}
136
+ }
@@ -1,3 +1,8 @@
1
+ import {evaluateCooperativeExternal,reconcileCooperativeExternal,EXTERNAL_ACTIONS,unresolvedExternal} from './external.mjs';
2
+ export {evaluateCooperativeExternal,reconcileCooperativeExternal};
3
+ import {evaluateCooperativeAcceptance,prepareCooperativeCommit} from './acceptance.mjs';
4
+ import {evaluateCooperativeVerification} from './verification.mjs';
5
+ import {prepareCooperativeAssignment} from './assignment.mjs';
1
6
  /**
2
7
  * @typedef {object} GateDecision
3
8
  * @property {'allow'|'deny'|'hold'} decision
@@ -9,6 +14,7 @@
9
14
  * @property {object} coverage
10
15
  */
11
16
  import path from 'node:path';
17
+ import {validateCooperativeState,validateCooperativeCommand} from './cooperative-schema.mjs';
12
18
  import {validateWorkflowState, validateRequest, validateEvidence} from './schema.mjs';
13
19
  import {ROLES, ROLE_OPERATIONS, GIT_OPERATIONS, EXTERNAL_OPERATIONS} from './operations.mjs';
14
20
  const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
@@ -21,7 +27,7 @@ export function evaluate(policy, state, request, evidence){
21
27
  const actor=state.identities.find(i=>i.actorRef===request.actorRef);
22
28
  if(!actor)return result('hold','runtime.identity','platform identity unresolved',['platform-identity']);
23
29
  const role=actor.role;
24
- const roleRule={orchestrator:'role.orchestrator-boundary',scouter:'role.scouter-read-only',planner:'role.planner-draft-only',coder:'role.coder-implementation',qa:'role.qa-verification',reviewer:'role.reviewer-independent'}[role];
30
+ const roleRule={orchestrator:'role.orchestrator-boundary',scouter:'role.scouter-read-only',planner:'role.planner-draft-only',implementer:'role.implementer-implementation',qa:'role.qa-verification',reviewer:'role.reviewer-independent'}[role];
25
31
  if(!ROLE_OPERATIONS[role]?.includes(request.operation))return result('deny',GIT_OPERATIONS.includes(request.operation)?'git.specialists-deny':roleRule,'operation forbidden for assigned role');
26
32
  const lease=state.leases.find(l=>l.actorRef===actor.actorRef);
27
33
  if(lease?.status==='retired')return result('deny','runtime.identity','retired identity');
@@ -44,7 +50,7 @@ export function evaluate(policy, state, request, evidence){
44
50
  const fact=kind=>evidence.facts?.find(f=>f.kind===kind&&f.workflowId===state.workflowId&&f.revision===state.revision&&same(f.target,state.target)&&f.policyHash===state.versions.policyHash&&evidence.provenance.some(p=>p.id===f.sourceRef));
45
51
  const task=state.tasks.find(t=>t.id===request.taskId);
46
52
  if(!task)return result('hold','runtime.assignment','task unavailable',['task']);
47
- if(role==='coder'&&['accepted','committed'].includes(task.status))return result('deny','runtime.identity','task execution ended');
53
+ if(role==='implementer'&&['accepted','committed'].includes(task.status))return result('deny','runtime.identity','task execution ended');
48
54
  if(request.operation==='temporary-write'){
49
55
  const temporary=assignment.temporary;
50
56
  if(!temporary||temporary.cleanupOwner!==request.actorRef||!Number.isFinite(Date.parse(temporary.expiresAt))||!Number.isFinite(Date.parse(evidence.observedAt))||Date.parse(temporary.expiresAt)<=Date.parse(evidence.observedAt)||!request.paths.every(p=>temporary.paths?.some(base=>within(base,p))))return result('hold','role.qa-verification','temporary scope lifetime and cleanup ownership required',['temporary-assignment']);
@@ -58,7 +64,7 @@ export function evaluate(policy, state, request, evidence){
58
64
  if(!ROLES.includes(d.role)||d.taskId!==request.taskId)return result('deny','runtime.identity','invalid assignment');
59
65
  const previous=state.identities.find(i=>i.actorRef===d.actorRef);
60
66
  if(previous&&(previous.taskId!==d.taskId||state.leases.some(l=>l.actorRef===d.actorRef&&l.status==='retired')))return result('deny','runtime.identity','identity cannot cross tasks or revive');
61
- if(d.role==='coder'){
67
+ if(d.role==='implementer'){
62
68
  if(state.mode==='plan'){
63
69
  // T3 supplies verified Git object evidence. Local assertions never satisfy this gate.
64
70
  if(!state.documents.some(d=>d.kind==='spec')||!state.documents.some(d=>d.kind==='plan'))return result('hold','runtime.docs-commit','approved complete spec and plan required',['approved-documents','docs-only-commit']);
@@ -72,7 +78,7 @@ export function evaluate(policy, state, request, evidence){
72
78
  const expected={requestId:request.requestId,platform:request.platform,...d};
73
79
  const correlationProof=evidence.provenance.find(p=>p.id===correlation?.sourceRef&&p.source==='platform-event'&&p.verification.sourceRef===p.eventRef&&p.platform===request.platform&&p.actorRef===request.actorRef&&p.correlationRef===d.correlationRef&&p.requestId===request.requestId);
74
80
  if(d.actorRef!==undefined||!d.correlationRef||!correlationProof||!same(correlation?.data,expected))return result('hold','runtime.identity','trusted platform request correlation required',['assignment-correlation']);
75
- if(!Array.isArray(d.paths)||!d.paths.length||!same([...d.paths].sort(),[...request.paths].sort())||!Array.isArray(d.operations)||!d.operations.length||d.approvalRef!==approval.id||!approval.scope.roles?.includes(d.role)||d.paths.some(p=>!task.paths.some(base=>within(base,p)))||d.operations.some(op=>!ROLE_OPERATIONS[d.role].includes(op)||!approval.scope.operations.includes(op)))return result('hold','runtime.assignment','delegated role paths operations and approval must match',['delegated-scope']);
81
+ if(!Array.isArray(d.paths)||!d.paths.length||!same([...d.paths].sort(),[...request.paths].sort())||!Array.isArray(d.operations)||!d.operations.length||d.approvalRef!==approval.id||!approval.scope.roles?.some(role=>role===d.role)||d.paths.some(p=>!task.paths.some(base=>within(base,p)))||d.operations.some(op=>!ROLE_OPERATIONS[d.role].includes(op)||!approval.scope.operations.includes(op)))return result('hold','runtime.assignment','delegated role paths operations and approval must match',['delegated-scope']);
76
82
  if(d.checks&&(!Array.isArray(approval.scope.checks)||d.checks.some(c=>!approval.scope.checks.some(approved=>same(approved,c)))))return result('hold','runtime.assignment','delegated checks not approved',['delegated-checks']);
77
83
  if(d.temporary&&!same(d.temporary,approval.scope.temporary))return result('hold','runtime.assignment','delegated temporary scope not approved',['delegated-temporary-scope']);
78
84
  if(state.assignments.some(a=>a.assignmentId===request.requestId||a.correlationRef===d.correlationRef))return result('hold','runtime.identity','assignment correlation already registered',['new-assignment-correlation']);
@@ -101,3 +107,33 @@ export function evaluate(policy, state, request, evidence){
101
107
  if(request.operation==='transition')return result('hold','runtime.transition','result confirmation required',['observed-result']);
102
108
  return result('allow',roleRule,'assigned operation with verified provenance');
103
109
  }
110
+
111
+ /** Cooperative statements never enter provider validation or identity resolution. */
112
+ export function evaluateCooperative({policy,state,request,evidence}={}){
113
+ const base={expectedRevision:state?.revision??0,coverage:{enforcement:'cooperative',provenance:'operator-attested',nativeVerified:false}};
114
+ const hold=(id,reason)=>({...base,decision:'hold',ruleIds:[id],reason,requiredEvidence:[],nextActions:[],proposedTransition:null});
115
+ try{validateCooperativeState(state);validateCooperativeCommand(request);}catch(error){return hold('cooperative.schema',error.message);}
116
+ if(policy!==undefined&&policy.workflowKind!=='cooperative')return hold('cooperative.contract','Explicit cooperative policy required');
117
+ if(state.mode!=='plan'||request.command==='result')return hold('cooperative.unsupported','Operation is not supported in this CLI phase');
118
+ if(!evidence||!same(evidence.target,state.target))return hold('cooperative.target','Observed target changed');
119
+ if(!same(evidence.versions,state.versions))return hold('cooperative.versions','Policy or runtime changed');
120
+ if(request.input&&request.input.expectedRevision!==state.revision)return hold('cooperative.revision','Stale revision');
121
+ 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');
122
+ if(request.command==='prepare'){
123
+ if(unresolvedExternal(state))return hold('cooperative.external-unresolved','Investigate unresolved external effects before follow-up');
124
+ 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)});
125
+ if(request.input.operation==='accept')return evaluateCooperativeAcceptance({state,request:request.input,evidence});
126
+ if(request.input.operation==='commit')return prepareCooperativeCommit({state,request:request.input,evidence,gitEvidence:evidence.git});
127
+ 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});
128
+ }
129
+ if(request.command==='record'&&(['check','review','finding'].includes(request.input.evidence.kind)||request.input.evidence.kind==='acceptance-evidence'&&request.input.evidence.data.kind==='task'))return evaluateCooperativeVerification({state,request:{...request.input,operation:'verification-record'},evidence});
130
+ if(request.command==='status'&&(state.tasks.some(t=>t.invalidated||t.status==='committed'&&!state.commits.some(c=>c.taskId===t.id&&c.postCommitFingerprint===evidence.changes?.tasks?.[t.id]))||state.findings.some(f=>f.status!=='closed')||state.checks.some((c,index)=>!(c.scope==='task'&&state.tasks.some(t=>t.id===c.taskId&&t.status==='committed'))&&!state.checks.slice(index+1).some(next=>next.scope===c.scope&&next.taskId===c.taskId&&same(next.descriptor,c.descriptor))&&(c.stale||c.status!=='passed'||c.changeFingerprint!==(c.scope==='final'?evidence.changes?.final:evidence.changes?.tasks?.[c.taskId])))))return hold('cooperative.verification-stale','Verification or findings require current evidence');
131
+ return {...base,decision:'allow',ruleIds:['cooperative.recorded'],requiredEvidence:[],nextActions:[],proposedTransition:null};
132
+ }
133
+
134
+ /** Result statements preserve uncertainty. They do not authenticate native events. */
135
+ export function reconcileCooperativeResult({state,intent,result,observations}={}){
136
+ const base={expectedRevision:state?.revision??0,coverage:{enforcement:'cooperative',provenance:'operator-attested',nativeVerified:false},requiredEvidence:[],nextActions:[],proposedTransition:null};
137
+ try{validateCooperativeState(state);}catch(error){return {...base,decision:'hold',ruleIds:['cooperative.schema'],reason:error.message};}
138
+ return {...base,decision:'hold',ruleIds:[!intent?'cooperative.intent':result?.status==='unknown'?'cooperative.unknown':'cooperative.reconciliation'],requiredEvidence:[],nextActions:[],proposedTransition:null,reason:!intent?'No matching durable intent':'Recorded operator statement requires reconciliation; no execution authorized'};
139
+ }
@@ -0,0 +1,51 @@
1
+ import {fingerprintRequest} from './store.mjs';
2
+ import {absolute,within} from './scope.mjs';
3
+ export const EXTERNAL_ACTIONS=Object.freeze(['push','merge','deploy','publish']);
4
+ const keys={push:['repository','destination','refspec','sourceOid','expectedRemoteOid'],merge:['repository','changeRef','targetRef','expectedTargetOid'],deploy:['service','project','environment','artifactId','destination'],publish:['service','namespace','name','version','artifactHash','destination']};
5
+ const text=v=>typeof v==='string'&&v.length>0;
6
+ const same=(a,b)=>a===undefined||b===undefined?a===b:fingerprintRequest(a)===fingerprintRequest(b);
7
+ const fields=(v,allowed)=>v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).length===allowed.length&&allowed.every(k=>Object.hasOwn(v,k));
8
+ /** Reject credentials before audit persistence; errors never contain input values. */
9
+ export function assertSecretFree(value){
10
+ if(typeof value==='string'){
11
+ if(/(?:[a-z][a-z0-9+.-]*:\/\/|^)[^\s/]+:[^\s/]+@|[?&#](?:access[_-]?token|token|key|password|secret|signature|credential|auth)=|\b(?:Bearer|Basic)\s+\S+/i.test(value))throw Error('Secret-free identifiers required');
12
+ }else if(value&&typeof value==='object')for(const [key,item] of Object.entries(value)){
13
+ if(/^(?:password|passwd|token|accessToken|access_token|secret|credentials?|authorization|apiKey|api_key|privateKey)$/i.test(key))throw Error('Secret-free identifiers required');assertSecretFree(item);
14
+ }
15
+ }
16
+ function descriptorValid(operation,d){
17
+ if(!keys[operation]||!fields(d,keys[operation])||!Object.values(d).every(v=>text(v)&&!/[\r\n\x00]/.test(v)))return false;
18
+ if(d.destination&&/[?#]/.test(d.destination))return false;
19
+ const oid=v=>/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(v);
20
+ if(operation==='push'&&(!oid(d.sourceOid)||!oid(d.expectedRemoteOid)))return false;
21
+ if(operation==='merge'&&!oid(d.expectedTargetOid))return false;
22
+ return operation!=='publish'||/^sha256:[a-f0-9]{64}$/.test(d.artifactHash);
23
+ }
24
+ const coverage={enforcement:'cooperative',provenance:'operator-attested',nativeVerified:false,remoteVerified:false};
25
+ const decision=(state,reason,transition=null,allow=false)=>({expectedRevision:state?.revision??0,decision:allow?'allow':'hold',ruleIds:['cooperative.external'],reason,coverage:{...coverage},requiredEvidence:[],nextActions:[],proposedTransition:transition});
26
+ export const unresolvedExternal=state=>state.intents.some(i=>EXTERNAL_ACTIONS.includes(i.operation)&&['prepared','unknown','mismatch'].includes(i.status));
27
+ export function evaluateCooperativeExternal({state,request,approvalEvidence}={}){
28
+ const a=request?.arguments;
29
+ try{assertSecretFree(request);assertSecretFree(approvalEvidence);}catch{return decision(state,'Secret-free identifiers required');}
30
+ if(state?.mode!=='plan'||request?.expectedRevision!==state.revision||request.actor?.role!=='orchestrator'||!fields(a,['approvalRef','scope','descriptor'])||!descriptorValid(request.operation,a.descriptor)||!Array.isArray(a.scope)||!a.scope.length||new Set(a.scope).size!==a.scope.length||a.scope.some(p=>!absolute(p)||!within(state.target.realpath,p)))return decision(state,'Exact external action, scope and secret-free target required');
31
+ const approval=state.records.findLast(r=>r.kind==='external-approval'&&r.messageRef===a.approvalRef);
32
+ if(!approval||approval.source!=='operator-attested'||approval.scopeVersion!==state.scopeVersion||approval.targetVersion!==state.targetVersion||!same(approval,approvalEvidence)||!same(approval.data,{operation:request.operation,scope:a.scope,descriptor:a.descriptor}))return decision(state,'Exact explicit external approval required; local approval is insufficient');
33
+ if(unresolvedExternal(state)||state.unresolved.some(u=>!u.resolvedBy)||state.intents.some(i=>i.requestId===request.requestId||EXTERNAL_ACTIONS.includes(i.operation)&&i.approvalRef===a.approvalRef))return decision(state,'Unresolved effects or consumed approval require investigation; never retry');
34
+ return decision(state,'External intent only; operator execution is separate',{intent:{requestId:request.requestId,operation:request.operation,status:'prepared',scope:structuredClone(a.scope),descriptor:structuredClone(a.descriptor),approvalRef:a.approvalRef,approvalFingerprint:fingerprintRequest(approval),scopeVersion:state.scopeVersion,targetVersion:state.targetVersion,target:structuredClone(state.target),versions:structuredClone(state.versions),fingerprint:fingerprintRequest(request),source:'operator-attested'}},true);
35
+ }
36
+ export function reconcileCooperativeExternal({state,intent,result,observations}={}){
37
+ if(!intent||!EXTERNAL_ACTIONS.includes(intent.operation)||!['prepared','unknown','mismatch'].includes(intent.status))return decision(state,'Matching unresolved external intent required; replay cannot authorize another effect');
38
+ try{assertSecretFree(result);}catch{return decision(state,'Secret-free result required');}
39
+ const transition={result:{...structuredClone(result),status:'mismatch',messageRef:result?.messageRef??'invalid-result',data:{...structuredClone(result?.data??{}),remoteVerified:false},source:'operator-attested'}};
40
+ const reject=reason=>decision(state,reason,transition);
41
+ if(!result||!text(result.messageRef)||!['succeeded','failed','unknown','mismatch','cancelled'].includes(result.status))return reject('Exact external result statement required');
42
+ if(!fields(result.data,['operation','scope','descriptor'])||result.data.operation!==intent.operation||!same(result.data.scope,intent.scope)||!same(result.data.descriptor,intent.descriptor))return reject('External result action, scope or target mismatch');
43
+ transition.result={...structuredClone(result),data:{...structuredClone(result.data),remoteVerified:false},source:'operator-attested'};
44
+ const approval=state.records.findLast(r=>r.kind==='external-approval'&&r.messageRef===intent.approvalRef);
45
+ if(!same(observations?.target,intent.target)||!approval||fingerprintRequest(approval)!==intent.approvalFingerprint||state.mode!=='plan'||!same(intent.target,state.target)||!same(intent.versions,state.versions)||intent.scopeVersion!==state.scopeVersion||intent.targetVersion!==state.targetVersion){transition.result.status='mismatch';return reject('Stale external approval, target or versions');}
46
+ if(result.status==='succeeded')return decision(state,'Operator result recorded; no independent remote verification',transition,true);
47
+ if(result.status==='failed')return decision(state,'Operator confirms failure; approval remains consumed and no retry authorized',transition,true);
48
+ if(result.status==='cancelled'&&text(result.notExecutedEvidence?.messageRef))return decision(state,'Operator attests no execution; approval remains consumed',transition,true);
49
+ return reject('External effect unresolved or unsuccessful; investigate without retry');
50
+ }
51
+ export function cooperativeSupport(){return {phases:[{phase:1,status:'supported',areas:['durable-state','documents','assignment']},{phase:2,status:'supported',areas:['scope','verification','acceptance-commit']},{phase:3,status:'supported',areas:['external-approval','external-result','audit-recovery']}],modes:{plan:'supported','no-plan':'unsupported',omission:'unsupported'},limits:['operator-attested identity and approval','direct calls outside CLI are not blocked','no network, native or provider execution','audit is local mutable evidence','unknown effects require investigation, not retry'],remoteVerified:false};}
@@ -1,3 +1,5 @@
1
+ import {validateGitPreflight,validateGitCommitResult} from './git-evidence.mjs';
2
+ export {validateGitPreflight,validateGitCommitResult} from './git-evidence.mjs';
1
3
  import path from 'node:path';
2
4
 
3
5
  const reconciliations=new WeakMap();
@@ -44,10 +46,10 @@ export async function runGate({phase,state,request,gitEvidence,expectedCommit,re
44
46
  }
45
47
  if(phase==='preflight'){
46
48
  const g=gitEvidence;
47
- if(!g||!same(g.target,state.target)||g.indexTree!==d.indexTree||g.stagedFingerprint!==d.fingerprint||!samePaths(g.stagedPaths,d.paths)||!validPaths(g.workingPaths)||!validPaths(g.untrackedPaths))return hold('Actual Git target index diff and exact paths required');
48
- const dirty=[...g.workingPaths,...g.untrackedPaths];
49
+ const dirty=[...g?.workingPaths??[],...g?.untrackedPaths??[]];
49
50
  const scope=d.kind==='documents'?state.documents.map(x=>x.path):task.paths;
50
- if(dirty.some(file=>scope.some(base=>within(base,path.join(state.target.realpath,...file.split('/'))))))return hold('Unstaged or untracked task changes invalidate acceptance');
51
+ const invalid=validateGitPreflight({target:state.target,descriptor:d,scope,gitEvidence:g});
52
+ if(invalid)return hold(invalid);
51
53
  if(d.kind==='documents'){
52
54
  if(!Array.isArray(g.documents)||!samePaths(g.documents.map(x=>x.gitPath),d.paths)||state.documents.some(document=>!g.documents.some(observed=>observed.gitPath===document.gitPath&&observed.path===document.path&&observed.contentHash===document.contentHash&&observed.observedHash===document.contentHash)))return hold('Observed document bytes differ from approved hashes','runtime.docs-commit');
53
55
  }
@@ -56,10 +58,8 @@ export async function runGate({phase,state,request,gitEvidence,expectedCommit,re
56
58
  if(phase!=='reconcile')return hold('Unknown Git gate phase');
57
59
  const expected=expectedCommit;
58
60
  if(!expected||expected.requestId!==request.requestId||expected.taskId!==task.id||expected.parent!==d.parent||expected.tree!==d.indexTree||expected.fingerprint!==d.fingerprint||!samePaths(expected.paths,d.paths)||expected.kind!==undefined&&expected.kind!==d.kind||expected.target!==undefined&&!same(expected.target,state.target))return hold('Preflight correlation unavailable');
59
- if(!result||!oid(result.commit)||result.commit===expected.parent||result.commit.length!==expected.parent.length||result.parent!==expected.parent||result.tree!==expected.tree||!samePaths(result.paths,expected.paths)||result.head!==result.commit||!same(result.target,{...state.target,head:result.commit})||!validPaths(result.remainingPaths))return hold('Commit result unavailable or differs from expected parent tree paths and HEAD');
60
- const scopedPaths=d.kind==='documents'?state.documents.map(x=>x.path):task.paths;
61
- if(result.remainingPaths.some(file=>scopedPaths.some(base=>within(base,path.join(state.target.realpath,...file.split('/'))))))return hold('Committed task still has remaining changes');
62
- if(expected.remainingPaths!==undefined&&!samePaths(result.remainingPaths,expected.remainingPaths))return hold('Remaining changes differ from preflight observation');
61
+ const invalid=validateGitCommitResult({target:state.target,expected,result,scope:d.kind==='documents'?state.documents.map(x=>x.path):task.paths});
62
+ if(invalid)return hold(invalid);
63
63
  const next=structuredClone(state);next.target.head=result.commit;
64
64
  next.commits=[...state.commits??[],{requestId:request.requestId,taskId:task.id,kind:d.kind,purpose:d.kind,expectedParent:expected.parent,expectedTree:expected.tree,commit:result.commit,parent:result.parent,tree:result.tree,paths:[...result.paths],fingerprint:d.fingerprint,actorRef:request.actorRef}];
65
65
  if(d.kind==='task')next.tasks.find(t=>t.id===task.id).status='committed';
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs/promises';
2
+ import {inspectPath} from './scope.mjs';
1
3
  import crypto from 'node:crypto';
2
4
  import path from 'node:path';
3
5
  import {spawnSync} from 'node:child_process';
@@ -112,3 +114,47 @@ function pathsForTree(bytes){
112
114
  const output=text(bytes);if(!output||!output.endsWith('\0'))throw Error('Invalid document tree');
113
115
  return output.slice(0,-1).split('\0').map(entry=>{const match=/^(\d+) (blob|tree|commit) ([a-f0-9]+)\t([\s\S]+)$/.exec(entry);if(!match||!oid(match[3])||!gitPath(match[4]))throw Error('Invalid document tree entry');return {mode:match[1],type:match[2],path:match[4]};});
114
116
  }
117
+
118
+ /** Assignment observation additionally compares regular current files to Git objects.
119
+ * No Git facts or transport override are accepted from workflow command JSON. */
120
+ export async function readAssignmentGitEvidence({target,documents}){
121
+ const evidence=await readGitEvidence({target,documents});
122
+ for(const document of evidence.documents){
123
+ await inspectPath(document.path);
124
+ document.currentHash=hash(await fs.readFile(document.path));
125
+ if(document.currentHash!==document.contentHash)throw Error('Current approved document hash mismatch');
126
+ }
127
+ return evidence;
128
+ }
129
+
130
+ /** Cooperative observations come only from local read-only Git queries. */
131
+ export async function readCooperativeCommitGitEvidence({target,commit,gitRunner=defaultRunner}){
132
+ if(commit!==undefined&&!oid(commit))throw Error('Invalid commit object ID');
133
+ const evidence=await readGitEvidence({target,gitRunner});
134
+ if(commit===undefined)return evidence;
135
+ const run=async args=>{const r=await gitRunner({args,cwd:target.realpath});if(r?.status!==0||r.error||!(Buffer.isBuffer(r.stdout)||typeof r.stdout==='string'))throw Error('Commit evidence query failed');return Buffer.from(r.stdout);};
136
+ const metadata=text(await run(['show','--no-patch','--format=%H%n%P%n%T',commit])).trimEnd().split('\n');
137
+ if(metadata.length!==3||metadata[0]!==commit||!metadata.every(oid))throw Error('Expected exact single-parent commit');
138
+ const committedPaths=paths(await run(['diff-tree','--no-commit-id','--name-only','-r','-z',commit,'--']));
139
+ // Reobserve both HEAD and index after metadata so a concurrent mutation holds.
140
+ const after=await readGitEvidence({target,gitRunner});
141
+ if(!equal(evidence,after))throw Error('Git changed during commit observation');
142
+ return {...evidence,commit:{commit,parent:metadata[1],tree:metadata[2],paths:committedPaths,head:target.head}};
143
+ }
144
+
145
+ const validGitPaths=values=>Array.isArray(values)&&values.every(gitPath)&&unique(values);
146
+ const within=(base,file)=>{const relative=path.relative(base,file);return relative===''||!path.isAbsolute(relative)&&relative!=='..'&&!relative.startsWith('..'+path.sep);};
147
+ /** Provenance-neutral Git checks shared by trusted and cooperative callers.
148
+ * The caller must establish role, approval and acceptance separately. */
149
+ export function validateGitPreflight({target,descriptor:d,scope,gitEvidence:g}){
150
+ if(!g||!equal(g.target,target)||!oid(d?.parent)||d.parent!==target.head||!oid(d.indexTree)||!/^sha256:[a-f0-9]{64}$/.test(d.fingerprint)||!validGitPaths(d.paths)||!d.paths.length||g.indexTree!==d.indexTree||g.stagedFingerprint!==d.fingerprint||!samePaths(g.stagedPaths,d.paths)||!validGitPaths(g.workingPaths)||!validGitPaths(g.untrackedPaths))return 'Actual Git target index diff and exact paths required';
151
+ if(d.paths.some(file=>!scope.some(base=>within(base,path.join(target.realpath,...file.split('/'))))))return 'Commit contains another task or unrelated changes';
152
+ if([...g.workingPaths,...g.untrackedPaths].some(file=>scope.some(base=>within(base,path.join(target.realpath,...file.split('/'))))))return 'Unstaged or untracked task changes invalidate acceptance';
153
+ return null;
154
+ }
155
+ export function validateGitCommitResult({target,expected,result,scope}){
156
+ if(!result||!oid(result.commit)||result.commit===expected.parent||result.commit.length!==expected.parent.length||result.parent!==expected.parent||result.tree!==expected.tree||!samePaths(result.paths,expected.paths)||result.head!==result.commit||!equal(result.target,{...target,head:result.commit})||!validGitPaths(result.remainingPaths))return 'Commit result unavailable or differs from expected parent tree paths and HEAD';
157
+ if(result.remainingPaths.some(file=>scope.some(base=>within(base,path.join(target.realpath,...file.split('/'))))))return 'Committed task still has remaining changes';
158
+ if(expected.remainingPaths!==undefined&&!samePaths(result.remainingPaths,expected.remainingPaths))return 'Remaining changes differ from preflight observation';
159
+ return null;
160
+ }
@@ -1,6 +1,6 @@
1
1
  /** Canonical semantic operations, independent of platform tool aliases. */
2
- export const ROLES = Object.freeze(['orchestrator','scouter','planner','coder','qa','reviewer']);
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
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'],coder:['read','write','check'],qa:['read','check','temporary-write'],reviewer:['read','review']});
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']});
@@ -36,6 +36,7 @@ const unique = (items,key) => items.every(x=>object(x)&&text(x[key])) && new Set
36
36
  function approval(a){return object(a)&&text(a.id)&&['user-message','human-confirmation'].includes(a.source)&&text(a.messageRef)&&hash(a.contentHash)&&object(a.scope)&&text(a.scope.taskId)&&absolute(a.scope.target)&&strings(a.scope.operations)&&a.scope.operations.every(op=>OPERATIONS.includes(op))&&strings(a.scope.paths)&&a.scope.paths.every(absolute);}
37
37
  /** Validate stored workflow data. Observed provenance must still be checked by evaluate. */
38
38
  export function validateWorkflowState(s){
39
+ if(object(s)&&s.workflowKind!==undefined)fail('provider state cannot use cooperative contract');
39
40
  if(!object(s)||s.schemaVersion!==1||!text(s.workflowId)||!Number.isSafeInteger(s.revision)||s.revision<0)fail('version or identity');
40
41
  if(!object(s.versions)||['policy','runtime','adapter','policyHash','runtimeHash','adapterHash'].some(k=>!text(s.versions[k])))fail('versions');
41
42
  if(['policyHash','runtimeHash','adapterHash'].some(k=>!hash(s.versions[k])))fail('hash algorithm');
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {spawnSync} from 'node:child_process';
4
+ export const absolute=value=>typeof value==='string'&&path.isAbsolute(value)&&path.normalize(value)===value;
5
+ export const within=(base,file)=>absolute(file)&&!path.relative(base,file).split(path.sep).includes('..')&&!path.isAbsolute(path.relative(base,file));
6
+ /** Inspect every component, including ancestors outside the ARC namespace. */
7
+ export async function inspectPath(filename,{missing=false,directory=false}={}){
8
+ if(!absolute(filename))throw Error('Unsafe absolute path');
9
+ let cursor=path.parse(filename).root;
10
+ const parts=filename.slice(cursor.length).split(path.sep).filter(Boolean);
11
+ for(let i=0;i<parts.length;i++){
12
+ cursor=path.join(cursor,parts[i]);let stat;
13
+ try{stat=await fs.lstat(cursor);}catch(error){if(missing&&error.code==='ENOENT')return;throw error;}
14
+ if(stat.isSymbolicLink())throw Error('Unsafe symlink path');
15
+ if(i<parts.length-1||directory){if(!stat.isDirectory())throw Error('Unsafe directory type');}
16
+ else if(!stat.isFile())throw Error('State must be a regular file');
17
+ }
18
+ }
19
+ export function validateWorkflowId(id){if(typeof id!=='string'||!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id))throw Error('Invalid workflow ID');return id;}
20
+ /** Shell-free, read-only observation; inherited Git target/config overrides are removed. */
21
+ export async function observeCooperativeTarget(cwd){
22
+ if(!absolute(cwd))throw Error('Absolute workspace required');await inspectPath(cwd,{directory:true});
23
+ const env=Object.fromEntries(Object.entries(process.env).filter(([key])=>!key.toUpperCase().startsWith('GIT_')));
24
+ Object.assign(env,{GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:process.platform==='win32'?'NUL':'/dev/null',GIT_OPTIONAL_LOCKS:'0',GIT_NO_REPLACE_OBJECTS:'1',GIT_NO_LAZY_FETCH:'1',GIT_TERMINAL_PROMPT:'0'});
25
+ const query=(args,detached=false)=>{const r=spawnSync('git',['--no-pager','-c','core.fsmonitor=false',...args],{cwd,env,encoding:'utf8',timeout:10000,maxBuffer:1024*1024});if(detached&&r.status===1&&!r.error)return null;if(r.status!==0||r.error||!r.stdout.endsWith('\n')||r.stdout.slice(0,-1).includes('\n'))throw Error('Git target observation failed');return r.stdout.slice(0,-1);};
26
+ const target={realpath:query(['rev-parse','--show-toplevel']),gitCommonDir:query(['rev-parse','--path-format=absolute','--git-common-dir']),gitDir:query(['rev-parse','--absolute-git-dir']),head:query(['rev-parse','HEAD']),branch:query(['symbolic-ref','--quiet','--short','HEAD'],true)};
27
+ await validateObservedTarget(cwd,target);return target;
28
+ }
29
+ export async function validateObservedTarget(cwd,target){
30
+ if(!absolute(cwd)||!target||target.realpath!==cwd||!absolute(target.gitCommonDir)||!absolute(target.gitDir)||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(target.head)||!(target.branch===null||typeof target.branch==='string'&&target.branch))throw Error('Invalid observed target');
31
+ await inspectPath(cwd,{directory:true});await inspectPath(target.gitCommonDir,{directory:true});
32
+ return target;
33
+ }
34
+ export async function workflowStatePath(target,workflowId){
35
+ validateWorkflowId(workflowId);const statePath=path.join(target.gitCommonDir,'arc','workflows',workflowId,'state.json');await inspectPath(statePath,{missing:true});return statePath;
36
+ }
@@ -1,3 +1,4 @@
1
+ import {verifiedCooperativeReconciliation} from './acceptance.mjs';
1
2
  import {verifiedReconciliation} from './gate.mjs';
2
3
  import fs from 'node:fs/promises';
3
4
  import path from 'node:path';
@@ -51,13 +52,33 @@ export async function transactWorkflow({statePath,expectedRevision,requestId,req
51
52
  next.revision=state.revision+1;next.requests=prior?state.requests.map(r=>r.requestId===requestId?{requestId,requestFingerprint,decision}:r):[...state.requests,{requestId,requestFingerprint,decision}];
52
53
  next.audit=[...state.audit,{requestId,revision:next.revision,decision:decision.decision,ruleIds:decision.ruleIds??[],result:'pending'}];
53
54
  validateWorkflowState(next);
54
- temporary=path.join(parent,`.${path.basename(statePath)}.${crypto.randomUUID()}.tmp`);
55
- const handle=await fs.open(temporary,'wx',0o600);try{await handle.writeFile(JSON.stringify(next,null,2)+'\n');await handle.sync();}finally{await handle.close();}
56
- await fs.rename(temporary,statePath);temporary=undefined;
57
- const directory=await fs.open(parent,'r');try{await directory.sync();}finally{await directory.close();}
55
+ await saveDurableState({statePath,state:next});
58
56
  return {state:next,decision,replayed:false};
59
57
  }finally{
60
58
  if(temporary)await fs.unlink(temporary).catch(()=>{});
61
59
  if(lock){await lock.close();await fs.unlink(lockPath);}
62
60
  }
63
61
  }
62
+
63
+ /** Shared durability primitive. A post-rename sync error is reported as failure;
64
+ * the caller must re-read rather than assume rollback or repeat an effect. */
65
+ export async function saveDurableState({statePath,state,create=false,storage={}}){
66
+ const parent=path.dirname(statePath),temporary=path.join(parent,`.${path.basename(statePath)}.${crypto.randomUUID()}.tmp`);
67
+ let exists=false;
68
+ try{
69
+ const handle=await fs.open(temporary,'wx',0o600);exists=true;
70
+ try{await handle.writeFile(JSON.stringify(state,null,2)+'\n');await handle.sync();}finally{await handle.close();}
71
+ if(create){await (storage.link??fs.link)(temporary,statePath);await fs.unlink(temporary);}else await (storage.rename??fs.rename)(temporary,statePath);
72
+ exists=false;
73
+ const directory=await fs.open(parent,'r');try{await directory.sync();}finally{await directory.close();}
74
+ }finally{if(exists)await fs.unlink(temporary).catch(()=>{});}
75
+ }
76
+
77
+ /** Only a locally verified cooperative commit may advance the stored target. */
78
+ export function applyCooperativeCommitTransition({state,decision}){
79
+ if(decision?.decision!=='allow'||!verifiedCooperativeReconciliation(decision,state)||!decision.proposedTransition?.commit)throw Error('Verified cooperative reconciliation required');
80
+ const next=structuredClone(state),transition=decision.proposedTransition;
81
+ next.targetHistory.push({target:structuredClone(state.target),revision:state.revision+1,source:'local-observation',kind:'verified-commit-parent'});
82
+ next.target=structuredClone(transition.target);next.commits.push(structuredClone(transition.commit));next.tasks.find(t=>t.id===transition.commit.taskId).status='committed';
83
+ return next;
84
+ }