@yunhozz/arc 0.1.2 → 0.1.3

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 (78) hide show
  1. package/MANIFEST.sha256 +75 -36
  2. package/claude/agents/arc-coder.md +10 -9
  3. package/claude/agents/arc-planner.md +13 -12
  4. package/claude/agents/arc-qa.md +9 -8
  5. package/claude/agents/arc-reviewer.md +11 -10
  6. package/claude/agents/arc-scouter.md +10 -9
  7. package/claude/skill/SKILL.md +13 -17
  8. package/claude/skill/hooks/role-git-guard.mjs +3 -83
  9. package/claude/skill/hooks/runtime-gate.mjs +12 -0
  10. package/claude/skill/models.toml +11 -11
  11. package/claude/skill/references/commit.md +8 -9
  12. package/claude/skill/references/git-recovery.md +1 -1
  13. package/claude/skill/references/handoff-prep.md +8 -8
  14. package/claude/skill/references/handoff.md +8 -8
  15. package/claude/skill/references/limits.md +4 -4
  16. package/claude/skill/references/planning.md +9 -9
  17. package/claude/skill/references/verification.md +10 -10
  18. package/claude/skill/references/visual-companion.md +2 -3
  19. package/claude/skill/runtime/adapters/claude.mjs +81 -0
  20. package/claude/skill/runtime/adapters/codex.mjs +63 -0
  21. package/claude/skill/runtime/capabilities.mjs +32 -0
  22. package/claude/skill/runtime/evaluate.mjs +103 -0
  23. package/claude/skill/runtime/gate.mjs +71 -0
  24. package/claude/skill/runtime/git-evidence.mjs +114 -0
  25. package/claude/skill/runtime/git-query.mjs +88 -0
  26. package/claude/skill/runtime/identity.mjs +27 -0
  27. package/claude/skill/runtime/operations.mjs +6 -0
  28. package/claude/skill/runtime/role-policy.json +607 -0
  29. package/claude/skill/runtime/schema.mjs +78 -0
  30. package/claude/skill/runtime/store.mjs +63 -0
  31. package/claude/skill/scripts/model_config.py +78 -18
  32. package/codex/agents/arc-coder.toml +8 -8
  33. package/codex/agents/arc-planner.toml +9 -9
  34. package/codex/agents/arc-qa.toml +6 -6
  35. package/codex/agents/arc-reviewer.toml +7 -7
  36. package/codex/agents/arc-scouter.toml +6 -6
  37. package/codex/skill/SKILL.md +15 -19
  38. package/codex/skill/hooks/runtime-gate.mjs +10 -0
  39. package/codex/skill/models.toml +11 -11
  40. package/codex/skill/references/commit.md +9 -9
  41. package/codex/skill/references/git-recovery.md +1 -1
  42. package/codex/skill/references/handoff-prep.md +9 -10
  43. package/codex/skill/references/handoff-worktree.md +1 -1
  44. package/codex/skill/references/handoff.md +9 -9
  45. package/codex/skill/references/limits.md +4 -4
  46. package/codex/skill/references/planning.md +11 -12
  47. package/codex/skill/references/verification.md +10 -11
  48. package/codex/skill/references/visual-companion.md +3 -3
  49. package/codex/skill/runtime/adapters/claude.mjs +81 -0
  50. package/codex/skill/runtime/adapters/codex.mjs +63 -0
  51. package/codex/skill/runtime/capabilities.mjs +32 -0
  52. package/codex/skill/runtime/evaluate.mjs +103 -0
  53. package/codex/skill/runtime/gate.mjs +71 -0
  54. package/codex/skill/runtime/git-evidence.mjs +114 -0
  55. package/codex/skill/runtime/git-query.mjs +88 -0
  56. package/codex/skill/runtime/identity.mjs +27 -0
  57. package/codex/skill/runtime/operations.mjs +6 -0
  58. package/codex/skill/runtime/role-policy.json +607 -0
  59. package/codex/skill/runtime/schema.mjs +78 -0
  60. package/codex/skill/runtime/store.mjs +63 -0
  61. package/lib/models/profiles.mjs +41 -4
  62. package/lib/policy/claude-hook.mjs +57 -17
  63. package/lib/policy/render.mjs +1 -0
  64. package/lib/policy/role-policy.json +38 -8
  65. package/lib/runtime/adapters/claude.mjs +81 -0
  66. package/lib/runtime/adapters/codex.mjs +63 -0
  67. package/lib/runtime/capabilities.mjs +32 -0
  68. package/lib/runtime/evaluate.mjs +103 -0
  69. package/lib/runtime/gate.mjs +71 -0
  70. package/lib/runtime/git-evidence.mjs +114 -0
  71. package/lib/runtime/git-query.mjs +88 -0
  72. package/lib/runtime/identity.mjs +27 -0
  73. package/lib/runtime/operations.mjs +6 -0
  74. package/lib/runtime/schema.mjs +78 -0
  75. package/lib/runtime/store.mjs +63 -0
  76. package/package.json +1 -1
  77. package/scripts/probe-runtime-hooks.mjs +55 -0
  78. package/scripts/update-runtime-assets.mjs +19 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @typedef {object} GateDecision
3
+ * @property {'allow'|'deny'|'hold'} decision
4
+ * @property {string[]} ruleIds
5
+ * @property {string} reason
6
+ * @property {string[]} requiredEvidence
7
+ * @property {number} expectedRevision
8
+ * @property {object|null} proposedTransition
9
+ * @property {object} coverage
10
+ */
11
+ import path from 'node:path';
12
+ import {validateWorkflowState, validateRequest, validateEvidence} from './schema.mjs';
13
+ import {ROLES, ROLE_OPERATIONS, GIT_OPERATIONS, EXTERNAL_OPERATIONS} from './operations.mjs';
14
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
15
+ const within=(base,file)=>{const relative=path.relative(base,file);return relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative));};
16
+ /** Pure policy decision. Evidence is supplied by a trusted adapter/provider, never tool arguments. */
17
+ export function evaluate(policy, state, request, evidence){
18
+ 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'}});
19
+ try{validateWorkflowState(state);validateRequest(request);validateEvidence(evidence);}catch(error){return result('hold','runtime.schema',error.message,['valid-state-request-evidence']);}
20
+ if(policy?.runtime?.schemaVersion!==1||!same(policy.runtime.roles,ROLES)||!Array.isArray(policy.runtime.ruleIds)||!Array.isArray(policy.runtime.requiredEvidence)||policy.runtime.status!=='decision-only')return result('hold','runtime.policy','runtime policy unavailable',['runtime-policy']);
21
+ const actor=state.identities.find(i=>i.actorRef===request.actorRef);
22
+ if(!actor)return result('hold','runtime.identity','platform identity unresolved',['platform-identity']);
23
+ 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];
25
+ 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
+ const lease=state.leases.find(l=>l.actorRef===actor.actorRef);
27
+ if(lease?.status==='retired')return result('deny','runtime.identity','retired identity');
28
+ if(!lease||lease.status!=='active'||lease.taskId!==request.taskId||actor.taskId!==request.taskId)return result('hold','runtime.identity','active task lease missing',['active-lease']);
29
+ const observation=evidence.provenance.find(p=>p.id===actor.observationRef&&p.source==='platform-event'&&p.actorRef===actor.actorRef&&p.platform===request.platform&&p.verification.sourceRef===p.eventRef);
30
+ if(!observation)return result('hold','runtime.identity','platform observation unavailable',['platform-observation']);
31
+ if(!same(evidence.target,state.target)||evidence.revision!==state.revision||!same(evidence.versions,state.versions))return result('hold','runtime.target','target revision or versions mismatch',['current-target-versions']);
32
+ if(!within(state.target.realpath,request.cwd))return result('deny','scope.non-escalation','cwd outside workspace');
33
+ const assignment=state.assignments.find(a=>a.actorRef===actor.actorRef&&a.taskId===request.taskId&&a.role===role);
34
+ if(!assignment||!assignment.operations.includes(request.operation))return result('hold','runtime.assignment','approved operation assignment missing',['assignment']);
35
+ const approval=state.approvals.find(a=>a.id===assignment.approvalRef);
36
+ const proof=evidence.provenance.find(p=>p.id===approval?.id&&p.source===approval?.source&&p.messageRef===approval?.messageRef&&p.contentHash===approval?.contentHash&&p.verification.sourceRef===p.messageRef);
37
+ if(!approval||!proof||approval.scope.taskId!==request.taskId||approval.scope.target!==state.target.realpath||!approval.scope.operations.includes(request.operation))return result('hold','runtime.approval','user approval provenance unavailable',['user-approval']);
38
+ for(const filename of [request.cwd,...request.paths]){
39
+ const resolved=evidence.resolvedPaths?.find(p=>p.path===filename)?.realpath;
40
+ if(!resolved||!path.isAbsolute(resolved))return result('hold','runtime.paths','resolved path evidence missing',['resolved-paths']);
41
+ if(!within(state.target.realpath,resolved))return result('deny','scope.non-escalation','resolved path escapes workspace');
42
+ if(filename!==request.cwd&&(!assignment.paths.some(p=>within(p,resolved))||!approval.scope.paths.some(p=>within(p,resolved))))return result('deny','scope.non-escalation','path outside assigned approval');
43
+ }
44
+ 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
+ const task=state.tasks.find(t=>t.id===request.taskId);
46
+ 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');
48
+ if(request.operation==='temporary-write'){
49
+ const temporary=assignment.temporary;
50
+ 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']);
51
+ }
52
+ if(request.operation==='check'){
53
+ if(!assignment.checks?.some(c=>same(c,request.descriptor)))return result('hold',roleRule,'exact assigned check descriptor required',['check-descriptor']);
54
+ }
55
+ if(role==='orchestrator'&&request.operation==='write'&&request.descriptor.kind!=='document')return result('hold','role.orchestrator-boundary','direct implementation requires reviewed small-task exception',['small-task-exception']);
56
+ if(request.operation==='assign-agent'){
57
+ const d=request.descriptor;
58
+ if(!ROLES.includes(d.role)||d.taskId!==request.taskId)return result('deny','runtime.identity','invalid assignment');
59
+ const previous=state.identities.find(i=>i.actorRef===d.actorRef);
60
+ 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'){
62
+ if(state.mode==='plan'){
63
+ // T3 supplies verified Git object evidence. Local assertions never satisfy this gate.
64
+ 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']);
65
+ const docs=fact('docs-commit')?.data;
66
+ const approved=state.documents.every(d=>state.approvals.some(a=>a.id===d.approvalRef&&a.contentHash===d.contentHash&&evidence.provenance.some(p=>p.id===a.id&&p.source===a.source&&p.verification.sourceRef===p.messageRef&&p.messageRef===a.messageRef&&p.contentHash===d.contentHash)));
67
+ if(!approved||!docs||!docs.commit||!docs.parent||!docs.tree||!same(docs.documents,state.documents)||!same([...docs.paths??[]].sort(),state.documents.map(d=>d.gitPath).sort())||state.documents.some(d=>d.commit!==docs.commit))return result('hold','runtime.docs-commit','Git object and approval verification required',['verified-docs-only-commit']);
68
+ }
69
+ else return result('hold','runtime.mode','mode name does not establish exception eligibility',['verified-mode-exception']);
70
+ }
71
+ const correlation=fact('assignment-correlation');
72
+ const expected={requestId:request.requestId,platform:request.platform,...d};
73
+ 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
+ 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']);
76
+ 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
+ if(d.temporary&&!same(d.temporary,approval.scope.temporary))return result('hold','runtime.assignment','delegated temporary scope not approved',['delegated-temporary-scope']);
78
+ 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']);
79
+ const next=structuredClone(state);next.assignments.push({assignmentId:request.requestId,requestId:request.requestId,requestedBy:request.actorRef,status:'pending',actorRef:null,platform:request.platform,correlationRef:d.correlationRef,role:d.role,taskId:d.taskId,paths:[...d.paths],operations:[...d.operations],approvalRef:d.approvalRef,...(d.checks?{checks:structuredClone(d.checks)}:{}),...(d.temporary?{temporary:structuredClone(d.temporary)}:{})});
80
+ try{validateWorkflowState(next);}catch{return result('hold','runtime.assignment','invalid pending assignment scope',['valid-assignment']);}
81
+ return {...result('allow','runtime.assignment','pending assignment authorized; observed spawn result still required'),proposedTransition:next};
82
+ }
83
+ if(request.operation==='accept'){
84
+ const d=request.descriptor;const observed=fact('acceptance')?.data;
85
+ const checks=Array.isArray(d.checkIds)&&task.requiredChecks?.every(id=>d.checkIds?.includes(id)&&state.checks.some(c=>c.id===id&&c.taskId===task.id&&c.status==='passed'&&c.fingerprint===d.fingerprint&&c.resultRef&&c.actorRef!==actor.actorRef&&state.identities.some(i=>i.actorRef===c.actorRef&&i.taskId===task.id&&i.role==='qa')));
86
+ const findings=state.findings.filter(f=>f.taskId===task.id);
87
+ if(!observed||!same(observed,d)||!d.fingerprint||!task.criteria?.length||!same(task.criteria,d.criteria)||!checks||findings.some(f=>!Array.isArray(d.findingIds)||!d.findingIds.includes(f.id)||!f.resolutionRef))return result('hold','git.orchestrator-accept-commit','criteria checks findings and fingerprint verification required',['acceptance-evidence']);
88
+ }
89
+ if(GIT_OPERATIONS.includes(request.operation)){
90
+ const d=request.descriptor;const observed=fact('git-index')?.data;
91
+ const relative=p=>typeof p==='string'&&p.length>0&&!/[\\\x00\r\n:]/.test(p)&&!p.startsWith('/')&&!p.split('/').some(x=>!x||x==='.'||x==='..');
92
+ if(!observed||!same(observed,d)||d.parent!==state.target.head||!d.indexTree||!Array.isArray(d.paths)||!d.paths.length||!d.paths.every(relative)||new Set(d.paths).size!==d.paths.length||!same([...d.paths.map(p=>path.join(state.target.realpath,...p.split('/')))].sort(),[...request.paths].sort()))return result('hold','git.orchestrator-accept-commit','actual Git objects and relative path verification required',['git-evidence']);
93
+ if(d.kind==='documents'){
94
+ if(state.mode!=='plan'||state.documents.length!==2||!same(state.documents.map(x=>x.kind).sort(),['plan','spec'])||!same([...d.paths].sort(),state.documents.map(x=>x.gitPath).sort())||state.documents.some(doc=>doc.path!==path.join(state.target.realpath,...doc.gitPath.split('/'))||!state.approvals.some(a=>a.id===doc.approvalRef&&a.contentHash===doc.contentHash&&evidence.provenance.some(p=>p.id===a.id&&p.source===a.source&&p.messageRef===a.messageRef&&p.contentHash===doc.contentHash&&p.verification.sourceRef===a.messageRef))))return result('hold','runtime.docs-commit','exact approved documents required',['approved-documents']);
95
+ }else{
96
+ const accepted=state.acceptances.find(a=>a.taskId===task.id&&a.fingerprint===d.fingerprint&&state.identities.some(i=>i.actorRef===a.actorRef&&i.role==='orchestrator'));
97
+ 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']);
98
+ }
99
+ }
100
+ if(EXTERNAL_OPERATIONS.includes(request.operation))return result('hold','external.explicit-user-authority','explicit external target approval must be verified',['external-approval']);
101
+ if(request.operation==='transition')return result('hold','runtime.transition','result confirmation required',['observed-result']);
102
+ return result('allow',roleRule,'assigned operation with verified provenance');
103
+ }
@@ -0,0 +1,71 @@
1
+ import path from 'node:path';
2
+
3
+ const reconciliations=new WeakMap();
4
+ export function verifiedReconciliation(decision,state,next,expectedCommit){const proof=reconciliations.get(decision);return !!proof&&proof.before===JSON.stringify(state)&&proof.after===JSON.stringify(next)&&(expectedCommit===undefined||proof.expected===JSON.stringify(expectedCommit));}
5
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
6
+ const oid=value=>typeof value==='string'&&/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
7
+ const hash=value=>typeof value==='string'&&/^sha256:[a-f0-9]{64}$/.test(value);
8
+ const gitPath=value=>typeof value==='string'&&value.length>0&&!/[\\\x00\r\n:]/.test(value)&&!value.startsWith('/')&&!value.split('/').some(p=>!p||p==='.'||p==='..');
9
+ const validPaths=values=>Array.isArray(values)&&values.every(gitPath)&&new Set(values).size===values.length;
10
+ const samePaths=(a,b)=>validPaths(a)&&validPaths(b)&&same([...a].sort(),[...b].sort());
11
+ const within=(base,file)=>{const relative=path.relative(base,file);return relative===''||!path.isAbsolute(relative)&&relative!=='..'&&!relative.startsWith('..'+path.sep);};
12
+ const targetValid=t=>t&&path.isAbsolute(t.realpath)&&path.normalize(t.realpath)===t.realpath&&path.isAbsolute(t.gitCommonDir)&&oid(t.head)&&(t.branch===null||typeof t.branch==='string'&&t.branch);
13
+
14
+ /** Git-specific validation ONLY. Trusted adapters must separately satisfy T2
15
+ * evaluate() role/identity/approval policy and transactWorkflow() persistence.
16
+ * Inputs here are provider observations, never facts accepted from tool input.
17
+ * Preflight permission does not mean execution or completed commit. */
18
+ export async function runGate({phase,state,request,gitEvidence,expectedCommit,result}={}){
19
+ const hold=(reason,id='runtime.git-evidence')=>({decision:'hold',ruleIds:[id],reason,requiredEvidence:['observed-git-state'],expectedRevision:state?.revision??0,proposedTransition:null,coverage:{enforcement:'git-validation-only'}});
20
+ const allow=(reason,extra={})=>({...hold(reason),decision:'allow',requiredEvidence:[],...extra});
21
+ try{
22
+ if(!state||!targetValid(state.target)||!Number.isSafeInteger(state.revision)||state.revision<0||!request||request.operation!=='commit'||typeof request.requestId!=='string'||!request.requestId||request.cwd!==state.target.realpath)return hold('Invalid Git gate input');
23
+ const actors=state.identities?.filter(i=>i.actorRef===request.actorRef);
24
+ const leases=state.leases?.filter(l=>l.actorRef===request.actorRef&&l.taskId===request.taskId&&l.status==='active');
25
+ if(actors?.length!==1||actors[0].role!=='orchestrator'||actors[0].taskId!==request.taskId||leases?.length!==1)return hold('Only the observed active Orchestrator may request commit','git.specialists-deny');
26
+ const task=state.tasks?.find(t=>t.id===request.taskId);
27
+ const d=request.descriptor;
28
+ if(!task||!d||!['documents','task'].includes(d.kind)||!oid(d.parent)||!oid(d.indexTree)||!hash(d.fingerprint)||!validPaths(d.paths)||!d.paths.length||d.parent!==state.target.head||d.parent.length!==d.indexTree.length)return hold('Incomplete or stale commit descriptor');
29
+ if(!Array.isArray(request.paths)||!samePaths(request.paths.map(file=>{
30
+ if(typeof file!=='string'||!path.isAbsolute(file)||!within(state.target.realpath,file)||path.normalize(file)!==file)throw Error('Requested path escapes target');
31
+ return path.relative(state.target.realpath,file).split(path.sep).join('/');
32
+ }),d.paths))return hold('Request paths differ from commit paths');
33
+ if(d.kind==='documents'){
34
+ if(state.mode!=='plan'||state.documents?.length!==2||!samePaths(state.documents.map(x=>x.kind),['spec','plan'])||!samePaths(d.paths,state.documents.map(x=>x.gitPath)))return hold('Complete exact approved spec and plan set required','runtime.docs-commit');
35
+ for(const document of state.documents){
36
+ const approvals=state.approvals?.filter(a=>a.id===document.approvalRef);
37
+ if(!hash(document.contentHash)||document.path!==path.join(state.target.realpath,...document.gitPath.split('/'))||approvals?.length!==1||!['user-message','human-confirmation'].includes(approvals[0].source)||!approvals[0].messageRef||approvals[0].contentHash!==document.contentHash)return hold('Approved document hash unavailable','runtime.docs-commit');
38
+ }
39
+ }else{
40
+ const accepted=state.acceptances?.filter(a=>a.taskId===task.id&&a.fingerprint===d.fingerprint&&state.identities.some(i=>i.actorRef===a.actorRef&&i.role==='orchestrator'));
41
+ if(task.status!=='accepted'||accepted?.length!==1||!task.criteria?.length||!same(accepted[0].criteria,task.criteria)||!Array.isArray(accepted[0].checkIds)||!task.requiredChecks?.every(id=>accepted[0].checkIds.includes(id))||!Array.isArray(accepted[0].findingIds)||state.commits?.some(c=>c.kind==='task'&&c.taskId===task.id))return hold('Exact accepted task diff required','git.orchestrator-accept-commit');
42
+ const allowed=task.paths;
43
+ if(!Array.isArray(allowed)||request.paths.some(file=>!allowed.some(base=>path.isAbsolute(base)&&within(base,file))))return hold('Commit contains another task or unrelated changes','git.orchestrator-accept-commit');
44
+ }
45
+ if(phase==='preflight'){
46
+ 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 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
+ if(d.kind==='documents'){
52
+ 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
+ }
54
+ return allow('Git preflight passed; observed commit result still required',{expectedCommit:{requestId:request.requestId,taskId:task.id,kind:d.kind,parent:d.parent,tree:d.indexTree,paths:[...d.paths],fingerprint:d.fingerprint,target:structuredClone(state.target),remainingPaths:[...new Set(dirty)].sort()}});
55
+ }
56
+ if(phase!=='reconcile')return hold('Unknown Git gate phase');
57
+ const expected=expectedCommit;
58
+ 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');
63
+ const next=structuredClone(state);next.target.head=result.commit;
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
+ if(d.kind==='task')next.tasks.find(t=>t.id===task.id).status='committed';
66
+ else for(const document of next.documents)document.commit=result.commit;
67
+ const decision=allow('Observed commit matches preflight',{proposedTransition:next});
68
+ reconciliations.set(decision,{before:JSON.stringify(state),after:JSON.stringify(next),expected:JSON.stringify(expected)});
69
+ return decision;
70
+ }catch(error){return hold('Git validation failed: '+error.message);}
71
+ }
@@ -0,0 +1,114 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import {spawnSync} from 'node:child_process';
4
+
5
+ const oid=value=>typeof value==='string'&&/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
6
+ const hash=bytes=>'sha256:'+crypto.createHash('sha256').update(bytes).digest('hex');
7
+ const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
8
+ const text=bytes=>new TextDecoder('utf-8',{fatal:true}).decode(bytes);
9
+ const gitPath=value=>typeof value==='string'&&value.length>0&&!/[\\\x00\r\n:]/.test(value)&&!value.startsWith('/')&&!value.split('/').some(p=>!p||p==='.'||p==='..');
10
+ const absolute=value=>typeof value==='string'&&path.isAbsolute(value)&&path.normalize(value)===value;
11
+ const unique=values=>new Set(values).size===values.length;
12
+ const samePaths=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&unique(a)&&unique(b)&&equal([...a].sort(),[...b].sort());
13
+ function paths(bytes){
14
+ const output=text(bytes);if(!output)return [];
15
+ if(!output.endsWith('\0'))throw Error('Incomplete Git path output');
16
+ const result=output.slice(0,-1).split('\0');if(!result.every(gitPath)||!unique(result))throw Error('Invalid Git paths');return result;
17
+ }
18
+ /** The default transport is read-only, bounded and shell-free. Inherited Git
19
+ * overrides and optional locks are disabled; no caller environment is accepted. */
20
+ function defaultRunner({args,cwd}){
21
+ const env=Object.fromEntries(Object.entries(process.env).filter(([key])=>!key.toUpperCase().startsWith('GIT_')));
22
+ 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'});
23
+ return spawnSync('git',['--no-pager','--literal-pathspecs','-c','core.fsmonitor=false','-c','core.untrackedCache=false',...args],{cwd,env,encoding:'buffer',maxBuffer:16*1024*1024,timeout:10000,windowsHide:true});
24
+ }
25
+ /** Construct Git tree object IDs in memory from the complete stage-0 index.
26
+ * No write-tree/hash-object -w or other repository mutation is needed. */
27
+ function indexTree(bytes,objectLength){
28
+ const entries=text(bytes);if(entries&&!entries.endsWith('\0'))throw Error('Incomplete index');
29
+ const root=new Map();
30
+ for(const entry of entries?entries.slice(0,-1).split('\0'):[]){
31
+ const match=/^(100644|100755|120000|160000) ([a-f0-9]+) ([0-3])\t([\s\S]+)$/.exec(entry);
32
+ if(!match||match[3]!=='0'||!oid(match[2])||match[2].length!==objectLength||!gitPath(match[4]))throw Error('Invalid or unmerged index');
33
+ const parts=match[4].split('/');let directory=root;
34
+ for(const part of parts.slice(0,-1)){if(directory.has(part)&&!(directory.get(part) instanceof Map))throw Error('Index path collision');if(!directory.has(part))directory.set(part,new Map());directory=directory.get(part);}
35
+ const name=parts.at(-1);if(directory.has(name))throw Error('Duplicate index path');directory.set(name,{mode:match[1],object:match[2]});
36
+ }
37
+ const algorithm=objectLength===64?'sha256':'sha1';
38
+ const tree=directory=>{
39
+ const rows=[...directory].map(([name,value])=>({name,directory:value instanceof Map,value}));
40
+ rows.sort((a,b)=>Buffer.compare(Buffer.from(a.name+(a.directory?'/':'')),Buffer.from(b.name+(b.directory?'/':''))));
41
+ const body=Buffer.concat(rows.map(row=>Buffer.concat([Buffer.from(`${row.directory?'40000':row.value.mode} ${row.name}\0`),Buffer.from(row.directory?tree(row.value):row.value.object,'hex')])));
42
+ return crypto.createHash(algorithm).update(Buffer.from(`tree ${body.length}\0`)).update(body).digest('hex');
43
+ };
44
+ return tree(root);
45
+ }
46
+ function documentInput(documents,target){
47
+ if(!Array.isArray(documents))throw Error('Documents must be an array');
48
+ for(const d of documents){
49
+ if(!d||!['spec','plan'].includes(d.kind)||!gitPath(d.gitPath)||!absolute(d.path)||d.path!==path.join(target.realpath,...d.gitPath.split('/'))||!/^sha256:[a-f0-9]{64}$/.test(d.contentHash)||typeof d.approvalRef!=='string'||!d.approvalRef||(d.commit!==undefined&&!oid(d.commit)))throw Error('Invalid approved document');
50
+ }
51
+ if(!unique(documents.map(d=>d.gitPath))||!unique(documents.map(d=>d.kind)))throw Error('Duplicate document');
52
+ const commits=new Set(documents.map(d=>d.commit));if(commits.size>1)throw Error('Documents must share a single commit');
53
+ }
54
+ /** Read actual objects/index and compare approved documents. gitRunner is a
55
+ * trusted transport injection for tests/providers, never a tool argument. */
56
+ export async function readGitEvidence({target,documents=[],gitRunner=defaultRunner,env,...options}={}){
57
+ if(!target||!absolute(target.realpath)||!absolute(target.gitCommonDir)||!oid(target.head)||!(target.branch===null||typeof target.branch==='string'&&target.branch)||typeof gitRunner!=='function'||env!==undefined&&(!env||Object.keys(env).length)||Object.keys(options).length)throw Error('Invalid Git evidence target or overrides');
58
+ documentInput(documents,target);
59
+ const run=async args=>{
60
+ const result=await gitRunner({args,cwd:target.realpath});
61
+ if(!result||result.status!==0||result.error||!(typeof result.stdout==='string'||Buffer.isBuffer(result.stdout)))throw Error('Git evidence query failed: '+args[0]);
62
+ return Buffer.isBuffer(result.stdout)?result.stdout:Buffer.from(result.stdout);
63
+ };
64
+ const line=async args=>{const output=text(await run(args));if(!output.endsWith('\n')||output.slice(0,-1).includes('\n'))throw Error('Invalid Git scalar output');return output.slice(0,-1);};
65
+ const observeTarget=async()=>{
66
+ const realpath=await line(['rev-parse','--show-toplevel']);
67
+ const gitCommonDir=await line(['rev-parse','--path-format=absolute','--git-common-dir']);
68
+ const head=await line(['rev-parse','HEAD']);
69
+ // Detached HEAD is deliberately represented as null, never silently rebound.
70
+ let branch;
71
+ if(target.branch===null){
72
+ const r=await gitRunner({args:['symbolic-ref','--quiet','--short','HEAD'],cwd:target.realpath});
73
+ if(r?.status!==1||r.error)throw Error('Expected detached HEAD');branch=null;
74
+ }else branch=await line(['symbolic-ref','--quiet','--short','HEAD']);
75
+ if(realpath!==target.realpath||gitCommonDir!==target.gitCommonDir||head!==target.head||branch!==target.branch)throw Error('Git target or HEAD drift');
76
+ return {...target,realpath,gitCommonDir,head,branch};
77
+ };
78
+ const observedTarget=await observeTarget();
79
+ const index=await run(['ls-files','--stage','-z']);
80
+ const stagedPaths=paths(await run(['diff','--cached','--name-only','-z','--']));
81
+ const diff=await run(['diff','--cached','--binary','--no-ext-diff','--no-textconv','--']);
82
+ const workingPaths=paths(await run(['diff','--name-only','-z','--no-ext-diff','--no-textconv','--']));
83
+ const untrackedPaths=paths(await run(['ls-files','--others','--exclude-standard','-z']));
84
+ const observedDocuments=[];let docsCommit;
85
+ if(documents.length){
86
+ const commit=documents[0].commit;
87
+ if(commit){
88
+ const metadata=text(await run(['show','--no-patch','--format=%P%n%T',commit])).trimEnd().split('\n');
89
+ if(metadata.length!==2||!oid(metadata[0])||!oid(metadata[1]))throw Error('Expected a single-parent document commit');
90
+ await run(['merge-base','--is-ancestor',commit,target.head]);
91
+ const committedPaths=paths(await run(['diff-tree','--no-commit-id','--name-only','-r','-z',commit,'--']));
92
+ if(!samePaths(committedPaths,documents.map(d=>d.gitPath)))throw Error('Document commit must contain the exact approved document set');
93
+ const rows=pathsForTree(await run(['ls-tree','-r','-z',commit,'--',...documents.map(d=>d.gitPath)]));
94
+ if(!samePaths(rows.map(row=>row.path),documents.map(d=>d.gitPath))||rows.some(row=>!['100644','100755'].includes(row.mode)||row.type!=='blob'))throw Error('Documents must be regular committed files');
95
+ docsCommit={commit,parent:metadata[0],tree:metadata[1],paths:committedPaths,documents:structuredClone(documents)};
96
+ }
97
+ const stagedModes=new Map(text(index).split('\0').filter(Boolean).map(entry=>{const m=/^(\d+) [a-f0-9]+ 0\t([\s\S]+)$/.exec(entry);return m?[m[2],m[1]]:[];}));
98
+ for(const d of documents){
99
+ if(!commit&&!['100644','100755'].includes(stagedModes.get(d.gitPath)))throw Error('Documents must be regular staged files');
100
+ const bytes=await run(['show',`${commit??''}:${d.gitPath}`]);
101
+ const observedHash=hash(bytes);if(observedHash!==d.contentHash)throw Error('Approved document hash mismatch');
102
+ observedDocuments.push({...d,observedHash});
103
+ }
104
+ }
105
+ // Reading a concurrent index twice is conservative: never publish a mixed
106
+ // snapshot or infer completion after the target changed during observation.
107
+ if(!(await run(['ls-files','--stage','-z'])).equals(index)||!(await run(['diff','--cached','--binary','--no-ext-diff','--no-textconv','--'])).equals(diff))throw Error('Index changed during Git observation');
108
+ await observeTarget();
109
+ return {target:observedTarget,indexTree:indexTree(index,target.head.length),stagedPaths,stagedFingerprint:hash(diff),workingPaths,untrackedPaths,documents:observedDocuments,...(docsCommit?{docsCommit}:{})};
110
+ }
111
+ function pathsForTree(bytes){
112
+ const output=text(bytes);if(!output||!output.endsWith('\0'))throw Error('Invalid document tree');
113
+ 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
+ }
@@ -0,0 +1,88 @@
1
+ /** Shared, non-executing Git query classifier. Target/configuration overrides fail closed. */
2
+ const deny = reason => ({allowed:false,reason});
3
+ const isGit = word => /(?:^|[\\/])git(?:\.exe)?$/i.test(word);
4
+ export function validateGitPolicy(policy) {
5
+ if (!policy || policy.schemaVersion !== 1 || !Array.isArray(policy.globalValueOptions) || !Array.isArray(policy.globalFlags) || !policy.commands || typeof policy.commands !== 'object' || Array.isArray(policy.commands)) throw Error('Invalid Git policy');
6
+ const strings = list => Array.isArray(list) && list.every(x=>typeof x === 'string' && x.startsWith('-'));
7
+ if (!strings(policy.globalValueOptions) || !strings(policy.globalFlags) || !Object.keys(policy.commands).length) throw Error('Invalid Git policy');
8
+ for (const rule of Object.values(policy.commands)) if (!rule || !strings(rule.flags) || !strings(rule.values) || !['any','none','query','list'].includes(rule.positionals)) throw Error('Invalid Git command policy');
9
+ return policy;
10
+ }
11
+ // This deliberately supports a small shell grammar, rather than executing a shell.
12
+ function tokenize(command) {
13
+ const tokens=[]; let word='', started=false, quote=null;
14
+ for(let i=0;i<command.length;i++) {
15
+ const char=command[i];
16
+ if(char==='\\' && /[\r\n]/.test(command[i+1]??'')) throw Error('Escaped line terminator');
17
+ if(quote) {
18
+ if(char===quote){quote=null;continue;}
19
+ if(quote==='"' && /[$`]/.test(char)) throw Error('Dynamic shell syntax');
20
+ // Preserve Windows paths in quoted executable names.
21
+ if(char==='\\' && quote==='"' && /["\\$`]/.test(command[i+1]??'')){word+=command[++i];continue;}
22
+ word+=char;continue;
23
+ }
24
+ if(char==='"'||char==="'"){quote=char;started=true;continue;}
25
+ if(/[\r\n\x00<>`$(){}&]/.test(char) && !(char==='&'&&command[i+1]==='&')) throw Error('Unsupported shell syntax');
26
+ if(char===';'||char==='|'||char==='&') {
27
+ if(started){tokens.push(word);word='';started=false;}
28
+ const operator=command.slice(i,i+2); if(operator==='&&'||operator==='||')i++;
29
+ tokens.push({operator:operator==='&&'||operator==='||'?operator:char});continue;
30
+ }
31
+ if(/\s/.test(char)){if(started){tokens.push(word);word='';started=false;}continue;}
32
+ if(char==='\\') {if(i+1>=command.length)throw Error('Incomplete escape'); word+=command[++i];started=true;continue;}
33
+ if(char==='#')throw Error('Unsupported shell comment');
34
+ word+=char;started=true;
35
+ }
36
+ if(quote)throw Error('Unclosed quote'); if(started)tokens.push(word);return tokens;
37
+ }
38
+ function classifyWords(words,policy,depth) {
39
+ if(!words.length)return deny('Empty shell segment');
40
+ const executable=words[0].split(/[\\/]/).at(-1).toLowerCase();
41
+ if(['sh','bash','sh.exe','bash.exe'].includes(executable)) {
42
+ if(depth>=4 || words.length!==3 || words[1]!=='-c')return deny('Unsupported shell wrapper');
43
+ return classifyGitCommand(words[2],policy,depth+1);
44
+ }
45
+ if(!isGit(words[0]))return words.some(isGit)?deny('Unsupported indirect Git invocation'):{allowed:true};
46
+ let i=1;
47
+ while(i<words.length && words[i].startsWith('-')) {
48
+ const option=words[i++],equal=option.indexOf('='),key=equal<0?option:option.slice(0,equal);
49
+ if(policy.globalFlags.includes(option))continue;
50
+ // A query must stay on the target already resolved by the trusted adapter.
51
+ if(['-C','--git-dir','--work-tree','--namespace','--config-env','-c'].includes(key))return deny('Git target or configuration override');
52
+ if(!policy.globalValueOptions.includes(key))return deny('Unknown Git global option');
53
+ if(equal<0 && (i>=words.length || words[i++].startsWith('-')))return deny('Missing Git option value');
54
+ if(equal>=0 && !option.slice(equal+1))return deny('Missing Git option value');
55
+ }
56
+ const combined=`${words[i]} ${words[i+1]}`;
57
+ const name=Object.hasOwn(policy.commands,combined)?(i+=2,combined):words[i++];
58
+ const rule=Object.hasOwn(policy.commands,name)?policy.commands[name]:null; if(!rule)return deny('Git mutation or unknown subcommand');
59
+ let positions=0,query=false,list=false,paths=false;
60
+ for(;i<words.length;i++) {
61
+ const arg=words[i];
62
+ if(arg==='--no-index')return deny('Query outside repository');
63
+ if(arg!=='--'&&!arg.startsWith('-')&&(/^(?:[\\/]|[A-Za-z]:)/.test(arg)||arg.split(/[\\/]/).includes('..')||arg.includes(':(')))return deny('Query path escapes target');
64
+ if(arg==='--' && rule.positionals==='any'){paths=true;continue;}
65
+ if(!paths && arg.startsWith('-')) {
66
+ if(/^-[0-9]+$/.test(arg)&&rule.flags.includes('-<count>'))continue;
67
+ const equal=arg.indexOf('='),key=equal<0?arg:arg.slice(0,equal);
68
+ if(rule.values.includes(key)) {if(equal<0 && (i+1>=words.length||words[++i].startsWith('-')))return deny('Missing query option value'); if(equal>=0&&!arg.slice(equal+1))return deny('Missing query option value');continue;}
69
+ if(!rule.flags.includes(arg))return deny('Unknown or mutating Git option');
70
+ if(['--get','--get-all','--get-regexp','--list','-l'].includes(arg))query=true;
71
+ if(['--list','-l','--show-current'].includes(arg))list=true;
72
+ } else positions++;
73
+ }
74
+ if(rule.positionals==='none'&&positions || rule.positionals==='query'&&(!query||positions>1) || rule.positionals==='list'&&positions&&!list)return deny('Git write or unsupported arguments');
75
+ return {allowed:true};
76
+ }
77
+ export function classifyGitCommand(command,policy,depth=0) {
78
+ try {
79
+ validateGitPolicy(policy);
80
+ if(typeof command!=='string'||!command.trim())return deny('Missing shell command');
81
+ const tokens=tokenize(command); let words=[];
82
+ for(const token of [...tokens,{operator:'end'}]) {
83
+ if(typeof token==='string'){words.push(token);continue;}
84
+ const result=classifyWords(words,policy,depth);if(!result.allowed)return result;words=[];
85
+ }
86
+ return {allowed:true};
87
+ } catch {return deny('Invalid policy or unsupported command syntax');}
88
+ }
@@ -0,0 +1,27 @@
1
+ import {validateWorkflowState, text} from './schema.mjs';
2
+ import {ROLES} from './operations.mjs';
3
+ const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
4
+ /** Observation is supplied by the trusted platform provider, never agent/tool arguments. */
5
+ export function registerAgentObservation(state, observation){
6
+ validateWorkflowState(state);
7
+ const result=(decision,reason)=>({decision,reason,state,proposedTransition:null});
8
+ if(!observation||!text(observation.actorRef)||!text(observation.taskId)||!text(observation.observationRef)||!ROLES.includes(observation.role))return result('hold','complete observed identity required');
9
+ const old=state.identities.find(i=>i.actorRef===observation.actorRef);
10
+ const lease=state.leases.find(l=>l.actorRef===observation.actorRef);
11
+ if(old&&(old.taskId!==observation.taskId||old.role!==observation.role))return result('deny','identity rebind');
12
+ if(lease?.status==='retired')return result('deny','retired identity');
13
+ const p=observation.provenance;
14
+ if(!text(observation.eventRef)||!text(observation.correlationRef)||!p||p.source!=='platform-event'||p.eventRef!==observation.eventRef||p.actorRef!==observation.actorRef||p.platform!==observation.platform||p.correlationRef!==observation.correlationRef||p.verification?.status!=='verified'||!text(p.verification.verifierRef)||p.verification.sourceRef!==observation.eventRef)return result('hold','trusted platform spawn observation and correlation required');
15
+ const assignment=state.assignments.find(a=>a.assignmentId===observation.assignmentId&&a.correlationRef===observation.correlationRef);
16
+ if(!assignment||assignment.taskId!==observation.taskId||assignment.role!==observation.role||assignment.platform!==observation.platform||assignment.approvalRef!==observation.approvalRef||!same(assignment.paths,observation.paths)||!same(assignment.operations,observation.operations))return result('hold','observation does not match approved pending assignment');
17
+ if(old){
18
+ if(assignment.status==='active'&&assignment.actorRef===old.actorRef&&old.observationRef===observation.observationRef&&lease?.status==='active')return result('allow','same confirmed observation');
19
+ return result('hold','existing identity cannot be assigned through another observation');
20
+ }
21
+ if(assignment.status!=='pending'||assignment.actorRef!==null||lease)return result('hold','new identity requires pending unbound assignment');
22
+ const task=state.tasks.find(t=>t.id===assignment.taskId);if(!task||['accepted','committed'].includes(task.status))return result('hold','task no longer accepts agent observations');
23
+ const next=structuredClone(state);const active=next.assignments.find(a=>a.assignmentId===assignment.assignmentId);active.actorRef=observation.actorRef;active.status='active';active.observationRef=observation.observationRef;
24
+ next.identities.push({actorRef:observation.actorRef,taskId:observation.taskId,role:observation.role,observationRef:observation.observationRef});next.leases.push({actorRef:observation.actorRef,taskId:observation.taskId,status:'active'});next.tasks.find(t=>t.id===assignment.taskId).status='assigned';
25
+ validateWorkflowState(next);
26
+ return {decision:'allow',reason:'matching observed spawn bound to pending assignment',state:next,proposedTransition:next};
27
+ }
@@ -0,0 +1,6 @@
1
+ /** Canonical semantic operations, independent of platform tool aliases. */
2
+ export const ROLES = Object.freeze(['orchestrator','scouter','planner','coder','qa','reviewer']);
3
+ export const GIT_OPERATIONS = Object.freeze(['stage','commit','push','merge','rebase','reset','branch-create','branch-switch','worktree-create','worktree-remove']);
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'],coder:['read','write','check'],qa:['read','check','temporary-write'],reviewer:['read','review']});