@codedology/code-forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,855 @@
1
+ /**
2
+ * The facts rule (plan §3.8, C16; block B9b): `forge facts` builds the facts sheet a Plan job
3
+ * must read before it designs anything.
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.
8
+ * Step 2: ONE L0 `facts` session (closed-book; Claude gets `--tools "Bash"` with the forbidden list
9
+ * rendered, §5.2) whose packet is the claim list and the rule "run the cheapest read-only check,
10
+ * quote the command and the output line, tag it, never infer". B9b.1: when the caller names the
11
+ * 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
13
+ * dropped, every file 0444 and directory 0555) so `./…` path claims can be checked; the snapshot
14
+ * lives under the run root — or, when that root itself sits inside a git work tree, under a
15
+ * private B0.1 root in `os.tmpdir()` (`factsTmpRoot`) — and is removed when the verb ends;
16
+ * `spawnSession` refuses a facts cwd below any `.git`. {@link pathViolation} still refuses
17
+ * `~`, `..` and absolute paths outside the tool roots, so the delegate cannot leave it.
18
+ * Step 3 (deterministic): the answer is validated against `schema/facts.schema.json`; a row whose
19
+ * `command` is not an allowed read-only form ({@link readOnlyViolation}'s allow-list; env claims only
20
+ * `printenv NAME >/dev/null`, their excerpt always blanked) or a VERIFIED row with an empty excerpt
21
+ * refuses the whole sheet; every claim, command, excerpt and reason passes through B0 `redact`. The
22
+ * sheet records the brief's sha256 and mtime in its header, both taken from the ONE read of the
23
+ * brief that also feeds the extractor; {@link checkSheetFresh} is what `author` and `plan check` use
24
+ * to refuse a stale one.
25
+ *
26
+ * The sheet carries its rows twice: a table for the reader and a `<!-- facts-json … -->` block
27
+ * that {@link parseSheet} reads back (`plan check` looks claims up there, also when the sheet is
28
+ * pasted verbatim into a plan's §0).
29
+ */
30
+
31
+ import { createHash, randomBytes } from 'node:crypto';
32
+ import { chmodSync, closeSync, constants as FS, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { Ajv2020 } from 'ajv/dist/2020.js';
37
+ import { appendRow } from '../ledger/write.mjs';
38
+ import { exec } from '../util/exec.mjs';
39
+ import { redact } from '../util/redact.mjs';
40
+ import { currentRunRoot } from '../util/tmp.mjs';
41
+ import { factsTmpRoot, gitWorkTreeAncestor, spawnSession } from './spawn.mjs';
42
+
43
+ /** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'} ClaimKind */
44
+ /** @typedef {{token: string, kind: ClaimKind}} Claim */
45
+ /**
46
+ * @typedef {object} Fact
47
+ * @property {string} fact_id @property {string} claim @property {ClaimKind} kind
48
+ * @property {string} command @property {string} output_excerpt
49
+ * @property {'VERIFIED'|'NOT-FOUND'|'UNVERIFIABLE'} tag @property {string | null} why
50
+ */
51
+
52
+ export const FACTS_SCHEMA_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'schema', 'facts.schema.json');
53
+
54
+ /** The delegate's answer schema, as the file states it (with `maxLength`). */
55
+ export const FACTS_SCHEMA = Object.freeze(JSON.parse(readFileSync(FACTS_SCHEMA_PATH, 'utf8')));
56
+
57
+ /** @param {any} node @returns {any} a copy without annotations and string-length keywords. */
58
+ function portable(node) {
59
+ if (Array.isArray(node)) return node.map(portable);
60
+ if (node === null || typeof node !== 'object') return node;
61
+ /** @type {Record<string, any>} */
62
+ const out = {};
63
+ for (const [key, value] of Object.entries(node)) {
64
+ if (['$schema', '$id', 'title', 'description', 'maxLength', 'minLength', 'pattern'].includes(key)) continue;
65
+ out[key] = portable(value);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * The schema handed to the provider: string-length and pattern keywords are not portable to every
72
+ * structured-output mode, so the session gets them stripped and step 3 re-checks the file schema.
73
+ */
74
+ export const FACTS_SESSION_SCHEMA = Object.freeze(portable(FACTS_SCHEMA));
75
+
76
+ const validateFile = new Ajv2020({ allErrors: true, strict: false }).compile(
77
+ Object.fromEntries(Object.entries(FACTS_SCHEMA).filter(([k]) => k !== '$id')),
78
+ );
79
+
80
+ /** Excerpt cap in characters (the schema's `maxLength`). */
81
+ export const EXCERPT_MAX = 200;
82
+
83
+ /** Default `--max-budget-usd` of the facts session (§3.8: "typically under $0.10"). */
84
+ export const FACTS_MAX_BUDGET_USD = 0.5;
85
+
86
+ /** An error raised before or after the session (`code`: `usage`, `refused`, `stale`, `snapshot`). */
87
+ export class FactsError extends Error {
88
+ /** @param {string} code @param {string} message */
89
+ constructor(code, message) {
90
+ super(message);
91
+ this.name = 'FactsError';
92
+ this.code = code;
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------------------------
97
+ // Step 1 — the claim-token extractor
98
+ // ---------------------------------------------------------------------------------------------
99
+
100
+ /** Kinds in the order they are matched; a span matched by an earlier kind is masked for later ones. */
101
+ const PATTERNS = /** @type {ReadonlyArray<[ClaimKind, RegExp]>} */ ([
102
+ ['endpoint', /https?:\/\/[^\s`'"<>)\]|]+/g],
103
+ ['package', /(?<![\w./@-])@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*/g],
104
+ ['path', /(?<![\w/.~-])(?:~|\.{1,2})\/[^\s`'"<>)\]|,;]*/g],
105
+ ['flag', /(?<![\w-])--[a-z][a-z0-9-]*/g],
106
+ ['version', /(?<![\w.§])\d+\.\d+(?:\.\d+)*/g],
107
+ ['env', /(?<![\w])\$?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![\w])/g],
108
+ ]);
109
+
110
+ /** A `<cli> <verb>` pair at the start of a code span (`claude plugin validate .` ⇒ `claude plugin`). */
111
+ const COMMAND_IN_SPAN = /^([a-z][a-z0-9-]*) ([a-z][a-z0-9-]*)(?![\w.\/-])/;
112
+
113
+ /** @param {string} token */
114
+ const trimTail = (token) => token.replace(/[.,:;]+$/, '');
115
+
116
+ /**
117
+ * Every claim token of `text`, deduplicated by (kind, token), in order of first appearance.
118
+ * @param {string} text
119
+ * @returns {Claim[]}
120
+ */
121
+ export function extractClaims(text) {
122
+ let masked = String(text);
123
+ /** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
124
+ const found = [];
125
+ /** @param {number} start @param {number} length */
126
+ const mask = (start, length) => {
127
+ masked = masked.slice(0, start) + ' '.repeat(length) + masked.slice(start + length);
128
+ };
129
+ /** @param {ClaimKind} kind @param {RegExp} re */
130
+ const scan = (kind, re) => {
131
+ for (const m of [...masked.matchAll(re)]) {
132
+ const token = trimTail(kind === 'env' ? m[0].replace(/^\$/, '') : m[0]);
133
+ if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})\/$/.test(token))) continue;
134
+ found.push({ index: /** @type {number} */ (m.index), token, kind });
135
+ mask(/** @type {number} */ (m.index), m[0].length);
136
+ }
137
+ };
138
+ scan(...PATTERNS[0]);
139
+ scan(...PATTERNS[1]);
140
+ scan(...PATTERNS[2]);
141
+ for (const m of [...masked.matchAll(/`([^`\n]+)`/g)]) {
142
+ const cmd = COMMAND_IN_SPAN.exec(m[1]);
143
+ if (!cmd) continue;
144
+ const start = /** @type {number} */ (m.index) + 1;
145
+ found.push({ index: start, token: `${cmd[1]} ${cmd[2]}`, kind: 'command' });
146
+ mask(start, cmd[0].length);
147
+ }
148
+ for (const entry of PATTERNS.slice(3)) scan(...entry);
149
+ found.sort((a, b) => a.index - b.index);
150
+ const seen = new Set();
151
+ /** @type {Claim[]} */
152
+ const claims = [];
153
+ for (const { token, kind } of found) {
154
+ const key = `${kind}\0${token}`;
155
+ if (seen.has(key)) continue;
156
+ seen.add(key);
157
+ claims.push({ token, kind });
158
+ }
159
+ return claims;
160
+ }
161
+
162
+ /**
163
+ * The text of every source: a file as is, a directory's top-level `.md`/`.txt` files in name order.
164
+ * @param {ReadonlyArray<string>} sources
165
+ * @returns {string[]}
166
+ */
167
+ function readSourcesText(sources) {
168
+ /** @type {string[]} */
169
+ const parts = [];
170
+ for (const source of sources) {
171
+ const st = statSync(source);
172
+ if (st.isDirectory()) {
173
+ for (const name of readdirSync(source).filter((n) => /\.(md|txt)$/.test(n)).sort()) {
174
+ parts.push(readFileSync(path.join(source, name), 'utf8'));
175
+ }
176
+ } else {
177
+ parts.push(readFileSync(source, 'utf8'));
178
+ }
179
+ }
180
+ return parts;
181
+ }
182
+
183
+ /**
184
+ * The brief's text plus every source it names ({@link readSourcesText}).
185
+ * @param {string} briefPath @param {ReadonlyArray<string>} [sources]
186
+ * @returns {string}
187
+ */
188
+ export function readClaimText(briefPath, sources = []) {
189
+ return [readFileSync(briefPath, 'utf8'), ...readSourcesText(sources)].join('\n');
190
+ }
191
+
192
+ /**
193
+ * The brief, read ONCE: its bytes (sha256 and claim text) and its mtime from the same open file.
194
+ * @param {string} briefPath
195
+ * @returns {{bytes: Buffer, mtime: string}}
196
+ */
197
+ function readBriefOnce(briefPath) {
198
+ const fd = openSync(briefPath, 'r');
199
+ try {
200
+ return { bytes: readFileSync(fd), mtime: fstatSync(fd).mtime.toISOString() };
201
+ } finally {
202
+ closeSync(fd);
203
+ }
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------------------------
207
+ // Step 3 — the read-only check and the answer validation
208
+ // ---------------------------------------------------------------------------------------------
209
+
210
+ /** Separators between commands: `;`, `&&`, `||`, `|`, `&`, newline, carriage return. */
211
+ const SEPARATORS = new Set([';', '&', '|', '\n', '\r']);
212
+
213
+ /** Characters the shell expands into file names when they are not quoted or escaped. */
214
+ const GLOB_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
215
+
216
+ /**
217
+ * Split a command into segments of words. Quotes group; a backslash escapes the next character.
218
+ * Any `$`, backtick, `<` or `>` outside single quotes — and inside them too, for `<`/`>` — is
219
+ * reported instead of parsed, and so is an unquoted `* ? [ ] { }` (a glob expands AFTER the
220
+ * secret-path check and could name what the check never saw): the allow-list never needs one
221
+ * (fail closed).
222
+ * @param {string} command
223
+ * @returns {{segments: string[][], refused: string | null}}
224
+ */
225
+ function splitCommand(command) {
226
+ if (/[`]/.test(command)) return { segments: [], refused: 'backticks are not allowed' };
227
+ if (/\$/.test(command)) return { segments: [], refused: '$ expansion is not allowed' };
228
+ if (/[<>]/.test(command)) return { segments: [], refused: 'redirection is not allowed' };
229
+ /** @type {string[][]} */
230
+ const segments = [[]];
231
+ let word = '';
232
+ let inWord = false;
233
+ const flush = () => {
234
+ if (inWord) segments[segments.length - 1].push(word);
235
+ word = '';
236
+ inWord = false;
237
+ };
238
+ for (let i = 0; i < command.length; i += 1) {
239
+ const c = command[i];
240
+ if (c === "'" || c === '"') {
241
+ const end = command.indexOf(c, i + 1);
242
+ if (end < 0) return { segments: [], refused: 'unbalanced quote' };
243
+ word += command.slice(i + 1, end);
244
+ inWord = true;
245
+ i = end;
246
+ } else if (c === '\\') {
247
+ if (i + 1 >= command.length || command[i + 1] === '\n' || command[i + 1] === '\r') return { segments: [], refused: 'line continuation is not allowed' };
248
+ word += command[i + 1];
249
+ inWord = true;
250
+ i += 1;
251
+ } else if (SEPARATORS.has(c)) {
252
+ flush();
253
+ if ((c === '&' || c === '|') && command[i + 1] === c) i += 1;
254
+ segments.push([]);
255
+ } else if (c === ' ' || c === '\t') {
256
+ flush();
257
+ } else {
258
+ if (GLOB_CHARS.has(c)) return { segments: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
259
+ word += c;
260
+ inWord = true;
261
+ }
262
+ }
263
+ flush();
264
+ return { segments: segments.filter((s) => s.length > 0), refused: null };
265
+ }
266
+
267
+ /** A token that names a secret-looking file (read refused, whatever the command). */
268
+ const SECRET_PATH = [
269
+ /(^|\/)\.env[^/]*$/,
270
+ /(^|\/)\.npmrc$/,
271
+ /(^|\/)\.netrc$/,
272
+ /id_rsa/,
273
+ /id_ed25519/,
274
+ /(^|\/)\.ssh(\/|$)/,
275
+ /(^|\/)\.aws(\/|$)/,
276
+ /(^|\/)\.config\/gh(\/|$)/,
277
+ /\.pem$/,
278
+ /\.key$/,
279
+ /(^|\/)\.code-forge(\/|$)/,
280
+ /(^|\/)\.git-credentials$/,
281
+ /(^|\/)\.docker\/config\.json$/,
282
+ /(^|\/)\.kube\/config$/,
283
+ /(^|\/)\.claude\/\.credentials\.json$/,
284
+ /(^|\/)\.pgpass$/,
285
+ /^\/proc\/[^/]+\/environ$/,
286
+ ];
287
+
288
+ /** The only roots an absolute argument may point into (tool installs; never `~`, `/` or the rest). */
289
+ const TOOL_ROOTS = ['/usr', '/opt/homebrew', '/bin'];
290
+
291
+ /**
292
+ * Why one argument reaches outside the delegate's cwd, or null. Relative paths stay inside the
293
+ * cwd (the read-only project snapshot, or an empty directory); `~`, a `..` segment and any absolute path outside {@link TOOL_ROOTS} are refused, so
294
+ * a recursive reader (`rg . ~`, `find / -type f`, `ls -R /etc`) has nowhere to go.
295
+ * @param {string} word
296
+ * @returns {string | null}
297
+ */
298
+ function pathViolation(word) {
299
+ if (word.startsWith('~')) return 'paths under ~ are not allowed';
300
+ if (/(^|\/)\.\.(\/|$)/.test(word)) return 'paths with a .. segment are not allowed';
301
+ if (word.startsWith('/') && !TOOL_ROOTS.some((root) => word === root || word.startsWith(`${root}/`))) {
302
+ return 'absolute paths outside /usr, /opt/homebrew and /bin are not allowed';
303
+ }
304
+ return null;
305
+ }
306
+
307
+ /** Commands that read and print, allowed with any in-cwd arguments (after the secret-path check). */
308
+ const PLAIN_READERS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'rg', 'stat', 'file', 'uname', 'sw_vers', 'jq', 'which']);
309
+
310
+ const GIT_READS = new Set(['log', 'show', 'status', 'rev-parse', 'ls-files', 'cat-file', '--version']);
311
+ const NPM_READS = new Set(['view', 'ls', '--version']);
312
+ const HELP_FORMS = new Set(['--help', '-h', '--version']);
313
+ /**
314
+ * find: an ALLOWED predicate list (B9b.1). Any other word starting with `-` (`-exec`, `-execdir`,
315
+ * `-ok`, `-okdir`, `-delete`, `-fls`, `-fprint*`, …) is refused by omission; `!` and bare words are
316
+ * operands (paths, patterns, numbers).
317
+ */
318
+ export const FIND_PREDICATES = Object.freeze(['-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-print', '-print0', '-newer', '-size', '-empty', '-mtime', '-mmin', '-not', '!', '-a', '-o', '-and', '-or']);
319
+ const FIND_ALLOWED = new Set(FIND_PREDICATES);
320
+ /** The predicates whose next word is their operand (`-mtime -1`, `-size -10k` stay operands). */
321
+ const FIND_TAKES_VALUE = new Set(['-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-newer', '-size', '-mtime', '-mmin']);
322
+ const FIND_MESSAGE = `find: only ${FIND_PREDICATES.join(' ')} and paths`;
323
+
324
+ /**
325
+ * @param {string[]} rest - the words after `find`.
326
+ * @returns {string | null}
327
+ */
328
+ function findViolation(rest) {
329
+ for (let i = 0; i < rest.length; i += 1) {
330
+ const t = rest[i];
331
+ if (FIND_ALLOWED.has(t)) {
332
+ if (FIND_TAKES_VALUE.has(t)) {
333
+ if (i + 1 >= rest.length) return FIND_MESSAGE;
334
+ i += 1;
335
+ }
336
+ } else if (t.startsWith('-') || ['(', ')', ','].includes(t)) {
337
+ return FIND_MESSAGE;
338
+ }
339
+ }
340
+ return null;
341
+ }
342
+ /** Build/task runners: their `help`/default targets run user recipes, so the `<cli> --help` case never admits them. */
343
+ 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']);
344
+ /** curl: the long options allowed; the short ones are any cluster of `s S I L f`; `--max-time <N>` is handled apart. */
345
+ const CURL_LONG = new Set(['--head', '--silent', '--show-error', '--location', '--fail']);
346
+ const CURL_MESSAGE = 'curl: only -s -S -I -L -f --head --silent --show-error --location --fail --max-time <N> and exactly one http(s) URL, as a HEAD request';
347
+
348
+ /**
349
+ * curl as an option allow-list: every token is an allowed flag, `--max-time <digits>` or the one
350
+ * `http(s)://` URL, and one of the flags asks for HEAD. Anything else (`-o`, `-D`, `-c`, `--trace*`,
351
+ * `--stderr`, `--libcurl`, `--etag-save`, `--hsts`, `--alt-svc`, `-K`, …) is refused by omission.
352
+ * @param {string[]} rest
353
+ * @returns {string | null}
354
+ */
355
+ function curlViolation(rest) {
356
+ let head = false;
357
+ let urls = 0;
358
+ for (let i = 0; i < rest.length; i += 1) {
359
+ const t = rest[i];
360
+ if (/^https?:\/\//.test(t)) {
361
+ urls += 1;
362
+ } else if (/^-[sSILf]+$/.test(t)) {
363
+ if (t.includes('I')) head = true;
364
+ } else if (CURL_LONG.has(t)) {
365
+ if (t === '--head') head = true;
366
+ } else if (t === '--max-time' && /^\d+$/.test(rest[i + 1] ?? '')) {
367
+ i += 1;
368
+ } else {
369
+ return CURL_MESSAGE;
370
+ }
371
+ }
372
+ return head && urls === 1 ? null : CURL_MESSAGE;
373
+ }
374
+
375
+ /**
376
+ * Why one segment is not an allowed read-only form, or null.
377
+ * @param {string[]} argv
378
+ * @returns {string | null}
379
+ */
380
+ function segmentViolation(argv) {
381
+ if (argv.some((t) => SECRET_PATH.some((re) => re.test(t)))) return 'reads a secret-looking file';
382
+ const [verb, ...rest] = argv;
383
+ if (verb.includes('/')) return 'a verb may not be a path';
384
+ if (!/^[A-Za-z0-9._-]+$/.test(verb)) return `not an allowed read-only form: "${verb}"`;
385
+ for (const word of rest) {
386
+ const why = pathViolation(word);
387
+ if (why) return why;
388
+ }
389
+ if (PLAIN_READERS.has(verb)) {
390
+ if (verb === 'rg' && rest.some((t) => t.startsWith('--pre'))) return 'rg --pre runs a command';
391
+ 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';
392
+ return null;
393
+ }
394
+ switch (verb) {
395
+ case 'command':
396
+ return rest.length === 2 && rest[0] === '-v' ? null : 'command: only command -v <name>';
397
+ case 'find':
398
+ return findViolation(rest);
399
+ case 'git':
400
+ 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';
401
+ return rest.some((t) => /^--(output|ext-diff|textconv)/.test(t)) ? 'git: --output, --ext-diff and --textconv are not allowed' : null;
402
+ case 'npm':
403
+ return rest.length > 0 && NPM_READS.has(rest[0]) ? null : 'npm: only view, ls or --version';
404
+ case 'node':
405
+ return rest.length === 1 && (rest[0] === '--version' || rest[0] === '-v') ? null : 'node: only --version';
406
+ case 'test':
407
+ return rest.length === 2 && ['-e', '-f', '-d', '-n'].includes(rest[0]) ? null : 'test: only -e, -f, -d or -n with one operand';
408
+ case 'curl':
409
+ return curlViolation(rest);
410
+ default:
411
+ // `<cli> --help|-h|--version` — exactly two tokens, a bare CLI name (never a path), never a build runner
412
+ if (BUILD_RUNNERS.has(verb)) return `${verb}: build and task runners are not allowed`;
413
+ 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}"`;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Why a delegate's `command` is not an allowed read-only command, or null when it is. An
419
+ * ALLOW-LIST (fail closed): every segment between `;`, `&&`, `||`, `|`, `&`, newline or CR must
420
+ * be one of the forms in {@link segmentViolation}; `$`, backticks and redirections are refused.
421
+ * @param {string} command
422
+ * @returns {string | null}
423
+ */
424
+ export function readOnlyViolation(command) {
425
+ const { segments, refused } = splitCommand(command);
426
+ if (refused) return refused;
427
+ if (segments.length === 0) return 'empty command';
428
+ for (const argv of segments) {
429
+ const why = segmentViolation(argv);
430
+ if (why) return why;
431
+ }
432
+ return null;
433
+ }
434
+
435
+ /** The one form an env claim may be checked with: existence only, the value never printed. */
436
+ const ENV_CHECK = /^printenv ([A-Z][A-Z0-9_]*) >\/dev\/null$/;
437
+
438
+ /**
439
+ * Validate the delegate's answer (step 3). Claims the delegate left out are kept as UNVERIFIABLE
440
+ * rows ("no answer") — a missing answer is never read as a fact.
441
+ * @param {unknown} answer @param {ReadonlyArray<Claim>} claims
442
+ * @returns {Fact[]} the rows, in claim order.
443
+ * @throws {FactsError} `refused` naming each fact id and reason (never the output excerpt).
444
+ */
445
+ export function validateFacts(answer, claims) {
446
+ if (!validateFile(answer)) {
447
+ const where = (validateFile.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).slice(0, 5);
448
+ throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
449
+ }
450
+ const rows = /** @type {{facts: Fact[]}} */ (answer).facts;
451
+ const problems = [];
452
+ for (const row of rows) {
453
+ if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) problems.push(`${row.fact_id}: VERIFIED with an empty output excerpt`);
454
+ if (row.command.trim().length === 0) {
455
+ if (row.tag !== 'UNVERIFIABLE') problems.push(`${row.fact_id}: ${row.tag} without a command`);
456
+ continue;
457
+ }
458
+ if (row.kind === 'env') {
459
+ const env = ENV_CHECK.exec(row.command);
460
+ if (!env || env[1] !== row.claim) problems.push(`${row.fact_id}: an env claim may only be checked with printenv ${row.claim} >/dev/null`);
461
+ continue;
462
+ }
463
+ const why = readOnlyViolation(row.command);
464
+ if (why) problems.push(`${row.fact_id}: command is not read-only: ${why}`);
465
+ }
466
+ if (problems.length > 0) throw new FactsError('refused', `facts sheet refused: ${problems.join('; ')}`);
467
+ /** @type {Fact[]} */
468
+ const out = [];
469
+ claims.forEach((claim, i) => {
470
+ const row = rows.find((r) => r.claim === claim.token && r.kind === claim.kind);
471
+ out.push(
472
+ row
473
+ ? { ...row, fact_id: `F${i + 1}`, claim: redact(row.claim), command: redact(row.command), output_excerpt: row.kind === 'env' ? '' : redact(row.output_excerpt), why: row.why === null ? null : redact(row.why) }
474
+ : { fact_id: `F${i + 1}`, claim: redact(claim.token), kind: claim.kind, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: 'no answer from the delegate' },
475
+ );
476
+ });
477
+ return out;
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------------------------
481
+ // The sheet
482
+ // ---------------------------------------------------------------------------------------------
483
+
484
+ /** @param {string | Buffer} bytes @returns {string} */
485
+ export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
486
+
487
+ /** @param {string} text */
488
+ const cell = (text) => String(text).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
489
+
490
+ /** @param {string} text @returns {string} a code span that survives backticks inside. */
491
+ const code = (text) => (text.length === 0 ? '—' : text.includes('`') ? `\`\` ${text} \`\`` : `\`${text}\``);
492
+
493
+ /**
494
+ * @param {{briefRef: string, briefSha: string, briefMtime: string, builtAt: string, facts: ReadonlyArray<Fact>}} sheet
495
+ * @returns {string} the facts sheet markdown.
496
+ */
497
+ export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts }) {
498
+ const count = (/** @type {string} */ tag) => facts.filter((f) => f.tag === tag).length;
499
+ const lines = [
500
+ '# Facts sheet',
501
+ '',
502
+ `- brief: \`${briefRef}\``,
503
+ `- brief_sha256: \`${briefSha}\``,
504
+ `- brief_mtime: \`${briefMtime}\``,
505
+ `- built_at: \`${builtAt}\``,
506
+ `- claims: ${facts.length} (VERIFIED ${count('VERIFIED')} · NOT-FOUND ${count('NOT-FOUND')} · UNVERIFIABLE ${count('UNVERIFIABLE')})`,
507
+ '',
508
+ '| fact_id | tag | kind | claim | command | output | why |',
509
+ '|---|---|---|---|---|---|---|',
510
+ ...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 ?? '—')} |`),
511
+ '',
512
+ '<!-- facts-json',
513
+ `[\n${facts.map((f) => JSON.stringify(f)).join(',\n')}\n]`.replace(/</g, '\\u003c').replace(/>/g, '\\u003e'),
514
+ '-->',
515
+ '',
516
+ ];
517
+ return lines.join('\n');
518
+ }
519
+
520
+ /**
521
+ * Read a sheet (or a plan whose §0 is the sheet, verbatim) back.
522
+ * @param {string} text
523
+ * @returns {{briefRef: string, briefSha: string, briefMtime: string, facts: Fact[]} | null} null when no sheet is there.
524
+ */
525
+ export function parseSheet(text) {
526
+ const sha = /^- brief_sha256: `([0-9a-f]{64})`$/m.exec(text);
527
+ const ref = /^- brief: `([^`]+)`$/m.exec(text);
528
+ const mtime = /^- brief_mtime: `([^`]+)`$/m.exec(text);
529
+ const json = /^<!-- facts-json\n([\s\S]*?)\n-->$/m.exec(text);
530
+ if (!sha || !ref || !json) return null;
531
+ let facts;
532
+ try {
533
+ facts = JSON.parse(json[1]);
534
+ } catch {
535
+ return null;
536
+ }
537
+ if (!Array.isArray(facts)) return null;
538
+ return { briefRef: ref[1], briefSha: sha[1], briefMtime: mtime ? mtime[1] : '', facts };
539
+ }
540
+
541
+ /**
542
+ * Is the sheet still about this brief? Stale ⇔ the brief's current sha256 differs from the one the
543
+ * sheet recorded (a touched-but-unchanged brief is not stale; an edited one is).
544
+ * @param {string} sheetText @param {string} briefPath
545
+ * @returns {{ok: true, sheet: NonNullable<ReturnType<typeof parseSheet>>} | {ok: false, code: 'bad-sheet' | 'stale', message: string}}
546
+ */
547
+ export function checkSheetFresh(sheetText, briefPath) {
548
+ const sheet = parseSheet(sheetText);
549
+ if (!sheet) return { ok: false, code: 'bad-sheet', message: 'facts sheet is not a facts sheet: run forge facts first' };
550
+ if (sha256(readFileSync(briefPath)) !== sheet.briefSha) {
551
+ return { ok: false, code: 'stale', message: 'facts sheet is stale: brief changed after it was built' };
552
+ }
553
+ return { ok: true, sheet };
554
+ }
555
+
556
+ // ---------------------------------------------------------------------------------------------
557
+ // Step 2 + the whole verb
558
+ // ---------------------------------------------------------------------------------------------
559
+
560
+ const RULE = [
561
+ '# Facts delegate',
562
+ '',
563
+ 'For each claim below, run the cheapest READ-ONLY check (`<cli> --help | grep -c <flag>`, `which <cli>`, `ls <path>`,',
564
+ '`<cli> --version`, `npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
565
+ 'characters). Tag each claim VERIFIED (the output shows it), NOT-FOUND (the check ran and it is absent) or',
566
+ 'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess. Only these forms are',
567
+ 'accepted, joined by `|` if needed: which, command -v, ls, cat, head, tail, wc, grep (no -r), rg,',
568
+ 'find (only -name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin',
569
+ '-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',
570
+ 'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, node --version, test -e|-f|-d|-n, `curl -sI <one URL>`.',
571
+ 'No `$`, no backticks, no redirection, no unquoted glob, no `~`, no absolute path outside /usr, /opt/homebrew or',
572
+ '/bin, no secret files.',
573
+ 'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
574
+ '',
575
+ 'Reply with ONE JSON object: {"facts": [{"fact_id", "claim", "kind", "command", "output_excerpt", "tag", "why"}]} —',
576
+ 'one row per claim, `fact_id`, `claim` and `kind` copied from the list, `why` null unless UNVERIFIABLE.',
577
+ '',
578
+ '## Claims',
579
+ '',
580
+ ].join('\n');
581
+
582
+ /**
583
+ * @param {ReadonlyArray<Claim>} claims
584
+ * @returns {string} the delegate's packet.
585
+ */
586
+ export function buildFactsPacket(claims) {
587
+ return `${RULE}${claims.map((c, i) => `- F${i + 1} ${c.kind}: ${c.token}`).join('\n')}\n`;
588
+ }
589
+
590
+ /**
591
+ * @typedef {object} BuildFactsOpts
592
+ * @property {Record<string, any>} cfg
593
+ * @property {string} briefPath - absolute.
594
+ * @property {string[]} [sources]
595
+ * @property {string} outPath - absolute path of the sheet to write.
596
+ * @property {string} [runRoot] @property {string} [run] @property {string} [slug]
597
+ * @property {number} [maxBudgetUsd] @property {number} [timeoutMs]
598
+ * @property {string} [projectDir] - the project (the verb's cwd): the delegate runs in a read-only
599
+ * snapshot of its git HEAD plus the in-project `sources`; absent ⇒ an empty cwd.
600
+ * @property {() => Date} [now]
601
+ */
602
+
603
+ // ---------------------------------------------------------------------------------------------
604
+ // B9b.1 — the delegate's read-only project snapshot
605
+ // ---------------------------------------------------------------------------------------------
606
+
607
+ const GIT_TIMEOUT_MS = 120000;
608
+
609
+ /** `process.env` minus every inherited `GIT_*`, with no system/global git config. */
610
+ function gitEnv() {
611
+ /** @type {NodeJS.ProcessEnv} */
612
+ const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
613
+ for (const [key, value] of Object.entries(process.env)) {
614
+ if (!key.startsWith('GIT_')) env[key] = value;
615
+ }
616
+ return env;
617
+ }
618
+
619
+ /**
620
+ * Drop symlinks and secret-looking entries, then make every file 0444 and every directory 0555
621
+ * (children first). A symlink could point anywhere outside the snapshot; a secret file is never
622
+ * handed to the delegate even though its reads are refused by name.
623
+ * @param {string} dir @param {string} [rel] - `dir` relative to the snapshot root.
624
+ */
625
+ function sealTree(dir, rel = '') {
626
+ for (const name of readdirSync(dir)) {
627
+ const full = path.join(dir, name);
628
+ const relPath = rel ? `${rel}/${name}` : name;
629
+ const st = lstatSync(full);
630
+ if (st.isSymbolicLink() || SECRET_PATH.some((re) => re.test(relPath))) {
631
+ rmSync(full, { recursive: true, force: true });
632
+ } else if (st.isDirectory()) {
633
+ sealTree(full, relPath);
634
+ } else if (st.isFile()) {
635
+ chmodSync(full, 0o444);
636
+ } else {
637
+ unlinkSync(full); // fifos, sockets, devices: never part of a read-only view
638
+ }
639
+ }
640
+ chmodSync(dir, 0o555);
641
+ }
642
+
643
+ /** @param {string} p @returns {string} `p` realpath'd when it exists, else resolved as given. */
644
+ function realOrResolved(p) {
645
+ try {
646
+ return realpathSync(p);
647
+ } catch {
648
+ return path.resolve(p);
649
+ }
650
+ }
651
+
652
+ /** @param {string} dir - make every directory under `dir` writable again, then remove it. */
653
+ export function removeSnapshot(dir) {
654
+ /** @param {string} d */
655
+ const unseal = (d) => {
656
+ chmodSync(d, 0o700);
657
+ for (const name of readdirSync(d)) {
658
+ const full = path.join(d, name);
659
+ if (lstatSync(full).isDirectory()) unseal(full);
660
+ }
661
+ };
662
+ try {
663
+ unseal(dir);
664
+ } catch (err) {
665
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') throw err;
666
+ }
667
+ rmSync(dir, { recursive: true, force: true });
668
+ }
669
+
670
+ /** @param {string} p @returns {import('node:fs').Stats | null} lstat of `p`, or null when absent. */
671
+ function lstatOrNull(p) {
672
+ try {
673
+ return lstatSync(p);
674
+ } catch {
675
+ return null;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Remove every symlink and special entry below `dir` (regular files and directories stay). Runs
681
+ * right after `tar -x`, BEFORE any source is copied in: a symlink tracked at HEAD (`foo -> ../..`)
682
+ * that the working tree has replaced with a real directory would otherwise turn a source copy
683
+ * into a write outside the snapshot.
684
+ * @param {string} dir
685
+ */
686
+ function dropLinks(dir) {
687
+ for (const name of readdirSync(dir)) {
688
+ const full = path.join(dir, name);
689
+ const st = lstatSync(full);
690
+ if (st.isDirectory()) dropLinks(full);
691
+ else if (!st.isFile()) unlinkSync(full); // a symlink (to anything), fifo, socket or device
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Refuse a path below `snapDir` that crosses a symlink: every segment from `snapDir` down to `dir`
697
+ * (inclusive) is lstat'd; a missing segment ends the walk (the copy creates it next).
698
+ * @param {string} snapDir @param {string} dir
699
+ */
700
+ function assertNoLinkSegments(snapDir, dir) {
701
+ const rel = path.relative(snapDir, dir);
702
+ let cur = snapDir;
703
+ for (const seg of rel === '' ? [] : rel.split(path.sep)) {
704
+ cur = path.join(cur, seg);
705
+ const st = lstatOrNull(cur);
706
+ if (st === null) return;
707
+ if (st.isSymbolicLink()) throw new FactsError('snapshot', `the project snapshot refused a copy across a symlink (${path.relative(snapDir, cur)})`);
708
+ if (!st.isDirectory()) throw new FactsError('snapshot', `the project snapshot has a file where a directory is needed (${path.relative(snapDir, cur)})`);
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Copy `src` (a working-tree file or directory) to `dst` inside `snapDir` without following any
714
+ * symlink: entries are lstat'd (symlinks, `.git` and secret-looking paths dropped), parents are
715
+ * created here and checked segment by segment, and every file is created with
716
+ * `O_EXCL | O_NOFOLLOW` — a HEAD file of the same name is unlinked first, never written through.
717
+ * @param {string} src @param {string} dst @param {string} snapDir
718
+ */
719
+ function copyNoFollow(src, dst, snapDir) {
720
+ const rel = path.relative(snapDir, dst).split(path.sep).join('/');
721
+ const st = lstatSync(src);
722
+ if (st.isSymbolicLink() || /(^|\/)\.git(\/|$)/.test(rel) || SECRET_PATH.some((re) => re.test(rel))) return;
723
+ assertNoLinkSegments(snapDir, path.dirname(dst));
724
+ mkdirSync(path.dirname(dst), { recursive: true, mode: 0o700 }); // only segments the check found missing
725
+ const at = lstatOrNull(dst);
726
+ if (st.isDirectory()) {
727
+ if (at === null) mkdirSync(dst, { mode: 0o700 });
728
+ else if (!at.isDirectory()) throw new FactsError('snapshot', `the project snapshot has a file where a directory is needed (${rel})`);
729
+ for (const name of readdirSync(src)) copyNoFollow(path.join(src, name), path.join(dst, name), snapDir);
730
+ return;
731
+ }
732
+ if (!st.isFile()) return;
733
+ if (at !== null) {
734
+ if (!at.isFile()) throw new FactsError('snapshot', `the project snapshot has a directory where a file is needed (${rel})`);
735
+ unlinkSync(dst); // the HEAD copy: the source's current content replaces it
736
+ }
737
+ const fd = openSync(dst, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | FS.O_NOFOLLOW, 0o600);
738
+ try {
739
+ writeFileSync(fd, readFileSync(src));
740
+ } finally {
741
+ closeSync(fd);
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Build the facts delegate's read-only view of the project at `snapDir`: the tracked tree at HEAD
747
+ * (`git archive` + `tar -x`, binary-safe, the real index and tree never touched; every symlink and
748
+ * special entry dropped right after extraction) plus every `source` inside the project at its
749
+ * current content ({@link copyNoFollow}; untracked sources included; a source outside `projectDir`
750
+ * — a sibling directory of the repo included — is left out: it could only be reached through
751
+ * `..`). Not a git repo, or no commit yet (`git rev-parse --verify HEAD` fails) ⇒ the sources
752
+ * alone; HEAD resolves but the archive or the extraction fails ⇒ `FactsError('snapshot')`. The
753
+ * tar file never outlives the call.
754
+ * @param {string} projectDir @param {ReadonlyArray<string>} sources - absolute.
755
+ * @param {string} snapDir - created here. @param {string} workDir - scratch for the tar file.
756
+ * @param {typeof exec} [run] - the git/tar runner (tests inject a failing one).
757
+ * @returns {Promise<string>} the session cwd: `snapDir` joined with the project's path below the git
758
+ * top level (the project itself when it is a subdirectory of the repo).
759
+ */
760
+ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run = exec) {
761
+ mkdirSync(snapDir, { recursive: true, mode: 0o700 });
762
+ const project = realpathSync(projectDir);
763
+ let root = project;
764
+ const top = await run(['git', 'rev-parse', '--show-toplevel'], { cwd: project, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
765
+ if (top.result === 'ok' && top.stdout.trim().length > 0) {
766
+ root = realpathSync(top.stdout.trim());
767
+ const head = await run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: root, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
768
+ if (head.result === 'ok' && head.stdout.trim().length > 0) {
769
+ const tarPath = path.join(workDir, 'head.tar');
770
+ try {
771
+ const archived = await run(['git', 'archive', '--format=tar', '-o', tarPath, 'HEAD'], { cwd: root, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
772
+ if (archived.result !== 'ok') throw new FactsError('snapshot', `the project snapshot could not be archived (git archive exit ${archived.code})`);
773
+ const untar = await run(['tar', '-xf', tarPath, '-C', snapDir], { timeoutMs: GIT_TIMEOUT_MS });
774
+ if (untar.result !== 'ok') throw new FactsError('snapshot', `the project snapshot could not be extracted (tar exit ${untar.code})`);
775
+ } finally {
776
+ rmSync(tarPath, { force: true });
777
+ }
778
+ dropLinks(snapDir);
779
+ }
780
+ }
781
+ // the project may be a subdirectory of the git top level: `./x` claims resolve against IT
782
+ const sub = path.relative(root, project);
783
+ const sessionCwd = path.join(snapDir, sub);
784
+ assertNoLinkSegments(snapDir, sessionCwd);
785
+ mkdirSync(sessionCwd, { recursive: true, mode: 0o700 });
786
+ for (const source of sources) {
787
+ const real = realpathSync(source);
788
+ const inProject = path.relative(project, real);
789
+ if (inProject === '' || inProject.startsWith('..') || path.isAbsolute(inProject)) continue;
790
+ copyNoFollow(real, path.join(sessionCwd, inProject), snapDir);
791
+ }
792
+ sealTree(snapDir);
793
+ return sessionCwd;
794
+ }
795
+
796
+ /**
797
+ * Build the facts sheet (steps 1–3) and write it to `outPath`.
798
+ * @param {BuildFactsOpts} opts
799
+ * @param {import('./spawn.mjs').SessionDeps} [deps]
800
+ * @returns {Promise<{status: string, reason?: string | null, outPath?: string, claims: Claim[], facts?: Fact[]}>}
801
+ */
802
+ export async function buildFacts(opts, deps = {}) {
803
+ const brief = readBriefOnce(opts.briefPath);
804
+ const claims = extractClaims([brief.bytes.toString('utf8'), ...readSourcesText(opts.sources ?? [])].join('\n'));
805
+ const root = opts.runRoot ?? currentRunRoot();
806
+ // B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
807
+ // the snapshot — git and the harness would walk up into the live repo — so the facts area moves
808
+ // to a private B0.1 root under os.tmpdir(); `spawnSession` refuses the cwd either way otherwise.
809
+ const area = gitWorkTreeAncestor(realOrResolved(root)) === null ? path.join(root, 'facts') : path.join(factsTmpRoot(root, { create: true }), 'facts');
810
+ const dir = path.join(area, `${Date.now()}-${randomBytes(4).toString('hex')}`);
811
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
812
+ const packetPath = path.join(dir, 'packet.md');
813
+ const snapDir = path.join(dir, 'snapshot');
814
+ try {
815
+ writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
816
+ const cwd = opts.projectDir === undefined ? undefined : await buildSnapshot(opts.projectDir, opts.sources ?? [], snapDir, dir);
817
+ const result = await spawnSession(
818
+ {
819
+ cfg: opts.cfg,
820
+ level: 'L0',
821
+ role: 'facts',
822
+ promptPath: packetPath,
823
+ schema: FACTS_SESSION_SCHEMA,
824
+ maxBudgetUsd: opts.maxBudgetUsd ?? FACTS_MAX_BUDGET_USD,
825
+ runRoot: root,
826
+ run: opts.run,
827
+ slug: opts.slug,
828
+ timeoutMs: opts.timeoutMs,
829
+ ...(cwd === undefined ? {} : { cwd }),
830
+ },
831
+ deps,
832
+ );
833
+ if (result.status !== 'ok') return { status: result.status, reason: result.reason ?? null, claims };
834
+ const facts = validateFacts(result.answer, claims);
835
+ const now = (opts.now ?? (() => new Date()))();
836
+ const rel = path.relative(path.dirname(opts.outPath), opts.briefPath).split(path.sep).join('/');
837
+ const text = renderSheet({
838
+ briefRef: rel,
839
+ briefSha: sha256(brief.bytes),
840
+ briefMtime: brief.mtime,
841
+ builtAt: now.toISOString(),
842
+ facts,
843
+ });
844
+ mkdirSync(path.dirname(opts.outPath), { recursive: true });
845
+ writeFileSync(opts.outPath, text);
846
+ const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
847
+ if (writeRow) {
848
+ await writeRow({ event: 'facts.built', role: 'facts', run: opts.run ?? null, claims: claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length, tokens_in: result.row?.tokens_in ?? null, tokens_out: result.row?.tokens_out ?? null });
849
+ }
850
+ return { status: 'ok', outPath: opts.outPath, claims, facts };
851
+ } finally {
852
+ removeSnapshot(snapDir);
853
+ rmSync(dir, { recursive: true, force: true });
854
+ }
855
+ }