paperlint 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CONTRIBUTING.md +16 -4
  2. package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
  3. package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
  4. package/dist/eslint-rules/js-needs-reason.js +35 -0
  5. package/dist/eslint-rules/js-needs-reason.js.map +1 -0
  6. package/dist/package-dirs.d.ts +6 -0
  7. package/dist/package-dirs.d.ts.map +1 -1
  8. package/dist/package-dirs.js +6 -0
  9. package/dist/package-dirs.js.map +1 -1
  10. package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
  11. package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
  12. package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
  13. package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
  14. package/docs/skills.md +2 -0
  15. package/docs/testing.md +13 -0
  16. package/eslint-rules/js-needs-reason.test.ts +141 -0
  17. package/eslint-rules/js-needs-reason.ts +77 -0
  18. package/eslint.config.mjs +22 -2
  19. package/lib/skill-trigger-cases.test.ts +211 -0
  20. package/package.json +8 -4
  21. package/scripts/coverage-config.test.ts +5 -3
  22. package/skills/build-benchmark/SKILL.md +4 -1
  23. package/skills/build-benchmark/SKILL.md.spec.ts +3 -0
  24. package/skills/build-label-page/SKILL.md +3 -1
  25. package/skills/build-label-page/SKILL.md.spec.ts +2 -0
  26. package/skills/label-agent-runs/SKILL.md +103 -0
  27. package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
  28. package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
  29. package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
  30. package/skills/label-agent-runs/references/formats.md +64 -0
  31. package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
  32. package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
  33. package/skills/label-agent-runs/scripts/labels.js +396 -0
  34. package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
  35. package/skills/label-agent-runs/scripts/labels.ts +501 -0
  36. package/skills/paper-pipeline/SKILL.md +8 -1
  37. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -0
  38. package/skills/run-agent-grid/SKILL.md +181 -0
  39. package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
  40. package/skills/run-agent-grid/references/failure-catalog.md +36 -0
  41. package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
  42. package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
  43. package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
  44. package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
  45. package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
  46. package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
  47. package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
  48. package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
  49. package/src/package-dirs.ts +8 -0
  50. package/tsconfig.base.json +22 -0
  51. package/tsconfig.json +6 -24
  52. package/tsconfig.pkg.json +3 -1
  53. package/tsconfig.skills.json +14 -0
  54. package/tsconfig.src.json +12 -0
@@ -0,0 +1,700 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * grid-guard — the checks of the run-agent-grid skill that a driver or a commit hook calls, so
4
+ * that a rule of the skill is a refusal and not a sentence.
5
+ *
6
+ * lock <root> --owner <id> [--pid <pid>] [--attached]
7
+ * take the lock on a working-copy root; the holder
8
+ * must be detached (its own session) unless --attached
9
+ * release <root> --owner <id> give it back
10
+ * gate --registry <f> --ledger <f> --unit <u> --arm <a> --model <m> --seed <s>
11
+ * --attempt <id> [--reserve-sec <n>] may this cell run now? If so it is RESERVED for
12
+ * <id> in the ledger, atomically, until the attempt's
13
+ * end record or n seconds (default 6 h)
14
+ * overlap <ledger> attempts that shared a root at the same time
15
+ * env-probe [--allow <NAME>]... [--git] secret-named variables, and whether git still works
16
+ * scan-keys <path>... key-shaped strings in files on disk
17
+ * precommit [--runs <dir>] [--done <file>] the same scan over the staged files, and staged run
18
+ * folders that have no completion record
19
+ *
20
+ * EXIT CODES — a driver branches on them:
21
+ * 0 allowed, clean, done 1 refused, or a finding 2 usage
22
+ * 3 (gate) the cell is already counted: skip it, do not run it again
23
+ *
24
+ * TypeScript, compiled beside itself to `grid-guard.js` by `npm run build`: a consumer runs the
25
+ * skill from `node_modules`, where Node does not strip types. The skill names the `.js`.
26
+ *
27
+ * A value that looks like a key is NEVER printed, not even in part: the output of this script
28
+ * goes into logs and transcripts, which are exactly where a leaked key gets committed.
29
+ */
30
+ import { spawnSync } from "node:child_process";
31
+ import { appendFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
32
+ import { hostname, tmpdir } from "node:os";
33
+ import { dirname, join, relative, resolve, sep } from "node:path";
34
+ import { parseArgs } from "node:util";
35
+ import { z } from "zod";
36
+ import { COMMANDS } from "./grid-guard-commands.js";
37
+ class Refusal extends Error {
38
+ exitCode;
39
+ constructor(exitCode, message) {
40
+ super(message);
41
+ this.exitCode = exitCode;
42
+ }
43
+ }
44
+ const refuse = (exitCode, message) => {
45
+ throw new Refusal(exitCode, message);
46
+ };
47
+ const say = (line) => {
48
+ process.stdout.write(`${line}\n`);
49
+ };
50
+ /** A property of a thrown value, whatever was thrown. */
51
+ const prop = (e, key) => Reflect.get(Object(e), key);
52
+ // ── lock / release ──────────────────────────────────────────────────────────────────────────
53
+ // The lock sits BESIDE the root, not inside it: a driver that deletes the root's tree must not
54
+ // delete the lock that was supposed to stop it.
55
+ const Lock = z.object({
56
+ owner: z.string(),
57
+ pid: z.number(),
58
+ host: z.string(),
59
+ at: z.string(),
60
+ });
61
+ const lockPath = (root) => `${resolve(root)}.lock`;
62
+ const readLock = (path) => Lock.parse(JSON.parse(readFileSync(path, "utf8")));
63
+ /** Is `pid` a live process on this host? EPERM means it exists and belongs to someone else. */
64
+ const alive = (pid) => {
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ }
69
+ catch (e) {
70
+ return prop(e, "code") !== "ESRCH";
71
+ }
72
+ };
73
+ /** Create the lock file; false when someone holds it already. */
74
+ function take(path, me) {
75
+ try {
76
+ writeFileSync(path, `${JSON.stringify(me)}\n`, { flag: "wx" });
77
+ return true;
78
+ }
79
+ catch (e) {
80
+ if (prop(e, "code") === "EEXIST")
81
+ return false;
82
+ throw e;
83
+ }
84
+ }
85
+ // ── detached? ───────────────────────────────────────────────────────────────────────────────
86
+ // A driver started as a task the agent session tracks dies with the session (its time cap). Only
87
+ // a process in a session of its own survives that: `setsid` makes it the session leader, so its
88
+ // session id equals its pid. `GRID_GUARD_PROC` stands in for `/proc` in tests.
89
+ const procRoot = () => process.env.GRID_GUARD_PROC ?? "/proc";
90
+ /** The session id of `pid` from `/proc/<pid>/stat` (field 6), or null where that cannot be read. */
91
+ function sessionOf(pid) {
92
+ const stat = join(procRoot(), String(pid), "stat");
93
+ if (!existsSync(stat))
94
+ return null;
95
+ // The command name (field 2) is in parentheses and may hold spaces or parentheses itself.
96
+ const text = readFileSync(stat, "utf8");
97
+ const after = text.slice(text.lastIndexOf(")") + 2);
98
+ return Number(after.split(" ")[3]);
99
+ }
100
+ /**
101
+ * Why `pid` may not hold a grid lock, or null. Liveness is asked of the kernel (`kill(pid, 0)`),
102
+ * which every platform answers; only the session check needs `/proc`, and where it cannot be read
103
+ * (macOS, a hidden `/proc`) it warns and passes.
104
+ */
105
+ function detachProblem(pid) {
106
+ if (!alive(pid))
107
+ return `pid ${String(pid)} is not a live process`;
108
+ const sid = sessionOf(pid);
109
+ if (sid === null) {
110
+ process.stderr.write(`grid-guard: warning: cannot read ${join(procRoot(), String(pid), "stat")} here, so whether pid ${String(pid)} is detached is not checked\n`);
111
+ return null;
112
+ }
113
+ return sid === pid
114
+ ? null
115
+ : `pid ${String(pid)} is not detached (it runs in session ${String(sid)}), so it dies with the ` +
116
+ `session that started it. Start the driver as \`setsid nohup <driver> > driver.log 2>&1 &\` ` +
117
+ `and lock with --pid $$ inside it, or pass --attached for a short run.`;
118
+ }
119
+ function lock(root, owner, pid, attached) {
120
+ const path = lockPath(root);
121
+ const me = {
122
+ owner,
123
+ pid: Number(pid ?? process.ppid),
124
+ host: hostname(),
125
+ at: new Date().toISOString(),
126
+ };
127
+ const notDetached = attached ? null : detachProblem(me.pid);
128
+ if (notDetached !== null)
129
+ refuse(1, notDetached);
130
+ if (take(path, me)) {
131
+ say(`locked ${path} for ${me.owner} (pid ${String(me.pid)})`);
132
+ return;
133
+ }
134
+ const held = readLock(path);
135
+ if (takeOverStale(path, me, held))
136
+ return;
137
+ refuse(1, `${path} is held by ${held.owner} (pid ${String(held.pid)} on ${held.host} since ${held.at}). ` +
138
+ `Wait for it, or give this driver its own working-copy root.`);
139
+ }
140
+ /**
141
+ * Take the lock from a holder that is provably gone: same host, and its process is dead. A lock
142
+ * from another host cannot be judged from here, so it is never taken over.
143
+ */
144
+ function takeOverStale(path, me, held) {
145
+ const stale = held.host === me.host && !alive(held.pid);
146
+ if (stale)
147
+ rmSync(path, { force: true });
148
+ if (!(stale && take(path, me)))
149
+ return false;
150
+ say(`took over a stale lock on ${path} (holder ${held.owner}, pid ${String(held.pid)}, is gone)`);
151
+ return true;
152
+ }
153
+ function release(root, owner) {
154
+ const path = lockPath(root);
155
+ if (!existsSync(path))
156
+ refuse(1, `${path} is not held`);
157
+ const held = readLock(path);
158
+ if (held.owner !== owner)
159
+ refuse(1, `${path} is held by ${held.owner}, not ${owner}`);
160
+ rmSync(path);
161
+ say(`released ${path}`);
162
+ }
163
+ // ── the attempt ledger ──────────────────────────────────────────────────────────────────────
164
+ const Seed = z.union([z.string(), z.number()]);
165
+ /**
166
+ * One line of the ledger. A driver appends one line when an attempt starts and one when it
167
+ * ends, both carrying the same `attempt` id; later lines add to earlier ones, so a crashed
168
+ * attempt is still on record — with a start and no end.
169
+ */
170
+ const Attempt = z.looseObject({
171
+ attempt: z.string(),
172
+ agent: z.string().optional(),
173
+ unit: z.string().optional(),
174
+ arm: z.string().optional(),
175
+ model: z.string().optional(),
176
+ seed: Seed.optional(),
177
+ root: z.string().optional(),
178
+ start: z.string().optional(),
179
+ end: z.string().optional(),
180
+ status: z.string().optional(),
181
+ cost: z.number().optional(),
182
+ expires: z.string().optional(),
183
+ });
184
+ /** A `reserved` record whose time is up: no longer holds its cell, no longer counts as spent. */
185
+ const lapsed = (r, now) => r.status === "reserved" && Date.parse(String(r.expires)) <= now;
186
+ function readLedger(path) {
187
+ if (!existsSync(path))
188
+ return [];
189
+ const byId = new Map();
190
+ readFileSync(path, "utf8")
191
+ .split("\n")
192
+ .filter((line) => line.trim())
193
+ .map((line) => Attempt.parse(JSON.parse(line)))
194
+ .forEach((rec) => {
195
+ byId.set(rec.attempt, { ...byId.get(rec.attempt), ...rec });
196
+ });
197
+ return [...byId.values()];
198
+ }
199
+ const sameCell = (a, b) => a.unit === b.unit &&
200
+ a.arm === b.arm &&
201
+ a.model === b.model &&
202
+ String(a.seed) === b.seed;
203
+ // ── gate ────────────────────────────────────────────────────────────────────────────────────
204
+ const Unit = z.looseObject({
205
+ id: z.string(),
206
+ role: z.string().optional(),
207
+ reason: z.string().optional(),
208
+ approval: z
209
+ .looseObject({
210
+ by: z.string().optional(),
211
+ at: z.string().optional(),
212
+ channel: z.string().optional(),
213
+ })
214
+ .optional(),
215
+ proof: z
216
+ .looseObject({
217
+ toolVisible: z.unknown().optional(),
218
+ firesOnPinned: z.unknown().optional(),
219
+ acceptsReference: z.unknown().optional(),
220
+ })
221
+ .optional(),
222
+ });
223
+ const Model = z.looseObject({
224
+ id: z.string(),
225
+ smoke: z
226
+ .looseObject({
227
+ timeoutSec: z.unknown().optional(),
228
+ costPerRun: z.unknown().optional(),
229
+ })
230
+ .optional(),
231
+ });
232
+ const Registry = z.looseObject({
233
+ author: z.string().optional(),
234
+ draw: z.looseObject({ seed: Seed.optional() }).optional(),
235
+ arms: z.array(z.string()).default([]),
236
+ seeds: z.array(Seed).default([]),
237
+ budget: z.looseObject({ cap: z.unknown().optional() }).optional(),
238
+ models: z.array(Model).default([]),
239
+ units: z.array(Unit).default([]),
240
+ });
241
+ /** The registry as COMMITTED: a declaration that exists only in the working tree allows nothing. */
242
+ function committedRegistry(file) {
243
+ const abs = resolve(file);
244
+ const git = (...args) => spawnSync("git", ["-C", dirname(abs), ...args], { encoding: "utf8" });
245
+ const top = git("rev-parse", "--show-toplevel");
246
+ if (top.status !== 0)
247
+ refuse(1, `${file} is not inside a git repository; the registry must be committed`);
248
+ const rel = relative(top.stdout.trim(), abs).split(sep).join("/");
249
+ const head = git("show", `HEAD:${rel}`);
250
+ if (head.status !== 0)
251
+ refuse(1, `${file} is not committed; commit it before any run it allows`);
252
+ if (head.stdout !== readFileSync(abs, "utf8"))
253
+ refuse(1, `${file} differs from its committed version; commit the change (a dated addendum) before any run it allows`);
254
+ return Registry.parse(JSON.parse(head.stdout));
255
+ }
256
+ const filled = (v) => typeof v === "string" && v.trim() !== "";
257
+ const num = (v) => (typeof v === "number" ? v : null);
258
+ /** Everything the gate decides from, computed once. */
259
+ function facts(reg, cell, ledgerPath) {
260
+ const unit = reg.units.find((u) => u.id === cell.unit);
261
+ const model = reg.models.find((m) => m.id === cell.model);
262
+ const timeoutSec = num(model?.smoke?.timeoutSec);
263
+ const costPerRun = num(model?.smoke?.costPerRun);
264
+ const smoked = timeoutSec !== null && costPerRun !== null;
265
+ const counted = unit?.role === "counted";
266
+ const ledger = readLedger(ledgerPath);
267
+ const now = Date.now();
268
+ const spent = ledger
269
+ .filter((r) => !lapsed(r, now))
270
+ .reduce((sum, r) => sum + (r.cost ?? 0), 0);
271
+ const cap = num(reg.budget?.cap);
272
+ const next = counted && costPerRun !== null ? costPerRun : 0;
273
+ return {
274
+ now,
275
+ unit,
276
+ model,
277
+ smoked,
278
+ counted,
279
+ ledger,
280
+ spent,
281
+ cap,
282
+ next,
283
+ timeoutSec,
284
+ };
285
+ }
286
+ /** Is the cell declared at all? */
287
+ function declared(reg, cell, f) {
288
+ const role = String(f.unit?.role);
289
+ const why = filled(f.unit?.reason) ? ` (${String(f.unit?.reason)})` : "";
290
+ return [
291
+ [!f.unit, `unit "${cell.unit}" is not in the registry`],
292
+ [
293
+ f.unit !== undefined && !["counted", "smoke"].includes(role),
294
+ `unit "${cell.unit}" has role "${role}"${why}; only "counted" and "smoke" units run`,
295
+ ],
296
+ [!reg.arms.includes(cell.arm), `arm "${cell.arm}" is not declared`],
297
+ [!f.model, `model "${cell.model}" is not declared`],
298
+ [
299
+ !reg.seeds.map(String).includes(cell.seed),
300
+ `seed "${cell.seed}" is not declared`,
301
+ ],
302
+ [
303
+ reg.units.some((u) => u.role === "excluded" && !filled(u.reason)),
304
+ `an excluded unit has no reason`,
305
+ ],
306
+ [f.cap === null, `the registry declares no budget.cap`],
307
+ ];
308
+ }
309
+ /** What a COUNTED run needs on top: approval, proof, a draw, a smoked model. */
310
+ function countedNeeds(reg, cell, f) {
311
+ const a = f.unit?.approval ?? {};
312
+ const p = f.unit?.proof ?? {};
313
+ const approved = filled(reg.author) &&
314
+ a.by === reg.author &&
315
+ filled(a.at) &&
316
+ filled(a.channel);
317
+ const proven = [p.toolVisible, p.firesOnPinned, p.acceptsReference].every((v) => v === true);
318
+ return [
319
+ [
320
+ !approved,
321
+ `unit "${cell.unit}" has no approval by the author (approval.by = author, with at and channel)`,
322
+ ],
323
+ [
324
+ !proven,
325
+ `unit "${cell.unit}" has no sandbox proof (toolVisible, firesOnPinned, acceptsReference)`,
326
+ ],
327
+ [reg.draw?.seed === undefined, `the registry records no draw seed`],
328
+ [
329
+ f.model !== undefined && !f.smoked,
330
+ `model "${cell.model}" has no smoke result (smoke.timeoutSec, smoke.costPerRun)`,
331
+ ],
332
+ ];
333
+ }
334
+ /** Every reason this cell may not run, in a fixed order. */
335
+ function problems(reg, cell, f) {
336
+ const over = f.cap !== null && f.spent + f.next > f.cap;
337
+ const checks = [
338
+ ...declared(reg, cell, f),
339
+ ...(f.counted ? countedNeeds(reg, cell, f) : []),
340
+ [
341
+ over,
342
+ `budget: spent ${String(f.spent)} + next ${String(f.next)} exceeds the cap ${String(f.cap)}`,
343
+ ],
344
+ ];
345
+ return checks.filter(([bad]) => bad).map(([, message]) => message);
346
+ }
347
+ /** Who already holds this cell: a counted attempt (exit 3), a live reservation (exit 3), or nobody. */
348
+ function holder(f, cell) {
349
+ const mine = f.ledger.filter((r) => sameCell(r, cell));
350
+ if (mine.some((r) => r.status === "complete"))
351
+ return `${JSON.stringify(cell)} is already counted; skip it`;
352
+ const held = mine.find((r) => r.status === "reserved" && !lapsed(r, f.now));
353
+ return held
354
+ ? `${JSON.stringify(cell)} is reserved by attempt ${held.attempt} until ${String(held.expires)}; skip it`
355
+ : null;
356
+ }
357
+ /**
358
+ * May this cell run now — and if so, CLAIM it. Read, check and append happen under the ledger's
359
+ * lock, so two drivers asking at once cannot both get one cell, nor both fit under the cap: the
360
+ * `reserved` record holds the cell and counts its estimated cost until the driver's end record for
361
+ * the same attempt replaces it, or until it lapses.
362
+ */
363
+ /** The cell a gate call names. */
364
+ const cellOf = (o) => ({
365
+ unit: String(o.unit),
366
+ arm: String(o.arm),
367
+ model: String(o.model),
368
+ seed: String(o.seed),
369
+ });
370
+ function gate(o) {
371
+ const reg = committedRegistry(String(o.registry));
372
+ const cell = cellOf(o);
373
+ const ledgerPath = String(o.ledger);
374
+ const attempt = String(o.attempt);
375
+ withLedgerLock(ledgerPath, () => {
376
+ const f = facts(reg, cell, ledgerPath);
377
+ const found = problems(reg, cell, f);
378
+ if (found.length)
379
+ refuse(1, `refused ${JSON.stringify(cell)}:\n - ${found.join("\n - ")}`);
380
+ const taken = holder(f, cell);
381
+ if (taken !== null)
382
+ refuse(3, taken);
383
+ if (f.ledger.some((r) => r.attempt === attempt))
384
+ refuse(1, `attempt ${attempt} is already on the ledger; give each attempt its own id`);
385
+ reserve(ledgerPath, {
386
+ attempt,
387
+ cell,
388
+ f,
389
+ seconds: Number(o["reserve-sec"]),
390
+ });
391
+ });
392
+ }
393
+ /** Append the cell's reservation for `attempt`, and report the gate's answer. */
394
+ function reserve(ledgerPath, r) {
395
+ const record = {
396
+ attempt: r.attempt,
397
+ ...r.cell,
398
+ status: "reserved",
399
+ cost: r.f.next,
400
+ start: new Date(r.f.now).toISOString(),
401
+ expires: new Date(r.f.now + r.seconds * 1000).toISOString(),
402
+ };
403
+ appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`);
404
+ say(JSON.stringify({
405
+ allowed: true,
406
+ cell: r.cell,
407
+ attempt: r.attempt,
408
+ counted: r.f.counted,
409
+ timeoutSec: r.f.smoked ? r.f.timeoutSec : null,
410
+ spent: r.f.spent,
411
+ }));
412
+ }
413
+ // ── the ledger's lock ───────────────────────────────────────────────────────────────────────
414
+ /** How long a gate waits for another gate's turn. `GRID_GUARD_LEDGER_WAIT_MS` shortens it in tests. */
415
+ const ledgerWaitMs = () => Number(process.env.GRID_GUARD_LEDGER_WAIT_MS ?? 10_000);
416
+ /** Sleep without spinning: the process blocks until the time passes. */
417
+ const pause = (ms) => {
418
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
419
+ };
420
+ /**
421
+ * The ledger lock's owner, or null while it cannot be read — another gate may have created the
422
+ * file and not yet written it, so an unreadable lock is a held one.
423
+ */
424
+ const peekLock = (path) => {
425
+ try {
426
+ return readLock(path);
427
+ }
428
+ catch {
429
+ return null;
430
+ }
431
+ };
432
+ /** Take `<ledger>.lock`, waiting for a live holder and replacing a dead one on this host. */
433
+ function acquireLedger(path, deadline) {
434
+ const me = {
435
+ owner: "gate",
436
+ pid: process.pid,
437
+ host: hostname(),
438
+ at: new Date().toISOString(),
439
+ };
440
+ if (take(path, me))
441
+ return;
442
+ const held = peekLock(path);
443
+ if (held !== null && held.host === me.host && !alive(held.pid))
444
+ rmSync(path, { force: true });
445
+ else if (Date.now() >= deadline)
446
+ refuse(1, held === null
447
+ ? `${path} exists and names no owner; if no gate is running, delete it`
448
+ : `${path} is held by pid ${String(held.pid)} on ${held.host} since ${held.at}; another gate is writing the ledger — try again`);
449
+ else
450
+ pause(25);
451
+ acquireLedger(path, deadline);
452
+ }
453
+ function withLedgerLock(ledger, work) {
454
+ const path = `${resolve(ledger)}.lock`;
455
+ acquireLedger(path, Date.now() + ledgerWaitMs());
456
+ try {
457
+ work();
458
+ }
459
+ finally {
460
+ rmSync(path, { force: true });
461
+ }
462
+ }
463
+ /** Pairs of attempts on one root whose windows intersect, at least one of them counted. */
464
+ const sharedRoots = (recs) => recs.flatMap((a, i) => recs
465
+ .slice(i + 1)
466
+ .filter((b) => a.root === b.root &&
467
+ (a.status === "complete" || b.status === "complete") &&
468
+ a.from < b.to &&
469
+ b.from < a.to)
470
+ .map((b) => `${a.attempt} and ${b.attempt} shared ${a.root}`));
471
+ function overlap(file) {
472
+ if (!existsSync(file))
473
+ refuse(1, `no ledger at ${file}`);
474
+ const recs = readLedger(file).map((r) => ({
475
+ attempt: r.attempt,
476
+ status: r.status,
477
+ root: resolve(String(r.root)),
478
+ from: Date.parse(String(r.start)),
479
+ to: r.end === undefined ? Infinity : Date.parse(r.end),
480
+ }));
481
+ const findings = [
482
+ ...recs
483
+ .filter((r) => Number.isNaN(r.from) || Number.isNaN(r.to))
484
+ .map((r) => `${r.attempt}: start or end is not a date`),
485
+ ...sharedRoots(recs),
486
+ ];
487
+ if (findings.length)
488
+ refuse(1, `${findings.join("\n")}\nmark each contaminated attempt "harness-error" (keep it) and run the cell again`);
489
+ say(`no overlap among ${String(recs.length)} attempts`);
490
+ }
491
+ // ── env-probe ───────────────────────────────────────────────────────────────────────────────
492
+ const SECRET_NAME = /KEY|TOKEN|SECRET|PASSW|CREDENTIAL|COOKIE|SESSION|AUTH(?!OR)/i;
493
+ function envProbe(allow, withGit) {
494
+ const allowed = new Set(allow);
495
+ const names = Object.keys(process.env)
496
+ .filter((n) => SECRET_NAME.test(n) && !allowed.has(n))
497
+ .sort();
498
+ if (names.length)
499
+ refuse(1, `secret-named variables reach this environment (names only): ${names.join(", ")}`);
500
+ if (withGit)
501
+ gitProbe();
502
+ say(`no secret-named variables${withGit ? "; git init, add and commit work" : ""}`);
503
+ }
504
+ /** The policy that drops secrets must not drop what the agent's tools need: prove git commits. */
505
+ function gitProbe() {
506
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "grid-git-probe-")));
507
+ try {
508
+ writeFileSync(join(dir, "probe.txt"), "probe\n");
509
+ [
510
+ ["init", "-q"],
511
+ ["add", "probe.txt"],
512
+ ["commit", "-q", "-m", "probe"],
513
+ ].forEach((args) => {
514
+ const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" });
515
+ if (r.status !== 0)
516
+ refuse(1, `git ${String(args[0])} fails in this environment: ${(r.stderr || String(r.error)).trim()}`);
517
+ });
518
+ }
519
+ finally {
520
+ rmSync(dir, { recursive: true, force: true });
521
+ }
522
+ }
523
+ // ── scan-keys / precommit ───────────────────────────────────────────────────────────────────
524
+ // Shapes, not vendors: each names a FORM a credential takes. The last one catches an
525
+ // environment dump — NAME=value lines — which is how secrets got into transcripts.
526
+ const KEY_SHAPES = [
527
+ ["private key block", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
528
+ ["access key id", /\bAKIA[0-9A-Z]{16}\b/],
529
+ [
530
+ "git host token",
531
+ /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/,
532
+ ],
533
+ ["chat bot token", /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
534
+ ["cloud api key", /\bAIza[0-9A-Za-z_-]{35}\b/],
535
+ ["sk- api key", /\bsk-[A-Za-z0-9_-]{20,}/],
536
+ [
537
+ "secret-named assignment",
538
+ /\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSW(?:OR)?D)[A-Z0-9_]*["']?\s*[=:]\s*["']?(?![<$])[^\s"'<>]{12,}/,
539
+ ],
540
+ ];
541
+ /** `file:line: shape` for every key-shaped string in `text`; the value is never part of it. */
542
+ const scanText = (name, text) => text.includes("\0")
543
+ ? []
544
+ : text
545
+ .split("\n")
546
+ .flatMap((line, i) => KEY_SHAPES.filter(([, re]) => re.test(line)).map(([shape]) => `${name}:${String(i + 1)}: ${shape}`));
547
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
548
+ const walk = (path) => statSync(path).isDirectory()
549
+ ? readdirSync(path)
550
+ .filter((e) => !SKIP_DIRS.has(e))
551
+ .flatMap((e) => walk(join(path, e)))
552
+ : [path];
553
+ function reportKeys(findings, scanned) {
554
+ if (findings.length)
555
+ refuse(1, `key-shaped strings (values not shown):\n${findings.join("\n")}`);
556
+ say(`no key-shaped strings in ${String(scanned)} files`);
557
+ }
558
+ function scanKeys(paths) {
559
+ const files = paths.flatMap(walk);
560
+ reportKeys(files.flatMap((f) => scanText(f, readFileSync(f, "utf8"))), files.length);
561
+ }
562
+ /** Staged run folders under `runs` whose completion record `done` is not in the index. */
563
+ const halfWritten = (staged, index, runs, done) => [
564
+ ...new Set(staged
565
+ .filter((f) => f.startsWith(`${runs}/`))
566
+ .map((f) => f.slice(runs.length + 1).split("/"))
567
+ .filter((parts) => parts.length > 1)
568
+ .map((parts) => `${runs}/${String(parts[0])}`)),
569
+ ].filter((dir) => !index.has(`${dir}/${done}`));
570
+ /** stdout of a git command run where the caller stands; a failure is a refusal. */
571
+ const gitOut = (...args) => {
572
+ const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 1 << 28 });
573
+ if (r.status !== 0)
574
+ refuse(1, `git ${String(args[0])}: ${r.stderr.trim()}`);
575
+ return r.stdout;
576
+ };
577
+ const nulList = (s) => s.split("\0").filter(Boolean);
578
+ function precommit(runsOpt, done) {
579
+ const top = gitOut("rev-parse", "--show-toplevel").trim();
580
+ const cached = (...filter) => nulList(gitOut("-C", top, "diff", "--cached", "--name-only", "-z", ...filter));
581
+ // Content is scanned only where it exists in the index; a run folder is affected by any staged
582
+ // path in it, a DELETION included — deleting a completion record must not pass as finished.
583
+ const staged = cached("--diff-filter=ACMR");
584
+ const touched = cached();
585
+ const keys = staged.flatMap((f) => scanText(f, gitOut("-C", top, "show", `:${f}`)));
586
+ const index = new Set(nulList(gitOut("-C", top, "ls-files", "-z")));
587
+ const half = runsOpt
588
+ ? halfWritten(touched, index, runsOpt.replace(/\/+$/, ""), done)
589
+ : [];
590
+ if (half.length)
591
+ refuse(1, `run folders without ${done} are staged (commit only finished runs):\n${half.join("\n")}`);
592
+ reportKeys(keys, staged.length);
593
+ }
594
+ /**
595
+ * Each command: the options it requires, how many paths it takes (-1: one or more), what it does.
596
+ * Keyed by the names in `grid-guard-commands.ts`, all of them and nothing else.
597
+ */
598
+ const DISPATCH = {
599
+ lock: [
600
+ ["owner"],
601
+ 1,
602
+ (p, o) => {
603
+ lock(String(p[0]), String(o.owner), o.pid, o.attached);
604
+ },
605
+ ],
606
+ release: [
607
+ ["owner"],
608
+ 1,
609
+ (p, o) => {
610
+ release(String(p[0]), String(o.owner));
611
+ },
612
+ ],
613
+ gate: [
614
+ ["registry", "ledger", "unit", "arm", "model", "seed", "attempt"],
615
+ 0,
616
+ (_, o) => {
617
+ gate(o);
618
+ },
619
+ ],
620
+ overlap: [
621
+ [],
622
+ 1,
623
+ (p) => {
624
+ overlap(String(p[0]));
625
+ },
626
+ ],
627
+ "env-probe": [
628
+ [],
629
+ 0,
630
+ (_, o) => {
631
+ envProbe(o.allow, o.git);
632
+ },
633
+ ],
634
+ "scan-keys": [
635
+ [],
636
+ -1,
637
+ (p) => {
638
+ scanKeys(p);
639
+ },
640
+ ],
641
+ precommit: [
642
+ [],
643
+ 0,
644
+ (_, o) => {
645
+ precommit(o.runs, o.done);
646
+ },
647
+ ],
648
+ };
649
+ const BY_NAME = new Map(Object.entries(DISPATCH));
650
+ const USAGE = `usage: grid-guard.js <${Object.values(COMMANDS).join("|")}> … (see the header of this file)`;
651
+ /** The command line, parsed; an unknown option is a usage error. */
652
+ function parse() {
653
+ try {
654
+ const { values: v, positionals } = parseArgs({
655
+ allowPositionals: true,
656
+ strict: true,
657
+ options: {
658
+ owner: { type: "string" },
659
+ pid: { type: "string" },
660
+ registry: { type: "string" },
661
+ ledger: { type: "string" },
662
+ unit: { type: "string" },
663
+ arm: { type: "string" },
664
+ model: { type: "string" },
665
+ seed: { type: "string" },
666
+ attempt: { type: "string" },
667
+ "reserve-sec": { type: "string", default: "21600" },
668
+ allow: { type: "string", multiple: true, default: [] },
669
+ git: { type: "boolean", default: false },
670
+ attached: { type: "boolean", default: false },
671
+ runs: { type: "string" },
672
+ done: { type: "string", default: "done.json" },
673
+ },
674
+ });
675
+ return { positionals, opts: v };
676
+ }
677
+ catch (e) {
678
+ return refuse(2, `${String(prop(e, "message"))}\n${USAGE}`);
679
+ }
680
+ }
681
+ function main() {
682
+ const { positionals, opts } = parse();
683
+ const [name, ...paths] = positionals;
684
+ const command = BY_NAME.get(String(name));
685
+ if (!command)
686
+ return refuse(2, USAGE);
687
+ const [required, arity, run] = command;
688
+ const missing = required.filter((k) => opts[k] === undefined);
689
+ const badArity = arity === -1 ? paths.length === 0 : paths.length !== arity;
690
+ if (missing.length || badArity)
691
+ refuse(2, `${String(name)}: ${missing.length ? `needs --${missing.join(", --")}` : "wrong number of paths"}\n${USAGE}`);
692
+ run(paths, opts);
693
+ }
694
+ try {
695
+ main();
696
+ }
697
+ catch (e) {
698
+ process.stderr.write(`grid-guard: ${String(prop(e, "message"))}\n`);
699
+ process.exitCode = e instanceof Refusal ? e.exitCode : 1;
700
+ }