nccgs 2.1.0 → 2.1.1
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/agents/nccgs-adversarial-reviewer.md +1 -1
- package/.claude/agents/nccgs-engine-programmer.md +1 -1
- package/.claude/agents/nccgs-fast-implementer.md +1 -1
- package/.claude/agents/nccgs-game-designer.md +1 -1
- package/.claude/agents/nccgs-gameplay-programmer.md +1 -1
- package/.claude/agents/nccgs-qa-engineer.md +1 -1
- package/.claude/agents/nccgs-task-scout.md +1 -1
- package/.claude/agents/nccgs-ui-programmer.md +1 -1
- package/.claude/agents/nccgs-unity-build-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-implementer.md +1 -1
- package/.claude/agents/nccgs-unity-systems-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-ui-specialist.md +1 -1
- package/.claude/agents/nccgs-verification-engineer.md +1 -1
- package/.claude/nccgs/MEMORY.md +3 -1
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/constitution.md +187 -176
- package/.claude/nccgs/hooks/closure-guard.mjs +2 -0
- package/.claude/nccgs/hooks/unity-transport-guard.mjs +11 -0
- package/.claude/nccgs/pipeline.json +260 -260
- package/.claude/nccgs/protocols/context-packets.md +5 -0
- package/.claude/nccgs/protocols/model-routing.md +46 -46
- package/.claude/nccgs/protocols/review-handoff.md +21 -1
- package/.claude/nccgs/protocols/shared-workflow-context.md +33 -0
- package/.claude/nccgs/protocols/unity-boundary.md +20 -0
- package/.claude/nccgs/routing.generated.md +4 -4
- package/.claude/nccgs/settings.fragment.json +84 -24
- package/.claude/nccgs/studio.json +53 -53
- package/.claude/nccgs/tools/autonomy.mjs +13 -0
- package/.claude/nccgs/tools/command-tokens.mjs +22 -0
- package/.claude/nccgs/tools/hook-transport.mjs +34 -0
- package/.claude/nccgs/tools/native-completion.mjs +10 -1
- package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
- package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
- package/.claude/nccgs/tools/shared-context.mjs +25 -0
- package/.claude/nccgs/tools/tool-guard.mjs +120 -131
- package/.claude/nccgs/tools/unity-transport.mjs +170 -0
- package/.claude/nccgs/workflow-catalog.json +4 -4
- package/CHANGELOG.md +235 -200
- package/CLAUDE.md +10 -8
- package/MEMORY.md +109 -60
- package/README.md +206 -191
- package/UPGRADING.md +130 -118
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +131 -131
- package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -116
- package/docs/NCCGS-1.5.md +237 -237
- package/docs/PROJECT-POLICY.md +94 -94
- package/docs/RELEASE-STATUS.md +17 -9
- package/docs/UNINSTALL.md +3 -3
- package/docs/WFK17-HOOK-REPAIR.md +71 -0
- package/docs/WORKFLOWS.md +85 -85
- package/package.json +1 -1
- package/scaffold/.nccgs/project.yaml +109 -108
- package/scripts/benchmark-v15.mjs +173 -173
- package/scripts/doctor.mjs +261 -261
- package/scripts/install.mjs +530 -529
- package/scripts/run.mjs +5 -4
- package/scripts/supervise.mjs +4 -0
- package/scripts/uninstall.mjs +292 -292
- package/scripts/validate.mjs +285 -280
- package/tests/autonomy.test.mjs +126 -113
- package/tests/design-readiness.test.mjs +199 -199
- package/tests/fixtures/compiler-shutdown.cs +67 -64
- package/tests/fixtures/native-provider.mjs +61 -0
- package/tests/framework.test.mjs +740 -740
- package/tests/hook-repair.test.mjs +106 -0
- package/tests/lifecycle-v15.test.mjs +346 -300
- package/tests/native-completion-notices.test.mjs +181 -0
- package/tests/phase1.test.mjs +427 -416
- package/tests/phase2.test.mjs +250 -250
- package/tests/policy-v15.test.mjs +234 -234
- package/tests/returned-handoff-notices.test.mjs +170 -0
- package/tests/runtime-v15.test.mjs +1197 -1191
- package/tests/task-slices.test.mjs +285 -285
- package/tests/unity-transport.test.mjs +348 -0
package/tests/phase1.test.mjs
CHANGED
|
@@ -1,416 +1,427 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import crypto from "node:crypto";
|
|
6
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import test from "node:test";
|
|
9
|
-
import { atomicJson, json } from "../.claude/nccgs/tools/policy.mjs";
|
|
10
|
-
import { initialize } from "../.claude/nccgs/tools/gates.mjs";
|
|
11
|
-
import { acquireLock, delay, pidState, treeState } from "../.claude/nccgs/tools/process-lock.mjs";
|
|
12
|
-
import { startCommand } from "../.claude/nccgs/tools/command-process.mjs";
|
|
13
|
-
import { startVerification, waitVerification, jobStatus, readJob, jobDirectory } from "../.claude/nccgs/tools/verification-jobs.mjs";
|
|
14
|
-
import { safeVerifyCommand, guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
|
|
15
|
-
import { cumulativeCost, supervise } from "../scripts/supervise.mjs";
|
|
16
|
-
import { registerReviewBudget, reviewBudgetStatus } from "../.claude/nccgs/tools/review-budget.mjs";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
fs.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
write("
|
|
29
|
-
write(".nccgs/
|
|
30
|
-
write(".nccgs/
|
|
31
|
-
write(".nccgs/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
assert.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project
|
|
49
|
-
assert.equal(safeVerifyCommand(`${prefix} -- node
|
|
50
|
-
assert.equal(safeVerifyCommand(`${prefix} --
|
|
51
|
-
assert.equal(safeVerifyCommand(`${prefix
|
|
52
|
-
|
|
53
|
-
assert.equal(safeVerifyCommand(`${prefix} -- node
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
assert.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
assert.throws(() =>
|
|
77
|
-
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
assert.equal(receipt.
|
|
96
|
-
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
assert.equal(
|
|
112
|
-
assert.equal(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
assert.
|
|
116
|
-
assert.
|
|
117
|
-
assert.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
"--
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
assert.
|
|
132
|
-
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
f.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
assert.equal(receipt.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
assert.equal(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
assert.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
assert.equal(values.
|
|
234
|
-
assert.equal(values.
|
|
235
|
-
assert.equal(
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
assert.equal(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
assert.equal(
|
|
262
|
-
assert.equal(
|
|
263
|
-
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
atomicJson(path.join(directory, "
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
assert.equal(
|
|
276
|
-
assert.
|
|
277
|
-
assert.
|
|
278
|
-
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
path.join(root, '
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
assert.
|
|
293
|
-
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
import
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
assert.equal(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
assert.throws(() =>
|
|
333
|
-
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
assert.equal(state.
|
|
347
|
-
assert.equal(
|
|
348
|
-
assert.equal(
|
|
349
|
-
assert.equal(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { atomicJson, json } from "../.claude/nccgs/tools/policy.mjs";
|
|
10
|
+
import { initialize } from "../.claude/nccgs/tools/gates.mjs";
|
|
11
|
+
import { acquireLock, delay, pidState, treeState } from "../.claude/nccgs/tools/process-lock.mjs";
|
|
12
|
+
import { startCommand } from "../.claude/nccgs/tools/command-process.mjs";
|
|
13
|
+
import { startVerification, waitVerification, jobStatus, readJob, jobDirectory } from "../.claude/nccgs/tools/verification-jobs.mjs";
|
|
14
|
+
import { safeVerifyCommand, guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
|
|
15
|
+
import { cumulativeCost, supervise } from "../scripts/supervise.mjs";
|
|
16
|
+
import { registerReviewBudget, reviewBudgetStatus } from "../.claude/nccgs/tools/review-budget.mjs";
|
|
17
|
+
import { nativeProvider } from "./fixtures/native-provider.mjs";
|
|
18
|
+
|
|
19
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
function fixture(timeoutMs = 5000, mode = "implement") {
|
|
21
|
+
const base = fs.realpathSync(os.tmpdir()), project = fs.mkdtempSync(path.join(base, "nccgs-phase1-"));
|
|
22
|
+
const write = (relative, value) => {
|
|
23
|
+
const file = path.join(project, relative); fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
fs.writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value)); return file;
|
|
25
|
+
};
|
|
26
|
+
fs.cpSync(path.join(root, ".claude/nccgs/tools"), path.join(project, ".claude/nccgs/tools"), { recursive: true });
|
|
27
|
+
for (const file of ["pipeline.json", "studio.json"]) write(`.claude/nccgs/${file}`, fs.readFileSync(path.join(root, ".claude/nccgs", file), "utf8"));
|
|
28
|
+
write("Assets/source.cs", "// frozen fixture source");
|
|
29
|
+
write(".nccgs/project.yaml", "schema_version: 2\nmodels:\n profile: claude-standalone\n");
|
|
30
|
+
write(".nccgs/execution.json", { schemaVersion: 1, name: "standalone", externalReview: { mode: "off" } });
|
|
31
|
+
write(".nccgs/inputs.json", { schemaVersion: 1, canonicalInputs: [], outputDirectories: [], checks: { test: { timeoutMs } } });
|
|
32
|
+
write(".nccgs/intake.json", { objective: "Process lifecycle fixture; no actual Claude/model/game execution", mode, requiredChecks: ["test"] });
|
|
33
|
+
initialize(project, "fixture", null, null, null, { intake: ".nccgs/intake.json" });
|
|
34
|
+
const env = { ...process.env };
|
|
35
|
+
for (const key of ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NCCGS_TASK_ID", "NCCGS_SUPERVISED_SESSION", "NCCGS_PROJECT_ROOT"]) delete env[key];
|
|
36
|
+
const cli = (...args) => spawnSync(process.execPath, [path.join(project, ".claude/nccgs/tools/task.mjs"), ...args, "--project", project], { cwd: project, env, encoding: "utf8", timeout: 20000, windowsHide: true });
|
|
37
|
+
return { project, write, env, cli, dispose() {
|
|
38
|
+
assert.equal(path.dirname(fs.realpathSync(project)).toLowerCase(), base.toLowerCase());
|
|
39
|
+
assert.ok(path.basename(project).startsWith("nccgs-phase1-"));
|
|
40
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
41
|
+
} };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("verify parser separates helper options from the framework root and rejects shell composition", () => {
|
|
45
|
+
const f = fixture();
|
|
46
|
+
try {
|
|
47
|
+
const prefix = `node "${f.project.replaceAll("\\", "/")}/.claude/nccgs/tools/task.mjs" verify --id fixture --name test --project "${f.project}"`;
|
|
48
|
+
assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project "${f.project}"`, f.project), true);
|
|
49
|
+
assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project=anything`, f.project), true);
|
|
50
|
+
assert.equal(safeVerifyCommand(`${prefix} -- node -e "console.log('a;b')"`, f.project), true);
|
|
51
|
+
assert.equal(safeVerifyCommand(`${prefix} --project other -- node x`, f.project), false);
|
|
52
|
+
assert.equal(safeVerifyCommand(`${prefix.replace(`--project "${f.project}"`, '--project "../foreign"')} -- node x`, f.project), false);
|
|
53
|
+
for (const suffix of ["; echo bad", " && echo bad", " > output", " | node x"]) assert.equal(safeVerifyCommand(`${prefix} -- node x${suffix}`, f.project), false);
|
|
54
|
+
assert.equal(safeVerifyCommand(`${prefix} -- node -e "$(touch bad)"`, f.project), false);
|
|
55
|
+
f.write(".nccgs/runtime/bindings/parser.json", { taskId: "fixture" });
|
|
56
|
+
assert.doesNotThrow(() => guardTool(f.project, { session_id: "parser", tool_name: "Bash", tool_input: { command: `${prefix} -- node helper.mjs --project "${f.project}"` } }));
|
|
57
|
+
} finally { f.dispose(); }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("detached verification survives its caller and repeated request keys reuse one immutable receipt", () => {
|
|
61
|
+
const f = fixture();
|
|
62
|
+
try {
|
|
63
|
+
const code = "setTimeout(()=>{require('node:fs').appendFileSync('.nccgs/evidence/count.txt','run\\n');console.log('real subprocess output')},400)";
|
|
64
|
+
const args = ["verify", "--id", "fixture", "--name", "test", "--background", "--request-key", "once", "--", process.execPath, "-e", code];
|
|
65
|
+
// Add the framework project before the separator, as real callers must do.
|
|
66
|
+
const first = spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"), ...args], { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true });
|
|
67
|
+
assert.equal(first.status, 0, first.stderr); const started = JSON.parse(first.stdout);
|
|
68
|
+
const result = waitVerification(f.project, started.jobId, "fixture", 10000);
|
|
69
|
+
assert.equal(result.status, "PASS");
|
|
70
|
+
const duplicate = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" });
|
|
71
|
+
assert.deepEqual(duplicate.evidence, result.evidence);
|
|
72
|
+
assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/evidence/count.txt"), "utf8"), "run\n");
|
|
73
|
+
const receipt = json(path.join(f.project, result.evidence.path));
|
|
74
|
+
assert.equal(receipt.sourceUnchanged, true); assert.equal(receipt.exitCode, 0);
|
|
75
|
+
f.write("Assets/source.cs", "// changed");
|
|
76
|
+
assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" }), /changed source/);
|
|
77
|
+
assert.throws(() => jobStatus(f.project, started.jobId, "other"), /another task/);
|
|
78
|
+
} finally { f.dispose(); }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("timeout and source mutation produce failed receipts, never reusable PASS", () => {
|
|
82
|
+
const f = fixture(200);
|
|
83
|
+
try {
|
|
84
|
+
const timed = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "setTimeout(()=>{},10000)"], requestKey: "timeout" });
|
|
85
|
+
const failure = waitVerification(f.project, timed.jobId, "fixture", 15000);
|
|
86
|
+
assert.equal(failure.status, "FAIL"); assert.equal(json(path.join(f.project, failure.evidence.path)).timedOut, true);
|
|
87
|
+
} finally { f.dispose(); }
|
|
88
|
+
// A mutation needs time to execute; the timeout probe's 200ms budget could kill
|
|
89
|
+
// Node startup first under concurrent Windows tests and never touch the source.
|
|
90
|
+
const mutationFixture = fixture(5000);
|
|
91
|
+
try {
|
|
92
|
+
const changed = startVerification(mutationFixture.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "require('node:fs').writeFileSync('Assets/source.cs','changed')"], requestKey: "mutation" });
|
|
93
|
+
const mutation = waitVerification(mutationFixture.project, changed.jobId, "fixture", 10000);
|
|
94
|
+
const receipt = json(path.join(mutationFixture.project, mutation.evidence.path));
|
|
95
|
+
assert.equal(mutation.status, "FAIL"); assert.equal(receipt.sourceUnchanged, false);
|
|
96
|
+
assert.equal(receipt.timedOut, false);
|
|
97
|
+
} finally { mutationFixture.dispose(); }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("per-attempt timeout is bounded, pinned and forwarded only before the helper separator", () => {
|
|
101
|
+
const f = fixture();
|
|
102
|
+
try {
|
|
103
|
+
const beforeInputs = fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8");
|
|
104
|
+
const command = [process.execPath, "-e", "console.log('bounded timeout fixture')"];
|
|
105
|
+
const invoke = extra => spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
|
|
106
|
+
"verify", "--id", "fixture", "--name", "long-check", "--request-key", "long-once", ...extra, "--", ...command],
|
|
107
|
+
{ cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
|
|
108
|
+
const first = invoke(["--timeout-ms", "900000"]);
|
|
109
|
+
assert.equal(first.status, 0, first.stderr);
|
|
110
|
+
const result = JSON.parse(first.stdout);
|
|
111
|
+
assert.equal(result.status, "PASS");
|
|
112
|
+
assert.equal(json(path.join(f.project, result.evidence.path)).timeoutMs, 900000);
|
|
113
|
+
assert.equal(readJob(f.project, result.jobId).timeoutMs, 900000);
|
|
114
|
+
const duplicate = invoke(["--timeout-ms", "900000"]);
|
|
115
|
+
assert.equal(duplicate.status, 0, duplicate.stderr);
|
|
116
|
+
assert.deepEqual(JSON.parse(duplicate.stdout).evidence, result.evidence);
|
|
117
|
+
assert.notEqual(invoke(["--timeout-ms", "800000"]).status, 0);
|
|
118
|
+
assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8"), beforeInputs);
|
|
119
|
+
for (const timeoutMs of [0, 99, 1.5, 7200001, NaN, Infinity, null, "900000"]) {
|
|
120
|
+
assert.throws(() => startVerification(f.project, "fixture", { name: "long-check", executable: process.execPath,
|
|
121
|
+
arguments: command.slice(1), requestKey: "invalid", timeoutMs }), /timeoutMs/);
|
|
122
|
+
}
|
|
123
|
+
assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath,
|
|
124
|
+
arguments: command.slice(1), requestKey: "configured-limit", timeoutMs: 5001 }), /configured check timeout/);
|
|
125
|
+
const helper = f.write(".nccgs/helper.mjs", "console.log(process.argv.slice(2).join(' '));");
|
|
126
|
+
const forwarded = spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
|
|
127
|
+
"verify", "--id", "fixture", "--name", "test", "--request-key", "helper-timeout", "--", process.execPath, helper,
|
|
128
|
+
"--timeout-ms", "900000"], { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
|
|
129
|
+
assert.equal(forwarded.status, 0, forwarded.stderr);
|
|
130
|
+
const forwardedReceipt = json(path.join(f.project, JSON.parse(forwarded.stdout).evidence.path));
|
|
131
|
+
assert.equal(forwardedReceipt.timeoutMs, 5000);
|
|
132
|
+
assert.match(fs.readFileSync(path.join(f.project, forwardedReceipt.log.path), "utf8"), /--timeout-ms 900000/);
|
|
133
|
+
} finally { f.dispose(); }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("long verification remains owned and bounded by its pinned supervisor deadline", () => {
|
|
137
|
+
const f = fixture();
|
|
138
|
+
try {
|
|
139
|
+
const runId = crypto.randomUUID(), sessionId = crypto.randomUUID(), deadline = Date.now() + 2500;
|
|
140
|
+
f.write(`.nccgs/runs/task-fixture.lock`, { runId });
|
|
141
|
+
const run = { project: f.project, taskId: "fixture", sessionId, deadline };
|
|
142
|
+
f.write(`.nccgs/runs/${runId}/run.json`, run);
|
|
143
|
+
const args = [path.join(f.project, ".claude/nccgs/tools/task.mjs"), "verify", "--id", "fixture",
|
|
144
|
+
"--name", "long-check", "--request-key", "supervised-long", "--timeout-ms", "900000", "--",
|
|
145
|
+
process.execPath, "-e", "setTimeout(()=>{require('node:fs').writeFileSync('.nccgs/evidence/late.txt','bad')},60000)"];
|
|
146
|
+
const env = { ...f.env, NCCGS_SUPERVISED_SESSION: sessionId };
|
|
147
|
+
const result = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 20000 });
|
|
148
|
+
assert.equal(result.status, 1, result.stderr);
|
|
149
|
+
const stopped = JSON.parse(result.stdout), receipt = json(path.join(f.project, stopped.evidence.path));
|
|
150
|
+
assert.equal(receipt.status, "FAIL"); assert.equal(receipt.timedOut, true);
|
|
151
|
+
assert.equal(receipt.timeoutMs, 900000); assert.equal(receipt.supervisorDeadline, deadline);
|
|
152
|
+
const launch = json(path.join(jobDirectory(f.project, stopped.jobId), "launch.json"));
|
|
153
|
+
assert.equal(pidState(launch.pid), "dead");
|
|
154
|
+
assert.equal(fs.existsSync(path.join(f.project, ".nccgs/evidence/late.txt")), false);
|
|
155
|
+
// A settled request remains idempotent after expiry; a fresh attempt cannot run.
|
|
156
|
+
const repeat = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
|
|
157
|
+
assert.deepEqual(JSON.parse(repeat.stdout).evidence, stopped.evidence);
|
|
158
|
+
const freshArgs = args.map(a => a === "supervised-long" ? "too-late" : a);
|
|
159
|
+
const expired = spawnSync(process.execPath, freshArgs, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
|
|
160
|
+
assert.notEqual(expired.status, 0); assert.match(expired.stderr, /deadline reached/);
|
|
161
|
+
const foreign = spawnSync(process.execPath, freshArgs, { cwd: f.project, env: { ...env, NCCGS_SUPERVISED_SESSION: crypto.randomUUID() }, encoding: "utf8", windowsHide: true, timeout: 10000 });
|
|
162
|
+
assert.notEqual(foreign.status, 0); assert.match(foreign.stderr, /binding mismatch/);
|
|
163
|
+
} finally { f.dispose(); }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("dead lock owners recover with evidence; live, unknown, legacy and spawning owners do not expire", () => {
|
|
167
|
+
const f = fixture();
|
|
168
|
+
try {
|
|
169
|
+
const file = path.join(f.project, ".nccgs/test.lock");
|
|
170
|
+
const module = new URL("../.claude/nccgs/tools/process-lock.mjs", import.meta.url).href;
|
|
171
|
+
const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import {acquireLock} from ${JSON.stringify(module)}; acquireLock(${JSON.stringify(file)});`], { encoding: "utf8", windowsHide: true });
|
|
172
|
+
assert.equal(held.status, 0, held.stderr);
|
|
173
|
+
const recovered = acquireLock(file);
|
|
174
|
+
assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
|
|
175
|
+
assert.throws(() => acquireLock(file), /live or unverified/); recovered.release();
|
|
176
|
+
for (const owner of [{ pid: 99999999 }, { schemaVersion: 1, host: "other-machine", pid: 99999999, token: "remote" },
|
|
177
|
+
{ schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "gap", phase: "spawning" }]) {
|
|
178
|
+
atomicJson(file, owner); assert.throws(() => acquireLock(file), /live or unverified/); fs.unlinkSync(file);
|
|
179
|
+
}
|
|
180
|
+
} finally { f.dispose(); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a dead owner with a live command retains its engine lock", async () => {
|
|
184
|
+
const f = fixture(), child = spawn(process.execPath, ["-e", "setTimeout(()=>{},20000)"], { stdio: "ignore", windowsHide: true });
|
|
185
|
+
try {
|
|
186
|
+
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
|
187
|
+
const file = path.join(f.project, ".nccgs/engine.lock");
|
|
188
|
+
atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "dead-parent", phase: "running", child: { pid: child.pid, group: false } });
|
|
189
|
+
assert.throws(() => acquireLock(file), /live or unverified/);
|
|
190
|
+
const closed = new Promise(resolve => child.once("close", resolve)); child.kill(); await closed;
|
|
191
|
+
// Legacy/unmanaged children cannot prove that descendants have exited.
|
|
192
|
+
if (process.platform === "win32") assert.throws(() => acquireLock(file), /live or unverified/);
|
|
193
|
+
} finally { if (pidState(child.pid) === "alive") child.kill(); f.dispose(); }
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("OS-owned process group retains orphan descendants and proves their exit after host termination", { skip: process.platform !== "win32" }, async () => {
|
|
197
|
+
const f = fixture(); let host, descendant;
|
|
198
|
+
try {
|
|
199
|
+
const id = crypto.randomUUID(), directory = path.join(f.project, ".nccgs");
|
|
200
|
+
const code = "const cp=require('node:child_process'),fs=require('node:fs');const p=cp.spawn(process.execPath,['-e','setTimeout(()=>{},20000)'],{stdio:'ignore',detached:true});fs.writeFileSync('.nccgs/descendant.json',JSON.stringify({pid:p.pid}));p.unref();";
|
|
201
|
+
const managed = startCommand({ executable: process.execPath, args: ["-e", code], cwd: f.project, stateDirectory: directory, id, stdio: "ignore" });
|
|
202
|
+
host = managed.child;
|
|
203
|
+
const closed = new Promise(resolve => host.once("close", resolve));
|
|
204
|
+
const deadline = Date.now() + 10000;
|
|
205
|
+
while (!fs.existsSync(path.join(directory, "descendant.json")) && Date.now() < deadline) await delay(50);
|
|
206
|
+
descendant = json(path.join(directory, "descendant.json")).pid;
|
|
207
|
+
assert.equal(pidState(descendant), "alive"); assert.equal(pidState(host.pid), "alive");
|
|
208
|
+
const file = path.join(directory, "engine.lock");
|
|
209
|
+
atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan-owner", phase: "running", child: managed.identity() });
|
|
210
|
+
assert.throws(() => acquireLock(file), /live or unverified/);
|
|
211
|
+
host.kill("SIGKILL"); await closed;
|
|
212
|
+
assert.equal(treeState(managed.identity()), "dead"); assert.equal(pidState(descendant), "dead");
|
|
213
|
+
const recovered = acquireLock(file); recovered.release();
|
|
214
|
+
assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
|
|
215
|
+
} finally { if (host && pidState(host.pid) === "alive") host.kill("SIGKILL"); f.dispose(); }
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("managed wrappers avoid the resident Unity auth broker without changing the caller or persisting its environment", () => {
|
|
219
|
+
const f = fixture();
|
|
220
|
+
const key = "UNITY_NO_AUTH_BROKER", previous = process.env[key];
|
|
221
|
+
const sentinelKey = "NCCGS_TEST_PRIVATE_ENV", previousSentinel = process.env[sentinelKey];
|
|
222
|
+
const sentinel = crypto.randomUUID();
|
|
223
|
+
try {
|
|
224
|
+
// Even a wrapper, rather than a direct `unity` executable, must inherit it.
|
|
225
|
+
// Explicitly avoid modifying stored auth, peer-signature or account settings.
|
|
226
|
+
process.env[key] = "0"; process.env[sentinelKey] = sentinel;
|
|
227
|
+
const code = "const cp=require('node:child_process'); const c=cp.spawnSync(process.execPath,['-e',\"console.log(JSON.stringify({broker:process.env.UNITY_NO_AUTH_BROKER,peer:process.env.UNITY_PEER_AUTH_MODE??null,privateEnv:process.env.NCCGS_TEST_PRIVATE_ENV}))\"],{encoding:'utf8'});process.stdout.write(c.stdout);process.exitCode=c.status;";
|
|
228
|
+
const started = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "direct-keyring" });
|
|
229
|
+
const settled = waitVerification(f.project, started.jobId, "fixture", 15000);
|
|
230
|
+
assert.equal(settled.status, "PASS");
|
|
231
|
+
const receipt = json(path.join(f.project, settled.evidence.path));
|
|
232
|
+
const values = JSON.parse(fs.readFileSync(path.join(f.project, receipt.log.path), "utf8"));
|
|
233
|
+
assert.equal(values.broker, "1");
|
|
234
|
+
assert.equal(values.peer, process.env.UNITY_PEER_AUTH_MODE ?? null);
|
|
235
|
+
assert.equal(values.privateEnv, sentinel);
|
|
236
|
+
assert.equal(process.env[key], "0"); assert.equal(process.env[sentinelKey], sentinel);
|
|
237
|
+
// Environment values are inherited in memory, never serialized in the job
|
|
238
|
+
// specification or receipt (which may otherwise expose credentials).
|
|
239
|
+
assert.equal(JSON.stringify(receipt).includes(sentinel), false);
|
|
240
|
+
const spec = path.join(jobDirectory(f.project, started.jobId), `${started.jobId}-command.json`);
|
|
241
|
+
if (fs.existsSync(spec)) assert.equal(fs.readFileSync(spec, "utf8").includes(sentinel), false);
|
|
242
|
+
} finally {
|
|
243
|
+
if (previous === undefined) delete process.env[key]; else process.env[key] = previous;
|
|
244
|
+
if (previousSentinel === undefined) delete process.env[sentinelKey]; else process.env[sentinelKey] = previousSentinel;
|
|
245
|
+
f.dispose();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a completed wrapper still waits for its real descendant and retains the wrapper exit code", { skip: process.platform !== "win32" }, async () => {
|
|
250
|
+
const f = fixture();
|
|
251
|
+
try {
|
|
252
|
+
const descendant = "setTimeout(()=>require('node:fs').writeFileSync('.nccgs/evidence/descendant-completed.txt','done'),1800)";
|
|
253
|
+
const wrapper = `const cp=require('node:child_process'),fs=require('node:fs');const p=cp.spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:'ignore',detached:true});fs.writeFileSync('.nccgs/evidence/wrapper-completed.txt','done');p.unref();process.exitCode=7;`;
|
|
254
|
+
const started = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", wrapper], requestKey: "descendant-drain" });
|
|
255
|
+
const marker = path.join(f.project, '.nccgs/evidence/wrapper-completed.txt');
|
|
256
|
+
const deadline = Date.now() + 10000;
|
|
257
|
+
while (!fs.existsSync(marker) && Date.now() < deadline) await delay(20);
|
|
258
|
+
assert.equal(fs.existsSync(marker), true);
|
|
259
|
+
assert.equal(jobStatus(f.project, started.jobId, "fixture").status, "RUNNING");
|
|
260
|
+
const result = waitVerification(f.project, started.jobId, "fixture", 15000);
|
|
261
|
+
assert.equal(result.status, "FAIL");
|
|
262
|
+
assert.equal(json(path.join(f.project, result.evidence.path)).exitCode, 7);
|
|
263
|
+
assert.equal(fs.readFileSync(path.join(f.project, '.nccgs/evidence/descendant-completed.txt'), 'utf8'), 'done');
|
|
264
|
+
} finally { f.dispose(); }
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("orphan verification becomes a retained failed attempt without rerunning the command", () => {
|
|
268
|
+
const f = fixture();
|
|
269
|
+
try {
|
|
270
|
+
const jobId = crypto.randomUUID(), directory = jobDirectory(f.project, jobId);
|
|
271
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
272
|
+
atomicJson(path.join(directory, "request.json"), { schemaVersion: 1, id: jobId, project: fs.realpathSync(f.project), taskId: "fixture", name: "test", queuedAt: new Date().toISOString(), snapshot: "unknown", executable: "unlaunched", arguments: [] });
|
|
273
|
+
atomicJson(path.join(directory, "owner.lock"), { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan", phase: "owned", child: null });
|
|
274
|
+
const failed = jobStatus(f.project, jobId, "fixture");
|
|
275
|
+
assert.equal(failed.status, "FAIL");
|
|
276
|
+
assert.equal(json(path.join(f.project, failed.evidence.path)).exitCode, null);
|
|
277
|
+
assert.match(json(path.join(f.project, failed.evidence.path)).error, /without a terminal receipt/);
|
|
278
|
+
assert.deepEqual(jobStatus(f.project, jobId, "fixture").evidence, failed.evidence);
|
|
279
|
+
} finally { f.dispose(); }
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("Unity compiler shutdown verifies exact pipe ownership, qualified binaries and bounded acknowledgement", { skip: process.platform !== "win32" }, () => {
|
|
283
|
+
const f = fixture();
|
|
284
|
+
try {
|
|
285
|
+
const binary = path.join(f.project, '.nccgs/compiler-shutdown-fixture.exe');
|
|
286
|
+
const compiler = path.join(process.env.SystemRoot || 'C:/Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe');
|
|
287
|
+
const built = spawnSync(compiler, ['/nologo', '/target:exe', `/out:${binary}`,
|
|
288
|
+
path.join(root, '.claude/nccgs/tools/windows-compiler-server.cs'),
|
|
289
|
+
path.join(root, 'tests/fixtures/compiler-shutdown.cs')], { encoding: 'utf8', windowsHide: true, timeout: 30000 });
|
|
290
|
+
assert.equal(built.status, 0, built.stdout + built.stderr);
|
|
291
|
+
const result = spawnSync(binary, [path.join(f.project, '.nccgs/fake-install')], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
|
|
292
|
+
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
293
|
+
assert.match(result.stdout, /compiler shutdown guards PASS/);
|
|
294
|
+
} finally { f.dispose(); }
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// The fake native executable below is explicitly a fixture, never Claude evidence.
|
|
298
|
+
function fakeNative(f, { missingUsage = false, omitReport = false } = {}) {
|
|
299
|
+
return f.write(".nccgs/fake-native.mjs", `
|
|
300
|
+
import fs from 'node:fs';
|
|
301
|
+
import {run} from '../.claude/nccgs/tools/task.mjs';
|
|
302
|
+
const args=process.argv.slice(2), resumed=args.includes('--resume');
|
|
303
|
+
fs.appendFileSync('.nccgs/fixture-argv.jsonl', JSON.stringify(args)+'\\n');
|
|
304
|
+
const session=args[args.indexOf(resumed?'--resume':'--session-id')+1];
|
|
305
|
+
if(!resumed) run(['verify','--id','fixture','--name','test','--background','--request-key','native-once','--',process.execPath,'-e',"setTimeout(()=>console.log('fixture command done'),800)"]);
|
|
306
|
+
else if(${!omitReport}) run(['report','--id','fixture','--outcome','partial','--summary','Fixture native continuation read the managed receipt; no game acceptance']);
|
|
307
|
+
console.log(JSON.stringify({type:'result',session_id:session,total_cost_usd:${missingUsage ? "null" : "resumed?0.6:0.4"},result:'Synthetic native result; no actual model execution'}));
|
|
308
|
+
`);
|
|
309
|
+
}
|
|
310
|
+
test("supervisor clamps real launch arguments to work allowance and retains the final review reserve", async () => {
|
|
311
|
+
const f = fixture();
|
|
312
|
+
try {
|
|
313
|
+
f.write('.nccgs/evidence/budget-request.json', { schemaVersion: 1, authority: 'Synthetic test only', totalUsd: 1, finalReviewUsd: 0.4, totalDurationMs: 60000, finalReviewDurationMs: 10000 });
|
|
314
|
+
registerReviewBudget(f.project, 'fixture', '.nccgs/evidence/budget-request.json');
|
|
315
|
+
const binary = fakeNative(f), prompt = f.write('.nccgs/prompt.txt', 'Synthetic fixture only');
|
|
316
|
+
assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
|
|
317
|
+
supervision: { taskId: 'fixture', promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000, budgetPhase: 'work' } }), 0);
|
|
318
|
+
const id = fs.readdirSync(path.join(f.project, '.nccgs/runs'), { withFileTypes: true }).find(e => e.isDirectory()).name;
|
|
319
|
+
const run = json(path.join(f.project, `.nccgs/runs/${id}/run.json`));
|
|
320
|
+
assert.equal(run.budgetUsd, 0.6); assert.equal(run.invocations[0].allowanceUsd, 0.6);
|
|
321
|
+
assert.ok(Math.abs(run.invocations[1].allowanceUsd - 0.2) < 1e-9); assert.equal(run.cumulativeCostUsd, 0.6);
|
|
322
|
+
assert.equal(reviewBudgetStatus(f.project, 'fixture').protectedReviewUsd, 0.4);
|
|
323
|
+
} finally { f.dispose(); }
|
|
324
|
+
});
|
|
325
|
+
test("final-review session blocks new engine jobs in both hook and direct lifecycle command", () => {
|
|
326
|
+
const f = fixture(), names = ['NCCGS_SUPERVISED_RUN','NCCGS_SUPERVISED_SESSION'], saved = names.map(n => process.env[n]);
|
|
327
|
+
try {
|
|
328
|
+
const id = crypto.randomUUID(), session = 'final-review-fixture';
|
|
329
|
+
f.write(`.nccgs/runs/${id}/run.json`, { sessionId: session, budgetPhase: 'final-review' });
|
|
330
|
+
f.write(`.nccgs/runtime/bindings/${session}.json`, { taskId: 'fixture' });
|
|
331
|
+
process.env.NCCGS_SUPERVISED_RUN = id; process.env.NCCGS_SUPERVISED_SESSION = session;
|
|
332
|
+
assert.throws(() => startVerification(f.project, 'fixture', { name: 'test', executable: process.execPath, arguments: ['-e','console.log(1)'] }), /Final-review reserve/);
|
|
333
|
+
assert.throws(() => guardTool(f.project, { session_id: session, tool_name: 'Bash', tool_input: { command: 'node .claude/nccgs/tools/task.mjs verify --id fixture --name test -- node helper.mjs' } }), /Final-review reserve/);
|
|
334
|
+
assert.throws(() => guardTool(f.project, { session_id: session, tool_name: 'Bash', tool_input: { command: 'node .claude/nccgs/tools/task.mjs budget-plan --id fixture --file .nccgs/evidence/budget.json' } }), /External decisions/);
|
|
335
|
+
} finally { names.forEach((n,i) => { if(saved[i]===undefined) delete process.env[n]; else process.env[n]=saved[i]; }); f.dispose(); }
|
|
336
|
+
});
|
|
337
|
+
test("supervisor waits outside the model, resumes the same session once, preserves cumulative budget and receipts", async () => {
|
|
338
|
+
const f = fixture();
|
|
339
|
+
try {
|
|
340
|
+
const binary = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
|
|
341
|
+
const plan = { project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
|
|
342
|
+
supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } };
|
|
343
|
+
assert.equal(await supervise(plan), 0);
|
|
344
|
+
const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
|
|
345
|
+
const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
|
|
346
|
+
assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 2);
|
|
347
|
+
assert.equal(state.cumulativeCostUsd, 0.6); assert.equal(state.invocations[1].allowanceUsd, 0.6);
|
|
348
|
+
assert.equal(state.invocations[0].terminal.session_id, state.invocations[1].terminal.session_id);
|
|
349
|
+
assert.equal(fs.readdirSync(path.join(f.project, ".nccgs/jobs")).length, 1);
|
|
350
|
+
// R08: the actual spawned provider argv keeps the owned prefix, strict flag before
|
|
351
|
+
// all other options, and one config ended by the supervisor's own lifecycle flags.
|
|
352
|
+
const argv = fs.readFileSync(path.join(f.project, ".nccgs/fixture-argv.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line));
|
|
353
|
+
assert.equal(argv.length, 2);
|
|
354
|
+
for (const [index, args] of argv.entries()) {
|
|
355
|
+
assert.deepEqual(args.slice(0, 11), ["--permission-mode", "bypassPermissions", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
356
|
+
"-p", "--output-format", "stream-json", "--verbose", "--max-budget-usd", index ? "0.6" : "1"]);
|
|
357
|
+
assert.deepEqual(args.slice(11), [index ? "--resume" : "--session-id", state.sessionId]);
|
|
358
|
+
}
|
|
359
|
+
assert.equal(await supervise({ ...plan, supervision: { recoverRun: runId } }), 0);
|
|
360
|
+
assert.equal(json(path.join(f.project, `.nccgs/runs/${runId}/run.json`)).invocations.length, 2);
|
|
361
|
+
} finally { f.dispose(); }
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("supervisor can settle a plan without granting source writes or shell checks", async () => {
|
|
365
|
+
const f = fixture(5000, "plan");
|
|
366
|
+
try {
|
|
367
|
+
const binary = f.write(".nccgs/plan-native.mjs", `import {run} from '../.claude/nccgs/tools/task.mjs';
|
|
368
|
+
const args=process.argv.slice(2);const session=args[args.indexOf('--session-id')+1];
|
|
369
|
+
run(['report','--id','fixture','--outcome','partial','--summary','Synthetic plan report; document review pending']);
|
|
370
|
+
console.log(JSON.stringify({type:'result',session_id:session,total_cost_usd:0.1,result:'Synthetic, not real Claude'}));`);
|
|
371
|
+
const prompt = f.write(".nccgs/prompt.txt", "Synthetic plan supervision");
|
|
372
|
+
assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
|
|
373
|
+
supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 0, timeoutMs: 20000 } }), 0);
|
|
374
|
+
const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
|
|
375
|
+
const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
|
|
376
|
+
assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 1);
|
|
377
|
+
assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Write", tool_input: { file_path: "Assets/source.cs" } }), /cannot edit source/);
|
|
378
|
+
assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Bash", tool_input: { command: "node helper.mjs" } }), /shell execution is blocked/);
|
|
379
|
+
} finally { f.dispose(); }
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("supervisor retains unknown usage and refuses a renewed budget", async () => {
|
|
383
|
+
const f = fixture();
|
|
384
|
+
try {
|
|
385
|
+
const binary = fakeNative(f, { missingUsage: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
|
|
386
|
+
assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
|
|
387
|
+
supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 2, timeoutMs: 20000 } }), 1);
|
|
388
|
+
const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
|
|
389
|
+
const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
|
|
390
|
+
assert.equal(state.status, "blocked"); assert.equal(state.invocations.length, 1); assert.match(state.reason, /cost/);
|
|
391
|
+
const jobId = fs.readdirSync(path.join(f.project, ".nccgs/jobs"))[0];
|
|
392
|
+
const verified = waitVerification(f.project, jobId, "fixture", 10000);
|
|
393
|
+
assert.equal(verified.status, "PASS", JSON.stringify(verified.evidence ? json(path.join(f.project, verified.evidence.path)) : verified));
|
|
394
|
+
} finally { f.dispose(); }
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("cumulative native cost rejects double counting, missing and mismatched sessions", () => {
|
|
398
|
+
assert.equal(cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.6 }, "s"), 0.6);
|
|
399
|
+
assert.throws(() => cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.2 }, "s"), /decreasing/);
|
|
400
|
+
assert.throws(() => cumulativeCost(0, { type: "result", session_id: "wrong", total_cost_usd: 0.2 }, "s"), /correlated/);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("supervisor stops at the pinned continuation limit without resetting the attempt", async () => {
|
|
404
|
+
const f = fixture();
|
|
405
|
+
try {
|
|
406
|
+
const executable = fakeNative(f, { omitReport: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
|
|
407
|
+
assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, executable, { env: f.env }),
|
|
408
|
+
supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } }), 1);
|
|
409
|
+
const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
|
|
410
|
+
const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
|
|
411
|
+
assert.equal(state.invocations.length, 2); assert.match(state.reason, /Continuation limit/);
|
|
412
|
+
assert.equal(state.cumulativeCostUsd, 0.6); assert.ok(state.checkpoint);
|
|
413
|
+
} finally { f.dispose(); }
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("supervisor refuses a fresh native invocation after the session allowance is spent", async () => {
|
|
417
|
+
const f = fixture();
|
|
418
|
+
try {
|
|
419
|
+
const executable = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
|
|
420
|
+
assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, executable, { env: f.env }),
|
|
421
|
+
supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 0.4, maxResumes: 2, timeoutMs: 20000 } }), 1);
|
|
422
|
+
const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
|
|
423
|
+
const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
|
|
424
|
+
assert.equal(state.invocations.length, 1); assert.equal(state.cumulativeCostUsd, 0.4);
|
|
425
|
+
assert.match(state.reason, /budget exhausted/); assert.equal(state.costCoverage, "complete");
|
|
426
|
+
} finally { f.dispose(); }
|
|
427
|
+
});
|