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
@@ -1,12 +1,18 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { artifact, atomicJson, contained, hash, json, loadPipeline, resolveExecution, verifyArtifact } from "./policy.mjs";
5
6
  import { snapshot, taskPath, check } from "./gates.mjs";
6
7
  import { acquireLock } from "./process-lock.mjs";
7
8
  import { deliveryBudget, requireDeliveryRoom, dispatchPurpose, requireEarlyCheckpoint } from "./delivery-budget.mjs";
8
9
  import { pinRepairPacket, verifyRepairPacket, repairPrompt, repairToolScope } from "./repair-packet.mjs";
9
10
  import { requireDesignReady } from "./design-readiness.mjs";
11
+ import { handoffTemplate } from "./review-handoff.mjs";
12
+ import { activeSessionBudget } from "./session-budget.mjs";
13
+ import { plannedDispatchBudget } from "./review-budget.mjs";
14
+ import { captureUnityRepairScope, verifyUnityRepairScope, unityEditorInvocation, blockedUnityScene } from "./unity-repair.mjs";
15
+ import { validateSlicePlan, readSlicePlan, validateSliceRevision, sliceProgress, bindSlice, slicePromptContext, nextSliceRequest, sliceHash, sameSlice } from "./task-slices.mjs";
10
16
 
11
17
  const ledgerPath = ".nccgs/orchestration.json";
12
18
  const ledgerMarker = ".nccgs/orchestration.initialized.json";
@@ -36,6 +42,15 @@ function load(project) {
36
42
  need(reserved.length === Object.keys(ledger.dispatches).length && new Set(reserved.map(e => e.dispatchId)).size === reserved.length, "Corrupt ledger reservation history");
37
43
  for (const event of ledger.events) if (event.dispatchId) need(ledger.dispatches[event.dispatchId], "Corrupt ledger: event refers to missing dispatch");
38
44
  for (const event of reserved) need(ledger.dispatches[event.dispatchId]?.taskId === event.taskId, "Corrupt ledger reservation identity");
45
+ const plans = new Map();
46
+ for (const event of ledger.events.filter(e => e.action === "slice-plan")) {
47
+ need(ledger.tasks[event.taskId] && event.planId && event.requestHash && event.reference?.path?.startsWith(".nccgs/evidence/slices/"), "Corrupt slice-plan history");
48
+ verifyArtifact(project, event.reference); plans.set(event.taskId, event);
49
+ }
50
+ for (const [id, entry] of Object.entries(ledger.tasks)) {
51
+ const latest = plans.get(id);
52
+ need(Boolean(entry.slicePlan) === Boolean(latest) && (!latest || same(entry.slicePlan, { planId: latest.planId, requestHash: latest.requestHash, reference: latest.reference })), "Slice-plan history missing or detached; restore it, never reset planned work");
53
+ }
39
54
  return ledger;
40
55
  }
41
56
  function transaction(project, action) {
@@ -108,6 +123,87 @@ function validPaths(project, paths) {
108
123
  }
109
124
  return [...new Set(paths)].sort();
110
125
  }
126
+
127
+ function sliceCapacity(project, ledger, ancestors) {
128
+ return ancestors.map(ancestor => {
129
+ const entry = ledger.tasks[ancestor], items = Object.values(ledger.dispatches).filter(d => belongs(ledger, d.taskId, ancestor));
130
+ let pending = 0, minimumReviews = 0;
131
+ for (const [id] of Object.entries(ledger.tasks).filter(([id, e]) => e.slicePlan && belongs(ledger, id, ancestor))) {
132
+ const task = taskRecord(project, id), progress = sliceProgress(project, task, ledger);
133
+ pending += progress.minimumRemainingDispatches;
134
+ const own = items.filter(d => d.taskId === id), reviews = own.filter(d => d.kind === "review");
135
+ const consumed = new Set([...Object.values(task.stages ?? {}), ...(task.history ?? []).flatMap(h => Object.values(h.stages ?? {}))].map(s => s.dispatchId).filter(Boolean));
136
+ const lastWork = own.filter(d => d.kind === "implementation" && d.status === "completed").at(-1);
137
+ const reviewed = progress.slices.every(s => s.state === "DELIVERED") && lastWork && task.stages.review?.verdict === "PASS"
138
+ && task.stages.implementation?.dispatchId === lastWork.id && task.stages.review.contractHash === task.contractHash
139
+ && task.stages.review.snapshot === lastWork.completedSnapshot && Date.parse(task.stages.review.recordedAt) >= Date.parse(lastWork.finishedAt);
140
+ const awaiting = progress.slices.every(s => s.state === "DELIVERED") && reviews.some(d => active(d) || (d.status === "completed"
141
+ && d.completedSnapshot === lastWork?.completedSnapshot && Date.parse(d.finishedAt) >= Date.parse(lastWork.finishedAt) && !consumed.has(d.id)));
142
+ if (!reviewed && !awaiting) {
143
+ const used = Math.max(0, task.reviewBudget.used - reviews.filter(d => consumed.has(d.id)).length) + reviews.length;
144
+ need(used < task.reviewBudget.limit, `Task slices: independent-review budget exhausted for ${id}; splitting cannot create new review rounds`);
145
+ minimumReviews++;
146
+ }
147
+ }
148
+ const protectedBudget = deliveryBudget(entry, items);
149
+ const protectedSlots = (protectedBudget?.protectedSlots ?? 0) + Math.max(0, minimumReviews - (protectedBudget?.remaining.review ?? 0));
150
+ need(items.length + pending + protectedSlots <= entry.limits.maxDispatches,
151
+ `Task slices: aggregate budget for ${ancestor} cannot fit ${pending} remaining delivery slices plus ${protectedSlots} protected slots; preserve spent attempts and replan unattempted work or use an externally authorized extension`);
152
+ return { taskId: ancestor, limits: entry.limits, spentDispatches: items.length, minimumRemainingSlices: pending, minimumIndependentReviews: minimumReviews, protectedSlots,
153
+ spareDispatches: entry.limits.maxDispatches - items.length - pending - protectedSlots };
154
+ });
155
+ }
156
+
157
+ export function planTaskSlices(project, id, relative, commit = false) {
158
+ const prepare = ledger => {
159
+ const task = taskRecord(project, id);
160
+ need(task.mode === "implement" && !task.closed, "Task slices require an open implementation task");
161
+ const state = check(project, task, "implementation"); need(state.allowed, state.message);
162
+ need(typeof relative === "string" && relative.startsWith(".nccgs/evidence/slice-plans/") && relative.endsWith(".json"), "Slice plan draft must be JSON under .nccgs/evidence/slice-plans/");
163
+ const input = artifact(project, relative);
164
+ need(fs.statSync(verifyArtifact(project, input)).size <= 65536, "Slice plan exceeds 64 KiB; narrow the assignments");
165
+ const plan = json(verifyArtifact(project, input)), requestHash = sliceHash(plan);
166
+ validateSlicePlan(project, task, plan);
167
+ const existing = ledger.events.find(e => e.action === "slice-plan" && e.taskId === id && e.planId === plan.planId);
168
+ if (existing) {
169
+ need(existing.requestHash === requestHash, "Slice planId reused with different content");
170
+ return { status: ledger.tasks[id].slicePlan.planId === plan.planId ? "ALREADY_REGISTERED" : "SUPERSEDED", reference: existing.reference,
171
+ scope: "Original plan retained; no reactivation, attempt, budget or task change." };
172
+ }
173
+ need(plan.sourceSnapshot === state.snapshot, "Slice plan source snapshot is stale");
174
+ need(!Object.values(ledger.dispatches).some(d => belongs(ledger, d.taskId, id) && active(d)), "Settle active task/descendant attempts before registering another plan");
175
+ const ancestors = chain(project, id, ledger);
176
+ const old = ledger.tasks[id].slicePlan;
177
+ const previous = old ? { plan: json(verifyArtifact(project, old.reference)), reference: old.reference } : null;
178
+ validateSliceRevision(previous, plan, Object.values(ledger.dispatches).filter(d => d.taskId === id));
179
+ // Preview mutates only this in-memory ledger. No task/contract/approval is
180
+ // rewritten and no dispatch is reserved. Commit pins the normal budget once.
181
+ ledger.tasks[id].slicePlan = { planId: plan.planId, requestHash, reference: input };
182
+ const budgets = sliceCapacity(project, ledger, ancestors);
183
+ verifyArtifact(project, input);
184
+ let reference = input;
185
+ if (commit) {
186
+ const output = `.nccgs/evidence/slices/${crypto.randomUUID()}.json`;
187
+ atomicJson(contained(project, output), plan); reference = artifact(project, output);
188
+ ledger.tasks[id].slicePlan.reference = reference;
189
+ ledger.events.push({ action: "slice-plan", at: now(), taskId: id, planId: plan.planId, requestHash, reference,
190
+ supersedes: old?.reference ?? null, reason: plan.reason });
191
+ }
192
+ return { status: commit ? "REGISTERED" : "PREVIEW", taskId: id, planId: plan.planId, reference, budgets,
193
+ scope: "Same task, original criteria and shared budgets. Capacity is a dispatch lower bound, not a duration/cost estimate or approval." };
194
+ };
195
+ return commit ? transaction(project, prepare) : prepare(load(project));
196
+ }
197
+
198
+ export function taskSliceStatus(project, id) {
199
+ const task = taskRecord(project, id), ledger = load(project), stored = readSlicePlan(project, task, ledger);
200
+ if (!stored) return { taskId: id, status: "UNPLANNED", next: null };
201
+ const progress = sliceProgress(project, task, ledger, stored);
202
+ let budgets = null, capacityError = null;
203
+ try { budgets = sliceCapacity(project, ledger, chain(project, id, ledger)); } catch (error) { capacityError = error.message; }
204
+ return { taskId: id, ...progress, budgets, capacityError, next: capacityError ? null : nextSliceRequest(task, ledger, stored, progress),
205
+ history: ledger.events.filter(e => e.action === "slice-plan" && e.taskId === id) };
206
+ }
111
207
  export function reserve(project, id, request) {
112
208
  return transaction(project, ledger => {
113
209
  const task = taskRecord(project, id), policy = resolveExecution(project);
@@ -127,6 +223,9 @@ export function reserve(project, id, request) {
127
223
  const handoffPaths = request.handoffPaths ?? [];
128
224
  need(Array.isArray(handoffPaths) && handoffPaths.every(p => typeof p === "string" && p.startsWith(".nccgs/evidence/analysis/")), "Handoff paths must be explicit analysis artifact paths");
129
225
  for (const p of handoffPaths) contained(project, p);
226
+ need(request.handoffTransport === undefined || ["file", "native-result"].includes(request.handoffTransport), "Unknown handoff transport");
227
+ if (request.handoffTransport === "native-result")
228
+ need(request.kind !== "implementation" && handoffPaths.length === 1, "Native result handoff requires one declared read-only artifact");
130
229
  if (request.kind !== "implementation") for (const p of handoffPaths)
131
230
  need(task.contract.analysisArtifacts?.includes(p), "Read-only handoff must be a declared analysis artifact");
132
231
  need(request.kind === "implementation" || writePaths.length === 0, "Review/analysis dispatch is read-only");
@@ -138,11 +237,18 @@ export function reserve(project, id, request) {
138
237
  if (previous) { need(previous.requestHash === requestHash, "requestKey reused for different dispatch"); return previous; }
139
238
  for (const p of handoffPaths) need(!fs.existsSync(contained(project, p)), "Handoff artifact must be fresh for this dispatch");
140
239
  const ancestors = chain(project, id, ledger, request);
141
- const snap = snapshot(project, task).sha256;
240
+ const protectedAllocation = plannedDispatchBudget(project, id, request);
241
+ const source = snapshot(project, task, { includeFiles: Boolean(request.workPacket) }), snap = source.sha256;
142
242
  const repair = Object.values(ledger.dispatches).filter(d => d.taskId === id && d.purpose === "repair" && d.status === "completed" && d.contractHash === task.contractHash).at(-1);
143
243
  const { purpose, feedback } = dispatchPurpose(project, task, { ...request, repairFeedback: repair?.feedback ?? null }, snap);
244
+ const slice = bindSlice(project, task, ledger, { ...request, writePaths }, purpose);
245
+ if (ledger.tasks[id].slicePlan) {
246
+ const state = check(project, task, "implementation"); need(state.allowed, state.message);
247
+ if (slice) need(!Object.values(ledger.dispatches).some(d => belongs(ledger, d.taskId, id) && active(d)), "Planned slices use sequential foreground dispatch; finish existing task/descendant work first");
248
+ }
144
249
  const workPacket = purpose === "repair" ? pinRepairPacket(project, task, { ...request, writePaths }, snap) : null;
145
250
  need(purpose === "repair" || request.workPacket === undefined, "workPacket is only supported for repair dispatches");
251
+ const budgetEvidence = [];
146
252
  for (const ancestor of ancestors) {
147
253
  const items = Object.values(ledger.dispatches).filter(d => belongs(ledger, d.taskId, ancestor)), limits = ledger.tasks[ancestor].limits;
148
254
  need(items.length < limits.maxDispatches, `Aggregate dispatch budget exhausted for ${ancestor}; cancellation/reclassification never refunds attempts`);
@@ -151,11 +257,17 @@ export function reserve(project, id, request) {
151
257
  const measured = telemetryFrom(ledger, ancestor, project);
152
258
  for (const [limit, field] of [["maxDurationMs", "durationMs"], ["maxCostUsd", "costUsd"]]) if (limits[limit] !== null) {
153
259
  const data = measured.metrics[field];
260
+ if (field === "costUsd" && (data.expected !== data.measured || process.env.NCCGS_SUPERVISED_RUN)) {
261
+ budgetEvidence.push(activeSessionBudget(project, ancestor, items, limits[limit]));
262
+ continue;
263
+ }
154
264
  need(data.expected === data.measured, `Cannot enforce ${limit}: missing ${field} telemetry; reconcile measurements or stop`);
155
265
  need(data.knownSubtotal < limits[limit], `${limit} budget exhausted for ${ancestor}`);
156
266
  }
157
267
  }
158
268
  const records = Object.values(ledger.dispatches), live = records.filter(active);
269
+ need(!live.some(d => d.workPacket?.schemaVersion === 2), "Typed Unity repair owns the project until completion or external reconciliation");
270
+ if (workPacket?.schemaVersion === 2) need(live.length === 0, "Typed Unity repair requires exclusive project ownership");
159
271
  if (request.kind === "review") {
160
272
  const reviewAttempts = records.filter(d => d.taskId === id && d.kind === "review");
161
273
  const consumed = new Set([...Object.values(task.stages ?? {}), ...(task.history ?? []).flatMap(h => Object.values(h.stages ?? {}))].map(s => s.dispatchId).filter(Boolean));
@@ -172,10 +284,16 @@ export function reserve(project, id, request) {
172
284
  for (const item of live) need(!item.writePaths.some(a => writePaths.some(b => overlap(a, b))), "Write ownership conflicts with another active dispatch");
173
285
  } else need(!live.some(d => d.kind === "implementation"), "Cannot review/analyze a snapshot while implementation is active");
174
286
  const reservation = { id: crypto.randomUUID(), taskId: id, kind: request.kind, agent: request.agent, reason: request.reason.trim(),
175
- requestKey: request.requestKey, requestHash, writePaths, handoffPaths, handoffProtocol: 1, purpose, feedback, status: "reserved", createdAt: now(),
287
+ requestKey: request.requestKey, requestHash, writePaths, handoffPaths, handoffProtocol: 1,
288
+ ...(request.handoffTransport ? { handoffTransport: request.handoffTransport } : {}), purpose, feedback, status: "reserved", createdAt: now(),
176
289
  snapshot: snap, contractHash: task.contractHash, sessionId: null, toolUseId: null, agentId: null, telemetry: null,
290
+ ...(budgetEvidence.length ? { budgetEvidence } : {}),
291
+ ...(protectedAllocation ? { protectedAllocation } : {}),
292
+ ...(slice ? { slice } : {}), ...(ledger.tasks[id].slicePlan ? { slicePlan: ledger.tasks[id].slicePlan.reference } : {}),
177
293
  ...(workPacket ? { workPacket, toolBudget: { work: 0, read: 0, handoff: 0, admissions: [] } } : {}) };
294
+ captureUnityRepairScope(project, reservation, source);
178
295
  ledger.dispatches[reservation.id] = reservation;
296
+ sliceCapacity(project, ledger, ancestors);
179
297
  ledger.events.push({ action: "reserve", at: reservation.createdAt, dispatchId: reservation.id, taskId: id });
180
298
  return reservation;
181
299
  });
@@ -183,6 +301,8 @@ export function reserve(project, id, request) {
183
301
  export function claim(project, dispatchId, identity) {
184
302
  return transaction(project, ledger => {
185
303
  const d = ledger.dispatches[dispatchId]; need(d, "Unknown dispatch reservation");
304
+ for (const coverage of d.budgetEvidence ?? []) need(coverage.currentSessionId === identity.sessionId, "Dispatch must use the supervised session that owns its budget allowance");
305
+ if (d.protectedAllocation) need(d.protectedAllocation.sessionId === identity.sessionId, "Dispatch must use the session that owns its protected budget allocation");
186
306
  need(identity.sessionId && identity.toolUseId && identity.agent === d.agent, "Dispatch identity/role mismatch");
187
307
  if (d.status === "running") { need(d.sessionId === identity.sessionId && d.toolUseId === identity.toolUseId, "Dispatch already claimed by another invocation"); return d; }
188
308
  need(d.status === "reserved", "Dispatch is not available to claim");
@@ -191,6 +311,10 @@ export function claim(project, dispatchId, identity) {
191
311
  need(task.contractHash === d.contractHash, "Dispatch contract stale");
192
312
  need(snapshot(project, task).sha256 === d.snapshot, "Dispatch source snapshot stale; cancel/reconcile and reserve a fresh attempt");
193
313
  if (d.workPacket) verifyRepairPacket(project, d);
314
+ if (d.slice) {
315
+ const current = bindSlice(project, task, ledger, { ...d, sliceId: d.slice.id }, d.purpose);
316
+ need(current.definitionHash === d.slice.definitionHash && sameSlice(current.dependencies, d.slice.dependencies), "Slice dependency handoffs changed after reservation");
317
+ }
194
318
  need(!Object.values(ledger.dispatches).some(item => item.id !== d.id && item.sessionId === identity.sessionId && item.toolUseId === identity.toolUseId), "Tool invocation already belongs to another reservation");
195
319
  Object.assign(d, { status: "running", sessionId: identity.sessionId, toolUseId: identity.toolUseId, startedAt: now() });
196
320
  ledger.events.push({ action: "claim", at: d.startedAt, dispatchId }); return d;
@@ -204,21 +328,53 @@ export function attachAgent(project, dispatchId, agentId) {
204
328
  d.agentId = agentId; return d;
205
329
  });
206
330
  }
331
+ export function getDispatch(project, id, dispatchId) {
332
+ taskRecord(project, id);
333
+ const d = load(project).dispatches[dispatchId];
334
+ need(d?.taskId === id, "Dispatch does not belong to this task");
335
+ return d;
336
+ }
207
337
  export function dispatchPrompt(project, id, dispatchId) {
208
- const task = taskRecord(project, id), d = load(project).dispatches[dispatchId];
209
- need(d?.taskId === id && d.workPacket, "A bounded repair dispatch is required for dispatch-prompt");
210
- need(d.contractHash === task.contractHash, "Repair contract stale");
211
- verifyRepairPacket(project, d);
212
- return { dispatchId, prompt: repairPrompt(task, d) };
338
+ const task = taskRecord(project, id), d = getDispatch(project, id, dispatchId);
339
+ need(d.contractHash === task.contractHash, "Dispatch contract stale");
340
+ if (d.workPacket) {
341
+ verifyRepairPacket(project, d);
342
+ return { dispatchId, prompt: repairPrompt(task, d) };
343
+ }
344
+ need(["reserved", "running"].includes(d.status), "Prompt requires a reserved or running dispatch");
345
+ const state = check(project, task, d.kind === "review" ? "review" : task.mode === "implement" ? "implementation" : "analysis");
346
+ need(state.allowed, state.message);
347
+ if (d.status === "reserved" || d.kind !== "implementation") need(state.snapshot === d.snapshot, "Dispatch source snapshot stale");
348
+ const route = d.kind === "review" ? task.execution.profile.models.review : task.implementationRoute;
349
+ const handoffs = (d.handoffPaths ?? []).map(file => ({ path: file, content: handoffTemplate(task, d) }));
350
+ const prompt = `[NCCGS-DISPATCH:${dispatchId}]\nTask ${id}, ${d.kind}, ${task.risk}: ${task.contract.objective}\n` +
351
+ `Assignment: ${d.reason}\nTask record: .nccgs/tasks/${id}.json\nIntake: ${task.intake.path}\n` +
352
+ `Contract: ${task.contractHash}\nStarting source: ${d.snapshot}\nRequested route: ${route.model}/${route.effort ?? "unspecified"}; not observed execution.\n` +
353
+ `Check the installed role's maxTurns and native session overrides before launch; prompt text cannot increase capacity.\n` +
354
+ `Source write ownership: ${JSON.stringify(d.writePaths)}\nCanonical sources: ${JSON.stringify(task.contract.sources ?? [])}\n` +
355
+ `Read the pinned task contract for acceptance, checks, manual gates and design references; preserve all of them.\n` +
356
+ slicePromptContext(project, task, d) +
357
+ `Use foreground execution. Do not delegate or change task records. The coordinator owns verification and stage recording.\n` +
358
+ (d.kind === "review" ? "Review independently; follow .claude/nccgs/protocols/review-handoff.md. Keep negative findings and pending conditions.\n" : "Follow .claude/nccgs/protocols/agent-contract.md for this bounded assignment.\n") +
359
+ (d.handoffTransport === "native-result"
360
+ ? "Return the entire assigned Markdown handoff as your sole final text response, without an outer code fence. Do not write a file or run handoff validation. The correlated native hook copies your exact text to the pinned path; all existing completion and review gates still apply.\n"
361
+ : handoffs.length ? "Write the assigned draft early. Fill it with actual findings; mark COMPLETE only when your assignment has finished.\n" : "No handoff path was reserved; report this to the coordinator if an artifact is required.\n") +
362
+ handoffs.map(h => `Handoff file: ${h.path}\nInitial draft (not evidence):\n${h.content}\n` +
363
+ (/^[\w./ -]+$/.test(h.path)
364
+ ? (d.handoffTransport === "native-result" ? "Return this completed document as text; retain negative findings and pending conditions.\n" : `Before returning, validate structure with one plain command:\nnode .claude/nccgs/tools/task.mjs handoff-validate --id ${id} --dispatch ${dispatchId} --file "${h.path}"\n`)
365
+ : "Ask the coordinator to validate this handoff with the fixed handoff-validate command; its path needs shell-specific quoting.\n")).join("\n");
366
+ return { dispatchId, agent: d.agent, prompt, handoffs, scope: "Preparation only; does not launch, write artifacts, approve work or spend another attempt." };
213
367
  }
214
368
  export function pinnedRepairEvidence(project, relative) {
215
369
  return Object.values(load(project).dispatches).some(d => d.workPacket &&
216
- [d.workPacket.reference, ...d.workPacket.context].some(ref => ref.path.toLowerCase() === relative.toLowerCase()));
370
+ [d.workPacket.reference, ...d.workPacket.context, d.workPacket.scope, d.workPacket.unity?.capabilities].filter(Boolean).some(ref => ref.path.toLowerCase() === relative.toLowerCase()));
217
371
  }
218
372
  export function admitRepairTool(project, dispatchId, input) {
219
373
  const result = transaction(project, ledger => {
220
374
  const d = ledger.dispatches[dispatchId];
221
- need(d?.workPacket && d.status === "running" && input.agent_id && input.agent_id === d.agentId && input.session_id === d.sessionId, "Repair tool requires the running, correlated worker");
375
+ const uncertainHandoff = d?.workPacket?.schemaVersion === 2 && d.status === "uncertain" &&
376
+ ["Write", "Edit", "MultiEdit"].includes(input.tool_name) && d.handoffPaths.includes(path.relative(project, path.resolve(project, input.tool_input?.file_path ?? ".")).split(path.sep).join("/"));
377
+ need(d?.workPacket && (d.status === "running" || uncertainHandoff) && input.agent_id && input.agent_id === d.agentId && input.session_id === d.sessionId, "Repair tool requires the running, correlated worker");
222
378
  need(typeof input.tool_use_id === "string" && input.tool_use_id.length > 0, "Repair tool-use identity missing");
223
379
  const budget = d.toolBudget, limits = d.workPacket.budget;
224
380
  need(budget && Array.isArray(budget.admissions) && [budget.work, budget.read, budget.handoff].every(n => Number.isSafeInteger(n) && n >= 0), "Repair tool budget missing/corrupt; restore history, never reset it");
@@ -236,6 +392,7 @@ export function admitRepairTool(project, dispatchId, input) {
236
392
  if (previous.allowed && previous.bucket !== "handoff") {
237
393
  verifyRepairPacket(project, d);
238
394
  need(taskRecord(project, d.taskId).contractHash === d.contractHash, "Repair contract stale");
395
+ need(!(d.editorSteps ?? []).some(s => s.status !== "completed"), "Editor operation failed or remains uncertain; retain the attempt and return PARTIAL");
239
396
  }
240
397
  return previous;
241
398
  }
@@ -254,16 +411,72 @@ export function admitRepairTool(project, dispatchId, input) {
254
411
  need(Date.now() - Date.parse(d.startedAt) <= limits.durationMs, "Repair work deadline reached");
255
412
  need(taskRecord(project, d.taskId).contractHash === d.contractHash, "Repair contract stale");
256
413
  verifyRepairPacket(project, d);
414
+ need(!(d.editorSteps ?? []).some(s => s.status !== "completed"), "Editor operation failed or remains uncertain; retain the attempt and return PARTIAL");
257
415
  }
258
416
  } catch (error) { reason = error.message; }
259
417
  const remaining = { work: Math.max(0, limits.workCalls - budget.work), read: Math.max(0, limits.readCalls - budget.read), handoff: Math.max(0, limits.handoffCalls - budget.handoff) };
260
- const entry = { toolUseId: input.tool_use_id, fingerprint, bucket: scope.bucket, read: scope.read, allowed: !reason, reason, at: now(), remaining };
418
+ const entry = { toolUseId: input.tool_use_id, fingerprint, bucket: scope.bucket, read: scope.read, allowed: !reason, reason, at: now(), remaining,
419
+ ...(scope.editorStep ? { editorStep: scope.editorStep } : {}) };
261
420
  budget.admissions.push(entry);
262
421
  return entry;
263
422
  });
264
423
  need(result.allowed, `${result.reason}. Write the assigned PARTIAL handoff and return; remaining handoff calls: ${result.remaining.handoff}`);
265
424
  return { additionalContext: `NCCGS repair tool allowance remaining: ${result.remaining.work} work, ${result.remaining.read} reads, ${result.remaining.handoff} handoff writes. Verification belongs to the coordinator. Return the assigned handoff before native turn exhaustion.` };
266
425
  }
426
+
427
+ // Called only by the fixed worker CLI after its tool admission. A step is pinned
428
+ // before launch and never replayed automatically, including after an interruption.
429
+ export function runRepairEditor(project, id, dispatchId, stepId, execute = spawnSync) {
430
+ const prepared = transaction(project, ledger => {
431
+ const d = ledger.dispatches[dispatchId];
432
+ need(d?.taskId === id && d.status === "running" && d.workPacket?.schemaVersion === 2, "Editor step needs a running typed Unity repair");
433
+ const task = taskRecord(project, id);
434
+ need(task.contractHash === d.contractHash, "Repair contract stale");
435
+ requireDesignReady(project, task);
436
+ const readiness = check(project, task, "implementation"); need(readiness.allowed, readiness.message);
437
+ verifyRepairPacket(project, d); verifyUnityRepairScope(project, d);
438
+ const steps = d.workPacket.unity.steps, step = steps.find(s => s.id === stepId);
439
+ need(step, "Unknown pinned Editor step");
440
+ d.editorSteps ??= [];
441
+ need(!d.editorSteps.some(s => s.id === stepId), "Editor step already attempted; inspect its original receipt, never replay");
442
+ need(steps.slice(0, steps.indexOf(step)).every(s => d.editorSteps.some(e => e.id === s.id && e.status === "completed")), "Editor steps must finish in packet order");
443
+ need(Date.now() - Date.parse(d.startedAt) + step.timeoutMs + 1000 <= d.workPacket.budget.durationMs, "Repair deadline cannot fit this Editor step");
444
+ const admission = d.toolBudget.admissions.find(a => a.allowed && a.editorStep === stepId && !d.editorSteps.some(s => s.toolUseId === a.toolUseId));
445
+ need(admission, "Editor step requires its correlated worker tool admission");
446
+ const invocation = unityEditorInvocation(project, step, d.workPacket.sceneSafety ?? null);
447
+ d.editorSteps.push({ id: stepId, status: "running", startedAt: now(), toolUseId: admission.toolUseId });
448
+ ledger.events.push({ action: "repair-editor-start", at: now(), dispatchId, stepId });
449
+ return { step, invocation };
450
+ });
451
+ let result;
452
+ try { result = execute(prepared.invocation.executable, prepared.invocation.arguments, { cwd: project, shell: false, windowsHide: true,
453
+ encoding: "utf8", timeout: prepared.step.timeoutMs + 1000, maxBuffer: 4 * 1024 * 1024 }); }
454
+ catch (error) { result = { error, status: null, stdout: "", stderr: "" }; }
455
+ return transaction(project, ledger => {
456
+ const d = ledger.dispatches[dispatchId], step = d.editorSteps.find(s => s.id === stepId);
457
+ need(step?.status === "running", "Editor operation state changed; inspect before any continuation");
458
+ const notStarted = ["ENOENT", "EACCES"].includes(result.error?.code);
459
+ let status = result.status === 0 && !result.error && !result.signal ? "completed" : notStarted ? "failed" : "uncertain";
460
+ const blockedScene = blockedUnityScene(prepared.invocation, result);
461
+ if (blockedScene !== null) status = "failed"; // Known precondition refusal: packet body never ran.
462
+ let scopeError = null;
463
+ try { verifyRepairPacket(project, d); verifyUnityRepairScope(project, d); }
464
+ catch (error) { scopeError = error.message; if (status === "completed") status = "failed"; }
465
+ const relative = `.nccgs/evidence/repair-operations/${dispatchId}-${stepId}.json`;
466
+ atomicJson(contained(project, relative), { schemaVersion: 1, origin: "unity-repair-operation", taskId: id, dispatchId, stepId,
467
+ contractHash: d.contractHash, startedAt: step.startedAt, finishedAt: now(), status, ...prepared.invocation,
468
+ exitCode: result.status ?? null, signal: result.signal ?? null, error: result.error?.message ?? null, scopeError, blockedScene,
469
+ stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""),
470
+ note: "Editor operation receipt only; not verification, acceptance or provider execution evidence." });
471
+ Object.assign(step, { status, finishedAt: now(), evidence: artifact(project, relative) });
472
+ if (status === "uncertain") { d.status = "uncertain"; d.uncertainty = "Unity Editor operation may still be active; external reconciliation required before releasing ownership"; }
473
+ ledger.events.push({ action: "repair-editor-finish", at: step.finishedAt, dispatchId, stepId, status, evidence: step.evidence });
474
+ return { status: status === "completed" ? "EXECUTED" : "FAIL", operationStatus: status, evidence: step.evidence,
475
+ message: status === "completed" ? "Inspect the Editor result; finish the handoff. Required verification and independent review remain pending."
476
+ : blockedScene !== null ? `Unsaved scene blocked the operation: ${blockedScene}. Nothing was saved or discarded. Return PARTIAL and preserve this attempt; the owner must resolve unsaved work before a new authorized packet.`
477
+ : "Return PARTIAL. Preserve this attempt; do not retry or release an uncertain Editor operation without external reconciliation." };
478
+ });
479
+ }
267
480
  // Called only by the launch guard before a native invocation is admitted. A
268
481
  // claimed/uncertain worker can never be released through this recovery path.
269
482
  export function rejectUnclaimed(project, dispatchId, rejection) {
@@ -280,8 +493,9 @@ export function rejectUnclaimed(project, dispatchId, rejection) {
280
493
  export function complete(project, dispatchId, input) {
281
494
  return transaction(project, ledger => {
282
495
  const d = ledger.dispatches[dispatchId]; need(d, "Unknown dispatch");
283
- if (["completed", "failed"].includes(d.status) && d.runtime) { need(same(d.runtime, input.runtime), "Dispatch already completed with different evidence"); return d; }
496
+ if ((["completed", "failed"].includes(d.status) || d.workStatus === "incomplete-editor") && d.runtime) { need(same(d.runtime, input.runtime), "Dispatch already completed with different evidence"); return d; }
284
497
  need(["running", "uncertain"].includes(d.status), "Only a claimed dispatch can complete");
498
+ const editorUncertain = (d.editorSteps ?? []).some(s => ["running", "uncertain"].includes(s.status));
285
499
  need(input.runtime?.path?.startsWith(".nccgs/evidence/runtime/observations/"), "Completion needs durable runtime observation");
286
500
  const trace = json(verifyArtifact(project, input.runtime)), raw = json(verifyArtifact(project, trace.observationSource));
287
501
  need(trace.observationSource?.path?.startsWith(".nccgs/evidence/runtime/sources/"), "Completion requires durable raw runtime source");
@@ -296,6 +510,16 @@ export function complete(project, dispatchId, input) {
296
510
  need(Date.parse(trace.capturedAt) >= Date.parse(d.startedAt) && Date.parse(trace.capturedAt) <= Date.now(), "Completion time is invalid");
297
511
  const task = taskRecord(project, d.taskId), finalSnapshot = snapshot(project, task).sha256;
298
512
  need(task.contractHash === d.contractHash && trace.snapshot === finalSnapshot, "Completion covers stale contract/snapshot");
513
+ let returnedHandoffFailure = null;
514
+ if (d.handoffTransport === "native-result") {
515
+ need(same(trace.returnedHandoff ?? null, raw.returnedHandoff ?? null), "Returned handoff differs from raw source");
516
+ const captured = raw.returnedHandoff;
517
+ if (captured?.status !== "captured") returnedHandoffFailure = captured?.reason ?? "missing-native-result";
518
+ else {
519
+ need(d.handoffPaths.length === 1 && captured.evidence?.path === d.handoffPaths[0], "Returned handoff path mismatch");
520
+ verifyArtifact(project, captured.evidence);
521
+ }
522
+ }
299
523
  const mutatedReadOnly = d.kind !== "implementation" && d.snapshot !== finalSnapshot;
300
524
  const metrics = Object.fromEntries(["durationMs", "toolUseCount", "totalTokens", "costUsd"].map(key => {
301
525
  const value = raw[key] ?? null;
@@ -321,11 +545,26 @@ export function complete(project, dispatchId, input) {
321
545
  d.status = "failed";
322
546
  d.failure = missing.length ? "Required handoff artifacts are missing; reserve a new bounded attempt" : "Handoff is partial or missing its COMPLETE marker; reserve a new bounded attempt";
323
547
  }
548
+ if (returnedHandoffFailure) { d.status = "failed"; d.failure = `Returned handoff rejected: ${returnedHandoffFailure}`; }
324
549
  if (d.workPacket) {
325
- try { verifyRepairPacket(project, d); }
550
+ try {
551
+ verifyRepairPacket(project, d);
552
+ verifyUnityRepairScope(project, d, true);
553
+ if (d.workPacket.schemaVersion === 2) {
554
+ need(d.workPacket.unity.steps.every(s => d.editorSteps?.some(e => e.id === s.id && e.status === "completed")), "Required Editor operations did not complete");
555
+ for (const s of d.editorSteps ?? []) verifyArtifact(project, s.evidence);
556
+ }
557
+ }
326
558
  catch (error) { d.status = "failed"; d.failure = `Repair context changed: ${error.message}`; }
327
559
  }
328
- d.workStatus = d.status === "completed" ? "completed" : "incomplete";
560
+ // The native worker can finish while a resident Editor operation remains
561
+ // uncertain. Keep its observed usage and original handoff without releasing
562
+ // ownership or converting that observation into completed repair work.
563
+ if (editorUncertain) {
564
+ d.status = "uncertain";
565
+ d.uncertainty = "Native worker finished but Editor operation is still running or uncertain; external reconciliation required";
566
+ }
567
+ d.workStatus = editorUncertain ? "incomplete-editor" : d.status === "completed" ? "completed" : "incomplete";
329
568
  ledger.events.push({ action: "complete", at: d.finishedAt, dispatchId, status: d.status }); return d;
330
569
  });
331
570
  }
@@ -400,15 +639,26 @@ export function correlateAgentStart(project, input) {
400
639
  return items.length === 1 ? attachAgent(project, items[0].id, input.agent_id) : null;
401
640
  }
402
641
  export function validateStageDispatch(project, task, input, trace, kind) {
403
- const d = load(project).dispatches[input.dispatchId];
642
+ const ledger = load(project), d = ledger.dispatches[input.dispatchId];
404
643
  need(d?.handoff?.status !== "missing", `${kind}: invocation ended but required handoff artifacts are missing; reserve a new attempt`);
405
644
  need(!d?.handoff?.partial?.length, `${kind}: handoff is partial; reserve a new bounded attempt`);
406
645
  need(d && d.taskId === task.id && d.kind === kind && d.status === "completed", `${kind}: completed managed dispatch required`);
407
646
  need(d.handoff?.status !== "missing", `${kind}: invocation ended but required handoff artifacts are missing; reserve a new attempt`);
408
647
  for (const ref of d.handoff?.artifacts ?? []) verifyArtifact(project, ref);
409
648
  if (d.workPacket) verifyRepairPacket(project, d);
649
+ for (const step of d.editorSteps ?? []) verifyArtifact(project, step.evidence);
410
650
  need(d.contractHash === task.contractHash && d.completedSnapshot === input.snapshot && same(d.runtime, input.runtime), "Stage dispatch covers different snapshot/contract/evidence");
411
651
  need(d.sessionId === trace.sessionId && d.agentId === trace.agentId && d.toolUseId === trace.toolUseId, "Stage dispatch/runtime identity mismatch");
652
+ const stored = readSlicePlan(project, task, ledger);
653
+ if (stored) {
654
+ const progress = sliceProgress(project, task, ledger, stored);
655
+ need(progress.slices.every(s => s.state === "DELIVERED"), "All planned slices need complete current native handoffs before integrated verification/review");
656
+ const integration = ledger.dispatches[progress.slices.find(s => s.id === stored.plan.integration).dispatchId];
657
+ if (kind === "implementation") need(d.id === integration.id || (d.purpose === "repair" && d.slicePlan && Date.parse(d.createdAt) >= Date.parse(integration.finishedAt)),
658
+ "Record the final integration dispatch (or subsequent repair), never promote an intermediate slice to whole-task completion");
659
+ if (kind === "review") need(!Object.values(ledger.dispatches).some(other => belongs(ledger, other.taskId, task.id) && other.kind === "implementation"
660
+ && other.sessionId === trace.sessionId && other.agentId === trace.agentId), "Review must be independent of every implementation contributor, including earlier slices and failed repairs");
661
+ }
412
662
  // Recording rejects consumed IDs; read-only inspection must accept an already
413
663
  // recorded stage without treating it as a new attempt.
414
664
  return d;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { artifact, contained, json, verifyArtifact } from "./policy.mjs";
4
+ import { validateUnityRepair, unityEditorToolScope, repairEditorCommand, unityCodePath, unitySceneSafety } from "./unity-repair.mjs";
4
5
 
5
6
  const need = (ok, message) => { if (!ok) throw new Error(message); };
6
7
  export const repairLimits = Object.freeze({ workCalls: 12, readCalls: 6, handoffCalls: 3, durationMs: 600000 });
@@ -12,20 +13,24 @@ const keys = (value, allowed) => value && typeof value === "object" && !Array.is
12
13
  export function pinRepairPacket(project, task, request, snapshot) {
13
14
  need(typeof request.workPacket === "string" && request.workPacket.startsWith(".nccgs/evidence/work-packets/") && request.workPacket.endsWith(".json"), "Repair requires a workPacket JSON under .nccgs/evidence/work-packets/");
14
15
  const reference = artifact(project, request.workPacket);
15
- need(fs.statSync(contained(project, reference.path)).size <= 16384, "Repair packet exceeds 16 KiB; narrow the work before dispatch");
16
+ need(fs.statSync(contained(project, reference.path)).size <= 32768, "Repair packet exceeds 32 KiB; narrow the work before dispatch");
16
17
  const packet = json(verifyArtifact(project, reference));
17
- need(keys(packet, ["schemaVersion", "taskId", "contractHash", "sourceSnapshot", "problem", "solution", "changes", "context", "verification", "budget"]) && packet.schemaVersion === 1, "Invalid repair packet schema");
18
+ const unity = packet.schemaVersion === 2;
19
+ need(keys(packet, ["schemaVersion", "taskId", "contractHash", "sourceSnapshot", "problem", "solution", "changes", "context", "verification", "budget", ...(unity ? ["repairType", "unity"] : [])]) && [1, 2].includes(packet.schemaVersion), "Invalid repair packet schema");
20
+ if (!unity) need(fs.statSync(contained(project, reference.path)).size <= 16384, "Repair packet exceeds 16 KiB; narrow the work before dispatch");
18
21
  need(packet.taskId === task.id && packet.contractHash === task.contractHash && packet.sourceSnapshot === snapshot, "Repair packet task/contract/source snapshot is stale or mismatched");
19
22
  need(text(packet.problem, 800) && text(packet.solution, 1600), "Repair packet needs a concise problem and chosen solution");
20
- need(Array.isArray(packet.changes) && packet.changes.length >= 1 && packet.changes.length <= 4, "Repair packet must specify 1-4 exact existing source files");
23
+ need(Array.isArray(packet.changes) && packet.changes.length >= 1 && packet.changes.length <= (unity ? 8 : 4), "Repair packet must specify 1-4 exact existing source files (up to 8 in a typed Unity packet)");
21
24
  for (const change of packet.changes) {
22
- need(keys(change, ["path", "instruction"]) && text(change.instruction, 1600), "Each repair change needs an exact path and bounded instruction");
25
+ need(keys(change, ["path", "instruction", ...(unity ? ["operation"] : [])]) && text(change.instruction, 1600), "Each repair change needs an exact path and bounded instruction");
23
26
  const file = contained(project, change.path);
24
- need(!/^\.(?:nccgs|claude|git)(?:\/|$)/i.test(change.path) && fs.statSync(file).isFile(), "Repair changes must be existing source files, not governance or evidence");
27
+ if (!unity) need(!/^\.(?:nccgs|claude|git)(?:\/|$)/i.test(change.path) && fs.statSync(file).isFile(), "Repair changes must be existing source files, not governance or evidence");
25
28
  }
29
+ const limits = unity ? validateUnityRepair(project, packet) : repairLimits;
26
30
  const paths = packet.changes.map(c => c.path).sort();
27
31
  need(new Set(paths.map(p => p.toLowerCase())).size === paths.length && JSON.stringify(paths) === JSON.stringify([...request.writePaths].sort()), "Repair writePaths must exactly match packet files; directory ownership is not allowed");
28
32
  need(request.handoffPaths?.length === 1, "Repair requires exactly one fresh handoff path");
33
+ if (unity) need(!request.handoffPaths.includes(packet.unity.capabilities?.path), "Unity capability evidence cannot be its handoff");
29
34
  need(Array.isArray(packet.context) && packet.context.length <= 4, "Repair context must contain at most four pinned evidence references");
30
35
  for (const ref of packet.context) {
31
36
  need(ref?.path?.startsWith(".nccgs/evidence/") && !ref.path.startsWith(".nccgs/evidence/work-packets/") && !request.handoffPaths.includes(ref.path), "Repair context must reference existing evidence, not the packet or its handoff");
@@ -34,15 +39,19 @@ export function pinRepairPacket(project, task, request, snapshot) {
34
39
  need(Array.isArray(packet.verification) && packet.verification.length > 0 && new Set(packet.verification).size === packet.verification.length &&
35
40
  packet.verification.every(name => task.contract.requiredChecks.includes(name)) && task.contract.requiredChecks.every(name => packet.verification.includes(name)), "Repair verification must list the task's required checks for the coordinator");
36
41
  need(packet.budget === undefined || keys(packet.budget, Object.keys(repairLimits)), "Unknown repair budget field");
37
- const budget = { ...repairLimits, ...packet.budget };
38
- for (const [name, value] of Object.entries(budget)) need(Number.isSafeInteger(value) && value >= 1 && value <= repairLimits[name], `Repair ${name} must be between 1 and ${repairLimits[name]}`);
42
+ const budget = { ...limits, ...packet.budget };
43
+ for (const [name, value] of Object.entries(budget)) need(Number.isSafeInteger(value) && value >= 1 && value <= limits[name], `Repair ${name} must be between 1 and ${limits[name]}`);
39
44
  need(budget.readCalls <= budget.workCalls, "Repair readCalls cannot exceed workCalls");
40
- return { ...packet, reference, budget };
45
+ return { ...packet, reference, budget, ...(unity ? { sceneSafety: unitySceneSafety } : {}) };
41
46
  }
42
47
 
43
48
  export function verifyRepairPacket(project, dispatch) {
44
49
  verifyArtifact(project, dispatch.workPacket.reference);
45
50
  for (const ref of dispatch.workPacket.context) verifyArtifact(project, ref);
51
+ if (dispatch.workPacket.schemaVersion === 2) {
52
+ if (dispatch.workPacket.unity.capabilities) verifyArtifact(project, dispatch.workPacket.unity.capabilities);
53
+ verifyArtifact(project, dispatch.workPacket.scope);
54
+ }
46
55
  }
47
56
 
48
57
  export function repairPrompt(task, dispatch) {
@@ -50,10 +59,17 @@ export function repairPrompt(task, dispatch) {
50
59
  return `[NCCGS-DISPATCH:${dispatch.id}]\nBounded repair for task ${task.id}: ${task.contract.objective ?? task.objective ?? dispatch.reason}\n` +
51
60
  `Packet: ${p.reference.path} sha256=${p.reference.sha256}\nContract: ${dispatch.contractHash}\nStarting source: ${dispatch.snapshot}\n` +
52
61
  `Problem: ${p.problem}\nChosen solution: ${p.solution}\n` +
53
- p.changes.map(c => `Edit ${c.path}: ${c.instruction}`).join("\n") +
62
+ p.changes.map(c => `${c.operation ?? "Edit"} ${c.path}: ${c.instruction}`).join("\n") +
54
63
  `\nContext references: ${JSON.stringify(p.context)}\n` +
55
64
  `Limits: ${p.budget.workCalls} work tool calls, including at most ${p.budget.readCalls} reads/searches; ${p.budget.durationMs} ms from claim. Separate ${p.budget.handoffCalls} handoff writes. Native turns are separate and are not guaranteed by these counters.\n` +
56
- `Read/Grep/Glob within this project; Write/Edit/MultiEdit only the listed files. No shell, MCP, web, nested agents, or parameter sweeps.\n` +
65
+ (p.schemaVersion === 2
66
+ ? `Repair profile: ${p.repairType}. Read/Grep/Glob within this project. ${p.repairType === "unity-code" ? "Write/Edit/MultiEdit only the listed source files." : "Write/Edit/MultiEdit only the listed code files. Change serialized assets only through pinned Editor steps; no raw asset or metadata edits."} Never hand-write .meta files. No general shell, MCP, web, nested agents or parameter sweeps.\n` +
67
+ (p.sceneSafety ? "Every Editor step first checks all loaded scenes in the same eval. If any scene is dirty, the operation is blocked without saving/discarding it. Return PARTIAL; preserve the attempt and let the owner resolve unsaved work.\n" : "") +
68
+ (p.unity.steps.length ? `Pinned Unity capabilities: ${JSON.stringify(p.unity.capabilities)}\nRun these foreground Bash tool inputs once, in order; stop and return PARTIAL on a failed or uncertain step:\n`
69
+ : "No Editor steps are reserved; apply only the existing-file code correction.\n") +
70
+ p.unity.steps.map(s => JSON.stringify({ command: repairEditorCommand(dispatch, s), timeout: s.timeoutMs + 5000 }) + `\nEditor operation: ${s.command} ${JSON.stringify(s.arguments)}\n`).join("") +
71
+ "Finish saving/importing all exact files and their Unity-generated metadata before returning; new paths, deleted files or changed existing GUIDs fail completion. Editor return/exit zero is not verification evidence.\n"
72
+ : `Read/Grep/Glob within this project; Write/Edit/MultiEdit only the listed files. No shell, MCP, web, nested agents, or parameter sweeps.\n`) +
57
73
  `Handoff: ${dispatch.handoffPaths[0]}. Write NCCGS-HANDOFF: PARTIAL early, then replace with NCCGS-HANDOFF: COMPLETE only when these edits are finished. Include changed files, rationale, unverified checks, risks and next owner. Return PARTIAL if the chosen solution needs more investigation.\n` +
58
74
  `Coordinator runs required checks after return: ${p.verification.join(", ")}. COMPLETE is edit handoff, not acceptance PASS. Do not run or invent verification.\n`;
59
75
  }
@@ -69,9 +85,12 @@ function toolPath(project, value, rootAllowed = false) {
69
85
  // Classifies the request before admission; never executes a tool or command.
70
86
  export function repairToolScope(project, dispatch, input) {
71
87
  const tool = input.tool_input ?? {}, name = input.tool_name;
88
+ const editor = unityEditorToolScope(dispatch, input);
89
+ if (editor) return editor;
72
90
  if (["Write", "Edit", "MultiEdit"].includes(name)) {
73
91
  const relative = toolPath(project, tool.file_path);
74
92
  if (dispatch.handoffPaths.includes(relative)) return { bucket: "handoff", read: false };
93
+ need(dispatch.workPacket.repairType !== "unity-editor" || unityCodePath(relative), "Unity assets must be changed through pinned Editor steps, never raw file edits");
75
94
  need(dispatch.writePaths.includes(relative), "Repair write is outside the packet's exact files");
76
95
  return { bucket: "work", read: false };
77
96
  }
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { artifact, contained } from "./policy.mjs";
4
+
5
+ // Opt-in transport for read-only workers whose native client returns reports as
6
+ // text. Copy the sole model text block verbatim; never synthesize a verdict,
7
+ // COMPLETE marker, identity, missing content or a replacement for an old attempt.
8
+ export function captureReturnedHandoff(project, dispatch, input, completion, currentSnapshot) {
9
+ if (dispatch?.handoffTransport !== "native-result") return null;
10
+ const reject = reason => ({ transport: "native-result", status: "rejected", reason });
11
+ const response = input.tool_response;
12
+ if (input.hook_event_name !== "PostToolUse" || !["Agent", "Task"].includes(input.tool_name)
13
+ || dispatch.status !== "running" || !["analysis", "review"].includes(dispatch.kind)
14
+ || dispatch.sessionId !== input.session_id || dispatch.toolUseId !== input.tool_use_id
15
+ || !dispatch.agentId || dispatch.agentId !== response?.agentId
16
+ || dispatch.agent !== input.tool_input?.subagent_type || dispatch.snapshot !== currentSnapshot)
17
+ return reject("identity-or-snapshot-mismatch");
18
+ if (completion?.status !== "completed" || completion.reason || response?.harnessNoteCount || response?.harnessTailCount)
19
+ return reject("native-incomplete");
20
+ const blocks = response?.content;
21
+ if (!Array.isArray(blocks) || blocks.length !== 1 || blocks[0]?.type !== "text"
22
+ || typeof blocks[0].text !== "string" || !blocks[0].text.trim()
23
+ || Buffer.byteLength(blocks[0].text, "utf8") > 262144)
24
+ return reject("expected-one-bounded-text-block");
25
+ if (dispatch.handoffPaths?.length !== 1) return reject("expected-one-pinned-handoff");
26
+ const relative = dispatch.handoffPaths[0];
27
+ try {
28
+ const file = contained(project, relative);
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ // Revalidate newly created parents and refuse symlinks, existing drafts or
31
+ // concurrent writes. Existing file transport retains its original behavior.
32
+ contained(project, relative);
33
+ fs.writeFileSync(file, blocks[0].text, { flag: "wx" });
34
+ return { transport: "native-result", status: "captured", evidence: artifact(project, relative) };
35
+ } catch (error) {
36
+ return reject(error.code === "EEXIST" ? "handoff-already-exists" : "handoff-publication-failed");
37
+ }
38
+ }