@codedology/code-forge 0.11.0 → 0.13.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 (50) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +30 -2
  3. package/docs/reference/blocks.json +359 -1
  4. package/docs/reference/config.md +7 -5
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +4 -2
  7. package/skill/SKILL.md +2 -0
  8. package/skill/references/code.md +19 -5
  9. package/skill/references/proof.md +8 -2
  10. package/skill/references/review.md +16 -7
  11. package/skill/references/rules-core.md +1 -1
  12. package/skill/scripts/forge +1 -1
  13. package/skill/templates/coder-brief.md +15 -3
  14. package/src/autopilot/actions.mjs +4 -5
  15. package/src/cli/block.mjs +41 -0
  16. package/src/cli/context.mjs +3 -2
  17. package/src/cli/gates.mjs +15 -6
  18. package/src/cli/review-file.mjs +114 -3
  19. package/src/cli/review.mjs +2 -2
  20. package/src/config/validate.mjs +12 -5
  21. package/src/context/pack.mjs +20 -13
  22. package/src/doctor/index.mjs +2 -1
  23. package/src/doctor/local.mjs +19 -0
  24. package/src/doctor/worker-probe.mjs +15 -2
  25. package/src/gates/expand.mjs +102 -9
  26. package/src/gates/run.mjs +6 -4
  27. package/src/install/wizard/flags.mjs +1 -1
  28. package/src/ledger/spend.mjs +3 -3
  29. package/src/proof/lock.mjs +27 -10
  30. package/src/proof/rule.mjs +9 -0
  31. package/src/proof/tiers.mjs +2 -14
  32. package/src/review/fixloop.mjs +11 -6
  33. package/src/review/gate-check.mjs +34 -9
  34. package/src/review/lenses/A.md +4 -0
  35. package/src/review/lenses/B.md +4 -0
  36. package/src/review/lenses/full.md +4 -0
  37. package/src/review/lenses/judge.md +4 -0
  38. package/src/review/lenses/quick.md +4 -0
  39. package/src/review/lenses/recheck.md +4 -0
  40. package/src/review/paired.mjs +1 -0
  41. package/src/review/reopen.mjs +18 -6
  42. package/src/review/schema-fallback.mjs +3 -3
  43. package/src/util/exec.mjs +2 -1
  44. package/src/util/file-locks.mjs +118 -0
  45. package/src/util/forbidden.mjs +29 -2
  46. package/src/util/gate-token.mjs +3 -0
  47. package/src/worker/engine.mjs +2 -2
  48. package/src/worker/loop.mjs +98 -33
  49. package/src/worker/queue.mjs +183 -33
  50. package/src/worker/watch.mjs +115 -0
@@ -5,6 +5,8 @@
5
5
  * review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
6
6
  * {ticket, status: pending, reason: wait_timeout}
7
7
  * when --max elapses (the review is still running)
8
+ * review-file --wait-block <id> --run <id> join owned changed files' current tickets;
9
+ * prints {block, files, done}; never reviews here
8
10
  * review-file <answering file> --block <id> --answers <ids> --of <source> [--run <id>]
9
11
  * B71: enqueue an answers ticket; prints {ticket,
10
12
  * status, kind: answers, file: source, by_file, answers}
@@ -67,20 +69,22 @@
67
69
 
68
70
  import { readFileSync, realpathSync, rmSync } from 'node:fs';
69
71
  import path from 'node:path';
70
- import { answersTicketId, assertAnswers, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
72
+ import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
71
73
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
72
- import { namedOnHash } from '../review/gate-check.mjs';
74
+ import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
73
75
  import { STOP_EXITS } from '../review/stop-exits.mjs';
74
76
  import { ownsFile } from '../state/registry.mjs';
75
77
  import { readAllRows } from '../ledger/write.mjs';
76
78
  import { parseFlags } from '../state/cli-args.mjs';
77
79
  import { StateError } from '../state/paths.mjs';
80
+ import { assertRunId } from '../state/paths.mjs';
78
81
  import { readRun } from '../state/run.mjs';
79
82
  import { blockTree } from '../proof/worktree.mjs';
80
83
  import { writeSafe } from '../util/redact.mjs';
81
84
 
82
85
  const USAGE =
83
86
  'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
87
+ ' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
84
88
  ' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
85
89
 
86
90
  export const DEFAULT_WAIT_MS = 90_000;
@@ -355,6 +359,106 @@ export function fixList(result) {
355
359
  return out;
356
360
  }
357
361
 
362
+ /**
363
+ * B100: read block scope from the workspace mirror, never the private run record. This is a
364
+ * progress summary, not an approval: block close still verifies the authoritative signed rows.
365
+ * @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
366
+ * @returns {Promise<{entry: Record<string, any>, tree: string}>}
367
+ */
368
+ async function waitBlockScope(repoRoot, cwd, run, block) {
369
+ const top = await repoRootOf(cwd);
370
+ const candidates = new Set([repoRoot]);
371
+ for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
372
+ candidates.add(dir);
373
+ if (dir === top || dir === path.dirname(dir)) break;
374
+ }
375
+ for (const dir of candidates) {
376
+ let record;
377
+ try {
378
+ record = JSON.parse(readFileSync(path.join(dir, '.code-forge', 'runs', `${run}.json`), 'utf8'));
379
+ } catch (err) {
380
+ if (err.code === 'ENOENT') continue;
381
+ throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
382
+ }
383
+ const entry = record?.blocks?.[block];
384
+ if (record?.run_id !== run || !entry || !Array.isArray(entry.owned_files) || entry.owned_files.length === 0) {
385
+ throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
386
+ }
387
+ const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
388
+ return { entry, tree };
389
+ }
390
+ throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
391
+ }
392
+
393
+ /** A block wait reads only the marker, never review results or the signing key.
394
+ * @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
395
+ */
396
+ function blockTicketMarker(repoRoot, ticket) {
397
+ try {
398
+ const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
399
+ if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return marker;
400
+ } catch (err) {
401
+ if (err.code === 'ENOENT') return null;
402
+ }
403
+ throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
404
+ }
405
+
406
+ /**
407
+ * A block wait reads only completion/delivery markers, never review results or the signing key.
408
+ * Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
409
+ * before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
410
+ * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
411
+ */
412
+ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
413
+ const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
414
+ const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
415
+ const deadline = Date.now() + maxMs;
416
+ let downPolls = 0;
417
+ const deliveredHere = new Set();
418
+ for (;;) {
419
+ const worker = liveWorker(repoRoot);
420
+ const live = worker?.run === run;
421
+ downPolls = live ? 0 : downPolls + 1;
422
+ const tickets = blockTickets({ repoRoot, run, block, ...where });
423
+ const current = await blockFileSet({ repoRoot: tree, base: entry.base_sha, owned: entry.owned_files, ownedKinds: entry.owned_kinds });
424
+ const files = current.map(({ file }) => {
425
+ const opts = { repoRoot, run, block, file, ...where };
426
+ const { ticket } = resolveTicket(opts);
427
+ const previous = tickets.filter((t) => t.file === file);
428
+ let present = previous.find((t) => t.ticket === ticket);
429
+ if (!present && previous.length > 0) {
430
+ const newest = previous[previous.length - 1];
431
+ const marker = blockTicketMarker(repoRoot, newest.ticket);
432
+ if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
433
+ if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
434
+ if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
435
+ deliveredHere.add(newest.ticket);
436
+ return { file, ticket: newest.ticket, status: 'findings' };
437
+ }
438
+ }
439
+ }
440
+ if (!present && previous.length > 0 && live) {
441
+ enqueue(opts);
442
+ present = { ...previous[previous.length - 1], ticket };
443
+ }
444
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketMarker(repoRoot, ticket) ? 'done' : 'pending' };
445
+ });
446
+ if (downPolls >= 2) {
447
+ say({ block, files, done: false, status: 'worker_down' });
448
+ return 3;
449
+ }
450
+ if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
451
+ say({ block, files, done: true });
452
+ return 0;
453
+ }
454
+ if (live && (files.some((f) => f.status === 'not_queued') || Date.now() >= deadline)) {
455
+ say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
456
+ return 0;
457
+ }
458
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
459
+ }
460
+ }
461
+
358
462
  /**
359
463
  * @param {string[]} args
360
464
  * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
@@ -366,10 +470,17 @@ export async function runReviewFile(args, deps = {}) {
366
470
  const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
367
471
  const cwd = deps.cwd ?? process.cwd();
368
472
  try {
369
- const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
473
+ const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
370
474
  // the queue and the worker: the main tree, also from inside a block's worktree
371
475
  const repoRoot = await mainRootOf(cwd);
372
476
 
477
+ if (flags['wait-block'] !== undefined) {
478
+ if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
479
+ throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
480
+ }
481
+ return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
482
+ }
483
+
373
484
  if (typeof flags.wait === 'string') {
374
485
  if (positionals.length > 0 || flags.block !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true) throw new WorkerError('usage', '--wait takes only a ticket and --max');
375
486
  const ticket = assertTicketId(flags.wait);
@@ -48,7 +48,7 @@ import { semaphore } from '../util/locks.mjs';
48
48
  import { writeSafe } from '../util/redact.mjs';
49
49
  import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
50
50
  import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
51
- import { readWorker } from '../worker/queue.mjs';
51
+ import { liveWorker } from '../worker/queue.mjs';
52
52
  import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
53
53
 
54
54
  const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
@@ -288,7 +288,7 @@ async function runWorkers(repoRoot, runId) {
288
288
  } catch {
289
289
  // no record: nothing pinned
290
290
  }
291
- const w = readWorker(repoRoot);
291
+ const w = liveWorker(repoRoot);
292
292
  if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
293
293
  found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
294
294
  }
@@ -66,7 +66,7 @@
66
66
  import { Ajv2020 } from 'ajv/dist/2020.js';
67
67
  import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
68
68
  import { redact } from '../util/redact.mjs';
69
- import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
69
+ import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
70
70
  import { checkEffort, displayEffort } from '../engines/efforts.mjs';
71
71
  import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
72
72
  import { isKnownId, resolveLevel } from './known-ids.mjs';
@@ -484,10 +484,10 @@ function gateMessages(key, value) {
484
484
  // full-suite run would be an empty argv
485
485
  /** @param {string[]} argv @returns {string[]} */
486
486
  const tokenMessages = (argv) =>
487
- argv.length === 1 && argv[0] === TEST_FILES_TOKEN
488
- ? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the only item (the full suite would run an empty command)`]
489
- : argv[0] === TEST_FILES_TOKEN
490
- ? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the command (argv[0])`]
487
+ argv.length === 1 && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
488
+ ? [`gates.${key}: '${argv[0]}' cannot be the only item (the full suite would run an empty command)`]
489
+ : [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
490
+ ? [`gates.${key}: '${argv[0]}' cannot be the command (argv[0])`]
491
491
  : [];
492
492
  if (typeof value !== 'object' || Array.isArray(value)) {
493
493
  if (!isArgvArray(value)) return [`gates.${key} must be a non-empty argv array of strings, an {argv, env} object, or null`];
@@ -1162,3 +1162,10 @@ export function validateConfig(cfg, opts = {}) {
1162
1162
  }
1163
1163
 
1164
1164
  export { schema as configSchema };
1165
+
1166
+ /** The effective file loop for a validated config, including opt-in commit watch.
1167
+ * @param {Record<string, any>} cfg @returns {string}
1168
+ */
1169
+ export function reviewFileLoop(cfg) {
1170
+ return cfg?.review?.file_loop ?? 'wait';
1171
+ }
@@ -12,11 +12,11 @@
12
12
  *
13
13
  * `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
14
14
  * owned files, test files when the block has them, worktree path and branch when it has one), the
15
- * commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), each
16
- * rules file, then the lessons file — every file section headed by its source path (an empty
17
- * file: the heading alone). Over `max_lines` the lessons are cut first, then the
15
+ * commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), the
16
+ * built-in proof rule, each rules file, then the lessons file — every file section headed by its
17
+ * source path (an empty file: the heading alone). Over `max_lines` the lessons are cut first, then the
18
18
  * rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
19
- * header and the commands are never cut.
19
+ * header, commands and proof rule are never cut; all count toward the budget.
20
20
  *
21
21
  * `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
22
22
  * lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
@@ -41,6 +41,7 @@ import path from 'node:path';
41
41
  import { estimateTokens } from '../review/packet.mjs';
42
42
  import { splitLines } from '../review/context.mjs';
43
43
  import { redact } from '../util/redact.mjs';
44
+ import { PROOF_RULE } from '../proof/rule.mjs';
44
45
 
45
46
  /** The pack's line cap when `context.max_lines` is not set. */
46
47
  export const DEFAULT_MAX_LINES = 300;
@@ -58,7 +59,7 @@ export const DIGEST_LESSONS_HEADING = '### lessons';
58
59
  * A refusal with a stable `code`. Thrown here: `bad-context` (a bad `context` value),
59
60
  * `control-character` (in a path or a header value), `outside`, `missing` (ENOENT), `unreadable`
60
61
  * (any other lstat or read error, with its code), `not-file`. Thrown by `src/cli/context.mjs`:
61
- * `no-block`, `bad-config`, `no-context`, `no-run-root`, `write-failed`.
62
+ * `no-block`, `bad-config`, `no-run-root`, `write-failed`.
62
63
  */
63
64
  export class ContextError extends Error {
64
65
  /** @param {string} code @param {string} message */
@@ -215,11 +216,12 @@ function readLines(repoRoot, rel, key) {
215
216
 
216
217
  /**
217
218
  * Every rules file and the lessons file, read. A missing or outside file is a refusal.
218
- * @param {string} repoRoot @param {ContextSettings} settings
219
+ * @param {string} repoRoot @param {ContextSettings | null} settings
219
220
  * @returns {{rules: Array<{path: string, lines: string[]}>, lessons: {path: string, lines: string[]} | null}}
220
221
  * @throws {ContextError}
221
222
  */
222
223
  export function readSources(repoRoot, settings) {
224
+ if (settings === null) return { rules: [], lessons: null };
223
225
  const rules = settings.rules.map((rel) => ({ path: rel, lines: readLines(repoRoot, rel, 'context.rules') }));
224
226
  const lessons = settings.lessons === null ? null : { path: settings.lessons, lines: readLines(repoRoot, settings.lessons, 'context.lessons') };
225
227
  return { rules, lessons };
@@ -248,7 +250,8 @@ export const argvText = (argv) => argv.map(argvToken).join(' ');
248
250
 
249
251
  /**
250
252
  * Build the pack text for a block (pure over what it is given).
251
- * @param {{settings: ContextSettings, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
253
+ * Missing settings give a header and proof rule only, with the default line cap.
254
+ * @param {{settings: ContextSettings | null, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
252
255
  * @returns {{text: string, lines: number, cut: number}} `cut`: the lines dropped.
253
256
  * @throws {ContextError} `control-character` when a header value holds one.
254
257
  */
@@ -266,11 +269,15 @@ export function assemblePack({ settings, sources, block }) {
266
269
  for (const f of block.tests) header.push(`- ${f}`);
267
270
  }
268
271
  if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
269
- header.push('## commands (never run by code-forge)');
270
- const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
271
- if (keys.length === 0) header.push('(none)');
272
- // a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
273
- for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
272
+ if (settings !== null) {
273
+ header.push('## commands (never run by code-forge)');
274
+ const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
275
+ if (keys.length === 0) header.push('(none)');
276
+ // a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
277
+ for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
278
+ }
279
+
280
+ header.push(...splitLines(PROOF_RULE));
274
281
 
275
282
  /** @type {Array<{heading: string, body: string[], dropped: number}>} */
276
283
  const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
@@ -279,7 +286,7 @@ export function assemblePack({ settings, sources, block }) {
279
286
  const total = () => header.length + sections.reduce((n, s) => n + 1 + s.body.length + (s.dropped > 0 ? 1 : 0), 0);
280
287
  // cut order: the lessons first, then the rules from the last file backwards
281
288
  for (const s of [...sections].reverse()) {
282
- let excess = total() - settings.maxLines;
289
+ let excess = total() - (settings?.maxLines ?? DEFAULT_MAX_LINES);
283
290
  if (excess <= 0) break;
284
291
  // the marker takes one line: a section is only cut when that leaves it shorter
285
292
  if (s.body.length < 2) continue;
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
14
  import { newRunId } from '../state/paths.mjs';
15
15
  import { runRoot } from '../util/tmp.mjs';
16
- import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
16
+ import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
17
17
  import { checkCoderSandbox, configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
18
18
  import { row } from './rows.mjs';
19
19
  import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
@@ -67,6 +67,7 @@ export async function runDoctor(opts = {}, deps = {}) {
67
67
  };
68
68
  rows.push(...(await checkTools(toolOpts)));
69
69
  rows.push(...(await checkLedger()));
70
+ rows.push(...(await checkLocks()));
70
71
  rows.push(...checkTmp(cfg));
71
72
  rows.push(...(await checkReviewState(cfg)));
72
73
  rows.push(checkReviewConcurrency(cfg));
@@ -25,13 +25,32 @@ import { assertRunId, runsDir } from '../state/paths.mjs';
25
25
  import { readRun } from '../state/run.mjs';
26
26
  import { loadKey } from '../state/signer.mjs';
27
27
  import { listEntries } from '../util/reaper.mjs';
28
+ import { listBadLocks } from '../util/file-locks.mjs';
28
29
  import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
29
30
  import { parallelTickets } from '../worker/loop.mjs';
31
+ import { workerRegistry } from '../worker/queue.mjs';
32
+ import { mainRootOf } from '../worker/ticket.mjs';
30
33
  import { missingStateFiles } from '../worker/review-state.mjs';
31
34
  import { row } from './rows.mjs';
32
35
 
33
36
  /** @typedef {import('./rows.mjs').Row} Row */
34
37
 
38
+ /** @param {string} [cwd] @returns {Promise<Row[]>} */
39
+ export async function checkLocks(cwd = process.cwd()) {
40
+ try {
41
+ const paths = listBadLocks();
42
+ const rows = paths.length === 0
43
+ ? [row('locks', 'OK', 'locks', 'no unreadable or malformed locks')]
44
+ : paths.map((file) => row('locks', 'WARN', 'locks', file));
45
+ let repoRoot = cwd;
46
+ try { repoRoot = await mainRootOf(cwd); } catch { /* non-repository doctor */ }
47
+ rows.push(...workerRegistry(repoRoot).filter((entry) => entry.worker === null).map((entry) => row('worker-registry', 'WARN', 'worker registry', entry.file)));
48
+ return rows;
49
+ } catch (err) {
50
+ return [row('locks', 'WARN', 'locks', `could not list locks (${err?.code ?? 'error'})`)];
51
+ }
52
+ }
53
+
35
54
  /** The ids of the `--quick` rows, in print order. */
36
55
  export const QUICK_IDS = Object.freeze(['config', 'links', 'keys']);
37
56
 
@@ -11,8 +11,9 @@
11
11
  * MAC is tamper evidence within ordinary tool use, never a boundary against a same-user process.
12
12
  */
13
13
 
14
- import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
14
+ import { copyFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
15
15
  import path from 'node:path';
16
+ import { commandOnPath } from '../install/detect.mjs';
16
17
  import { runsDir } from '../state/paths.mjs';
17
18
  import { startRun } from '../state/run.mjs';
18
19
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
@@ -94,7 +95,19 @@ export async function probeWorkerAndSigner(ctx) {
94
95
  const signerOk = verifyRow(signed, key).ok && verifyRow(flipped, key).reason === 'mismatch';
95
96
  rows.push(row('signer-key', signerOk ? 'OK' : 'FAIL', 'signer key', signerOk ? 'generated; a signed row verifies, a changed one is refused' : 'sign/verify round trip failed'));
96
97
 
97
- const started = await launchWorker({ runId, workspace: repo, env });
98
+ // The boot lock needs ps even with a restricted provider PATH. Expose just that utility.
99
+ let workerEnv = env;
100
+ if (!await commandOnPath('ps', { pathEnv: env.PATH ?? '' })) {
101
+ const tools = path.join(repo, 'worker-tools');
102
+ for (const systemDir of ['/bin', '/usr/bin']) {
103
+ if (!await commandOnPath('ps', { pathEnv: systemDir })) continue;
104
+ mkdirSync(tools, { mode: 0o700 });
105
+ symlinkSync(path.join(systemDir, 'ps'), path.join(tools, 'ps'));
106
+ workerEnv = { ...env, PATH: [env.PATH, tools].filter(Boolean).join(path.delimiter) };
107
+ break;
108
+ }
109
+ }
110
+ const started = await launchWorker({ runId, workspace: repo, env: workerEnv });
98
111
  pid = started.pid;
99
112
  registerPid(pidsDir(ctx.runRootDir), pid, 'code-forge worker'); // a killed doctor's worker is reaped by the next sweep
100
113
  const announced = liveWorker(started.repoRoot)?.pid === pid;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * B60 (issue #13 item 3): run only the block's related tests, and give a gate its own environment.
3
- * Pure — no filesystem, no child process. A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
3
+ * Scope and argv rules are pure; B99's fileBaseline reads bounded hashes from the workspace.
4
+ * A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
4
5
  * (one whole item, never a substring, never a shell string) stands for the block's test files:
5
6
  *
6
7
  * - the block's `test_files` (`block open --tests`), else its owned entries that are concrete
@@ -20,8 +21,14 @@
20
21
  * `CI=1` or `MODE=test` would otherwise mask unrelated text (`npm test`).
21
22
  */
22
23
 
23
- import { isExactEntry } from '../state/registry.mjs';
24
- import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
24
+ import { createHash } from 'node:crypto';
25
+ import { access, lstat, readFile, realpath } from 'node:fs/promises';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { isExactEntry, ownsFile } from '../state/registry.mjs';
29
+ import { pairsFor } from '../review/paired.mjs';
30
+ import { exec } from '../util/exec.mjs';
31
+ import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
25
32
 
26
33
  export { TEST_FILES_TOKEN };
27
34
 
@@ -67,6 +74,7 @@ const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEn
67
74
 
68
75
  /**
69
76
  * @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
77
+ * @typedef {{scope: 'changed', files: string[], changedFiles: string[], reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'}} ChangedScope
70
78
  */
71
79
 
72
80
  /** A gate run with no block: no test file is known. */
@@ -108,30 +116,115 @@ export function hasTestFilesItem(gates) {
108
116
  return Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(TEST_FILES_TOKEN) === true);
109
117
  }
110
118
 
119
+ /** @param {Record<string, unknown> | null | undefined} gates @returns {boolean} */
120
+ export function hasScopeItem(gates) {
121
+ return hasTestFilesItem(gates) || Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(CHANGED_FILES_TOKEN));
122
+ }
123
+
124
+ /** A safe repo-relative path that cannot turn into an argv option. @param {string} file */
125
+ function safeFile(file) {
126
+ return file.length > 0 && !file.startsWith('-') && !path.isAbsolute(file) && !/[\\\x00-\x1f]/.test(file) && !file.split('/').some((s) => s === '' || s === '.' || s === '..');
127
+ }
128
+
129
+ /**
130
+ * Bounded content baseline. Missing listed files are omitted from hashes and candidates; an
131
+ * owned entry with no current file has no hash. More than 200 owned files, a symlink, a
132
+ * non-regular owned file, a path outside the root or another read error means no record;
133
+ * callers retain the old scope. Git sees only argv and an isolated git environment.
134
+ * @param {string} cwd @param {string[]} owned @param {Record<string, string>} [kinds]
135
+ * @returns {Promise<{files: Record<string, string>, candidates: string[]} | null>}
136
+ */
137
+ export async function fileBaseline(cwd, owned, kinds) {
138
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
139
+ env.GIT_CONFIG_NOSYSTEM = '1';
140
+ env.GIT_CONFIG_GLOBAL = os.devNull;
141
+ const listed = await exec(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd, env, timeoutMs: 30000 });
142
+ if (listed.result !== 'ok') return null;
143
+ const listedFiles = [...new Set(listed.stdout.split('\0').filter(Boolean))].sort();
144
+ const candidates = [];
145
+ const files = /** @type {Record<string, string>} */ (Object.create(null));
146
+ try {
147
+ const root = await realpath(cwd);
148
+ for (const file of listedFiles) {
149
+ const isOwned = ownsFile(owned, file, kinds);
150
+ if (isOwned && !safeFile(file)) return null;
151
+ const absolute = path.join(root, file);
152
+ if (!isOwned) {
153
+ const exists = await access(absolute).then(() => true).catch((error) => {
154
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
155
+ throw error;
156
+ });
157
+ if (exists) candidates.push(file);
158
+ continue;
159
+ }
160
+ const stat = await lstat(absolute).catch((error) => {
161
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
162
+ throw error;
163
+ });
164
+ if (stat === null) continue;
165
+ candidates.push(file);
166
+ if (Object.keys(files).length >= 200 || !stat.isFile() || !(await realpath(absolute)).startsWith(root + path.sep)) return null;
167
+ files[file] = createHash('sha256').update(await readFile(absolute)).digest('hex');
168
+ }
169
+ } catch { return null; }
170
+ return { files, candidates };
171
+ }
172
+
173
+ /**
174
+ * Compare the last green record. A source without a stem pair cannot narrow the test gate.
175
+ * @param {{baseline: unknown, current: Awaited<ReturnType<typeof fileBaseline>>, fallback: TestScope, threshold?: number, owned?: string[], kinds?: Record<string, string>}} opts
176
+ * @returns {ChangedScope}
177
+ */
178
+ export function changedScope({ baseline, current, fallback, threshold = DEFAULT_FULL_SUITE_THRESHOLD, owned, kinds }) {
179
+ const old = /** @type {Record<string, string> | null} */ (baseline);
180
+ const candidates = new Set(current?.candidates);
181
+ const fallbackFiles = fallback.scope === 'related' ? fallback.files.filter((file) => !current || candidates.has(file)) : [];
182
+ if (!current || !old || typeof old !== 'object' || Array.isArray(old) || Object.keys(old).length > 200 ||
183
+ Object.entries(old).some(([file, hash]) => !safeFile(file) || (owned && !ownsFile(owned, file, kinds)) || typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))) {
184
+ return { scope: 'changed', files: fallbackFiles, changedFiles: [], reason: 'no_baseline' };
185
+ }
186
+ const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
187
+ if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
188
+ const changedFiles = changes.filter((file) => candidates.has(file));
189
+ // B97 also pairs Swift Tests names outside lowercase test directories.
190
+ const isChangedTest = (/** @type {string} */ file) => isTestPath(file) || file.endsWith('Tests.swift');
191
+ const tests = new Set(changedFiles.filter(isChangedTest));
192
+ for (const file of changes.filter((f) => !isChangedTest(f))) {
193
+ const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
194
+ if (pairs.length === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'unpaired' };
195
+ for (const paired of pairs) tests.add(paired);
196
+ }
197
+ if (tests.size === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'no_changes' };
198
+ return { scope: 'changed', files: [...tests].sort(), changedFiles };
199
+ }
200
+
111
201
  /**
112
202
  * One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
113
203
  * test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
114
204
  * unchanged) when the gate has no such item.
115
- * @param {ReadonlyArray<string>} argv @param {TestScope} scope
205
+ * @param {ReadonlyArray<string>} argv @param {TestScope | ChangedScope} scope
116
206
  * @returns {{argv: string[], scoped: boolean}}
117
207
  * @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
118
208
  * open --tests` refuses one too).
119
209
  */
120
210
  export function expandArgv(argv, scope) {
121
- if (!argv.includes(TEST_FILES_TOKEN)) return { argv: [...argv], scoped: false };
122
- const files = scope.scope === 'related' ? scope.files : [];
211
+ if (!argv.includes(TEST_FILES_TOKEN) && !argv.includes(CHANGED_FILES_TOKEN)) return { argv: [...argv], scoped: false };
212
+ const files = scope.scope === 'full' ? [] : scope.files;
213
+ const changed = scope.scope === 'changed' ? scope.changedFiles : [];
214
+ if (changed.some((file) => !safeFile(file))) throw new TypeError('expandArgv: unsafe changed path');
123
215
  const dashed = files.find((f) => f.startsWith('-'));
124
216
  if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
125
- return { argv: argv.flatMap((item) => (item === TEST_FILES_TOKEN ? files : [item])), scoped: true };
217
+ return { argv: argv.flatMap((item) => item === TEST_FILES_TOKEN ? files : item === CHANGED_FILES_TOKEN ? changed : [item]), scoped: true };
126
218
  }
127
219
 
128
220
  /**
129
221
  * The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
130
222
  * or `reason` when full.
131
- * @param {TestScope} scope
132
- * @returns {{scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold' | 'no_tests'}}
223
+ * @param {TestScope | ChangedScope} scope
224
+ * @returns {{scope: 'changed', test_count: number, reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'} | {scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'|'no_tests'}}
133
225
  */
134
226
  export function scopeFields(scope) {
227
+ if (scope.scope === 'changed') return { scope: 'changed', test_count: scope.files.length, ...(scope.reason ? { reason: scope.reason } : {}) };
135
228
  return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
136
229
  }
137
230
 
package/src/gates/run.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { exec } from '../util/exec.mjs';
10
10
  import { registerSecret } from '../util/redact.mjs';
11
+ import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
11
12
  import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
12
13
 
13
14
  /** @typedef {import('./detect.mjs').Gate} Gate */
@@ -25,9 +26,9 @@ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } fro
25
26
  * @property {number | null} code
26
27
  * @property {string} stdout
27
28
  * @property {string} stderr
28
- * @property {'related'|'full'} [scope] - B60: only on a gate whose argv has the `{test_files}` item.
29
+ * @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
29
30
  * @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
30
- * @property {'threshold'|'no_tests'} [reason] - B60: with `scope: full`, why the item was dropped.
31
+ * @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'} [reason] - why narrowing fell back.
31
32
  * @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
32
33
  * NAMES, sorted (the values are never in a result).
33
34
  */
@@ -53,7 +54,7 @@ function normalizeCommand(command) {
53
54
  * no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
54
55
  * variables added to the inherited environment; a gate with neither runs exactly as before B60.
55
56
  * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
56
- * @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
57
+ * @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
57
58
  * @returns {Promise<{results: GateResult[], allOk: boolean}>}
58
59
  */
59
60
  export async function runGates(gates, opts) {
@@ -77,8 +78,9 @@ export async function runGates(gates, opts) {
77
78
  const command = gates[gate] ?? null;
78
79
  if (command === null) return { gate, command: null };
79
80
  const normalized = normalizeCommand(command);
81
+ if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
80
82
  const { argv, scoped } = expandArgv(normalized.argv, scope);
81
- if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{test_files}' is dropped for the full suite — put the test command before it`);
83
+ if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once ${normalized.argv.map((item) => `'${item}'`).join(' and ')} is dropped for the full suite — put the test command before it`);
82
84
  return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
83
85
  });
84
86
  /** @type {GateResult[]} */
@@ -3,7 +3,7 @@
3
3
  * wizard answer (`ANSWER_FLAGS` maps flag -> answer key); `--no-interaction` is a mode switch and
4
4
  * overrides no answer.
5
5
  *
6
- * **Q16 = cut (Ricardo, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
6
+ * **Q16 = cut (the owner, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
7
7
  * v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
8
8
  * (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
9
9
  * (two tokens that each set the scope answer) there are 21 answer flags: B65 added
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { logWarning } from '../util/error-log.mjs';
15
- import { keyedLock } from '../util/locks.mjs';
15
+ import { withLock } from '../util/file-locks.mjs';
16
16
  import { writeSafe } from '../util/redact.mjs';
17
17
  import { estimateCostUsd } from './prices.mjs';
18
18
 
@@ -199,7 +199,7 @@ export const SPEND_UNREADABLE_MESSAGE = "budget.usd is set but the run's spend c
199
199
  * Several sessions of one run can start at once (B40 slots, B42 ticket pool). The ledger only
200
200
  * learns a session's cost when its row is written, so a plain "spent < budget" check lets every
201
201
  * session that starts before the first row lands go through. Instead the check AND a reservation
202
- * of the session's estimated cost happen under ONE in-process lock per run (`withBudgetLock`):
202
+ * of the session's estimated cost happen under ONE cross-process lock per run (`withBudgetLock`):
203
203
  *
204
204
  * spent ≥ budget, or spent + reserved + estimate > budget ⇒ refused (the B33 message, unchanged)
205
205
  *
@@ -275,7 +275,7 @@ export function reserveBudget(run, usd) {
275
275
  */
276
276
  export function withBudgetLock(run, fn) {
277
277
  lockCalls += 1;
278
- return keyedLock(`budget:${run}`, fn);
278
+ return withLock(`budget:${run}`, fn);
279
279
  }
280
280
 
281
281
  /** Test spy: how many times {@link withBudgetLock} was entered since the last reset. */