@codedology/code-forge 0.5.1 → 0.6.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +39 -2
  3. package/docs/README.md +5 -4
  4. package/docs/reference/blocks.json +127 -1
  5. package/docs/reference/config.md +2 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +7 -0
  8. package/skill/SKILL.md +2 -0
  9. package/skill/references/adapters/claude-code.md +14 -0
  10. package/skill/references/autopilot.md +48 -0
  11. package/skill/references/code.md +1 -1
  12. package/skill/references/decisions.md +3 -1
  13. package/skill/references/harden.md +1 -1
  14. package/skill/references/ledger.md +2 -0
  15. package/skill/references/plan.md +6 -4
  16. package/skill/references/review.md +2 -2
  17. package/skill/references/rules-core.md +4 -1
  18. package/skill/scripts/forge +1 -1
  19. package/skill/templates/plan.md +4 -3
  20. package/src/autopilot/actions.mjs +491 -0
  21. package/src/autopilot/binnacle.mjs +494 -0
  22. package/src/autopilot/delegate.mjs +422 -0
  23. package/src/autopilot/grant.mjs +354 -0
  24. package/src/autopilot/limits.mjs +848 -0
  25. package/src/autopilot/scopes.mjs +95 -0
  26. package/src/cli/author.mjs +7 -5
  27. package/src/cli/autopilot.mjs +500 -0
  28. package/src/cli/block.mjs +40 -8
  29. package/src/cli/facts.mjs +7 -6
  30. package/src/cli/jev.mjs +10 -6
  31. package/src/cli/logs.mjs +4 -142
  32. package/src/cli/plan.mjs +18 -7
  33. package/src/cli/run.mjs +20 -61
  34. package/src/cli/s2.mjs +5 -4
  35. package/src/cli/spawn.mjs +5 -4
  36. package/src/config/closed-book.mjs +20 -3
  37. package/src/config/load.mjs +113 -1
  38. package/src/doctor/probes.mjs +34 -11
  39. package/src/engines/builders/claude.mjs +3 -3
  40. package/src/engines/builders/codex.mjs +6 -5
  41. package/src/engines/builders/grok.mjs +3 -3
  42. package/src/engines/builders/validate-params.mjs +3 -3
  43. package/src/ledger/report.mjs +2 -0
  44. package/src/review/fixloop.mjs +69 -5
  45. package/src/review/gate-check.mjs +156 -6
  46. package/src/session/facts.mjs +291 -73
  47. package/src/session/plan-check.mjs +75 -6
  48. package/src/session/spawn.mjs +68 -8
  49. package/src/state/run.mjs +8 -1
  50. package/src/util/breadcrumbs.mjs +1 -0
  51. package/src/util/forbidden.mjs +10 -0
  52. package/src/util/gh.mjs +175 -0
  53. package/src/worker/engine.mjs +8 -4
  54. package/src/worker/loop.mjs +42 -0
@@ -117,11 +117,93 @@ const COMMAND_IN_SPAN = /^([a-z][a-z0-9-]*) ([a-z][a-z0-9-]*)(?![\w.\/-])/;
117
117
  const trimTail = (token) => token.replace(/[.,:;]+$/, '');
118
118
 
119
119
  /**
120
- * Every claim token of `text`, deduplicated by (kind, token), in order of first appearance.
120
+ * @typedef {object} ExtractOpts
121
+ * @property {ReadonlyArray<string>} [projectBins] - the project's own CLI names (its package.json
122
+ * `bin` keys, {@link readProjectBins}); none ⇒ command claims are kept as written.
123
+ * @property {string} [briefText] - the brief alone (no sources): only IT can name the project CLI
124
+ * that a command claim gets prefixed with (default: the whole text).
125
+ */
126
+
127
+ /**
128
+ * `word` as a whole CLI word: not inside another word (`a-word`, `word2`), a path (`./word`,
129
+ * `dir/word`) or a file name (`.word.yml`, `word.config`); a sentence's full stop after it is fine.
130
+ * @param {string} word @returns {RegExp}
131
+ */
132
+ export const cliWordRe = (word) => new RegExp(`(?<![\\w./-])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w/-]|\\.\\w)`);
133
+
134
+ /**
135
+ * Does the brief use `word` as a CLI of its own in a code span — the span is `word` alone, or `word`
136
+ * followed by an option or a path (`` `gh` ``, `` `gh --repo x` ``, `` `jq ./a.json` ``)? A
137
+ * `` `word verb` `` span is the claim shape itself and does not count.
138
+ * @param {string} word @param {string} brief
139
+ * @returns {boolean}
140
+ */
141
+ function namedAsCli(word, brief) {
142
+ return [...brief.matchAll(/`([^`\n]+)`/g)].some((m) => {
143
+ const span = m[1].trim();
144
+ return span === word || (span.startsWith(`${word} `) && /^[-./~]/.test(span.slice(word.length + 1).trimStart()));
145
+ });
146
+ }
147
+
148
+ /** CLIs that are never a verb of the project's own CLI: a claim starting with one is never prefixed. */
149
+ const WELL_KNOWN_CLIS = new Set(['git', 'gh', 'npm', 'npx', 'node', 'pnpm', 'yarn', 'bun', 'deno', 'ls', 'cat', 'grep', 'which', 'find', 'test', 'make', 'docker', 'curl', 'claude', 'codex', 'python', 'python3', 'pip', 'go', 'cargo', 'op']);
150
+
151
+ /**
152
+ * B50: a `<cli> <verb>` claim written without the project's CLI (`run reload` in a brief about
153
+ * `code-forge`) gets it — deterministically, from the brief and package.json only (never PATH):
154
+ * the BRIEF (`opts.briefText`, never a source) names a project bin as a whole word
155
+ * ({@link cliWordRe}), the claim's first word is not itself a project bin nor a well-known CLI
156
+ * ({@link WELL_KNOWN_CLIS}), and the brief never uses that first word as a CLI of its own
157
+ * ({@link namedAsCli}). Then the token becomes `<bin> <token>`; anything else stays as written.
158
+ * @param {string} token @param {string} text @param {ExtractOpts} opts
159
+ * @returns {string}
160
+ */
161
+ function withProjectCli(token, text, opts) {
162
+ const bins = (opts.projectBins ?? []).filter((b) => BARE_CLI.test(b));
163
+ const first = token.split(' ')[0];
164
+ const brief = opts.briefText ?? text;
165
+ if (bins.length === 0 || bins.includes(first) || WELL_KNOWN_CLIS.has(first) || namedAsCli(first, brief)) return token;
166
+ const named = bins.find((b) => cliWordRe(b).test(brief));
167
+ return named === undefined ? token : `${named} ${token}`;
168
+ }
169
+
170
+ /**
171
+ * The CLI names a project ships: the keys of its package.json `bin` (a string `bin` names the
172
+ * package, without its scope). No package.json, or no `bin` ⇒ none.
173
+ * @param {string} projectDir
174
+ * @returns {string[]}
175
+ * @throws {FactsError} `usage` when package.json exists but cannot be read or is not JSON (the
176
+ * message names the file only, never its content).
177
+ */
178
+ export function readProjectBins(projectDir) {
179
+ let text;
180
+ try {
181
+ text = readFileSync(path.join(projectDir, 'package.json'), 'utf8');
182
+ } catch (err) {
183
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return []; // the project ships no CLI
184
+ throw new FactsError('usage', `package.json at the project root cannot be read (${/** @type {NodeJS.ErrnoException} */ (err).code ?? 'error'})`);
185
+ }
186
+ let pkg;
187
+ try {
188
+ pkg = JSON.parse(text);
189
+ } catch {
190
+ throw new FactsError('usage', 'package.json at the project root is not valid JSON');
191
+ }
192
+ if (typeof pkg?.bin === 'string' && typeof pkg.name === 'string') return [pkg.name.replace(/^@[^/]+\//, '')];
193
+ if (pkg?.bin !== null && typeof pkg?.bin === 'object' && !Array.isArray(pkg.bin)) return Object.keys(pkg.bin);
194
+ return [];
195
+ }
196
+
197
+
198
+ /**
199
+ * Every claim token of `text`, deduplicated by (kind, token), in order of first appearance. With
200
+ * `opts.projectBins`, a command claim written without its CLI gets the project's (B50,
201
+ * {@link withProjectCli}).
121
202
  * @param {string} text
203
+ * @param {ExtractOpts} [opts]
122
204
  * @returns {Claim[]}
123
205
  */
124
- export function extractClaims(text) {
206
+ export function extractClaims(text, opts = {}) {
125
207
  let masked = String(text);
126
208
  /** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
127
209
  const found = [];
@@ -145,7 +227,7 @@ export function extractClaims(text) {
145
227
  const cmd = COMMAND_IN_SPAN.exec(m[1]);
146
228
  if (!cmd) continue;
147
229
  const start = /** @type {number} */ (m.index) + 1;
148
- found.push({ index: start, token: `${cmd[1]} ${cmd[2]}`, kind: 'command' });
230
+ found.push({ index: start, token: withProjectCli(`${cmd[1]} ${cmd[2]}`, String(text), opts), kind: 'command' });
149
231
  mask(start, cmd[0].length);
150
232
  }
151
233
  for (const entry of PATTERNS.slice(3)) scan(...entry);
@@ -322,11 +404,13 @@ function pathViolation(word) {
322
404
  }
323
405
 
324
406
  /** Commands that read and print, allowed with any in-cwd arguments (after the secret-path check). */
325
- const PLAIN_READERS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'rg', 'stat', 'file', 'uname', 'sw_vers', 'jq', 'which']);
407
+ // B50: `rg` is not one — it searches recursively by default, so any pattern could sweep the tree
408
+ const PLAIN_READERS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'stat', 'file', 'uname', 'sw_vers', 'jq', 'which']);
326
409
 
327
- const GIT_READS = new Set(['log', 'show', 'status', 'rev-parse', 'ls-files', 'cat-file', '--version']);
328
- const NPM_READS = new Set(['view', 'ls', '--version']);
329
- const HELP_FORMS = new Set(['--help', '-h', '--version']);
410
+ // B50: no `--help`, `-h` or `--version` form anywhere (a program may read it as an operand: BSD
411
+ // `rm foo --help` deletes); a version claim is checked with `npm view`, `which` or `ls`
412
+ const GIT_READS = new Set(['log', 'show', 'status', 'rev-parse', 'ls-files', 'cat-file']);
413
+ const NPM_READS = new Set(['view', 'ls']);
330
414
  /**
331
415
  * find: an ALLOWED predicate list (B9b.1). Any other word starting with `-` (`-exec`, `-execdir`,
332
416
  * `-ok`, `-okdir`, `-delete`, `-fls`, `-fprint*`, …) is refused by omission; `!` and bare words are
@@ -356,7 +440,7 @@ function findViolation(rest) {
356
440
  }
357
441
  return null;
358
442
  }
359
- /** Build/task runners: their `help`/default targets run user recipes, so the `<cli> --help` case never admits them. */
443
+ /** Build/task runners: their targets run user recipes; named in the refusal so the delegate sees why. */
360
444
  const BUILD_RUNNERS = new Set(['make', 'gmake', 'just', 'task', 'rake', 'gradle', 'gradlew', 'mvn', 'mvnw', 'ant', 'ninja', 'cmake', 'bazel', 'bazelisk', 'tox', 'nox', 'invoke', 'inv', 'doit', 'earthly', 'mage', 'npx', 'pnpx', 'bunx']);
361
445
  /** curl: the long options allowed; the short ones are any cluster of `s S I L f`; `--max-time <N>` is handled apart. */
362
446
  const CURL_LONG = new Set(['--head', '--silent', '--show-error', '--location', '--fail']);
@@ -404,8 +488,7 @@ function segmentViolation(argv) {
404
488
  if (why) return why;
405
489
  }
406
490
  if (PLAIN_READERS.has(verb)) {
407
- if (verb === 'rg' && rest.some((t) => t.startsWith('--pre'))) return 'rg --pre runs a command';
408
- if (verb === 'grep' && rest.some((t) => /^-[a-zA-Z]*[rR]/.test(t) || t === '--recursive' || t === '--dereference-recursive')) return 'grep: -r, -R and --recursive are not allowed';
491
+ if (verb === 'grep') return grepViolation(rest);
409
492
  return null;
410
493
  }
411
494
  switch (verb) {
@@ -414,84 +497,178 @@ function segmentViolation(argv) {
414
497
  case 'find':
415
498
  return findViolation(rest);
416
499
  case 'git':
417
- if (rest.length === 0 || !GIT_READS.has(rest[0])) return 'git: only log, show, status, rev-parse, ls-files, cat-file or --version, with no global option';
500
+ if (rest.length === 0 || !GIT_READS.has(rest[0])) return 'git: only log, show, status, rev-parse, ls-files or cat-file, with no global option';
418
501
  return rest.some((t) => /^--(output|ext-diff|textconv)/.test(t)) ? 'git: --output, --ext-diff and --textconv are not allowed' : null;
419
502
  case 'npm':
420
- return rest.length > 0 && NPM_READS.has(rest[0]) ? null : 'npm: only view, ls or --version';
421
- case 'node':
422
- return rest.length === 1 && (rest[0] === '--version' || rest[0] === '-v') ? null : 'node: only --version';
503
+ return rest.length > 0 && NPM_READS.has(rest[0]) ? null : 'npm: only view or ls';
423
504
  case 'test':
424
505
  return rest.length === 2 && ['-e', '-f', '-d', '-n'].includes(rest[0]) ? null : 'test: only -e, -f, -d or -n with one operand';
425
506
  case 'curl':
426
507
  return curlViolation(rest);
427
508
  default:
428
- // `<cli> --help|-h|--version` — exactly two tokens, a bare CLI name (never a path), never a build runner
509
+ // B50: no `<cli> --help|-h|--version` form — any other program is refused
429
510
  if (BUILD_RUNNERS.has(verb)) return `${verb}: build and task runners are not allowed`;
430
- return rest.length === 1 && HELP_FORMS.has(rest[0]) && /^[a-z0-9][a-z0-9._-]*$/.test(verb) ? null : `not an allowed read-only form: "${verb}"`;
511
+ return `not an allowed read-only form: "${verb}"`;
431
512
  }
432
513
  }
433
514
 
434
- /** A bare CLI name (never a path) for the `<cli> … --help` forms. */
515
+ /** A bare CLI name (never a path). */
435
516
  const BARE_CLI = /^[a-z0-9][a-z0-9._-]*$/;
436
- /** A subcommand word of the `<cli> <subcommand> --help | grep -c -- <flag>` form. */
437
- const SUBCOMMAND_WORD = /^[a-z][a-z0-9-]*$/;
438
- /** The flag a help page is searched for (`grep -c -- <flag>`). */
439
- const HELP_FLAG = /^--?[A-Za-z0-9][A-Za-z0-9-]*$/;
517
+ /** The pattern of the recursive source grep: a long flag with at least 3 characters after `--`. */
518
+ const SOURCE_GREP_FLAG = /^--[a-z][a-z0-9-]{2,}$/;
440
519
 
441
520
  /**
442
- * Never a `<cli>` of the subcommand-help form, even when the brief names it: interpreters and shells
443
- * run a project file (`node scripts --help`), and BSD file tools take `--help` as an operand after
444
- * one (`rm foo --help`). git keeps its own read list.
521
+ * B50: the ONLY option words grep may carry before `--` (an exact allow-list — no digits, no long
522
+ * option, no other cluster: `-2r`, `--recur`, `--dir=recurse`, `-d`, `-R`, `-l`, `-L`, `-e`, `-f` are
523
+ * all refused). `-rn`/`-nr` is the recursive source grep and needs its exact form ({@link sourceGrep}).
445
524
  */
446
- export const SUBCOMMAND_HELP_DENY = Object.freeze([
447
- 'node', 'deno', 'bun', 'sh', 'bash', 'zsh', 'fish', 'dash', 'python', 'python3', 'ruby', 'perl', 'php',
448
- 'rm', 'mv', 'cp', 'kill', 'pkill', 'chmod', 'chown', 'ln', 'dd', 'tee', 'git',
449
- // launchers that run their subcommand as a program
450
- 'npx', 'pnpx', 'bunx', 'uvx', 'pipx', 'npm', 'pnpm', 'yarn', 'env', 'sudo', 'doas', 'xargs', 'exec', 'nice', 'nohup', 'timeout', 'time', 'watch', 'command', 'builtin', 'eval', 'source',
451
- ]);
452
- const SUBCOMMAND_HELP_DENIED = new Set(SUBCOMMAND_HELP_DENY);
525
+ const GREP_OPTIONS = new Set(['-n', '-c', '-i', '-w', '-F', '-rn', '-nr']);
526
+ const GREP_RECURSIVE = new Set(['-rn', '-nr']);
527
+ const GREP_MESSAGE = 'grep: options only -n -c -i -w -F before --, or the source grep grep -rn -- <flag> <path>';
528
+ const SOURCE_GREP_MESSAGE = 'grep -rn: only grep -rn -- <--long-flag> <path> (one relative subdirectory or file, never . or the root)';
529
+
530
+ /** @param {string[]} rest - the words after `grep`. @returns {string[]} every dash word before `--` (GNU grep permutes, so an option may follow an operand). */
531
+ function grepOptionWords(rest) {
532
+ const end = rest.indexOf('--');
533
+ return (end < 0 ? rest : rest.slice(0, end)).filter((t) => t.startsWith('-'));
534
+ }
535
+
536
+ /** @param {string[]} rest @returns {boolean} the grep asks for recursion (`-rn`/`-nr` before `--`). */
537
+ function grepRecurses(rest) {
538
+ return grepOptionWords(rest).some((t) => GREP_RECURSIVE.has(t));
539
+ }
453
540
 
454
541
  /**
455
- * The CLIs the subcommand-help form may name: the first word of every `command` claim of the brief.
456
- * @param {ReadonlyArray<Claim>} claims
457
- * @returns {Set<string>}
542
+ * Why a grep segment is not allowed, or null: every option word before `--` is in
543
+ * {@link GREP_OPTIONS}; a recursive one is exactly the source grep ({@link sourceGrep}).
544
+ * @param {string[]} rest - the words after `grep`.
545
+ * @returns {string | null}
546
+ */
547
+ function grepViolation(rest) {
548
+ if (!grepOptionWords(rest).every((t) => GREP_OPTIONS.has(t))) return GREP_MESSAGE;
549
+ if (grepRecurses(rest)) return sourceGrep(rest) === null ? SOURCE_GREP_MESSAGE : null;
550
+ return null;
551
+ }
552
+
553
+ /**
554
+ * Is `where` a relative subdirectory or file of the cwd — never the cwd itself (`.`, `./`, `src/..`),
555
+ * the root, `~`, an absolute path, a `..` segment or a secret-looking path? The facts snapshot
556
+ * already drops every secret-looking file at any depth (`sealTree` and `copyNoFollow` test the full
557
+ * relative path against {@link SECRET_PATH}, so `config/.env` goes too); a named subdirectory is a
558
+ * second fence, so a grep never sweeps the whole tree.
559
+ * @param {string} where
560
+ * @returns {boolean}
561
+ */
562
+ function isSubPath(where) {
563
+ if (where.length === 0 || where.startsWith('/') || where.startsWith('~') || where.startsWith('-')) return false;
564
+ if (pathViolation(where) !== null || SECRET_PATH.some((re) => re.test(where))) return false;
565
+ const norm = path.posix.normalize(where);
566
+ return norm !== '.' && norm !== './' && !norm.startsWith('..') && !norm.split('/').some((seg) => SECRET_PATH.some((re) => re.test(seg)));
567
+ }
568
+
569
+ /**
570
+ * B50: the one recursive `grep` form — a flag claim checked in the project's SOURCE TEXT, inside the
571
+ * read-only snapshot, alone (never in a pipe), EXACTLY `grep -rn -- <flag> <path>` (or `-nr`): five
572
+ * words, the pattern a long flag of 3+ characters ({@link SOURCE_GREP_FLAG}, so `-a`, `-e` or `-1`
573
+ * cannot match every line), one relative subdirectory or file ({@link isSubPath}). Claude runs grep
574
+ * as a read-only command, so the delegate needs no permission rule for it.
575
+ * @param {string[]} rest - the words after `grep`.
576
+ * @returns {{pattern: string, path: string} | null} null when `rest` is not exactly that form.
577
+ */
578
+ function sourceGrep(rest) {
579
+ if (rest.length !== 4 || !GREP_RECURSIVE.has(rest[0]) || rest[1] !== '--') return null;
580
+ const [, , pattern, where] = rest;
581
+ return SOURCE_GREP_FLAG.test(pattern) && isSubPath(where) ? { pattern, path: where } : null;
582
+ }
583
+
584
+ /**
585
+ * The pattern and path of a command that is exactly one source-grep segment ({@link sourceGrep}), or null.
586
+ * @param {string} command
587
+ * @returns {{pattern: string, path: string} | null}
458
588
  */
459
- export function claimClis(claims) {
460
- return new Set(claims.filter((c) => c.kind === 'command').map((c) => c.token.split(' ')[0]));
589
+ function sourceGrepOf(command) {
590
+ const { segments, separators, refused } = splitCommand(command);
591
+ if (refused || segments.length !== 1 || separators.length !== 0 || segments[0][0] !== 'grep') return null;
592
+ return sourceGrep(segments[0].slice(1));
461
593
  }
462
594
 
595
+ /** Folders whose text is about the code, not the code: a hit there could be the claim quoting itself. */
596
+ const NON_SOURCE_DIRS = new Set(['docs', 'plans', 'test', 'tests', 'fixtures', 'node_modules']);
597
+ /** Top-level folders a NOT-FOUND never searches as "the source": text, tests, vendored or built output. */
598
+ const NON_SOURCE_FOLDERS = new Set([...NON_SOURCE_DIRS, 'dist', 'build', 'coverage', 'out']);
599
+ /** Text files never prove a flag (the brief and every note are one). */
600
+ const NON_SOURCE_FILE = /\.(md|markdown|txt)$/i;
601
+
602
+ /** @param {string} p @returns {string} `p` as a normalised POSIX path without a leading `./` or a trailing `/`. */
603
+ const relPosix = (p) => path.posix.normalize(p).replace(/^(\.\/)+/, '').replace(/\/+$/, '');
604
+
463
605
  /**
464
- * Is `segments` (split at `separators`) exactly `<cli> <subcommand> --help | grep -c -- <flag>`?
465
- * One pipe and nothing else; `<cli>` a bare name that a `command` claim of the brief starts with
466
- * (`clis`), never a build runner nor one of {@link SUBCOMMAND_HELP_DENY}; a plain lowercase
467
- * subcommand word; a flag-shaped pattern. The two-token `<cli> --help` form stays a segment of its
468
- * own ({@link segmentViolation}).
469
- * @param {string[][]} segments @param {string[]} separators @param {ReadonlySet<string>} clis
606
+ * Is `file` (relative to the delegate's cwd) a SOURCE file — not a `.md`, `.markdown` or `.txt`
607
+ * file, under no `docs/ plans/ test/ tests/ fixtures/ node_modules/` folder (at any depth), and
608
+ * not the brief itself?
609
+ * @param {string} file @param {string | undefined} briefRel
470
610
  * @returns {boolean}
471
611
  */
472
- function isSubcommandHelpGrep(segments, separators, clis) {
473
- if (segments.length !== 2 || separators.length !== 1 || separators[0] !== '|') return false;
474
- const [help, grep] = segments;
475
- const cli = help[0];
476
- if (help.length !== 3 || help[2] !== '--help' || !BARE_CLI.test(cli) || !clis.has(cli) || BUILD_RUNNERS.has(cli) || SUBCOMMAND_HELP_DENIED.has(cli) || !SUBCOMMAND_WORD.test(help[1])) return false;
477
- return grep.length === 4 && grep[0] === 'grep' && grep[1] === '-c' && grep[2] === '--' && HELP_FLAG.test(grep[3]);
612
+ function isSourceFile(file, briefRel) {
613
+ const rel = relPosix(file);
614
+ if ((briefRel !== undefined && rel === relPosix(briefRel)) || NON_SOURCE_FILE.test(rel)) return false;
615
+ return !rel.split('/').slice(0, -1).some((seg) => NON_SOURCE_DIRS.has(seg));
478
616
  }
479
617
 
618
+ /**
619
+ * Does the excerpt hold at least one `file:line:text` hit whose file sits under the grep's `under`
620
+ * path, is a source file ({@link isSourceFile}) and whose text holds `flag` as a whole token
621
+ * ({@link holdsFlag})? A hit line without the `file:line:` prefix never counts.
622
+ * @param {string} excerpt @param {string} flag @param {string} under - the grep's path.
623
+ * @param {string | undefined} briefRel
624
+ * @returns {boolean}
625
+ */
626
+ function sourceHit(excerpt, flag, under, briefRel) {
627
+ const scope = relPosix(under);
628
+ return excerpt.split(/\r?\n/).some((line) => {
629
+ const hit = /^([^:\n]+):(\d+):(.*)$/.exec(line.trim());
630
+ if (hit === null) return false;
631
+ const file = relPosix(hit[1]);
632
+ return (file === scope || file.startsWith(`${scope}/`)) && isSourceFile(file, briefRel) && holdsFlag(hit[3], flag);
633
+ });
634
+ }
635
+
636
+ /**
637
+ * Is `where` a source FOLDER of the delegate's cwd (the snapshot): an existing directory, no segment
638
+ * hidden (`.x`) or one of {@link NON_SOURCE_FOLDERS}, and — with `topLevel` — one single name (a
639
+ * NOT-FOUND proves absence only over a whole top-level folder). A file (`package.json`,
640
+ * `src/a.mjs`) never counts; with no known cwd nothing can be checked, so nothing counts.
641
+ * @param {string} where @param {string | undefined} cwd @param {boolean} topLevel
642
+ * @returns {boolean}
643
+ */
644
+ function isSourceFolder(where, cwd, topLevel) {
645
+ const rel = relPosix(where);
646
+ const segs = rel.split('/');
647
+ if (cwd === undefined || rel.length === 0 || rel === '.' || (topLevel && segs.length !== 1)) return false;
648
+ if (segs.some((seg) => seg.startsWith('.') || NON_SOURCE_FOLDERS.has(seg))) return false;
649
+ try {
650
+ return statSync(path.join(cwd, rel)).isDirectory();
651
+ } catch {
652
+ return false;
653
+ }
654
+ }
655
+
656
+ const ONE_COMMAND_MESSAGE = 'one command per row: no pipe or list (| ; && || & or a newline)';
657
+
480
658
  /**
481
659
  * Why a delegate's `command` is not an allowed read-only command, or null when it is. An
482
- * ALLOW-LIST (fail closed): every segment between `;`, `&&`, `||`, `|`, `&`, newline or CR must
483
- * be one of the forms in {@link segmentViolation}, or the whole command is exactly
484
- * `<cli> <subcommand> --help | grep -c -- <flag>` for a `<cli>` in `opts.clis`
485
- * ({@link isSubcommandHelpGrep}); `$`, backticks and redirections are refused.
660
+ * ALLOW-LIST (fail closed): exactly ONE command (B50: any `;`, `&&`, `||`, `|`, `&`, newline or CR
661
+ * is refused, as the packet says), and it is one of the forms in {@link segmentViolation}; `$`,
662
+ * backticks and redirections are refused.
486
663
  * @param {string} command
487
- * @param {{clis?: ReadonlySet<string>}} [opts] - `clis`: {@link claimClis} of the brief (none ⇒ the subcommand form is never admitted).
488
664
  * @returns {string | null}
489
665
  */
490
- export function readOnlyViolation(command, opts = {}) {
666
+ export function readOnlyViolation(command) {
491
667
  const { segments, separators, refused } = splitCommand(command);
492
668
  if (refused) return refused;
493
669
  if (segments.length === 0) return 'empty command';
494
- if (isSubcommandHelpGrep(segments, separators, opts.clis ?? new Set()) && !segments.flat().some((t) => SECRET_PATH.some((re) => re.test(t)))) return null;
670
+ // B50: one command per row, as the packet says — no pipe and no list
671
+ if (separators.length > 0) return ONE_COMMAND_MESSAGE;
495
672
  for (const argv of segments) {
496
673
  const why = segmentViolation(argv);
497
674
  if (why) return why;
@@ -552,22 +729,48 @@ export function isSilentCommand(command) {
552
729
 
553
730
  /** @typedef {{fact_id: string, reason: string}} Downgrade */
554
731
 
732
+ const SOURCE_GREP_CLAIM_MESSAGE = 'a source grep counts only for a flag claim, with that flag as its pattern; not counted';
733
+ const SOURCE_GREP_HIT_MESSAGE = 'VERIFIED source grep without a file:line: hit in a source file naming the claimed flag (docs, plans, tests, fixtures, text files and the brief do not count); not counted';
734
+ const SOURCE_GREP_SCOPE_MESSAGE = 'NOT-FOUND source grep must search one whole top-level source folder (not docs, plans, test, tests, fixtures or node_modules); not counted';
735
+ const FLAG_PROOF_MESSAGE = 'a flag claim is proved only by grep -rn -- <flag> <source folder>; not counted';
736
+
737
+ /**
738
+ * Does a grep hit line hold `flag` as a whole token — not preceded by a word character or `-`, not
739
+ * followed by `[A-Za-z0-9_-]` (`--force-push` does not hold `--force`)? A `-l` file-name line never does.
740
+ * @param {string} excerpt @param {string} flag
741
+ * @returns {boolean}
742
+ */
743
+ function holdsFlag(excerpt, flag) {
744
+ return new RegExp(`(?<![\\w-])${flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9_-])`).test(excerpt);
745
+ }
746
+
555
747
  /**
556
748
  * Which check rule a counted row (VERIFIED or NOT-FOUND) breaks, or null. A row the delegate
557
749
  * already tagged UNVERIFIABLE is never counted, so it keeps its own `why`.
558
- * @param {Fact} row @param {Claim} claim @param {ReadonlySet<string>} clis - {@link claimClis}.
750
+ * @param {Fact} row @param {Claim} claim
751
+ * @param {{briefRel?: string, cwd?: string}} ctx - the brief relative to the delegate's cwd, and that cwd (the snapshot).
559
752
  * @returns {string | null}
560
753
  */
561
- function ruleBroken(row, claim, clis) {
754
+ function ruleBroken(row, claim, ctx) {
562
755
  if (row.tag === 'UNVERIFIABLE') return null;
563
756
  if (row.command.trim().length === 0) return `${row.tag} without a check command; not counted`;
564
757
  if (claim.kind === 'env') {
565
758
  const env = ENV_CHECK.exec(row.command);
566
759
  if (!env || env[1] !== claim.token) return `an env claim may only be checked with printenv ${claim.token} >/dev/null; not counted`;
567
760
  } else {
568
- const why = readOnlyViolation(row.command, { clis });
761
+ const why = readOnlyViolation(row.command);
569
762
  if (why) return `check command not allowed: ${why}; not counted`;
570
763
  if (row.tag === 'VERIFIED' && isSilentCommand(row.command)) return 'silent command; excerpt cannot be its output; not counted';
764
+ // B50: a flag claim is proved (VERIFIED or NOT-FOUND) ONLY by `grep -rn -- <flag> <dir>`, its
765
+ // pattern the claimed flag, over an existing source directory of the snapshot; a source grep
766
+ // proves nothing else. A VERIFIED one needs a `file:line:` hit in a source file under that
767
+ // directory holding the flag as a whole token (never the brief, a doc or a test: the claim would
768
+ // prove itself); a NOT-FOUND one needs one whole top-level source folder
769
+ const grep = sourceGrepOf(row.command);
770
+ if (grep !== null && (claim.kind !== 'flag' || grep.pattern !== claim.token)) return SOURCE_GREP_CLAIM_MESSAGE;
771
+ if (claim.kind === 'flag' && (grep === null || !isSourceFolder(grep.path, ctx.cwd, false))) return FLAG_PROOF_MESSAGE;
772
+ if (grep !== null && row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, grep.pattern, grep.path, ctx.briefRel)) return SOURCE_GREP_HIT_MESSAGE;
773
+ if (grep !== null && row.tag === 'NOT-FOUND' && !isSourceFolder(grep.path, ctx.cwd, true)) return SOURCE_GREP_SCOPE_MESSAGE;
571
774
  }
572
775
  if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) return 'VERIFIED with an empty output excerpt; not counted';
573
776
  return null;
@@ -584,10 +787,13 @@ function ruleBroken(row, claim, clis) {
584
787
  * empty excerpt or from a silent `test`) becomes UNVERIFIABLE with `why` = the rule and is listed
585
788
  * in `downgraded`. Every row shows the CANONICAL claim token and kind.
586
789
  * @param {unknown} answer @param {ReadonlyArray<Claim>} claims
790
+ * @param {{briefRel?: string, cwd?: string}} [opts] - `briefRel`: the brief's path relative to the
791
+ * delegate's cwd (a source-grep hit in it never counts); `cwd`: that cwd (the project snapshot),
792
+ * where a NOT-FOUND source grep's folder must exist as a directory (none ⇒ no NOT-FOUND grep counts).
587
793
  * @returns {{facts: Fact[], downgraded: Downgrade[]}} the rows in claim order, and the downgrades.
588
794
  * @throws {FactsError} `refused` when the answer does not match the schema.
589
795
  */
590
- export function validateFacts(answer, claims) {
796
+ export function validateFacts(answer, claims, opts = {}) {
591
797
  if (!validateFile(answer)) {
592
798
  const where = (validateFile.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).slice(0, 5);
593
799
  throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
@@ -597,7 +803,6 @@ export function validateFacts(answer, claims) {
597
803
  const facts = [];
598
804
  /** @type {Downgrade[]} */
599
805
  const downgraded = [];
600
- const clis = claimClis(claims);
601
806
  // each row answers at most ONE claim: pass 1 anchors rows by fact_id (with the kind, else with the
602
807
  // echoed claim); pass 2 gives the rows left over to claims still open, by kind + echoed claim
603
808
  /** @type {Set<Fact>} */
@@ -623,7 +828,7 @@ export function validateFacts(answer, claims) {
623
828
  return;
624
829
  }
625
830
  const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
626
- const broken = ruleBroken(row, claim, clis);
831
+ const broken = ruleBroken(row, claim, opts);
627
832
  if (broken) {
628
833
  const reason = redact(broken);
629
834
  downgraded.push({ fact_id: id, reason });
@@ -718,20 +923,22 @@ export function checkSheetFresh(sheetText, briefPath) {
718
923
  const RULE = [
719
924
  '# Facts delegate',
720
925
  '',
721
- 'For each claim below, run the cheapest READ-ONLY check (`<cli> --help | grep -c -- <flag>`,',
722
- '`<cli> <subcommand> --help | grep -c -- <flag>`, `which <cli>`, `ls <path>`, `<cli> --version`,',
723
- '`npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
926
+ 'For each claim below, run the cheapest READ-ONLY check (`grep -rn -- <flag> <path>`, `which <cli>`,',
927
+ '`ls <path>`, `npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
724
928
  'characters). Tag each claim VERIFIED (the output shows it), NOT-FOUND (the check ran and it is absent) or',
725
929
  'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess: the excerpt is the',
726
930
  "command's literal output, never your reading of it. For a path use `ls <path>` (it prints the path); `test` prints",
727
- 'nothing, so a `test` check can never be VERIFIED. Only these forms are accepted, joined by `|` if needed: which,',
728
- 'command -v, ls, cat, head, tail, wc, grep (no -r), rg,',
931
+ 'nothing, so a `test` check can never be VERIFIED. A flag is checked in the source text of the project, never by',
932
+ 'running a program: `grep -rn -- <flag> <path>` (`<path>` a top-level source folder such as `src`, never `.`,',
933
+ 'the root, docs, plans or tests) — VERIFIED only with a hit line (`file:line:text`) from a source file (not',
934
+ 'Markdown or text, not docs, plans, tests or fixtures) that contains the flag, as the excerpt. Never run a program with `--help`, `-h` or `--version`: a version is checked with `npm view <name>',
935
+ 'version`, `which` or `ls`. Run one command at a time, never a pipe. Only these forms are accepted: which,',
936
+ 'command -v, ls, cat, head, tail, wc, grep (options only -n -c -i -w -F; recursive only as',
937
+ '`grep -rn -- <flag> <path>`),',
729
938
  'find (only -name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin',
730
- '-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, `<cli> --help|-h|--version` (a bare CLI name, no path, no build runner), git',
731
- 'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, node --version, test -e|-f|-d|-n, `curl -sI <one URL>`;',
732
- 'and, as a whole command only, `<cli> <subcommand> --help | grep -c -- <flag>` (`<cli>` the first word of a',
733
- 'command claim below, never an interpreter, shell or file tool; a lowercase subcommand word; no other pipe or',
734
- 'argument). A check that breaks these rules is not counted.',
939
+ '-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, git',
940
+ 'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, test -e|-f|-d|-n, `curl -sI <one URL>`.',
941
+ 'A check that breaks these rules is not counted.',
735
942
  'No `$`, no backticks, no redirection, no unquoted glob, no `~`, no absolute path outside /usr, /opt/homebrew or',
736
943
  '/bin, no secret files.',
737
944
  'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
@@ -966,7 +1173,15 @@ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run =
966
1173
  */
967
1174
  export async function buildFacts(opts, deps = {}) {
968
1175
  const brief = readBriefOnce(opts.briefPath);
969
- const claims = extractClaims([brief.bytes.toString('utf8'), ...readSourcesText(opts.sources ?? [])].join('\n'));
1176
+ // B50: a command claim written without its CLI gets the project's own (package.json `bin`)
1177
+ const briefText = brief.bytes.toString('utf8');
1178
+ // prefix; a broken package.json only drops that optional prefix, never the whole sheet.
1179
+ let projectBins = [];
1180
+ if (opts.projectDir !== undefined) {
1181
+ try { projectBins = readProjectBins(opts.projectDir); } catch { projectBins = []; }
1182
+ }
1183
+ const extractOpts = opts.projectDir === undefined ? {} : { projectBins, briefText };
1184
+ const claims = extractClaims([briefText, ...readSourcesText(opts.sources ?? [])].join('\n'), extractOpts);
970
1185
  const root = opts.runRoot ?? currentRunRoot();
971
1186
  // B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
972
1187
  // the snapshot — git and the harness would walk up into the live repo — so the facts area moves
@@ -996,7 +1211,10 @@ export async function buildFacts(opts, deps = {}) {
996
1211
  deps,
997
1212
  );
998
1213
  if (result.status !== 'ok') return { status: result.status, reason: result.reason ?? null, claims };
999
- const { facts, downgraded } = validateFacts(result.answer, claims);
1214
+ // the delegate's cwd is the project (snapshot): the brief, seen from there
1215
+ const briefRel = opts.projectDir === undefined ? undefined : path.relative(realOrResolved(opts.projectDir), realOrResolved(opts.briefPath)).split(path.sep).join('/');
1216
+ // validated while the snapshot still exists (it is removed in `finally`)
1217
+ const { facts, downgraded } = validateFacts(result.answer, claims, { briefRel, ...(cwd === undefined ? {} : { cwd }) });
1000
1218
  const now = (opts.now ?? (() => new Date()))();
1001
1219
  const rel = path.relative(path.dirname(opts.outPath), opts.briefPath).split(path.sep).join('/');
1002
1220
  const text = renderSheet({
@@ -5,9 +5,11 @@
5
5
  * `acceptance` columns; the wave is the nearest heading that says `Wave <n>`) and checks:
6
6
  * - a facts sheet is present (pasted in the plan's §0 or given as `--facts`) and was built from
7
7
  * the brief as it is now; a separate sheet's `brief_sha256` line must also appear in the plan;
8
- * - every acceptance clause (the cell split on `;`) whose claim tokens the sheet does not mark
9
- * VERIFIED is listed in the "… the facts sheet cannot back" section by a line that names the
10
- * token, the block, and a tolerance — else `unbackable clause without tolerance: <block> <clause>`;
8
+ * - every acceptance clause (the cell split on `;` and on ` · (n) ` clause markers, B50) whose
9
+ * claim tokens the sheet does not mark VERIFIED is listed in the "… the facts sheet cannot
10
+ * back" section by a line that names the token, the block, and a tolerance (a list item saying
11
+ * `tolerance`, or a row of an older-template table with a non-empty tolerance column) — else
12
+ * `unbackable clause without tolerance: <block> <clause>`;
11
13
  * - a caller map section exists and names every block (C9);
12
14
  * - the required sections are found by their exact headings (`plan-sections.mjs`); a near miss at
13
15
  * the expected position is accepted with a WARN naming the exact heading (B36);
@@ -190,6 +192,65 @@ const listItems = (body) =>
190
192
  .filter((l) => /^\s*(?:[-*]|\d+\.)\s+/.test(l))
191
193
  .map((l) => l.trim());
192
194
 
195
+ /**
196
+ * The tolerance lines of the unbackable-clauses section (B50): every list item that says
197
+ * `tolerance` (the form `templates/plan.md` shows), plus — so plans written from the older table
198
+ * template keep passing — every data row of a table in the section whose HEADER (its first row,
199
+ * never a later one) has a `tolerance` cell, when that row's tolerance cell is filled: empty and
200
+ * the placeholders in {@link EMPTY_CELLS} do not count.
201
+ * @param {string} body @returns {string[]}
202
+ */
203
+ export function toleranceLines(body) {
204
+ const out = listItems(body).filter((l) => /\btolerance\b/i.test(l));
205
+ /** @type {number | null} null: not in a table; -1: in a table without a tolerance column. */
206
+ let col = null;
207
+ for (const raw of body.split('\n')) {
208
+ const line = raw.trim();
209
+ if (!line.startsWith('|')) {
210
+ col = null;
211
+ continue;
212
+ }
213
+ const row = cells(line);
214
+ if (col === null) {
215
+ col = row.findIndex((c) => /\btolerance\b/i.test(plain(c)));
216
+ continue;
217
+ }
218
+ if (col < 0 || row.every((c) => /^:?-+:?$/.test(c))) continue; // no tolerance column, or the separator row
219
+ if (!EMPTY_CELLS.has(plain(row[col] ?? '').toLowerCase())) out.push(line);
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /** Tolerance cells that say nothing (compared lower-cased, after trimming). */
225
+ const EMPTY_CELLS = new Set(['', '—', '–', '-', '--', '…', '...', 'n/a']);
226
+
227
+ /**
228
+ * Is a clause's claim backed by a VERIFIED fact? Its token is, or (B50) a command claim written
229
+ * without its CLI (`run reload`) matches the sheet's token that `forge facts` prefixed with the
230
+ * project's CLI (`code-forge run reload`: exactly one more leading word, and that word one of the
231
+ * project's own CLIs — `projectBins`).
232
+ * @param {{token: string, kind: string}} claim @param {ReadonlySet<string>} verified
233
+ * @param {() => ReadonlyArray<string>} projectBins - called only when a command claim needs it.
234
+ * @returns {boolean}
235
+ */
236
+ function isVerified(claim, verified, projectBins) {
237
+ if (verified.has(claim.token)) return true;
238
+ if (claim.kind !== 'command') return false;
239
+ return projectBins().some((bin) => verified.has(`${bin} ${claim.token}`));
240
+ }
241
+
242
+ /**
243
+ * The clauses of an acceptance cell (B50): split on `;` and on a ` · (n) ` clause marker (the
244
+ * template numbers clauses `(1) … · (2) …`); a `·` not followed by `(n)` stays inside its clause.
245
+ * @param {string} acceptance @returns {string[]}
246
+ */
247
+ export function splitClauses(acceptance) {
248
+ return acceptance
249
+ .split(/;|\s·\s+(?=\(\d+\))/)
250
+ .map((c) => plain(c))
251
+ .filter((c) => c.length > 0);
252
+ }
253
+
193
254
  /** Where a lane may come from: Jev's `lane` answer, or the deterministic fallback rule (`--rules`). */
194
255
  export const LANE_SOURCES = Object.freeze(['jev', 'rules']);
195
256
 
@@ -233,6 +294,10 @@ export function recordedLanes(rows, planName) {
233
294
  * @property {string} [planPath] - where the plan lives (resolves the sheet's brief reference).
234
295
  * @property {string} [factsText] @property {string} [factsPath] - a separate sheet (else the plan's own §0).
235
296
  * @property {Record<string, any>} [cfg] - `caps.coders`, `budget.block_cases`, `budget.block_lines`.
297
+ * @property {ReadonlyArray<string> | (() => ReadonlyArray<string>)} [projectBins] - B50: the
298
+ * project's own CLI names (`readProjectBins` of the project root), or a function giving them —
299
+ * called at most once, and only when a command claim is not VERIFIED as written; a clause's
300
+ * `run reload` is then backed by a VERIFIED `<bin> run reload`.
236
301
  * @property {Array<Record<string, any>>} [decisions] - the project's ledger rows (the recorded lanes);
237
302
  * none given ⇒ no block has a recorded lane.
238
303
  */
@@ -282,10 +347,14 @@ export function checkPlan(text, opts = {}) {
282
347
  // unbackable clauses
283
348
  const unbackable = required(REQUIRED_SECTIONS.unbackable);
284
349
  if (!unbackable) errors.push('unbackable-clauses section missing (write "none" when there is none)');
285
- const tolerances = unbackable ? listItems(unbackable.body).filter((l) => /\btolerance\b/i.test(l)) : [];
350
+ const tolerances = unbackable ? toleranceLines(unbackable.body) : [];
351
+ /** @type {ReadonlyArray<string> | undefined} */
352
+ let binsCache;
353
+ const given = opts.projectBins;
354
+ const projectBins = () => (binsCache ??= typeof given === 'function' ? given() : given ?? []);
286
355
  for (const b of blocks) {
287
- for (const clause of b.acceptance.split(';').map((c) => plain(c)).filter((c) => c.length > 0)) {
288
- const unbacked = extractClaims(clause).filter((c) => !verified.has(c.token));
356
+ for (const clause of splitClauses(b.acceptance)) {
357
+ const unbacked = extractClaims(clause).filter((c) => !isVerified(c, verified, projectBins));
289
358
  if (unbacked.every((c) => tolerances.some((t) => t.includes(c.token) && idRe(b.id).test(t)))) continue;
290
359
  errors.push(`unbackable clause without tolerance: ${b.id} ${clause}`);
291
360
  }