@codedology/code-forge 0.15.3 → 0.16.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 (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +9 -1
  4. package/docs/reference/config.md +11 -1
  5. package/native/README.md +10 -0
  6. package/native/bin/darwin-arm64/code-forge-native +0 -0
  7. package/native/bin/darwin-x64/code-forge-native +0 -0
  8. package/native/bin/linux-arm64/code-forge-native +0 -0
  9. package/native/bin/linux-x64/code-forge-native +0 -0
  10. package/package.json +7 -2
  11. package/schema/code-forge.schema.json +26 -1
  12. package/schema/facts.schema.json +1 -1
  13. package/skill/SKILL.md +1 -1
  14. package/skill/references/adapters/solo.md +1 -1
  15. package/skill/references/autopilot.md +6 -1
  16. package/skill/references/code.md +37 -0
  17. package/skill/references/decisions.md +1 -1
  18. package/skill/references/plan.md +18 -5
  19. package/skill/references/review.md +47 -9
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/facts-sheet.md +9 -3
  22. package/src/cli/block.mjs +284 -11
  23. package/src/cli/doctor.mjs +32 -1
  24. package/src/cli/help.mjs +1 -0
  25. package/src/cli/native-status.mjs +13 -0
  26. package/src/cli/review-file.mjs +126 -14
  27. package/src/cli/spawn.mjs +26 -22
  28. package/src/cli/status.mjs +80 -14
  29. package/src/decide/escalation.mjs +5 -5
  30. package/src/doctor/index.mjs +11 -0
  31. package/src/doctor/worker-probe.mjs +1 -1
  32. package/src/gates/dependents.mjs +200 -0
  33. package/src/gates/expand.mjs +83 -26
  34. package/src/gates/run.mjs +76 -14
  35. package/src/ledger/tail.mjs +89 -0
  36. package/src/review/agent-waiver.mjs +95 -8
  37. package/src/review/definitions.mjs +170 -0
  38. package/src/review/fixloop.mjs +4 -4
  39. package/src/review/gate-check.mjs +33 -13
  40. package/src/review/model-stats.mjs +125 -0
  41. package/src/review/packet.mjs +111 -22
  42. package/src/review/reopen.mjs +38 -13
  43. package/src/review/route.mjs +58 -4
  44. package/src/session/facts.mjs +252 -70
  45. package/src/session/plan-check.mjs +4 -1
  46. package/src/session/spawn.mjs +70 -19
  47. package/src/state/block-end.mjs +3 -1
  48. package/src/state/block.mjs +56 -21
  49. package/src/state/run.mjs +12 -5
  50. package/src/util/native.mjs +133 -0
  51. package/src/worker/engine.mjs +11 -26
  52. package/src/worker/loop.mjs +150 -20
  53. package/src/worker/queue.mjs +3 -2
  54. package/src/worker/review-state.mjs +15 -3
  55. package/src/worker/watch.mjs +97 -83
  56. package/src/worker/worker-warning.mjs +35 -0
@@ -2,14 +2,13 @@
2
2
  * The facts rule (plan §3.8, C16; block B9b): `forge facts` builds the facts sheet a Plan job
3
3
  * must read before it designs anything.
4
4
  *
5
- * Step 1 (deterministic): {@link extractClaims} lists every claim token of the brief and the
6
- * sources it names — CLI flags, `<cli> <verb>` commands (inside code spans), `~/…` and `./…`
7
- * paths, versions, http(s) endpoints, `@scope/name` packages and environment variables.
5
+ * Step 1 (deterministic): {@link extractClaims} lists claim tokens from the brief alone — flags,
6
+ * commands, paths, versions, endpoints, packages, environment variables and opt-in code symbols.
8
7
  * Step 2: ONE L0 `facts` session (closed-book; Claude gets `--tools "Bash"` with the forbidden list
9
8
  * rendered, §5.2) whose packet is the claim list and the rule "run the cheapest read-only check,
10
9
  * quote the command and the output line, tag it, never infer". B9b.1: when the caller names the
11
10
  * project (`projectDir`), the session runs in a READ-ONLY SNAPSHOT of it ({@link buildSnapshot}:
12
- * `git archive HEAD` plus the `--sources` inside the project, symlinks and secret-looking files
11
+ * `git archive HEAD` plus in-project sources and outside roots under `_sources/<label>`, symlinks and secret-looking files
13
12
  * dropped, every file 0444 and directory 0555) so `./…` path claims can be checked; the snapshot
14
13
  * lives under the run root — or, when that root itself sits inside a git work tree, under a
15
14
  * private B0.1 root in `os.tmpdir()` (`factsTmpRoot`) — and is removed when the verb ends;
@@ -43,13 +42,15 @@ import { redact } from '../util/redact.mjs';
43
42
  import { currentRunRoot } from '../util/tmp.mjs';
44
43
  import { factsTmpRoot, gitWorkTreeAncestor, spawnSession } from './spawn.mjs';
45
44
 
46
- /** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'} ClaimKind */
47
- /** @typedef {{token: string, kind: ClaimKind}} Claim */
45
+ /** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'|'symbol'} ClaimKind */
46
+ /** @typedef {{dir: string, label: string, prefix: string, file?: string}} SourceRoot */
47
+ /** @typedef {{token: string, kind: ClaimKind, source?: string, checkPath?: string, root?: SourceRoot, unavailable?: string}} Claim */
48
48
  /**
49
49
  * @typedef {object} Fact
50
50
  * @property {string} fact_id @property {string} claim @property {ClaimKind} kind
51
51
  * @property {string} command @property {string} output_excerpt
52
52
  * @property {'VERIFIED'|'NOT-FOUND'|'UNVERIFIABLE'} tag @property {string | null} why
53
+ * @property {string} [source]
53
54
  */
54
55
 
55
56
  export const FACTS_SCHEMA_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'schema', 'facts.schema.json');
@@ -104,9 +105,9 @@ export class FactsError extends Error {
104
105
  const PATTERNS = /** @type {ReadonlyArray<[ClaimKind, RegExp]>} */ ([
105
106
  ['endpoint', /https?:\/\/[^\s`'"<>)\]|]+/g],
106
107
  ['package', /(?<![\w./@-])@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*/g],
107
- ['path', /(?<![\w/.~-])(?:~|\.{1,2})\/[^\s`'"<>)\]|,;]*/g],
108
+ ['path', /(?<![\w/.~-])(?:~|\.{1,2}|[\w.-]+)?\/[^\s`'"<>)\]|,;]*/g],
108
109
  ['flag', /(?<![\w-])--[a-z][a-z0-9-]*/g],
109
- ['version', /(?<![\w.§])\d+\.\d+(?:\.\d+)*/g],
110
+ ['version', /(?<![\w.§])(?<!§ )\d+\.\d+(?:\.\d+)*/g],
110
111
  ['env', /(?<![\w])\$?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![\w])/g],
111
112
  ]);
112
113
 
@@ -122,6 +123,9 @@ const trimTail = (token) => token.replace(/[.,:;]+$/, '');
122
123
  * `bin` keys, {@link readProjectBins}); none ⇒ command claims are kept as written.
123
124
  * @property {string} [briefText] - the brief alone (no sources): only IT can name the project CLI
124
125
  * that a command claim gets prefixed with (default: the whole text).
126
+ * @property {string} [projectDir] - project root whose top-level folders admit extensionless paths.
127
+ * @property {boolean} [symbols] - whole code spans naming code identifiers or code file names.
128
+ * @property {boolean} [globPaths] - keep shell glob characters in path tokens (facts only).
125
129
  */
126
130
 
127
131
  /**
@@ -205,6 +209,8 @@ export function readProjectBins(projectDir) {
205
209
  */
206
210
  export function extractClaims(text, opts = {}) {
207
211
  let masked = String(text);
212
+ const topLevelFolders = new Set(opts.projectDir === undefined ? [] : readdirSync(opts.projectDir, { withFileTypes: true })
213
+ .filter((entry) => entry.isDirectory()).map((entry) => entry.name));
208
214
  /** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
209
215
  const found = [];
210
216
  /** @param {number} start @param {number} length */
@@ -215,15 +221,24 @@ export function extractClaims(text, opts = {}) {
215
221
  const scan = (kind, re) => {
216
222
  for (const m of [...masked.matchAll(re)]) {
217
223
  const token = trimTail(kind === 'env' ? m[0].replace(/^\$/, '') : m[0]);
218
- if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})\/$/.test(token))) continue;
224
+ if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})?\/$/.test(token))) continue;
225
+ if (kind === 'path' && !/^(~|\.{1,2})?\//.test(token)
226
+ && !(opts.globPaths && /[*?\[\]{}]/.test(token))
227
+ && !/\.[a-zA-Z0-9][\w-]*$/.test(token) && !topLevelFolders.has(token.split('/')[0])) continue;
219
228
  found.push({ index: /** @type {number} */ (m.index), token, kind });
220
229
  mask(/** @type {number} */ (m.index), m[0].length);
221
230
  }
222
231
  };
223
232
  scan(...PATTERNS[0]);
224
233
  scan(...PATTERNS[1]);
225
- scan(...PATTERNS[2]);
234
+ scan('path', opts.globPaths ? /(?<![\w/.~-])(?:~|\.{1,2}|[\w.-]+)?\/[^\s`'"<>)|;]*/g : PATTERNS[2][1]);
226
235
  for (const m of [...masked.matchAll(/`([^`\n]+)`/g)]) {
236
+ const symbol = m[1].trim();
237
+ if (opts.symbols && (IDENTIFIER_SYMBOL.test(symbol) || FILE_SYMBOL.test(symbol))) {
238
+ found.push({ index: /** @type {number} */ (m.index) + 1, token: symbol, kind: 'symbol' });
239
+ mask(/** @type {number} */ (m.index) + 1, m[1].length);
240
+ continue;
241
+ }
227
242
  const cmd = COMMAND_IN_SPAN.exec(m[1]);
228
243
  if (!cmd) continue;
229
244
  const start = /** @type {number} */ (m.index) + 1;
@@ -244,35 +259,8 @@ export function extractClaims(text, opts = {}) {
244
259
  return claims;
245
260
  }
246
261
 
247
- /**
248
- * The text of every source: a file as is, a directory's top-level `.md`/`.txt` files in name order.
249
- * @param {ReadonlyArray<string>} sources
250
- * @returns {string[]}
251
- */
252
- function readSourcesText(sources) {
253
- /** @type {string[]} */
254
- const parts = [];
255
- for (const source of sources) {
256
- const st = statSync(source);
257
- if (st.isDirectory()) {
258
- for (const name of readdirSync(source).filter((n) => /\.(md|txt)$/.test(n)).sort()) {
259
- parts.push(readFileSync(path.join(source, name), 'utf8'));
260
- }
261
- } else {
262
- parts.push(readFileSync(source, 'utf8'));
263
- }
264
- }
265
- return parts;
266
- }
267
-
268
- /**
269
- * The brief's text plus every source it names ({@link readSourcesText}).
270
- * @param {string} briefPath @param {ReadonlyArray<string>} [sources]
271
- * @returns {string}
272
- */
273
- export function readClaimText(briefPath, sources = []) {
274
- return [readFileSync(briefPath, 'utf8'), ...readSourcesText(sources)].join('\n');
275
- }
262
+ const IDENTIFIER_SYMBOL = /^(?:(?:[A-Z][a-z0-9]+|[A-Z]+(?=[A-Z][a-z]|$)){2,}|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)$/;
263
+ const FILE_SYMBOL = /^[\w.-]+\.(?:php|mjs|cjs|js|ts|tsx|jsx|go|py|rb|swift|kt|java|rs|cs|vue|svelte|sql)$/;
276
264
 
277
265
  /**
278
266
  * The brief, read ONCE: its bytes (sha256 and claim text) and its mtime from the same open file.
@@ -304,12 +292,12 @@ const GLOB_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
304
292
  * reported instead of parsed, and so is an unquoted `* ? [ ] { }` (a glob expands AFTER the
305
293
  * secret-path check and could name what the check never saw): the allow-list never needs one
306
294
  * (fail closed).
307
- * @param {string} command
295
+ * @param {string} command @param {boolean} [allowGlob] - only the exact glob ls proof uses this.
308
296
  * @returns {{segments: string[][], separators: string[], pipedOut?: boolean[], refused: string | null}}
309
297
  * `separators`: every separator met, in order (`|`, `||`, `&&`, `;`, `&`, newline, CR);
310
298
  * `pipedOut[i]`: segment i feeds a single `|` (its output is not the command's output).
311
299
  */
312
- function splitCommand(command) {
300
+ function splitCommand(command, allowGlob = false) {
313
301
  if (/[`]/.test(command)) return { segments: [], separators: [], refused: 'backticks are not allowed' };
314
302
  if (/\$/.test(command)) return { segments: [], separators: [], refused: '$ expansion is not allowed' };
315
303
  if (/[<>]/.test(command)) return { segments: [], separators: [], refused: 'redirection is not allowed' };
@@ -353,7 +341,7 @@ function splitCommand(command) {
353
341
  } else if (c === ' ' || c === '\t') {
354
342
  flush();
355
343
  } else {
356
- if (GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
344
+ if (!allowGlob && GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
357
345
  word += c;
358
346
  inWord = true;
359
347
  }
@@ -578,7 +566,8 @@ function isSubPath(where) {
578
566
  function sourceGrep(rest) {
579
567
  if (rest.length !== 4 || !GREP_RECURSIVE.has(rest[0]) || rest[1] !== '--') return null;
580
568
  const [, , pattern, where] = rest;
581
- return SOURCE_GREP_FLAG.test(pattern) && isSubPath(where) ? { pattern, path: where } : null;
569
+ return (SOURCE_GREP_FLAG.test(pattern) || /^(?:(?:class|interface|trait|enum|struct|type|function|func|def) )?[A-Za-z_][A-Za-z0-9_]*$/.test(pattern))
570
+ && isSubPath(where) ? { pattern, path: where } : null;
582
571
  }
583
572
 
584
573
  /**
@@ -620,16 +609,18 @@ function isSourceFile(file, briefRel) {
620
609
  * path, is a source file ({@link isSourceFile}) and whose text holds `flag` as a whole token
621
610
  * ({@link holdsFlag})? A hit line without the `file:line:` prefix never counts.
622
611
  * @param {string} excerpt @param {string} flag @param {string} under - the grep's path.
623
- * @param {string | undefined} briefRel
612
+ * @param {string | undefined} briefRel @param {boolean} [symbol] @param {string} [prefix]
624
613
  * @returns {boolean}
625
614
  */
626
- function sourceHit(excerpt, flag, under, briefRel) {
615
+ function sourceHit(excerpt, flag, under, briefRel, symbol = false, prefix = '') {
627
616
  const scope = relPosix(under);
628
617
  return excerpt.split(/\r?\n/).some((line) => {
629
618
  const hit = /^([^:\n]+):(\d+):(.*)$/.exec(line.trim());
630
619
  if (hit === null) return false;
631
620
  const file = relPosix(hit[1]);
632
- return (file === scope || file.startsWith(`${scope}/`)) && isSourceFile(file, briefRel) && holdsFlag(hit[3], flag);
621
+ const sourceFile = prefix ? file.slice(prefix.length + 1) : file;
622
+ return (file === scope || file.startsWith(`${scope}/`)) && file !== briefRel && isSourceFile(sourceFile, prefix ? undefined : briefRel)
623
+ && (symbol ? new RegExp(`\\b${flag}\\b`).test(hit[3]) : holdsFlag(hit[3], flag));
633
624
  });
634
625
  }
635
626
 
@@ -638,14 +629,15 @@ function sourceHit(excerpt, flag, under, briefRel) {
638
629
  * hidden (`.x`) or one of {@link NON_SOURCE_FOLDERS}, and — with `topLevel` — one single name (a
639
630
  * NOT-FOUND proves absence only over a whole top-level folder). A file (`package.json`,
640
631
  * `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
632
+ * @param {string} where @param {string | undefined} cwd @param {boolean} topLevel @param {string} [prefix]
642
633
  * @returns {boolean}
643
634
  */
644
- function isSourceFolder(where, cwd, topLevel) {
635
+ function isSourceFolder(where, cwd, topLevel, prefix = '') {
645
636
  const rel = relPosix(where);
646
- const segs = rel.split('/');
637
+ if (prefix && !rel.startsWith(`${prefix}/`)) return false;
638
+ const segs = (prefix ? rel.slice(prefix.length + 1) : rel).split('/');
647
639
  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;
640
+ if (segs.some((seg) => seg === '_sources' || seg.startsWith('.') || NON_SOURCE_FOLDERS.has(seg))) return false;
649
641
  try {
650
642
  return statSync(path.join(cwd, rel)).isDirectory();
651
643
  } catch {
@@ -733,6 +725,45 @@ const SOURCE_GREP_CLAIM_MESSAGE = 'a source grep counts only for a flag claim, w
733
725
  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
726
  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
727
  const FLAG_PROOF_MESSAGE = 'a flag claim is proved only by grep -rn -- <flag> <source folder>; not counted';
728
+ const SYMBOL_PROOF_MESSAGE = 'a symbol claim needs grep -rn -- <quoted token or keyword token> <top-level source folder> with a file:line: source hit, or find <root> -name <file name>; not counted';
729
+
730
+ /** @param {Fact} row @param {Claim} claim @param {{briefRel?: string, cwd?: string}} ctx */
731
+ function symbolRule(row, claim, ctx) {
732
+ if (readOnlyViolation(row.command)) return SYMBOL_PROOF_MESSAGE;
733
+ const prefix = claim.root?.prefix ?? '';
734
+ if (FILE_SYMBOL.test(claim.token)) {
735
+ const { segments } = splitCommand(row.command);
736
+ const argv = segments[0] ?? [];
737
+ if (argv.length !== 4 || argv[0] !== 'find' || argv[1] !== (prefix || '.') || argv[2] !== '-name' || argv[3] !== claim.token) return SYMBOL_PROOF_MESSAGE;
738
+ if (row.tag === 'VERIFIED' && !row.output_excerpt.split(/\r?\n/).some((line) => {
739
+ const file = relPosix(line.trim());
740
+ return line.trim().endsWith(`/${claim.token}`) && (prefix ? file.startsWith(`${prefix}/`) : !file.startsWith('_sources/')) && isSubPath(file);
741
+ })) return SYMBOL_PROOF_MESSAGE;
742
+ return null;
743
+ }
744
+ const grep = sourceGrepOf(row.command);
745
+ if (!grep || !/^grep -rn -- (['"])[^'"]+\1(?:\s|$)/.test(row.command)
746
+ || !(grep.pattern === claim.token || /^(class|interface|trait|enum|struct|type|function|func|def) /.test(grep.pattern) && grep.pattern.split(' ')[1] === claim.token)
747
+ || !isSourceFolder(grep.path, ctx.cwd, true, prefix)) return SYMBOL_PROOF_MESSAGE;
748
+ if (row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, claim.token, grep.path, ctx.briefRel, true, prefix)) return SYMBOL_PROOF_MESSAGE;
749
+ return null;
750
+ }
751
+
752
+ /** @param {Fact} row @param {Claim} claim */
753
+ function globRule(row, claim) {
754
+ const token = claim.checkPath ?? claim.token;
755
+ const { segments, separators, refused } = splitCommand(row.command, true);
756
+ const argv = segments[0] ?? [];
757
+ const ls = argv.length === 3 && argv[0] === 'ls' && argv[1] === '-d' && argv[2] === token;
758
+ const find = !/[*?\[\]{}]/.test(path.posix.dirname(token))
759
+ && argv.length === 4 && argv[0] === 'find' && argv[1] === path.posix.dirname(token)
760
+ && argv[2] === '-name' && argv[3] === path.posix.basename(token) && readOnlyViolation(row.command) === null
761
+ && /-name (['"])[^'"]+\1$/.test(row.command);
762
+ if (refused || separators.length || segments.length !== 1 || (!ls && !find) || segmentViolation(argv)) return 'a glob needs find <literal dir> -name <quoted pattern> or ls -d <exact glob>; not counted';
763
+ if (row.tag === 'NOT-FOUND') return /No such file/i.test(row.output_excerpt) ? null : 'a NOT-FOUND glob needs No such file output; not counted';
764
+ return row.output_excerpt.split(/\r?\n/).some((line) => path.matchesGlob(relPosix(line.trim()), relPosix(token)))
765
+ ? null : 'VERIFIED glob without a matching output path; not counted';
766
+ }
736
767
 
737
768
  /**
738
769
  * Does a grep hit line hold `flag` as a whole token — not preceded by a word character or `-`, not
@@ -754,6 +785,11 @@ function holdsFlag(excerpt, flag) {
754
785
  function ruleBroken(row, claim, ctx) {
755
786
  if (row.tag === 'UNVERIFIABLE') return null;
756
787
  if (row.command.trim().length === 0) return `${row.tag} without a check command; not counted`;
788
+ if (claim.kind === 'path' && (claim.token.startsWith('~') || /(^|\/)\.\.(\/|$)/.test(claim.token))) {
789
+ return `check command not allowed: ${pathViolation(claim.token)}; not counted`;
790
+ }
791
+ if (claim.kind === 'symbol') return symbolRule(row, claim, ctx);
792
+ if (claim.kind === 'path' && /[*?\[\]{}]/.test(claim.token)) return globRule(row, claim);
757
793
  if (claim.kind === 'env') {
758
794
  const env = ENV_CHECK.exec(row.command);
759
795
  if (!env || env[1] !== claim.token) return `an env claim may only be checked with printenv ${claim.token} >/dev/null; not counted`;
@@ -768,9 +804,9 @@ function ruleBroken(row, claim, ctx) {
768
804
  // prove itself); a NOT-FOUND one needs one whole top-level source folder
769
805
  const grep = sourceGrepOf(row.command);
770
806
  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;
807
+ if (claim.kind === 'flag' && (grep === null || !isSourceFolder(grep.path, ctx.cwd, false, claim.root?.prefix))) return FLAG_PROOF_MESSAGE;
808
+ if (grep !== null && row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, grep.pattern, grep.path, ctx.briefRel, false, claim.root?.prefix)) return SOURCE_GREP_HIT_MESSAGE;
809
+ if (grep !== null && row.tag === 'NOT-FOUND' && !isSourceFolder(grep.path, ctx.cwd, true, claim.root?.prefix)) return SOURCE_GREP_SCOPE_MESSAGE;
774
810
  }
775
811
  if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) return 'VERIFIED with an empty output excerpt; not counted';
776
812
  return null;
@@ -778,7 +814,8 @@ function ruleBroken(row, claim, ctx) {
778
814
 
779
815
  /**
780
816
  * Validate the delegate's answer (step 3). Null commands/excerpts are normalized on a copy and
781
- * their matched claims downgraded; every other schema-invalid answer is refused. A row answers
817
+ * their matched claims downgraded; long excerpts are capped without changing the tag. Every other
818
+ * schema-invalid answer is refused. A row answers
782
819
  * claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
783
820
  * the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
784
821
  * equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
@@ -802,9 +839,11 @@ export function validateFacts(answer, claims, opts = {}) {
802
839
  for (const row of normalized.facts) {
803
840
  if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
804
841
  const fields = ['command', 'output_excerpt'].filter((field) => row[field] === null);
805
- if (fields.length === 0) continue;
806
842
  for (const field of fields) row[field] = '';
807
- nullFields.set(row, fields);
843
+ if (fields.length > 0) nullFields.set(row, fields);
844
+ if (typeof row.output_excerpt === 'string' && row.output_excerpt.length > EXCERPT_MAX) {
845
+ row.output_excerpt = `${row.output_excerpt.slice(0, EXCERPT_MAX - 1)}…`;
846
+ }
808
847
  }
809
848
  }
810
849
  if (!validateFile(normalized)) {
@@ -829,6 +868,7 @@ export function validateFacts(answer, claims, opts = {}) {
829
868
  });
830
869
  claims.forEach((claim, i) => {
831
870
  if (chosen[i]) return;
871
+ if (claims.filter((c) => c.token === claim.token).length > 1) return;
832
872
  const row = rows.find((r) => !taken.has(r) && r.kind === claim.kind && echoedClaim(r.claim, claim.kind) === claim.token);
833
873
  if (row) taken.add(row);
834
874
  chosen[i] = row;
@@ -836,11 +876,12 @@ export function validateFacts(answer, claims, opts = {}) {
836
876
  claims.forEach((claim, i) => {
837
877
  const id = `F${i + 1}`;
838
878
  const row = chosen[i];
839
- if (!row) {
840
- facts.push({ fact_id: id, claim: redact(claim.token), kind: claim.kind, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: 'no answer from the delegate' });
879
+ const source = claim.source === undefined ? {} : { source: redact(claim.source) };
880
+ if (claim.unavailable || !row) {
881
+ facts.push({ fact_id: id, claim: redact(claim.token), kind: claim.kind, ...source, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: redact(claim.unavailable ?? 'no answer from the delegate') });
841
882
  return;
842
883
  }
843
- 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) };
884
+ const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, ...source, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
844
885
  const fields = nullFields.get(row);
845
886
  const broken = fields ? `delegate returned null ${fields.join(', ')}` : ruleBroken(row, claim, opts);
846
887
  if (broken) {
@@ -873,6 +914,7 @@ const code = (text) => (text.length === 0 ? '—' : text.includes('`') ? `\`\` $
873
914
  */
874
915
  export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts }) {
875
916
  const count = (/** @type {string} */ tag) => facts.filter((f) => f.tag === tag).length;
917
+ const sources = facts.some((f) => f.source !== undefined);
876
918
  const lines = [
877
919
  '# Facts sheet',
878
920
  '',
@@ -882,9 +924,9 @@ export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts })
882
924
  `- built_at: \`${builtAt}\``,
883
925
  `- claims: ${facts.length} (VERIFIED ${count('VERIFIED')} · NOT-FOUND ${count('NOT-FOUND')} · UNVERIFIABLE ${count('UNVERIFIABLE')})`,
884
926
  '',
885
- '| fact_id | tag | kind | claim | command | output | why |',
886
- '|---|---|---|---|---|---|---|',
887
- ...facts.map((f) => `| ${f.fact_id} | ${f.tag} | ${f.kind} | ${cell(code(f.claim))} | ${cell(code(f.command))} | ${cell(f.output_excerpt || '—')} | ${cell(f.why ?? '—')} |`),
927
+ `| fact_id | tag | kind |${sources ? ' source |' : ''} claim | command | output | why |`,
928
+ `|---|---|---|---|---|---|---|${sources ? '---|' : ''}`,
929
+ ...facts.map((f) => `| ${f.fact_id} | ${f.tag} | ${f.kind} |${sources ? ` ${cell(f.source ?? '—')} |` : ''} ${cell(code(f.claim))} | ${cell(code(f.command))} | ${cell(f.output_excerpt || '—')} | ${cell(f.why ?? '—')} |`),
888
930
  '',
889
931
  '<!-- facts-json',
890
932
  `[\n${facts.map((f) => JSON.stringify(f)).join(',\n')}\n]`.replace(/</g, '\\u003c').replace(/>/g, '\\u003e'),
@@ -953,13 +995,24 @@ const RULE = [
953
995
  '-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, git',
954
996
  'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, test -e|-f|-d|-n, `curl -sI <one URL>`.',
955
997
  'A check that breaks these rules is not counted.',
956
- 'No `$`, no backticks, no redirection, no unquoted glob, no `~`, no absolute path outside /usr, /opt/homebrew or',
998
+ 'A source root is the project (`.`) or `_sources/<label>`. A top-level source folder is directly',
999
+ 'under that root: `src` or `_sources/<label>/app`. `_sources` itself is never a source folder.',
1000
+ 'An identifier symbol needs `grep -rn -- <quoted token or keyword token> <top-level source folder>`.',
1001
+ 'Quote the pattern. Keywords: class interface trait enum struct type function func def.',
1002
+ 'VERIFIED needs a file:line: hit in a source file holding the whole token; NOT-FOUND needs a whole top-level source folder.',
1003
+ 'A file-name symbol needs `find <root> -name <token>`; VERIFIED needs an output path ending in /<token>.',
1004
+ 'For a glob path use the per-claim check command. A find directory must be literal and its pattern quoted.',
1005
+ 'If a directory segment has glob characters, only `ls -d <exact glob token>` is accepted. For any glob claim,',
1006
+ '`ls -d <exact glob token>` is also accepted, quoted or unquoted. VERIFIED needs a matching output path;',
1007
+ 'NOT-FOUND needs No such file output. Use the check path given for a source, never its live absolute path.',
1008
+ 'No `$`, no backticks, no redirection, no unquoted glob except the exact ls -d form above, no `~`, no absolute path outside /usr, /opt/homebrew or',
957
1009
  '/bin, no secret files.',
958
1010
  'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
959
1011
  '',
960
1012
  'Reply with ONE JSON object: {"facts": [{"fact_id", "claim", "kind", "command", "output_excerpt", "tag", "why"}]} —',
961
1013
  'one row per claim: `fact_id` (F<n>) and `kind` copied from the list, `claim` = the token alone (the text after',
962
1014
  '`<kind>: `), `why` null unless UNVERIFIABLE.',
1015
+ 'The claim is the token alone, without source/check annotations or the bracketed glob check hint.',
963
1016
  '',
964
1017
  '## Claims',
965
1018
  '',
@@ -970,7 +1023,23 @@ const RULE = [
970
1023
  * @returns {string} the delegate's packet.
971
1024
  */
972
1025
  export function buildFactsPacket(claims) {
973
- return `${RULE}${claims.map((c, i) => `- F${i + 1} ${c.kind}: ${c.token}`).join('\n')}\n`;
1026
+ return `${RULE}${claims.flatMap((c, i) => {
1027
+ if (c.unavailable) return [];
1028
+ const token = c.checkPath ?? c.token;
1029
+ let hint = '';
1030
+ if (c.kind === 'path' && /[*?\[\]{}]/.test(c.token)) {
1031
+ const dir = path.posix.dirname(token);
1032
+ hint = /[*?\[\]{}]/.test(dir)
1033
+ ? ` [check with ls -d ${shellQuote(token)}]`
1034
+ : ` [check with find ${shellQuote(dir)} -name ${shellQuote(path.posix.basename(token), true)}]`;
1035
+ }
1036
+ return [`- F${i + 1} ${c.kind}: ${c.token}${c.source === undefined ? (c.checkPath ? ` (check at ${c.checkPath})` : '') : ` (source ${c.source}, check at ${c.checkPath})`}${hint}`];
1037
+ }).join('\n')}\n`;
1038
+ }
1039
+
1040
+ /** @param {string} value @param {boolean} [always] */
1041
+ function shellQuote(value, always = false) {
1042
+ return !always && /^[\w./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'";
974
1043
  }
975
1044
 
976
1045
  /**
@@ -984,6 +1053,7 @@ export function buildFactsPacket(claims) {
984
1053
  * @property {string} [projectDir] - the project (the verb's cwd): the delegate runs in a read-only
985
1054
  * snapshot of its git HEAD plus the in-project `sources`; absent ⇒ an empty cwd.
986
1055
  * @property {() => Date} [now]
1056
+ * @property {typeof exec} [gitRun] - real git runner seam for the batched availability check.
987
1057
  */
988
1058
 
989
1059
  // ---------------------------------------------------------------------------------------------
@@ -1128,13 +1198,91 @@ function copyNoFollow(src, dst, snapDir) {
1128
1198
  }
1129
1199
  }
1130
1200
 
1201
+ /** @param {string} root @param {string} file */
1202
+ function belowRoot(root, file) {
1203
+ const rel = path.relative(root, file);
1204
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
1205
+ }
1206
+
1207
+ /** @param {string | undefined} projectDir @param {ReadonlyArray<string>} sources @returns {SourceRoot[]} */
1208
+ function sourceRoots(projectDir, sources) {
1209
+ /** @type {SourceRoot[]} */
1210
+ const roots = [];
1211
+ const project = projectDir === undefined ? undefined : realpathSync(projectDir);
1212
+ /** @param {string} dir @param {string} [file] */
1213
+ const add = (dir, file) => {
1214
+ const existing = roots.find((r) => r.dir === dir);
1215
+ if (existing && (!existing.file || existing.file === file)) return;
1216
+ const base = path.basename(dir);
1217
+ let label = base;
1218
+ for (let n = 2; roots.some((r) => r.label === label); n += 1) label = `${base}-${n}`;
1219
+ roots.push({ dir, label, prefix: dir === project ? '' : `_sources/${label}`, ...(file ? { file } : {}) });
1220
+ };
1221
+ if (project !== undefined) add(project);
1222
+ for (const source of sources) {
1223
+ const real = realpathSync(source);
1224
+ if (project !== undefined && belowRoot(project, real)) continue;
1225
+ if (statSync(real).isDirectory()) add(real);
1226
+ else add(path.dirname(real), real);
1227
+ }
1228
+ return roots;
1229
+ }
1230
+
1231
+ /** @param {ReadonlyArray<Claim>} claims @param {SourceRoot[]} roots @returns {Claim[]} */
1232
+ function mapClaims(claims, roots) {
1233
+ // An explicitly named outside file is mounted, but only directories multiply relative claims.
1234
+ const directories = roots.filter((r) => !r.file);
1235
+ return claims.flatMap((claim) => {
1236
+ if (claim.kind === 'path' && path.isAbsolute(claim.token)) {
1237
+ if (claim.token === '/dev' || claim.token.startsWith('/dev/')) return [];
1238
+ // A nested source is the most specific root for an absolute path.
1239
+ const root = [...roots].sort((a, b) => b.dir.length - a.dir.length).find((r) => belowRoot(r.dir, claim.token) && (!r.file || r.file === claim.token));
1240
+ if (root) return [{ ...claim, ...(roots.length > 1 || root.prefix !== '' ? { source: root.label } : {}), root, checkPath: path.posix.join(root.prefix, path.relative(root.dir, claim.token).split(path.sep).join('/')) || '.' }];
1241
+ if (TOOL_ROOTS.some((r) => belowRoot(r, claim.token))) return [claim];
1242
+ try {
1243
+ if (!statSync('/' + claim.token.split('/')[1]).isDirectory()) return [];
1244
+ } catch { return []; }
1245
+ return [{ ...claim, unavailable: 'outside every --sources root and the snapshot' }];
1246
+ }
1247
+ if (directories.length < 2 || !['path', 'symbol', 'flag'].includes(claim.kind)) return [claim];
1248
+ return directories.map((root) => ({ ...claim, source: root.label, root,
1249
+ checkPath: claim.kind === 'path' ? (root.prefix ? `${root.prefix}/${claim.token}` : claim.token) : root.prefix || '.',
1250
+ }));
1251
+ });
1252
+ }
1253
+
1254
+ /**
1255
+ * One git process per root, never per claim. Only lstat checks working-tree presence; Node never
1256
+ * searches source text. A path omitted by the snapshot cannot become a delegate NOT-FOUND.
1257
+ * @param {Claim[]} claims @param {SourceRoot[]} roots @param {string} cwd @param {typeof exec} run
1258
+ */
1259
+ async function markUnavailable(claims, roots, cwd, run) {
1260
+ const directories = roots.filter((r) => !r.file);
1261
+ for (const root of roots) {
1262
+ const paths = claims.filter((c) => c.kind === 'path' && !c.unavailable && (c.root === root || !c.root && directories.length === 1 && root === directories[0] && !path.isAbsolute(c.token))
1263
+ && pathViolation(c.checkPath ?? c.token) === null);
1264
+ const relatives = paths.map((c) => path.isAbsolute(c.token) ? path.relative(root.dir, c.token) || '.' : c.token);
1265
+ const checked = await run(['git', 'check-ignore', '-z', '--stdin'], {
1266
+ cwd: root.dir, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS, input: relatives.map((p) => `${p}\0`).join(''), okExitCodes: [0, 1],
1267
+ });
1268
+ if (checked.result !== 'ok' && !(checked.code === 128 && /not a git repository/i.test(checked.stderr))) throw new FactsError('snapshot', 'git check-ignore could not check the source paths');
1269
+ const ignored = new Set(checked.stdout.split('\0').filter(Boolean));
1270
+ paths.forEach((claim, i) => {
1271
+ if (ignored.has(relatives[i])) claim.unavailable = `git-ignored in ${root.label}: the snapshot leaves ignored paths out`;
1272
+ else if (lstatOrNull(path.join(root.dir, relatives[i])) && !lstatOrNull(path.join(cwd, claim.checkPath ?? claim.token))) {
1273
+ claim.unavailable = `not at HEAD in ${root.label}: outside the snapshot`;
1274
+ }
1275
+ });
1276
+ }
1277
+ }
1278
+
1131
1279
  /**
1132
1280
  * Build the facts delegate's read-only view of the project at `snapDir`: the tracked tree at HEAD
1133
1281
  * (`git archive` + `tar -x`, binary-safe, the real index and tree never touched; every symlink and
1134
1282
  * special entry dropped right after extraction) plus every `source` inside the project at its
1135
- * current content ({@link copyNoFollow}; untracked sources included; a source outside `projectDir`
1136
- * — a sibling directory of the repo included — is left out: it could only be reached through
1137
- * `..`). Not a git repo, or no commit yet (`git rev-parse --verify HEAD` fails) ⇒ the sources
1283
+ * current content ({@link copyNoFollow}; untracked sources included). Outside source roots live
1284
+ * under `_sources/<label>`: their HEAD subtree, or a no-follow copy for non-git sources and files.
1285
+ * Not a git repo, or no commit yet (`git rev-parse --verify HEAD` fails) ⇒ the sources
1138
1286
  * alone; HEAD resolves but the archive or the extraction fails ⇒ `FactsError('snapshot')`. The
1139
1287
  * tar file never outlives the call.
1140
1288
  * @param {string} projectDir @param {ReadonlyArray<string>} sources - absolute.
@@ -1175,6 +1323,34 @@ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run =
1175
1323
  if (inProject === '' || inProject.startsWith('..') || path.isAbsolute(inProject)) continue;
1176
1324
  copyNoFollow(real, path.join(sessionCwd, inProject), snapDir);
1177
1325
  }
1326
+ for (const source of sourceRoots(projectDir, sources).filter((r) => r.prefix)) {
1327
+ const dest = path.join(sessionCwd, source.prefix);
1328
+ assertNoLinkSegments(snapDir, dest);
1329
+ mkdirSync(dest, { recursive: true, mode: 0o700 });
1330
+ if (source.file) {
1331
+ copyNoFollow(source.file, path.join(dest, path.basename(source.file)), snapDir);
1332
+ continue;
1333
+ }
1334
+ const sourceTop = await run(['git', 'rev-parse', '--show-toplevel'], { cwd: source.dir, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
1335
+ if (sourceTop.result !== 'ok') {
1336
+ copyNoFollow(source.dir, dest, snapDir);
1337
+ continue;
1338
+ }
1339
+ const repo = realpathSync(sourceTop.stdout.trim());
1340
+ const head = await run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: repo, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
1341
+ if (head.result !== 'ok') continue;
1342
+ const subfolder = path.relative(repo, source.dir).split(path.sep).join('/');
1343
+ const tarPath = path.join(workDir, 'source.tar');
1344
+ try {
1345
+ const archived = await run(['git', 'archive', '--format=tar', '-o', tarPath, subfolder ? `HEAD:${subfolder}` : 'HEAD'], { cwd: repo, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
1346
+ if (archived.result !== 'ok') throw new FactsError('snapshot', `source ${source.label} could not be archived`);
1347
+ const untar = await run(['tar', '-xf', tarPath, '-C', dest], { timeoutMs: GIT_TIMEOUT_MS });
1348
+ if (untar.result !== 'ok') throw new FactsError('snapshot', `source ${source.label} could not be extracted`);
1349
+ dropLinks(dest);
1350
+ } finally {
1351
+ rmSync(tarPath, { force: true });
1352
+ }
1353
+ }
1178
1354
  sealTree(snapDir);
1179
1355
  return sessionCwd;
1180
1356
  }
@@ -1194,8 +1370,13 @@ export async function buildFacts(opts, deps = {}) {
1194
1370
  if (opts.projectDir !== undefined) {
1195
1371
  try { projectBins = readProjectBins(opts.projectDir); } catch { projectBins = []; }
1196
1372
  }
1197
- const extractOpts = opts.projectDir === undefined ? {} : { projectBins, briefText };
1198
- const claims = extractClaims([briefText, ...readSourcesText(opts.sources ?? [])].join('\n'), extractOpts);
1373
+ const extractOpts = { symbols: true, globPaths: true, projectBins, briefText, projectDir: opts.projectDir };
1374
+ const roots = sourceRoots(opts.projectDir, opts.sources ?? []);
1375
+ const claims = mapClaims(extractClaims(briefText, extractOpts), roots);
1376
+ const lines = briefText.split(/\r?\n/).length - Number(briefText.endsWith('\n'));
1377
+ if (lines > 40 && claims.length < 5) {
1378
+ (deps.stderr ?? process.stderr).write(`forge facts: the brief has ${lines} lines but only ${claims.length} claims; the sheet checks almost nothing\n`);
1379
+ }
1199
1380
  const root = opts.runRoot ?? currentRunRoot();
1200
1381
  // B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
1201
1382
  // the snapshot — git and the harness would walk up into the live repo — so the facts area moves
@@ -1206,8 +1387,9 @@ export async function buildFacts(opts, deps = {}) {
1206
1387
  const packetPath = path.join(dir, 'packet.md');
1207
1388
  const snapDir = path.join(dir, 'snapshot');
1208
1389
  try {
1209
- writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
1210
1390
  const cwd = opts.projectDir === undefined ? undefined : await buildSnapshot(opts.projectDir, opts.sources ?? [], snapDir, dir);
1391
+ if (cwd !== undefined) await markUnavailable(claims, roots, cwd, opts.gitRun ?? exec);
1392
+ writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
1211
1393
  const result = await spawnSession(
1212
1394
  {
1213
1395
  cfg: opts.cfg,
@@ -358,7 +358,10 @@ export function checkPlan(text, opts = {}) {
358
358
  const projectBins = () => (binsCache ??= typeof given === 'function' ? given() : given ?? []);
359
359
  for (const b of blocks) {
360
360
  for (const clause of splitClauses(b.acceptance)) {
361
- const unbacked = extractClaims(clause).filter((c) => !isVerified(c, verified, projectBins));
361
+ // A trailing parenthesized test citation names planned coverage, not a fact about
362
+ // an existing file. Paths in the clause itself still need facts or tolerances.
363
+ const factualClause = clause.replace(/\((?:\s*`(?:\.\/)?(?:test|tests|spec)\/[^`\n]+\.[\w]+`[\s,]*)+\)\s*$/, '');
364
+ const unbacked = extractClaims(factualClause).filter((c) => !isVerified(c, verified, projectBins));
362
365
  if (unbacked.every((c) => tolerances.some((t) => t.includes(c.token) && idRe(b.id).test(t)))) continue;
363
366
  errors.push(`unbackable clause without tolerance: ${b.id} ${clause}`);
364
367
  }