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.
- package/.claude/nccgs/MEMORY.md +3 -2
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/packages/ponytail/README.md +2 -2
- package/.claude/nccgs/protocols/agent-contract.md +15 -2
- package/.claude/nccgs/protocols/bounded-repair.md +135 -7
- package/.claude/nccgs/protocols/editor-session-evidence.md +52 -0
- package/.claude/nccgs/protocols/orchestration.md +7 -5
- package/.claude/nccgs/protocols/review-handoff.md +17 -1
- package/.claude/nccgs/protocols/standalone-gates.md +64 -8
- package/.claude/nccgs/protocols/standalone.md +33 -13
- package/.claude/nccgs/protocols/task-gates.md +13 -1
- package/.claude/nccgs/protocols/task-slices.md +106 -0
- package/.claude/nccgs/routing.generated.md +20 -0
- package/.claude/nccgs/tools/command-process.mjs +10 -4
- package/.claude/nccgs/tools/compile-policy.mjs +4 -0
- package/.claude/nccgs/tools/editor-session-evidence.mjs +66 -0
- package/.claude/nccgs/tools/gates.mjs +7 -4
- package/.claude/nccgs/tools/handoff.mjs +51 -0
- package/.claude/nccgs/tools/inputs.mjs +36 -1
- package/.claude/nccgs/tools/lifecycle.mjs +21 -0
- package/.claude/nccgs/tools/orchestration.mjs +264 -14
- package/.claude/nccgs/tools/repair-packet.mjs +29 -10
- package/.claude/nccgs/tools/returned-handoff.mjs +38 -0
- package/.claude/nccgs/tools/review-budget.mjs +171 -0
- package/.claude/nccgs/tools/review-handoff.mjs +42 -8
- package/.claude/nccgs/tools/runtime.mjs +7 -2
- package/.claude/nccgs/tools/session-budget.mjs +67 -0
- package/.claude/nccgs/tools/task-slices.mjs +147 -0
- package/.claude/nccgs/tools/task.mjs +39 -2
- package/.claude/nccgs/tools/tool-guard.mjs +19 -5
- package/.claude/nccgs/tools/unity-repair.mjs +128 -0
- package/.claude/nccgs/tools/verification-jobs.mjs +13 -3
- package/.claude/nccgs/tools/windows-compiler-server.cs +163 -0
- package/.claude/nccgs/tools/windows-job.cs +8 -1
- package/CHANGELOG.md +123 -0
- package/MEMORY.md +3 -2
- package/README.md +35 -27
- package/UPGRADING.md +26 -22
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +45 -18
- package/docs/BOUNDED-REPAIR.md +76 -7
- package/docs/EDITOR-SESSION-EVIDENCE.md +52 -0
- package/docs/HANDOFF-AUTOMATION.md +144 -0
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +40 -11
- package/docs/PHASE-1-OPERATIONS.md +142 -1
- package/docs/PHASE-2-OPERATIONS.md +1 -1
- package/docs/RELEASE-STATUS.md +92 -9
- package/docs/TASK-SLICING.md +57 -0
- package/docs/UPGRADE-QUALIFICATION.md +45 -0
- package/docs/WORKFLOWS.md +1 -1
- package/package.json +2 -2
- package/scaffold/.nccgs/templates/repair-unity-code.json +18 -0
- package/scaffold/.nccgs/templates/repair-unity-editor.json +21 -0
- package/scaffold/.nccgs/templates/task-slices.json +28 -0
- package/scripts/cli.mjs +4 -0
- package/scripts/install.mjs +10 -0
- package/scripts/run.mjs +4 -3
- package/scripts/supervise.mjs +30 -6
- package/scripts/validate.mjs +4 -0
- package/scripts/work-handoff.mjs +38 -0
- package/tests/editor-session-evidence.test.mjs +45 -0
- package/tests/fixtures/compiler-shutdown.cs +64 -0
- package/tests/framework.test.mjs +38 -0
- package/tests/orchestration-v15.test.mjs +52 -1
- package/tests/phase1.test.mjs +92 -0
- package/tests/policy-v15.test.mjs +14 -0
- package/tests/review-budget.test.mjs +177 -0
- package/tests/runtime-v15.test.mjs +572 -4
- package/tests/session-budget.test.mjs +34 -0
- package/tests/task-slices.test.mjs +285 -0
- 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
|
|
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,
|
|
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 =
|
|
209
|
-
need(d
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 <=
|
|
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
|
-
|
|
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 = { ...
|
|
38
|
-
for (const [name, value] of Object.entries(budget)) need(Number.isSafeInteger(value) && value >= 1 && value <=
|
|
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 =>
|
|
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
|
-
|
|
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
|
+
}
|