nccgs 2.0.0-rc.2 → 2.0.0

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 (71) hide show
  1. package/.claude/nccgs/MEMORY.md +3 -2
  2. package/.claude/nccgs/VERSION +1 -1
  3. package/.claude/nccgs/packages/ponytail/README.md +2 -2
  4. package/.claude/nccgs/protocols/agent-contract.md +15 -2
  5. package/.claude/nccgs/protocols/bounded-repair.md +135 -7
  6. package/.claude/nccgs/protocols/editor-session-evidence.md +52 -0
  7. package/.claude/nccgs/protocols/orchestration.md +7 -5
  8. package/.claude/nccgs/protocols/review-handoff.md +17 -1
  9. package/.claude/nccgs/protocols/standalone-gates.md +64 -8
  10. package/.claude/nccgs/protocols/standalone.md +33 -13
  11. package/.claude/nccgs/protocols/task-gates.md +13 -1
  12. package/.claude/nccgs/protocols/task-slices.md +106 -0
  13. package/.claude/nccgs/routing.generated.md +20 -0
  14. package/.claude/nccgs/tools/command-process.mjs +10 -4
  15. package/.claude/nccgs/tools/compile-policy.mjs +4 -0
  16. package/.claude/nccgs/tools/editor-session-evidence.mjs +66 -0
  17. package/.claude/nccgs/tools/gates.mjs +7 -4
  18. package/.claude/nccgs/tools/handoff.mjs +51 -0
  19. package/.claude/nccgs/tools/inputs.mjs +36 -1
  20. package/.claude/nccgs/tools/lifecycle.mjs +21 -0
  21. package/.claude/nccgs/tools/orchestration.mjs +264 -14
  22. package/.claude/nccgs/tools/repair-packet.mjs +29 -10
  23. package/.claude/nccgs/tools/returned-handoff.mjs +38 -0
  24. package/.claude/nccgs/tools/review-budget.mjs +171 -0
  25. package/.claude/nccgs/tools/review-handoff.mjs +42 -8
  26. package/.claude/nccgs/tools/runtime.mjs +7 -2
  27. package/.claude/nccgs/tools/session-budget.mjs +67 -0
  28. package/.claude/nccgs/tools/task-slices.mjs +147 -0
  29. package/.claude/nccgs/tools/task.mjs +39 -2
  30. package/.claude/nccgs/tools/tool-guard.mjs +19 -5
  31. package/.claude/nccgs/tools/unity-repair.mjs +128 -0
  32. package/.claude/nccgs/tools/verification-jobs.mjs +13 -3
  33. package/.claude/nccgs/tools/windows-compiler-server.cs +163 -0
  34. package/.claude/nccgs/tools/windows-job.cs +8 -1
  35. package/CHANGELOG.md +123 -0
  36. package/MEMORY.md +3 -2
  37. package/README.md +35 -27
  38. package/UPGRADING.md +26 -22
  39. package/VERSION +1 -1
  40. package/docs/ARCHITECTURE.md +45 -18
  41. package/docs/BOUNDED-REPAIR.md +76 -7
  42. package/docs/EDITOR-SESSION-EVIDENCE.md +52 -0
  43. package/docs/HANDOFF-AUTOMATION.md +144 -0
  44. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +40 -11
  45. package/docs/PHASE-1-OPERATIONS.md +142 -1
  46. package/docs/PHASE-2-OPERATIONS.md +1 -1
  47. package/docs/RELEASE-STATUS.md +92 -9
  48. package/docs/TASK-SLICING.md +57 -0
  49. package/docs/UPGRADE-QUALIFICATION.md +45 -0
  50. package/docs/WORKFLOWS.md +1 -1
  51. package/package.json +2 -2
  52. package/scaffold/.nccgs/templates/repair-unity-code.json +18 -0
  53. package/scaffold/.nccgs/templates/repair-unity-editor.json +21 -0
  54. package/scaffold/.nccgs/templates/task-slices.json +28 -0
  55. package/scripts/cli.mjs +4 -0
  56. package/scripts/install.mjs +10 -0
  57. package/scripts/run.mjs +4 -3
  58. package/scripts/supervise.mjs +30 -6
  59. package/scripts/validate.mjs +4 -0
  60. package/scripts/work-handoff.mjs +38 -0
  61. package/tests/editor-session-evidence.test.mjs +45 -0
  62. package/tests/fixtures/compiler-shutdown.cs +64 -0
  63. package/tests/framework.test.mjs +38 -0
  64. package/tests/orchestration-v15.test.mjs +52 -1
  65. package/tests/phase1.test.mjs +92 -0
  66. package/tests/policy-v15.test.mjs +14 -0
  67. package/tests/review-budget.test.mjs +177 -0
  68. package/tests/runtime-v15.test.mjs +572 -4
  69. package/tests/session-budget.test.mjs +34 -0
  70. package/tests/task-slices.test.mjs +285 -0
  71. package/tests/unity-generated.test.mjs +140 -0
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs";
2
+ import { artifact, atomicJson, contained, hash, json, verifyArtifact } from "./policy.mjs";
3
+ import { acquireLock, ownerState, pidState } from "./process-lock.mjs";
4
+ import { sessionBudgetLock, settledCost, taskWithin } from "./session-budget.mjs";
5
+
6
+ const need = (ok, message) => { if (!ok) throw new Error(`Review reserve: ${message}`); };
7
+ const taskFile = (project, id) => {
8
+ need(typeof id === "string" && /^[a-zA-Z0-9][\w-]{0,79}$/.test(id), "invalid task ID");
9
+ return contained(project, `.nccgs/tasks/${id}.json`);
10
+ };
11
+ const planPath = id => `.nccgs/budgets/${id}.json`;
12
+ const markerPath = id => `.nccgs/budgets/${id}.initialized.json`;
13
+ const terms = plan => ({ schemaVersion: plan.schemaVersion, taskId: plan.taskId, limits: plan.limits,
14
+ request: plan.request, createdAt: plan.createdAt, deadline: plan.deadline, workDeadline: plan.workDeadline });
15
+ function readPlan(project, id) {
16
+ const file = contained(project, planPath(id)), marker = contained(project, markerPath(id));
17
+ if (!fs.existsSync(file)) { need(!fs.existsSync(marker), "initialized budget history is missing; never reset it"); return null; }
18
+ const plan = json(file);
19
+ need(plan.schemaVersion === 1 && plan.taskId === id && Array.isArray(plan.allocations), "invalid budget plan");
20
+ need(fs.existsSync(marker) && json(marker).sha256 === hash(JSON.stringify(terms(plan))), "pinned budget terms changed or marker missing");
21
+ verifyArtifact(project, plan.request);
22
+ return plan;
23
+ }
24
+ export function findReviewBudget(project, id) {
25
+ const seen = new Set(); let found = null;
26
+ while (id) {
27
+ need(!seen.has(id), "task ancestry cycle"); seen.add(id);
28
+ const task = json(taskFile(project, id)), plan = readPlan(project, id);
29
+ need(!plan || !found, "nested budgets cannot mint another reserve");
30
+ if (plan) found = plan;
31
+ id = task.parentId;
32
+ }
33
+ return found;
34
+ }
35
+ function familyRuns(project, id) {
36
+ const dir = contained(project, ".nccgs/runs");
37
+ if (!fs.existsSync(dir)) return [];
38
+ return fs.readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => {
39
+ const run = json(contained(project, `.nccgs/runs/${e.name}/run.json`));
40
+ need(run.id === e.name && run.project === project, "invalid native run history"); return run;
41
+ }).filter(run => taskWithin(project, run.taskId, id));
42
+ }
43
+ function requireWorkerCoverage(project, id, runs, ledger = null) {
44
+ const file = contained(project, ".nccgs/orchestration.json");
45
+ ledger ??= fs.existsSync(file) ? json(file) : null;
46
+ const sessions = new Set(runs.map(run => run.sessionId));
47
+ need(sessions.size === runs.length, "duplicate session across run records");
48
+ const workers = Object.values(ledger?.dispatches ?? {}).filter(d => d.startedAt && taskWithin(project, d.taskId, id));
49
+ need(workers.every(d => sessions.has(d.sessionId)), "worker session has no supervised cost coverage; preserve and reconcile historical spend");
50
+ }
51
+ function history(project, plan, currentId = null) {
52
+ const records = plan.allocations.map(ref => ({ ...json(verifyArtifact(project, ref)), reference: ref }));
53
+ need(new Set(records.map(r => r.runId)).size === records.length, "duplicate run allocation");
54
+ const runs = familyRuns(project, plan.taskId), ids = new Set(runs.map(r => r.id));
55
+ requireWorkerCoverage(project, plan.taskId, runs);
56
+ need(records.every(r => ids.has(r.runId)), "allocated run history is missing; spent allowances cannot be refunded");
57
+ const neverStarted = r => r.status === "blocked" && r.costCoverage === "complete" && r.invocations.length === 0 && r.cumulativeCostUsd === 0;
58
+ need(runs.every(r => r.id === currentId || records.some(a => a.runId === r.id) || neverStarted(r)), "unallocated native session; reconcile history before spending");
59
+ let workUsd = 0, reviewUsd = 0;
60
+ const sessions = [];
61
+ for (const run of runs.filter(r => r.id !== currentId)) {
62
+ const allocation = records.find(r => r.runId === run.id);
63
+ if (!allocation && neverStarted(run)) { sessions.push({ runId: run.id, sessionId: run.sessionId, phase: "not-started", costUsd: 0 }); continue; }
64
+ need(allocation.sessionId === run.sessionId && ["work", "final-review"].includes(allocation.phase), "allocation identity mismatch");
65
+ if (!allocation.legacy) need(run.budgetAllocation?.sha256 === allocation.reference.sha256, "native run allocation changed");
66
+ const cost = settledCost(project, run);
67
+ if (allocation.phase === "final-review") reviewUsd += cost; else workUsd += cost;
68
+ sessions.push({ runId: run.id, sessionId: run.sessionId, phase: allocation.phase, costUsd: cost });
69
+ }
70
+ return { records, sessions, workUsd, reviewUsd, totalUsd: workUsd + reviewUsd };
71
+ }
72
+ export function registerReviewBudget(project, id, relative) {
73
+ need(!process.env.CLAUDECODE && !process.env.CLAUDE_CODE_ENTRYPOINT, "budget allocation is external only");
74
+ const lock = acquireLock(contained(project, sessionBudgetLock), { operation: "register-review-reserve", taskId: id });
75
+ try {
76
+ const task = json(taskFile(project, id)), request = artifact(project, relative), input = json(verifyArtifact(project, request));
77
+ need(task.schemaVersion === 2 && !task.closed, "an open standalone root task is required");
78
+ const existing = findReviewBudget(project, id);
79
+ if (existing) { need(existing.taskId === id && existing.request.sha256 === request.sha256, "budget terms are already pinned; no replacement or extension"); return existing; }
80
+ need(!task.parentId, "register the reserve on the existing task root, never on a child");
81
+ need(input.schemaVersion === 1 && typeof input.authority === "string" && input.authority.trim(), "explicit external allocation authority is required");
82
+ const { totalUsd, finalReviewUsd, totalDurationMs, finalReviewDurationMs } = input;
83
+ need(Number.isFinite(totalUsd) && totalUsd > 0 && Number.isFinite(finalReviewUsd) && finalReviewUsd > 0 && finalReviewUsd < totalUsd, "invalid total/review cost allocation");
84
+ need(Number.isSafeInteger(totalDurationMs) && totalDurationMs >= 1000 && totalDurationMs <= 86400000
85
+ && Number.isSafeInteger(finalReviewDurationMs) && finalReviewDurationMs >= 1000 && finalReviewDurationMs < totalDurationMs, "invalid total/review time allocation");
86
+ const ledgerFile = contained(project, ".nccgs/orchestration.json"), ledger = fs.existsSync(ledgerFile) ? json(ledgerFile) : null;
87
+ need(!Object.values(ledger?.dispatches ?? {}).some(d => ["reserved", "running", "uncertain"].includes(d.status)), "settle all workers before allocating a reserve");
88
+ const pinned = ledger?.tasks[id]?.limits.maxCostUsd;
89
+ need(pinned == null || totalUsd <= pinned, "allocation cannot raise the pinned aggregate cap");
90
+ const prior = familyRuns(project, id);
91
+ requireWorkerCoverage(project, id, prior, ledger);
92
+ const spent = prior.reduce((sum, run) => sum + settledCost(project, run), 0);
93
+ need(spent < totalUsd - finalReviewUsd, "past spending leaves insufficient work/review reserve; cannot reserve spent money retroactively");
94
+ const createdAt = new Date().toISOString();
95
+ const deadline = prior.length ? Math.min(Date.now() + totalDurationMs, Math.max(...prior.map(r => r.deadline))) : Date.now() + totalDurationMs;
96
+ const workDeadline = deadline - finalReviewDurationMs;
97
+ need(Date.now() < workDeadline, "past elapsed time leaves no protected review window");
98
+ const plan = { schemaVersion: 1, taskId: id, request, limits: { totalUsd, finalReviewUsd, totalDurationMs, finalReviewDurationMs },
99
+ createdAt, deadline, workDeadline, allocations: [] };
100
+ for (const run of prior) {
101
+ const file = `.nccgs/budgets/allocations/${run.id}.json`;
102
+ atomicJson(contained(project, file), { runId: run.id, sessionId: run.sessionId, taskId: run.taskId, phase: "work", legacy: true, recordedAt: createdAt });
103
+ plan.allocations.push(artifact(project, file));
104
+ }
105
+ atomicJson(contained(project, markerPath(id)), { sha256: hash(JSON.stringify(terms(plan))) });
106
+ atomicJson(contained(project, planPath(id)), plan);
107
+ return plan;
108
+ } finally { lock.release(); }
109
+ }
110
+ export function allocateReviewBudget(project, state, phase = "work") {
111
+ const plan = findReviewBudget(project, state.taskId);
112
+ if (!plan) { need(phase === "work", "final-review needs a pinned aggregate reserve"); return null; }
113
+ need(["work", "final-review"].includes(phase), "unknown budget phase");
114
+ const lock = json(contained(project, sessionBudgetLock));
115
+ need(lock.runId === state.id && lock.sessionId === state.sessionId && ownerState(lock) === "alive", "exclusive supervisor ownership is required");
116
+ const task = json(taskFile(project, state.taskId));
117
+ need(phase !== "final-review" || ["implement", "audit"].includes(task.mode), "design review cannot consume the final delivery reserve");
118
+ const prior = history(project, plan, state.id), existing = prior.records.find(a => a.runId === state.id);
119
+ const available = phase === "work"
120
+ ? plan.limits.totalUsd - prior.workUsd - Math.max(plan.limits.finalReviewUsd, prior.reviewUsd)
121
+ : plan.limits.totalUsd - prior.totalUsd;
122
+ const deadline = phase === "work" ? plan.workDeadline : plan.deadline;
123
+ need(available > 0 && Date.now() < deadline, "phase allowance exhausted; retain the final review reserve and original total");
124
+ if (existing) {
125
+ need(existing.phase === phase && state.budgetAllocation?.sha256 === existing.reference.sha256
126
+ && state.budgetUsd === existing.allowanceUsd && state.deadline === existing.deadline, "recovery cannot alter the allocated phase, cost or deadline");
127
+ need(state.budgetUsd <= available + 1e-9 && state.deadline <= deadline && Date.now() < state.deadline, "historical spending/time no longer covers this allocation");
128
+ return existing;
129
+ }
130
+ const requestedUsd = state.budgetUsd, requestedDeadline = state.deadline;
131
+ state.budgetUsd = Math.min(requestedUsd, available); state.deadline = Math.min(requestedDeadline, deadline); state.budgetPhase = phase;
132
+ const value = { schemaVersion: 1, rootTaskId: plan.taskId, taskId: state.taskId, runId: state.id, sessionId: state.sessionId,
133
+ phase, requestedUsd, allowanceUsd: state.budgetUsd, requestedDeadline, deadline: state.deadline,
134
+ recordedAt: new Date().toISOString(), priorSessions: prior.sessions, protectedReviewUsd: Math.max(0, plan.limits.finalReviewUsd - prior.reviewUsd),
135
+ workerCostInferred: false };
136
+ const file = `.nccgs/budgets/allocations/${state.id}.json`;
137
+ need(!fs.existsSync(contained(project, file)), "allocation already exists; preserve interrupted history");
138
+ atomicJson(contained(project, file), value); state.budgetAllocation = artifact(project, file);
139
+ plan.allocations.push(state.budgetAllocation); atomicJson(contained(project, planPath(plan.taskId)), plan);
140
+ return value;
141
+ }
142
+ export function plannedDispatchBudget(project, id, request) {
143
+ const plan = findReviewBudget(project, id); if (!plan) return null;
144
+ const runId = process.env.NCCGS_SUPERVISED_RUN;
145
+ need(typeof runId === "string" && /^[a-f0-9-]{36}$/.test(runId), "planned tasks require their supervised allocation");
146
+ const state = json(contained(project, `.nccgs/runs/${runId}/run.json`));
147
+ need(state.taskId === id && state.sessionId === process.env.NCCGS_SUPERVISED_SESSION && state.status === "running"
148
+ && pidState(state.invocations.at(-1)?.pid) === "alive", "current allocated native session is not live/correlated");
149
+ const allocation = allocateReviewBudget(project, state, state.budgetPhase);
150
+ const task = json(taskFile(project, id));
151
+ if (allocation.phase === "final-review") need(request.kind === "review" && ["implement", "audit"].includes(task.mode), "final-review funds cannot dispatch implementation, repair, analysis or design workers");
152
+ else need(request.kind !== "review" || !["implement", "audit"].includes(task.mode), "delivery/audit review must use the protected final-review phase");
153
+ return { ...allocation, reference: state.budgetAllocation };
154
+ }
155
+ export function inFinalReviewPhase(project, sessionId) {
156
+ const id = process.env.NCCGS_SUPERVISED_RUN; if (!id) return false;
157
+ need(/^[a-f0-9-]{36}$/.test(id), "invalid supervised run ID");
158
+ const run = json(contained(project, `.nccgs/runs/${id}/run.json`));
159
+ need(run.sessionId === sessionId, "supervised session mismatch");
160
+ return run.budgetPhase === "final-review";
161
+ }
162
+ export function reviewBudgetStatus(project, id) {
163
+ const plan = findReviewBudget(project, id); if (!plan) return { taskId: id, planned: false };
164
+ const live = familyRuns(project, plan.taskId).filter(r => ["ready", "running", "waiting-for-verification"].includes(r.status));
165
+ need(live.length <= 1, "multiple unresolved native allocations");
166
+ const current = process.env.NCCGS_SUPERVISED_RUN ?? live[0]?.id ?? null, prior = history(project, plan, current);
167
+ return { taskId: id, planned: true, rootTaskId: plan.taskId, limits: plan.limits, deadline: plan.deadline, workDeadline: plan.workDeadline,
168
+ settledSessions: prior.sessions, settledCostUsd: prior.totalUsd, protectedReviewUsd: Math.max(0, plan.limits.finalReviewUsd - prior.reviewUsd),
169
+ currentAllocation: prior.records.find(a => a.runId === current) ?? null,
170
+ note: "In-flight actual cost remains unknown; the current allocation is a ceiling, never a worker cost or acceptance result." };
171
+ }
@@ -7,26 +7,60 @@ const canonical = value => Array.isArray(value) ? value.map(canonical) : value &
7
7
  const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
8
8
  const text = value => typeof value === "string" && value.trim().length > 0;
9
9
 
10
- // The reviewer owns this block and the completed dispatch pins its bytes.
11
- // Coordinator input cannot remove conditions or replace a verdict/findings.
12
- export function verifyReviewHandoff(project, task, stage, dispatch) {
13
- if (task.execution.reviewContractRequired !== true) return; // Preserve legacy policy/hash.
14
- need(dispatch.handoff?.artifacts?.some(ref => same(ref, stage.evidence)), "evidence must be the completed reviewer's pinned handoff");
15
- const content = fs.readFileSync(verifyArtifact(project, stage.evidence), "utf8");
16
- need(/^NCCGS-HANDOFF: COMPLETE\r?$/m.test(content) && !/^NCCGS-HANDOFF: PARTIAL\r?$/m.test(content), "reviewer handoff is incomplete");
10
+ // Structural inspection is also available before the invocation ends. It never
11
+ // records a verdict, changes the document or substitutes for lifecycle gates.
12
+ export function readReviewHandoff(content, task, snapshot, requireComplete = true) {
13
+ const markers = [...content.matchAll(/^NCCGS-HANDOFF: (COMPLETE|PARTIAL)\r?$/gm)];
14
+ // Preserve the existing record gate's marker semantics. Draft validation adds
15
+ // its exact-one-marker diagnostic separately, without rewriting old evidence.
16
+ const complete = markers.some(m => m[1] === "COMPLETE") && !markers.some(m => m[1] === "PARTIAL");
17
+ need(!requireComplete || complete, "reviewer handoff is incomplete");
17
18
  const blocks = [...content.matchAll(/^```nccgs-review\r?\n([\s\S]*?)^```\s*$/gm)];
18
19
  need(blocks.length === 1, "exactly one nccgs-review JSON block is required");
19
20
  let value; try { value = JSON.parse(blocks[0][1]); } catch { need(false, "invalid nccgs-review JSON"); }
20
21
  const fields = ["schemaVersion", "taskId", "contractHash", "snapshot", "verdict", "criteriaMet", "findings", "conditions"];
21
22
  need(value && !Array.isArray(value) && Object.keys(value).length === fields.length && fields.every(key => Object.hasOwn(value, key)), "review JSON fields are incomplete or unsupported");
22
- need(value.schemaVersion === 1 && value.taskId === task.id && value.contractHash === task.contractHash && value.snapshot === stage.snapshot, "review JSON is for another task, contract or snapshot");
23
+ need(value.schemaVersion === 1 && value.taskId === task.id && value.contractHash === task.contractHash && value.snapshot === snapshot, "review JSON is for another task, contract or snapshot");
23
24
  need(Array.isArray(value.conditions), "conditions must be an explicit array, empty only when none remain");
24
25
  const ids = new Set();
25
26
  for (const condition of value.conditions) {
26
27
  need(condition && Object.keys(condition).length === 2 && text(condition.id) && text(condition.description) && !ids.has(condition.id), "conditions need unique id and description; coordinator cannot mark them resolved");
27
28
  ids.add(condition.id);
28
29
  }
30
+ return { value, complete };
31
+ }
32
+
33
+ export function validateReviewStructure(value, task) {
34
+ need(["PASS", "CHANGES_REQUIRED", "BLOCKED"].includes(value.verdict) && typeof value.criteriaMet === "boolean", "invalid verdict or criteriaMet");
35
+ need(Array.isArray(value.findings) && value.findings.every(f => f &&
36
+ ["BLOCKER", "HIGH", "MEDIUM", "LOW", "INFO"].includes(f.severity) &&
37
+ ["OPEN", "RESOLVED", "ACCEPTED"].includes(f.status) && text(f.description)),
38
+ "findings require severity, status (OPEN, RESOLVED or ACCEPTED) and description");
39
+ if (task.contract.workflow === "design") {
40
+ need(value.findings.every(f => ["none", "false-pass", "missing-coverage", "authority-gap"].includes(f.acceptanceImpact)), "design findings must classify acceptanceImpact");
41
+ need(value.verdict !== "PASS" || !value.findings.some(f => f.acceptanceImpact !== "none" && f.status !== "RESOLVED"), "unresolved design acceptance impacts block PASS");
42
+ }
43
+ need(value.verdict !== "PASS" || value.conditions.length === 0, "unresolved review conditions block PASS; complete the checks and obtain a fresh independent review");
44
+ need(value.verdict !== "PASS" || (value.criteriaMet && !value.findings.some(f => ["BLOCKER", "HIGH"].includes(f.severity) && f.status !== "RESOLVED")), "PASS requires criteriaMet and resolved blocking findings");
45
+ }
46
+
47
+ // The reviewer owns this block and the completed dispatch pins its bytes.
48
+ // Coordinator input cannot remove conditions or replace a verdict/findings.
49
+ export function verifyReviewHandoff(project, task, stage, dispatch) {
50
+ if (task.execution.reviewContractRequired !== true) return; // Preserve legacy policy/hash.
51
+ need(dispatch.handoff?.artifacts?.some(ref => same(ref, stage.evidence)), "evidence must be the completed reviewer's pinned handoff");
52
+ const content = fs.readFileSync(verifyArtifact(project, stage.evidence), "utf8");
53
+ const { value } = readReviewHandoff(content, task, stage.snapshot);
29
54
  for (const key of ["verdict", "criteriaMet", "findings", "conditions"]) need(same(stage[key], value[key]), `recorded ${key} differs from the reviewer's pinned handoff`);
30
55
  need(value.verdict !== "PASS" || value.conditions.length === 0, "unresolved review conditions block PASS; complete the checks and obtain a fresh independent review");
31
56
  return value;
32
57
  }
58
+
59
+ export function handoffTemplate(task, dispatch) {
60
+ const draft = `NCCGS-HANDOFF: PARTIAL\n\nTask: ${task.id}\nDispatch: ${dispatch.id}\n\n`;
61
+ const fields = "## Outcome\nPending assigned work.\n\n## Changed/produced\nPending.\n\n## Evidence\nPending; do not invent checks or runtime observations.\n\n## Risks and deviations\nPending assessment.\n\n## Decisions needed\nPending assessment.\n\n## Next owner\nCoordinator after this bounded assignment.\n";
62
+ if (dispatch.kind !== "review") return draft + fields;
63
+ const value = { schemaVersion: 1, taskId: task.id, contractHash: task.contractHash, snapshot: dispatch.snapshot,
64
+ verdict: "BLOCKED", criteriaMet: false, findings: [], conditions: [{ id: "REVIEW-PENDING", description: "Independent review has not finished; reviewer must assess and replace this draft." }] };
65
+ return draft + "```nccgs-review\n" + JSON.stringify(value, null, 2) + "\n```\n\n" + fields;
66
+ }
@@ -5,6 +5,7 @@ import { snapshot, taskPath } from "./gates.mjs";
5
5
  import { persistRuntimeObservation } from "./evidence.mjs";
6
6
  import { findDispatch, correlateAgentStart, attachAgent, complete, markUncertain } from "./orchestration.mjs";
7
7
  import { nativeCompletion } from "./native-completion.mjs";
8
+ import { captureReturnedHandoff } from "./returned-handoff.mjs";
8
9
 
9
10
  export function boundTask(project, session) {
10
11
  if (process.env.NCCGS_TASK_ID) { taskPath(project, process.env.NCCGS_TASK_ID); return process.env.NCCGS_TASK_ID; }
@@ -16,7 +17,7 @@ export function observe(project, input, event) {
16
17
  const { profile } = profilePolicy(project);
17
18
  const standalone = resolveExecution(project).name === "standalone";
18
19
  const dispatch = standalone ? (event === "agent_start" ? correlateAgentStart(project, input) : findDispatch(project, input)) : null;
19
- if (dispatch?.runtime && ["completed", "failed"].includes(dispatch.status)) {
20
+ if (dispatch?.runtime && (["completed", "failed"].includes(dispatch.status) || dispatch.workStatus === "incomplete-editor")) {
20
21
  const saved = json(verifyArtifact(project, dispatch.runtime));
21
22
  verifyArtifact(project, saved.observationSource);
22
23
  return { ...saved, path: dispatch.runtime.path, duplicate: true };
@@ -33,6 +34,8 @@ export function observe(project, input, event) {
33
34
  const modelsUsed = Array.isArray(response.modelsUsed) ? response.modelsUsed.filter(m => typeof m === "string") : observedModel ? [observedModel] : [];
34
35
  const capturedAt = new Date().toISOString();
35
36
  const sourceSnapshot = taskId ? snapshot(project, task).sha256 : null;
37
+ const returnedHandoff = event === "agent_result"
38
+ ? captureReturnedHandoff(project, dispatch, input, completion, sourceSnapshot) : null;
36
39
  const id = crypto.randomUUID();
37
40
  // Only documented model telemetry is copied. Prompts, arbitrary tool results,
38
41
  // credentials, and configuration guesses are never copied into observations.
@@ -43,6 +46,7 @@ export function observe(project, input, event) {
43
46
  contractHash: dispatch?.contractHash ?? task?.contractHash ?? null, toolUseId: input.tool_use_id ?? dispatch?.toolUseId ?? null,
44
47
  status: event === "agent_result" ? completion.status : event,
45
48
  ...(completion ? { nativeCompletion: completion } : {}),
49
+ ...(returnedHandoff ? { returnedHandoff } : {}),
46
50
  totalTokens: response.totalTokens ?? null, costUsd: null,
47
51
  provenance: { kind: "provider-log", reference: `Claude Code ${event} hook; session ${input.session_id ?? "unknown"}; tool ${input.tool_use_id ?? dispatch?.toolUseId ?? "unknown"}` } });
48
52
  const source = `.nccgs/evidence/runtime/sources/${id}.json`;
@@ -55,7 +59,8 @@ export function observe(project, input, event) {
55
59
  if (standalone) Object.assign(observation, { observedProvider: raw.observedProvider, dispatchId: raw.dispatchId,
56
60
  contractHash: raw.contractHash, toolUseId: raw.toolUseId, durationMs: raw.durationMs,
57
61
  toolUseCount: raw.toolUseCount, totalTokens: raw.totalTokens, costUsd: null, provenance: raw.provenance,
58
- ...(completion ? { nativeCompletion: completion } : {}) });
62
+ ...(completion ? { nativeCompletion: completion } : {}),
63
+ ...(returnedHandoff ? { returnedHandoff } : {}) });
59
64
  const ref = persistRuntimeObservation(project, observation, artifact(project, source));
60
65
  if (dispatch) {
61
66
  if (raw.agentId) attachAgent(project, dispatch.id, raw.agentId);
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { contained, json, verifyArtifact } from "./policy.mjs";
4
+ import { ownerState, pidState } from "./process-lock.mjs";
5
+
6
+ const need = (value, message) => { if (!value) throw new Error(`Session budget: ${message}`); };
7
+ export const sessionBudgetLock = ".nccgs/runs/supervision.lock";
8
+ export function taskWithin(project, id, ancestor) {
9
+ const seen = new Set();
10
+ while (id) {
11
+ need(/^[\w-]{1,80}$/.test(id) && !seen.has(id), "invalid task ancestry"); seen.add(id);
12
+ if (id === ancestor) return true;
13
+ id = json(contained(project, `.nccgs/tasks/${id}.json`)).parentId;
14
+ }
15
+ return false;
16
+ }
17
+ export function settledCost(project, run) {
18
+ if (run.status === "blocked" && run.costCoverage === "complete" && run.invocations.length === 0 && run.cumulativeCostUsd === 0) return 0; // supervisor refused before issuing any native invocation
19
+ need(run.costCoverage === "complete" && run.invocations.length > 0, "prior session usage is unknown");
20
+ let cost = 0;
21
+ for (const invocation of run.invocations) {
22
+ need(["finished", "recovered"].includes(invocation.status), "prior invocation is live or uncertain");
23
+ const expected = `.nccgs/runs/${run.id}/${invocation.log}`;
24
+ need(invocation.evidence?.path === expected, "prior invocation lacks pinned native output");
25
+ const records = fs.readFileSync(verifyArtifact(project, invocation.evidence), "utf8").trim().split(/\r?\n/);
26
+ let terminal;
27
+ for (const line of records) { try { const item = JSON.parse(line); if (item.type === "result") terminal = item; } catch { /* native partial line */ } }
28
+ need(terminal?.session_id === run.sessionId && Number.isFinite(terminal.total_cost_usd) && terminal.total_cost_usd >= cost, "missing, mismatched or decreasing native session cost");
29
+ cost = terminal.total_cost_usd; // Session totals are cumulative, never sum resumed totals.
30
+ }
31
+ need(cost === run.cumulativeCostUsd, "stored cost differs from native terminal evidence");
32
+ return cost;
33
+ }
34
+ // This is a conservative session ceiling, not measured worker telemetry. A single
35
+ // project supervisor owns the allowance; all historical sessions remain charged.
36
+ export function sessionBudgetCoverage(project, ancestor, items, limit, current, { running = true } = {}) {
37
+ need(current && current.project === project && taskWithin(project, current.taskId, ancestor), "current run is outside budget ancestry");
38
+ need(Number.isFinite(current.budgetUsd) && current.budgetUsd > 0 && Date.now() < current.deadline, "current allowance/deadline is invalid or exhausted");
39
+ const lock = json(contained(project, sessionBudgetLock));
40
+ need(lock.runId === current.id && lock.sessionId === current.sessionId && ownerState(lock) === "alive", "exclusive supervisor ownership is missing");
41
+ if (running) {
42
+ need(process.env.NCCGS_SUPERVISED_SESSION === current.sessionId && process.env.NCCGS_SUPERVISED_RUN === current.id, "current session identity mismatch");
43
+ need(current.status === "running" && current.invocations.at(-1)?.status === "running" && pidState(current.invocations.at(-1).pid) === "alive", "current native invocation is not live");
44
+ }
45
+ const sessions = new Set([current.sessionId]), previous = [];
46
+ for (const entry of fs.readdirSync(contained(project, ".nccgs/runs"), { withFileTypes: true })) {
47
+ if (!entry.isDirectory() || entry.name === current.id) continue;
48
+ const run = json(contained(project, `.nccgs/runs/${entry.name}/run.json`));
49
+ need(run.id === entry.name && run.project === project && Array.isArray(run.invocations), "invalid supervised run history");
50
+ if (!taskWithin(project, run.taskId, ancestor)) continue;
51
+ need(!sessions.has(run.sessionId), "duplicate session across run records"); sessions.add(run.sessionId);
52
+ previous.push({ runId: run.id, sessionId: run.sessionId, costUsd: settledCost(project, run) });
53
+ }
54
+ need(items.filter(d => d.startedAt).every(d => sessions.has(d.sessionId)), "worker session has no supervised cost coverage");
55
+ const previousCostUsd = previous.reduce((sum, run) => sum + run.costUsd, 0);
56
+ const ceilingUsd = previousCostUsd + current.budgetUsd;
57
+ need(Number.isFinite(limit) && ceilingUsd <= limit + 1e-9, "session allowances plus historical spend exceed aggregate maxCostUsd");
58
+ need(current.cumulativeCostUsd < current.budgetUsd, "current session allowance exhausted");
59
+ return { basis: "supervised-session-ceiling", ancestor, limitUsd: limit, previous, previousCostUsd,
60
+ currentRunId: current.id, currentSessionId: current.sessionId, currentAllowanceUsd: current.budgetUsd,
61
+ ceilingUsd, deadline: current.deadline, workerCostInferred: false };
62
+ }
63
+ export function activeSessionBudget(project, ancestor, items, limit) {
64
+ const id = process.env.NCCGS_SUPERVISED_RUN;
65
+ need(typeof id === "string" && /^[a-f0-9-]{36}$/.test(id), "missing costUsd telemetry; use a covered supervised session or stop");
66
+ return sessionBudgetCoverage(project, ancestor, items, limit, json(contained(project, `.nccgs/runs/${id}/run.json`)));
67
+ }
@@ -0,0 +1,147 @@
1
+ import fs from "node:fs";
2
+ import { contained, hash, json, verifyArtifact } from "./policy.mjs";
3
+ import { validateStandaloneRuntime } from "./lifecycle.mjs";
4
+
5
+ const need = (ok, message) => { if (!ok) throw new Error(`Task slices: ${message}`); };
6
+ const text = (v, max = 800) => typeof v === "string" && v.trim().length > 0 && v.length <= max;
7
+ const keys = (v, names) => v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).every(k => names.includes(k));
8
+ const id = v => typeof v === "string" && /^[a-zA-Z][\w-]{0,31}$/.test(v);
9
+ const list = v => Array.isArray(v) && v.every(s => text(s)) && new Set(v).size === v.length;
10
+ const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object"
11
+ ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
12
+ export const sliceHash = value => hash(JSON.stringify(canonical(value)));
13
+ export const sameSlice = (a, b) => sliceHash(a) === sliceHash(b);
14
+
15
+ export function validateSlicePlan(project, task, plan) {
16
+ need(task.schemaVersion === 2 && task.mode === "implement", "requires an existing standalone implementation task");
17
+ need(keys(plan, ["schemaVersion", "taskId", "contractHash", "sourceSnapshot", "planId", "supersedes", "reason", "integration", "slices"]) && plan.schemaVersion === 1,
18
+ "unsupported plan fields; slicing cannot supply replacement criteria, routes or budgets");
19
+ need(plan.taskId === task.id && plan.contractHash === task.contractHash && text(plan.sourceSnapshot), "plan task/contract mismatch");
20
+ need(id(plan.planId) && (plan.supersedes === null || id(plan.supersedes)) && text(plan.reason), "planId, explicit supersedes (or null) and reason required");
21
+ need(Array.isArray(plan.slices) && plan.slices.length >= 2 && plan.slices.length <= 12, "use 2-12 bounded slices within the existing budget");
22
+ const registry = json(contained(project, ".claude/nccgs/studio.json"));
23
+ const assigned = new Set(), criteria = new Set(task.contract.acceptance.map(c => c.id)), index = new Map();
24
+ for (const s of plan.slices) {
25
+ need(keys(s, ["id", "objective", "agent", "dependsOn", "writePaths", "criteria"]) && id(s.id) && !index.has(s.id) && text(s.objective), "slice needs unique id and bounded objective");
26
+ const role = registry.agents.find(a => a.name === s.agent);
27
+ need(role && role.modelClass !== "review" && s.agent !== "nccgs-task-scout", "slice must use an implementation role; it cannot lower the task's risk/model route");
28
+ need(list(s.dependsOn) && list(s.criteria) && list(s.writePaths) && s.writePaths.length >= 1 && s.writePaths.length <= 8, "slice needs unique dependencies, criterion IDs and 1-8 exact file paths");
29
+ for (const file of s.writePaths) {
30
+ const full = contained(project, file);
31
+ need(!/^\.(?:nccgs|claude|git)(?:\/|$)/i.test(file), "slices cannot own governance/evidence files");
32
+ need(!fs.existsSync(full) || fs.statSync(full).isFile(), "slice writePaths must name exact files, not directory ownership");
33
+ }
34
+ need(new Set(s.writePaths.map(p => p.toLowerCase())).size === s.writePaths.length, "duplicate case-aliased file path");
35
+ for (const criterion of s.criteria) {
36
+ need(criteria.has(criterion) && !assigned.has(criterion), "every original criterion needs exactly one primary slice owner, without unknown IDs");
37
+ assigned.add(criterion);
38
+ }
39
+ index.set(s.id, s);
40
+ }
41
+ need([...criteria].every(c => assigned.has(c)), "plan omits original acceptance criteria, including manual criteria");
42
+ const ordered = [], visiting = new Set(), visited = new Set();
43
+ const visit = name => {
44
+ need(index.has(name), `unknown dependency ${name}`); need(!visiting.has(name), "dependency cycle");
45
+ if (visited.has(name)) return;
46
+ visiting.add(name); for (const dep of index.get(name).dependsOn) visit(dep);
47
+ visiting.delete(name); visited.add(name); ordered.push(index.get(name));
48
+ };
49
+ for (const s of plan.slices) visit(s.id);
50
+ const ancestors = name => new Set(index.get(name).dependsOn.flatMap(d => [d, ...ancestors(d)]));
51
+ need(index.has(plan.integration) && ancestors(plan.integration).size === plan.slices.length - 1, "integration slice must transitively depend on every other slice");
52
+ for (let a = 0; a < plan.slices.length; a++) for (const b of plan.slices.slice(a + 1)) {
53
+ const first = plan.slices[a], overlaps = first.writePaths.some(p => b.writePaths.some(q => p.toLowerCase() === q.toLowerCase()));
54
+ need(!overlaps || ancestors(first.id).has(b.id) || ancestors(b.id).has(first.id), "shared files require an explicit dependency order");
55
+ }
56
+ return ordered;
57
+ }
58
+
59
+ export function readSlicePlan(project, task, ledger) {
60
+ const registration = ledger.tasks[task.id]?.slicePlan;
61
+ if (!registration) return null;
62
+ const plan = json(verifyArtifact(project, registration.reference));
63
+ need(plan.planId === registration.planId, "stored plan identity changed");
64
+ const ordered = validateSlicePlan(project, task, plan);
65
+ return { plan, ordered, reference: registration.reference };
66
+ }
67
+
68
+ export function validateSliceRevision(previous, plan, reservations) {
69
+ need(plan.supersedes === (previous?.plan.planId ?? null), "supersedes must identify the current plan; do not detach its history");
70
+ if (!previous || previous.plan.contractHash !== plan.contractHash) return;
71
+ for (const old of previous.plan.slices) {
72
+ if (!reservations.some(d => d.contractHash === plan.contractHash && d.slice?.id === old.id)) continue;
73
+ const next = plan.slices.find(s => s.id === old.id);
74
+ need(next, `attempted slice ${old.id} cannot be removed or renamed; revise its scope explicitly and retain its original attempts`);
75
+ }
76
+ }
77
+
78
+ export function sliceProgress(project, task, ledger, stored = readSlicePlan(project, task, ledger)) {
79
+ if (!stored) return null;
80
+ const rows = [], byId = new Map();
81
+ for (const spec of stored.ordered) {
82
+ const attempts = Object.values(ledger.dispatches).filter(d => d.taskId === task.id && d.slice?.id === spec.id);
83
+ const latest = attempts.at(-1);
84
+ const definitionCurrent = latest?.contractHash === task.contractHash && latest.slice.definitionHash === sliceHash(spec);
85
+ const dependenciesCurrent = spec.dependsOn.every(dep => byId.get(dep)?.state === "DELIVERED" && latest?.slice.dependencies?.[dep] === byId.get(dep).dispatchId);
86
+ let state = !latest ? "PENDING" : !definitionCurrent || !dependenciesCurrent ? "STALE" : ["reserved", "running", "uncertain"].includes(latest.status) ? "IN_FLIGHT"
87
+ : latest.status === "completed" ? "DELIVERED" : "PARTIAL";
88
+ let reason = null;
89
+ if (state === "DELIVERED") try {
90
+ const original = json(verifyArtifact(project, latest.slice.plan));
91
+ const originalSlice = original.slices.find(s => s.id === spec.id);
92
+ need(originalSlice && sameSlice(originalSlice, spec), "dispatch's pinned slice definition changed");
93
+ need(latest.handoff?.artifacts?.length === 1 && !latest.handoff.partial?.length && !latest.handoff.missing?.length, "complete handoff missing");
94
+ for (const ref of latest.handoff.artifacts) verifyArtifact(project, ref);
95
+ validateStandaloneRuntime(project, task, { runtime: latest.runtime }, task.implementationRoute, latest.completedSnapshot);
96
+ } catch (error) { state = "BLOCKED"; reason = error.message; }
97
+ const row = { id: spec.id, state, dispatchId: latest?.id ?? null, attempts: attempts.map(d => ({ dispatchId: d.id, status: d.status })),
98
+ ready: !["DELIVERED", "IN_FLIGHT"].includes(state) && spec.dependsOn.every(dep => byId.get(dep)?.state === "DELIVERED"), reason };
99
+ rows.push(row); byId.set(row.id, row);
100
+ }
101
+ return { planId: stored.plan.planId, reference: stored.reference, integration: stored.plan.integration, slices: rows,
102
+ minimumRemainingDispatches: rows.filter(r => !["DELIVERED", "IN_FLIGHT"].includes(r.state)).length,
103
+ scope: "DELIVERED is a complete native handoff, not acceptance PASS. Full current verification and independent review remain required." };
104
+ }
105
+
106
+ export function bindSlice(project, task, ledger, request, purpose) {
107
+ const stored = readSlicePlan(project, task, ledger);
108
+ if (!stored) { need(request.sliceId === undefined, "register a plan before selecting a slice"); return null; }
109
+ if (request.kind !== "implementation" || purpose !== "delivery") {
110
+ need(request.sliceId === undefined, "sliceId is only for implementation delivery; repair/review retain their original workflow"); return null;
111
+ }
112
+ const spec = stored.plan.slices.find(s => s.id === request.sliceId);
113
+ need(spec, "planned delivery requires an explicit sliceId");
114
+ need(request.agent === spec.agent && sameSlice([...request.writePaths].sort(), [...spec.writePaths].sort()), "dispatch agent/writePaths must exactly match its slice");
115
+ need(request.handoffPaths?.length === 1, "slice requires one fresh handoff");
116
+ const progress = sliceProgress(project, task, ledger, stored);
117
+ const dependencies = Object.fromEntries(spec.dependsOn.map(dep => {
118
+ const row = progress.slices.find(r => r.id === dep);
119
+ need(row.state === "DELIVERED", `dependency ${dep} needs a complete current native handoff`); return [dep, row.dispatchId];
120
+ }));
121
+ return { id: spec.id, definitionHash: sliceHash(spec), plan: stored.reference, dependencies };
122
+ }
123
+
124
+ export function slicePromptContext(project, task, dispatch) {
125
+ if (!dispatch.slice) return "";
126
+ const plan = json(verifyArtifact(project, dispatch.slice.plan)), spec = plan.slices.find(s => s.id === dispatch.slice.id);
127
+ need(spec && sliceHash(spec) === dispatch.slice.definitionHash, "pinned slice context changed");
128
+ const integration = spec.id === plan.integration;
129
+ const criteria = integration ? task.contract.acceptance : task.contract.acceptance.filter(c => spec.criteria.includes(c.id));
130
+ const design = task.contract.design ? json(verifyArtifact(project, task.contract.design.document)) : null;
131
+ const requirements = (design?.requirements ?? []).filter(r => integration || r.criteria.some(id => spec.criteria.includes(id)));
132
+ return `\nSlice ${spec.id}: ${spec.objective}\nPinned plan: ${dispatch.slice.plan.path}\nDependency dispatches: ${JSON.stringify(dispatch.slice.dependencies)}\n` +
133
+ `Primary criterion IDs: ${JSON.stringify(spec.criteria)}\nOriginal criteria for this assignment (unchanged): ${JSON.stringify(criteria)}\n` +
134
+ `Reviewed requirements/rules: ${JSON.stringify(requirements)}\nGlobal constraints: ${JSON.stringify({ assumptions: task.contract.assumptions, decisions: design?.decisions ?? [], outOfScope: design?.outOfScope ?? [] })}\n` +
135
+ (integration ? "Integrate all preceding slices against the full original contract, required checks, artifacts and manual gates.\n"
136
+ : "Complete only this bounded slice. Other criteria remain obligations of the same task; this handoff is not task approval.\n") +
137
+ "Keep the same task, risk/model route and shared budget. The coordinator owns verification; independent review covers the integrated result.\n";
138
+ }
139
+
140
+ export function nextSliceRequest(task, ledger, stored, progress) {
141
+ if (Object.values(ledger.dispatches).some(d => d.taskId === task.id && ["reserved", "running", "uncertain"].includes(d.status))) return null;
142
+ const row = progress.slices.find(r => r.ready); if (!row) return null;
143
+ const spec = stored.plan.slices.find(s => s.id === row.id), attempt = row.attempts.length + 1;
144
+ const key = `slice-${stored.reference.sha256.slice(0, 12)}-${spec.id}-${attempt}`;
145
+ return { kind: "implementation", purpose: "delivery", sliceId: spec.id, agent: spec.agent, requestKey: key,
146
+ reason: spec.objective, writePaths: spec.writePaths, handoffPaths: [`.nccgs/evidence/analysis/${key}.md`] };
147
+ }
@@ -3,10 +3,13 @@ import { fileURLToPath } from "node:url";
3
3
  import { json, contained, artifact, atomicJson, profilePolicy, resolveExecution } from "./policy.mjs";
4
4
  import { snapshot, initialize, record, check, close, extendReview, taskPath, refreshInputs, migrateEvidence, reclassify, exportExternal, importExternal } from "./gates.mjs";
5
5
  import { persistRuntimeObservation } from "./evidence.mjs";
6
- import { runDispatch } from "./orchestration.mjs";
6
+ import { runDispatch, runRepairEditor, planTaskSlices, taskSliceStatus } from "./orchestration.mjs";
7
7
  import { startVerification, waitVerification, jobStatus, listJobs, readJob } from "./verification-jobs.mjs";
8
8
  import { assessCriteria } from "./criteria.mjs";
9
9
  import { validateDesignCommand, compareDesignCommand } from "./design-readiness.mjs";
10
+ import { validateHandoff, recordReviewHandoff } from "./handoff.mjs";
11
+ import { migrateFrameworkBaseline } from "./lifecycle.mjs";
12
+ import { registerReviewBudget, reviewBudgetStatus } from "./review-budget.mjs";
10
13
 
11
14
  export function run(args) {
12
15
  const command = args[0];
@@ -19,6 +22,9 @@ export function run(args) {
19
22
  };
20
23
  const project = path.resolve(value("--project") || process.cwd());
21
24
  const id = value("--id");
25
+ if (command === "budget-plan") return registerReviewBudget(project, id, value("--file"));
26
+ if (command === "budget-status") return reviewBudgetStatus(project, id);
27
+ if (command === "migrate-framework") return migrateFrameworkBaseline(project, id, value("--decision"));
22
28
  if (command === "job-status") return value("--job") ? jobStatus(project, value("--job"), id) : listJobs(project, id);
23
29
  if (command === "job-wait") return waitVerification(project, value("--job"), id, Number(value("--timeout-ms") ?? 30000));
24
30
  // Diagnostics and failure reports must survive invalid/changed execution policy.
@@ -41,6 +47,37 @@ export function run(args) {
41
47
  if (delegated !== null && delegated !== undefined) return delegated;
42
48
  if (command === "design-validate") return validateDesignCommand(project, id, value("--file"));
43
49
  if (command === "design-compare") return compareDesignCommand(project, id, value("--baseline"), value("--file"));
50
+ if (["slice-preview", "slice-plan", "slice-status"].includes(command)) {
51
+ const allowed = new Set(["--id", "--project", ...(command === "slice-status" ? [] : ["--file"])]), seen = new Set();
52
+ for (let i = 1; i < args.length; i += 2) {
53
+ const flag = args[i];
54
+ if (!allowed.has(flag) || seen.has(flag) || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error("Slice commands require unique --id, optional --project, and --file for preview/plan; no forwarded arguments");
55
+ seen.add(flag);
56
+ }
57
+ if (!seen.has("--id") || (command !== "slice-status" && !seen.has("--file"))) throw new Error("Slice command requires --id TASK and --file PLAN for preview/plan");
58
+ return command === "slice-status" ? taskSliceStatus(project, id) : planTaskSlices(project, id, value("--file"), command === "slice-plan");
59
+ }
60
+ if (command === "repair-editor") {
61
+ const allowed = new Set(["--id", "--dispatch", "--step", "--project"]), seen = new Set();
62
+ for (let i = 1; i < args.length; i += 2) {
63
+ const flag = args[i];
64
+ if (!allowed.has(flag) || seen.has(flag) || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error("repair-editor requires unique --id, --dispatch, --step and optional --project values; no forwarded commands");
65
+ seen.add(flag);
66
+ }
67
+ if (!["--id", "--dispatch", "--step"].every(flag => seen.has(flag))) throw new Error("repair-editor requires --id TASK --dispatch ID --step STEP");
68
+ return runRepairEditor(project, id, value("--dispatch"), value("--step"));
69
+ }
70
+ if (["handoff-validate", "record-review"].includes(command)) {
71
+ const allowed = new Set(["--id", "--dispatch", "--file", "--project"]), seen = new Set();
72
+ if (args.includes("--")) throw new Error("Handoff commands do not forward shell commands");
73
+ for (let i = 1; i < args.length; i += 2) {
74
+ const flag = args[i];
75
+ if (!allowed.has(flag) || seen.has(flag) || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error("Handoff commands require unique --id, --dispatch, --file and optional --project values");
76
+ seen.add(flag);
77
+ }
78
+ if (!["--id", "--dispatch", "--file"].every(flag => seen.has(flag))) throw new Error("Handoff command requires --id TASK --dispatch ID --file PATH");
79
+ return (command === "handoff-validate" ? validateHandoff : recordReviewHandoff)(project, id, value("--dispatch"), value("--file"));
80
+ }
44
81
  if (command === "snapshot") return snapshot(project, id ? json(taskPath(project, id)) : null);
45
82
  if (command === "acceptance-status") {
46
83
  const task = json(taskPath(project, id));
@@ -96,7 +133,7 @@ export function run(args) {
96
133
  atomicJson(contained(project, `.nccgs/runtime/bindings/${session}.json`), { taskId: id });
97
134
  return { session, taskId: id };
98
135
  }
99
- throw new Error("Usage: task init|inspect|snapshot|ref|observe|verify|record|check|close|bind|extend-review|extend-dispatch|refresh-inputs|migrate-evidence|reclassify|report|export-external|import-external --id TASK [--project PATH]; init --intake|--contract|--plan FILE [--mode implement|audit|plan] [--risk FAST|STANDARD|CONTROLLED --reason TEXT]; reclassify --classification FILE; report --outcome success|partial|blocked|failed --summary TEXT; export-external --out FILE; import-external --file FILE; extend-review --reason TEXT --rounds N (external only); extend-dispatch --request FILE (external only); verify --name CHECK -- EXECUTABLE ARGS");
136
+ throw new Error("Usage: task init|inspect|snapshot|ref|observe|verify|dispatch-prompt|handoff-validate|record-review|repair-editor|slice-preview|slice-plan|slice-status|record|check|close|bind|extend-review|extend-dispatch|refresh-inputs|migrate-evidence|reclassify|report|export-external|import-external --id TASK [--project PATH]; slice-preview|slice-plan --file PLAN; init --intake|--contract|--plan FILE [--mode implement|audit|plan] [--risk FAST|STANDARD|CONTROLLED --reason TEXT]; reclassify --classification FILE; report --outcome success|partial|blocked|failed --summary TEXT; export-external --out FILE; import-external --file FILE; extend-review --reason TEXT --rounds N (external only); extend-dispatch --request FILE (external only); verify --name CHECK -- EXECUTABLE ARGS");
100
137
  }
101
138
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
102
139
  try {