@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,517 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The first real use of code-forge (plan v1.3 §10.4 B17, R13): one L1 block, "add `clamp()` with
4
+ * tests", end to end on a COPY of the example repository shipped in this package
5
+ * (`examples/node-lib/`). Never on this package's tree, never on a project of the user's: the copy
6
+ * lives in `<tmp-root>/first-use/<run>/`, where `<tmp-root>` is `--tmp-root` or the default temp
7
+ * base (`<os tmpdir>/code-forge`, the directory run roots live in). A given `--tmp-root` is also
8
+ * written to the copy's `tmp.root`, so the worker's and the sessions' run roots sit beside the copy.
9
+ *
10
+ * node scripts/first-use.mjs [--engine harness|subprocess] [--coder scripted|harness|spawn]
11
+ * [--tmp-root <abs dir>] [--run <id>] [--until <step>]
12
+ *
13
+ * Steps (each prints `== <n>. <step> ==`):
14
+ * 1 copy copy the example, `git init`, one base commit, `.env.example` → `.env` (untracked)
15
+ * 2 init `code-forge init --no-interaction …` writes `.code-forge.yml` in the copy; the
16
+ * engine banner of `skill/references/degraded.md` §2 is printed once
17
+ * 3 facts `code-forge facts --brief plans/clamp.md` (the facts rule: before any author)
18
+ * 4 author `code-forge author --job plan --brief … --facts …` → `plans/clamp.plan.md`
19
+ * 5 start `code-forge run start` (launches the review worker)
20
+ * 6 open `code-forge block open B1 --level L1 --owned … --acceptance … --brief …`
21
+ * 7 code the coder: `scripted` applies `examples/first-use/coder/` (the automated test);
22
+ * `harness` waits for the harness subagent (Agent tool) to finish — it appends its
23
+ * sentinel line to `.code-forge/runs/<run>/B1.log`; `spawn` runs
24
+ * `code-forge spawn --role coder` (engine subprocess)
25
+ * 8 review `code-forge review-file <file>` + `review-file --wait` per owned file
26
+ * 9 proof `proof export` (lists the export-ignore paths it restored), the test gate inside the
27
+ * export, `proof red-green` per test the acceptance names, `proof export --remove`
28
+ * 10 close `code-forge block close B1` — then the cost of the completed block is printed
29
+ * 11 report `code-forge report --slug <slug>`
30
+ * then `code-forge run end` (always, once the run started: it stops the worker).
31
+ *
32
+ * Engines: `harness` is the default (R2: the no-Solo default). `subprocess` is OPT-IN: it is
33
+ * never chosen unless `--engine subprocess` is given, and then it is written to `.code-forge.yml`
34
+ * as an explicit config edit (init never writes it, R2). Solo is manual evidence only: this script
35
+ * refuses `--engine solo` — run the skill under Solo instead.
36
+ *
37
+ * Output: the step lines, then one final line `FIRST-USE {json}` (run, copy, slug, approvals,
38
+ * proofs, sections, cost). Exit 0 when every step up to `--until` passed, 1 on a failed step,
39
+ * 2 on usage. Every child is an argv array (never a shell string); git runs with every inherited
40
+ * `GIT_*` stripped and no system/global config.
41
+ */
42
+
43
+ import { randomBytes } from 'node:crypto';
44
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
45
+ import os from 'node:os';
46
+ import path from 'node:path';
47
+ import { setTimeout as sleep } from 'node:timers/promises';
48
+ import { fileURLToPath } from 'node:url';
49
+ import { parse as parseYAML, parseDocument } from 'yaml';
50
+ import { readAllRows } from '../src/ledger/write.mjs';
51
+ import { estimateCostUsd } from '../src/ledger/prices.mjs';
52
+ import { exec } from '../src/util/exec.mjs';
53
+ import { runRoot, setRunRoot, tmpBase } from '../src/util/tmp.mjs';
54
+
55
+ const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
56
+ const BIN = path.join(PKG, 'bin', 'code-forge.mjs');
57
+ export const EXAMPLE_DIR = path.join(PKG, 'examples', 'node-lib');
58
+ export const INPUTS_DIR = path.join(PKG, 'examples', 'first-use');
59
+ const DEGRADED_DOC = path.join(PKG, 'skill', 'references', 'degraded.md');
60
+
61
+ export const BLOCK = 'B1';
62
+ export const OWNED = Object.freeze(['src/math.mjs', 'test/clamp.test.mjs']);
63
+ export const STEPS = Object.freeze(['copy', 'init', 'facts', 'author', 'start', 'open', 'code', 'review', 'proof', 'close', 'report']);
64
+ const ENGINES = ['harness', 'subprocess'];
65
+ const CODERS = ['scripted', 'harness', 'spawn'];
66
+ const CLI_TIMEOUT_MS = 30 * 60 * 1000;
67
+ const GIT_TIMEOUT_MS = 60 * 1000;
68
+ /** How long `--coder harness` waits for the sentinel line by default (one hour). */
69
+ export const DEFAULT_CODER_TIMEOUT_S = 3600;
70
+ /** The example ships `gitignore` (npm pack drops nested `.gitignore` files); the copy renames it. */
71
+ const SHIPPED_GITIGNORE = 'gitignore';
72
+
73
+ export const USAGE = [
74
+ 'usage: node scripts/first-use.mjs [--engine harness|subprocess] [--coder scripted|harness|spawn]',
75
+ ' [--tmp-root <abs dir>] [--run <id>] [--until <step>] [--coder-timeout <s>]',
76
+ ' --engine harness (default, R2) · subprocess (OPT-IN: never chosen unless given here; the',
77
+ ' script writes `engine: subprocess` to the copy\'s .code-forge.yml, which init never',
78
+ ' does) · Solo is manual evidence only: run the skill under Solo, not this script',
79
+ ' --coder scripted: the deterministic edit in examples/first-use/coder (the automated test)',
80
+ ' harness: wait for the harness subagent; it appends its ===BLOCK B1 COMPLETE===',
81
+ ' (or FAILED) line to .code-forge/runs/<run>/B1.log (default for harness)',
82
+ ' spawn: `code-forge spawn --role coder` (default for subprocess)',
83
+ ' --tmp-root where <tmp-root>/first-use/<run>/ is made (default: the code-forge temp base);',
84
+ ' when given it is also written to the copy\'s tmp.root, so the run roots live there too',
85
+ ` --coder-timeout seconds --coder harness waits for the sentinel line (default ${DEFAULT_CODER_TIMEOUT_S})`,
86
+ ` --until stop after this step: ${STEPS.join(' | ')}`,
87
+ ].join('\n');
88
+
89
+ class UsageError extends Error {}
90
+ export class StepFailed extends Error {}
91
+
92
+ /**
93
+ * @param {string[]} argv
94
+ * @returns {{engine: string, coder: string, tmpRoot: string | undefined, run: string, until: string, coderTimeoutMs: number, help: boolean}}
95
+ * @throws {UsageError}
96
+ */
97
+ export function parseArgs(argv) {
98
+ /** @type {Record<string, string>} */
99
+ const flags = {};
100
+ let help = false;
101
+ for (let i = 0; i < argv.length; i += 1) {
102
+ const arg = argv[i];
103
+ if (arg === '--help' || arg === '-h') {
104
+ help = true;
105
+ continue;
106
+ }
107
+ const name = arg.startsWith('--') ? arg.slice(2) : '';
108
+ if (!['engine', 'coder', 'tmp-root', 'run', 'until', 'coder-timeout'].includes(name)) throw new UsageError(`unknown argument ${JSON.stringify(arg)}`);
109
+ const value = argv[i + 1];
110
+ if (value === undefined || value.startsWith('--')) throw new UsageError(`--${name} needs a value`);
111
+ if (Object.hasOwn(flags, name)) throw new UsageError(`--${name} given more than once`);
112
+ flags[name] = value;
113
+ i += 1;
114
+ }
115
+ const engine = flags.engine ?? 'harness';
116
+ if (engine === 'solo') throw new UsageError('--engine solo: Solo is manual evidence only — run the code-forge skill under Solo, not this script');
117
+ if (!ENGINES.includes(engine)) throw new UsageError(`--engine must be ${ENGINES.join(' or ')}`);
118
+ const coder = flags.coder ?? (engine === 'subprocess' ? 'spawn' : 'harness');
119
+ if (!CODERS.includes(coder)) throw new UsageError(`--coder must be one of ${CODERS.join(', ')}`);
120
+ if (coder === 'spawn' && engine !== 'subprocess') throw new UsageError('--coder spawn needs --engine subprocess');
121
+ if (flags['tmp-root'] !== undefined && !path.isAbsolute(flags['tmp-root'])) throw new UsageError('--tmp-root must be an absolute path');
122
+ const run = flags.run ?? defaultRunId();
123
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(run)) throw new UsageError('--run must match ^[a-z0-9][a-z0-9-]{0,63}$');
124
+ const until = flags.until ?? 'report';
125
+ if (!STEPS.includes(until)) throw new UsageError(`--until must be one of ${STEPS.join(', ')}`);
126
+ const coderTimeout = flags['coder-timeout'] ?? String(DEFAULT_CODER_TIMEOUT_S);
127
+ if (!/^[1-9]\d{0,5}$/.test(coderTimeout)) throw new UsageError('--coder-timeout must be a whole number of seconds (1..999999)');
128
+ return { engine, coder, tmpRoot: flags['tmp-root'], run, until, coderTimeoutMs: Number(coderTimeout) * 1000, help };
129
+ }
130
+
131
+ /** @returns {string} `fu-<yyyymmdd>-<hhmmss>-<hex4>` */
132
+ function defaultRunId() {
133
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
134
+ return `fu-${stamp}-${randomBytes(2).toString('hex')}`;
135
+ }
136
+
137
+ /**
138
+ * The §2 banner of `skill/references/degraded.md` (the text the skill prints once per run), for
139
+ * this engine.
140
+ * @param {string} engine
141
+ * @returns {string}
142
+ */
143
+ export function degradedBanner(engine) {
144
+ const doc = readFileSync(DEGRADED_DOC, 'utf8');
145
+ const section = doc.slice(doc.indexOf('## §2'));
146
+ const open = section.indexOf('```\n');
147
+ const close = section.indexOf('\n```', open + 4);
148
+ if (!section.startsWith('## §2') || open < 0 || close < 0) throw new Error('degraded.md §2 has no banner block');
149
+ return section.slice(open + 4, close).replace('<harness|subprocess>', engine);
150
+ }
151
+
152
+ /**
153
+ * `process.env` minus every inherited `GIT_*` (a git hook's `GIT_DIR`/`GIT_INDEX_FILE` must never
154
+ * redirect a child into another repository). Every child this script starts gets it.
155
+ * @returns {Record<string, string>}
156
+ */
157
+ export function scrubbedEnv() {
158
+ /** @type {Record<string, string>} */
159
+ const env = {};
160
+ for (const [key, value] of Object.entries(process.env)) if (!key.startsWith('GIT_') && value !== undefined) env[key] = value;
161
+ return env;
162
+ }
163
+
164
+ /** {@link scrubbedEnv} plus no system/global git config. */
165
+ function gitEnv() {
166
+ return { ...scrubbedEnv(), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
167
+ }
168
+
169
+ /** @param {string} text @returns {any} the last line of `text` parsed as JSON, or null */
170
+ function lastJSON(text) {
171
+ const line = text.trim().split('\n').at(-1) ?? '';
172
+ try {
173
+ return JSON.parse(line);
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The cost of one block from its run's ledger rows: every row that carries a token count and a
181
+ * provider + level is priced with the static table (`src/ledger/prices.mjs`) — dollars are
182
+ * estimates, tokens are facts. Rows of the run without a block (facts, author) are the block's
183
+ * planning cost here, since this run has one block.
184
+ * @param {Array<Record<string, any>>} rows - the run's rows
185
+ * @param {string} block
186
+ * @returns {{usd: number, tokens_in: number, tokens_out: number, by_role: Record<string, number>, priced_rows: number}}
187
+ */
188
+ export function blockCost(rows, block) {
189
+ let usd = 0;
190
+ let tokensIn = 0;
191
+ let tokensOut = 0;
192
+ let priced = 0;
193
+ /** @type {Record<string, number>} */
194
+ const byRole = {};
195
+ for (const row of rows) {
196
+ if (row.block !== block && row.block !== null && row.block !== undefined) continue;
197
+ const tIn = typeof row.tokens_in === 'number' ? row.tokens_in : 0;
198
+ const tOut = typeof row.tokens_out === 'number' ? row.tokens_out : 0;
199
+ if (row.event !== 'session' || typeof row.provider !== 'string' || typeof row.level !== 'string' || tIn + tOut === 0) continue;
200
+ const cost = estimateCostUsd({ provider: row.provider, level: row.level, tokensIn: tIn, tokensOut: tOut });
201
+ usd += cost;
202
+ tokensIn += tIn;
203
+ tokensOut += tOut;
204
+ priced += 1;
205
+ const role = typeof row.role === 'string' ? row.role : 'other';
206
+ byRole[role] = Math.round(((byRole[role] ?? 0) + cost) * 10000) / 10000;
207
+ }
208
+ return { usd: Math.round(usd * 10000) / 10000, tokens_in: tokensIn, tokens_out: tokensOut, by_role: byRole, priced_rows: priced };
209
+ }
210
+
211
+ /**
212
+ * @param {string[]} argv
213
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [io]
214
+ * @returns {Promise<number>}
215
+ */
216
+ export async function main(argv, io = {}) {
217
+ const stdout = io.stdout ?? process.stdout;
218
+ const stderr = io.stderr ?? process.stderr;
219
+ const say = (/** @type {string} */ s) => stdout.write(`${s}\n`);
220
+ /** @type {ReturnType<typeof parseArgs>} */
221
+ let opts;
222
+ try {
223
+ opts = parseArgs(argv);
224
+ } catch (err) {
225
+ if (!(err instanceof UsageError)) throw err;
226
+ stderr.write(`first-use: ${err.message}\n${USAGE}\n`);
227
+ return 2;
228
+ }
229
+ if (opts.help) {
230
+ say(USAGE);
231
+ return 0;
232
+ }
233
+ const { engine, coder, run, until } = opts;
234
+
235
+ // The copy lives in `<tmp-root>/first-use/<run>/`; `first-use/` is a temp root this process owns
236
+ // (owner.json + pid registry), so every child `exec` starts is registered there and the next
237
+ // sweep after this process ends removes the copies (§9.6).
238
+ const container = runRoot('first-use', { root: tmpBase(opts.tmpRoot) });
239
+ setRunRoot(container);
240
+ const copy = path.join(container, run);
241
+ if (existsSync(copy)) {
242
+ stderr.write(`first-use: ${copy} already exists — pick another --run\n`);
243
+ return 2;
244
+ }
245
+
246
+ /** @type {Record<string, any>} */
247
+ const summary = { ok: false, run, engine, coder, copy, slug: null, steps: [] };
248
+ let started = false;
249
+ let blockOpen = false;
250
+ const abort = new AbortController();
251
+ let stepNo = 0;
252
+ const step = (/** @type {string} */ name) => {
253
+ stepNo += 1;
254
+ summary.steps.push(name);
255
+ say(`== ${stepNo}. ${name} ==`);
256
+ };
257
+ const done = (/** @type {string} */ name) => name === until;
258
+
259
+ /** @param {string[]} args @param {{cwd?: string, ok?: number[]}} [o] */
260
+ const cf = async (args, o = {}) => {
261
+ if (abort.signal.aborted) throw new StepFailed(String(abort.signal.reason));
262
+ const res = await exec([process.execPath, BIN, ...args], { cwd: o.cwd ?? copy, env: scrubbedEnv(), timeoutMs: CLI_TIMEOUT_MS });
263
+ if (abort.signal.aborted) throw new StepFailed(String(abort.signal.reason));
264
+ const ok = o.ok ?? [0];
265
+ if (!ok.includes(res.code ?? -1)) {
266
+ stderr.write(res.stderr);
267
+ throw new StepFailed(`code-forge ${args.slice(0, 2).join(' ')} exited ${res.code}`);
268
+ }
269
+ return res;
270
+ };
271
+ /** @param {string[]} args */
272
+ const git = async (args) => {
273
+ const res = await exec(['git', '-c', 'user.name=code-forge first use', '-c', 'user.email=first-use@example.invalid', '-c', 'commit.gpgsign=false', ...args], {
274
+ cwd: copy,
275
+ env: gitEnv(),
276
+ timeoutMs: GIT_TIMEOUT_MS,
277
+ });
278
+ if (res.result !== 'ok') throw new StepFailed(`git ${args[0]} failed (exit ${res.code})`);
279
+ return res.stdout;
280
+ };
281
+
282
+ /** @returns {Promise<number>} */
283
+ const body = async () => {
284
+ // 1 — copy + git init + one base commit
285
+ step('copy');
286
+ cpSync(EXAMPLE_DIR, copy, { recursive: true, filter: (src) => !['node_modules', '.code-forge', '.git', '.env', '.gitignore'].includes(path.basename(src)) });
287
+ renameSync(path.join(copy, SHIPPED_GITIGNORE), path.join(copy, '.gitignore'));
288
+ await git(['init', '-q']);
289
+ await git(['add', '-A']);
290
+ await git(['commit', '-q', '-m', 'base: examples/node-lib']);
291
+ const baseSha = (await git(['rev-parse', 'HEAD'])).trim();
292
+ writeFileSync(path.join(copy, '.env'), readFileSync(path.join(copy, '.env.example'))); // untracked by design (.gitignore)
293
+ say(`copy ${realpathSync(copy)} · base ${baseSha.slice(0, 12)}`);
294
+ if (done('copy')) return 0;
295
+
296
+ // 2 — init (non-interactive), then the engine banner once
297
+ step('init');
298
+ const initArgs = ['init', '--no-interaction', '--tools', 'current', '--harness', 'claude', '-p', '--engine', engine === 'harness' ? 'harness' : 'auto', '--no-jev', '--proof', 'copy_untracked=.env', '--skip-doctor'];
299
+ const init = await cf(initArgs);
300
+ const initJSON = lastJSON(init.stdout);
301
+ if (initJSON && initJSON.ok !== true) throw new StepFailed('init did not report ok');
302
+ const configFile = path.join(copy, '.code-forge.yml');
303
+ if (engine === 'subprocess' || opts.tmpRoot !== undefined) {
304
+ const doc = parseDocument(readFileSync(configFile, 'utf8'));
305
+ // R2: subprocess is never automatic — this is the explicit config edit the stop text asks for
306
+ if (engine === 'subprocess') doc.set('engine', 'subprocess');
307
+ // the run roots (worker, facts, author) go where the copy is
308
+ if (opts.tmpRoot !== undefined) doc.setIn(['tmp', 'root'], opts.tmpRoot);
309
+ writeFileSync(configFile, doc.toString());
310
+ }
311
+ const config = parseYAML(readFileSync(configFile, 'utf8'));
312
+ summary.slug = config?.project?.slug ?? null;
313
+ say(`.code-forge.yml written · engine ${config?.engine} · slug ${summary.slug}${config?.tmp?.root ? ` · tmp.root ${config.tmp.root}` : ''}`);
314
+ say(degradedBanner(engine).replace('<slug>', String(summary.slug)));
315
+ if (done('init')) return 0;
316
+
317
+ // 3 + 4 — the facts rule: the sheet exists before the author designs anything
318
+ mkdirSync(path.join(copy, 'plans'), { recursive: true });
319
+ cpSync(path.join(INPUTS_DIR, 'clamp.brief.md'), path.join(copy, 'plans', 'clamp.md'));
320
+ cpSync(path.join(INPUTS_DIR, 'clamp.acceptance.yml'), path.join(copy, 'plans', 'clamp.acceptance.yml'));
321
+ step('facts');
322
+ const facts = lastJSON((await cf(['facts', '--brief', 'plans/clamp.md', '--run', run])).stdout);
323
+ say(`facts sheet ${path.relative(copy, facts?.out ?? '')} · claims ${facts?.claims} · verified ${facts?.verified}`);
324
+ if (done('facts')) return 0;
325
+
326
+ step('author');
327
+ const author = lastJSON((await cf(['author', '--job', 'plan', '--brief', 'plans/clamp.md', '--facts', 'plans/clamp.facts.md', '--run', run])).stdout);
328
+ say(`plan draft ${path.relative(copy, author?.draft ?? '')} · questions ${author?.questions?.length ?? 0} · cost ${JSON.stringify(author?.cost ?? null)}`);
329
+ if (done('author')) return 0;
330
+
331
+ // 5 + 6 — the run (worker) and the block
332
+ step('start');
333
+ started = true;
334
+ say((await cf(['run', 'start', '--run', run, '--engine', engine])).stdout.trimEnd());
335
+ if (done('start')) return 0;
336
+
337
+ step('open');
338
+ const opened = await cf(['block', 'open', BLOCK, '--run', run, '--level', 'L1', '--owned', ...OWNED, '--acceptance', 'plans/clamp.acceptance.yml', '--brief', 'plans/clamp.plan.md', '--lines', '40']);
339
+ blockOpen = true;
340
+ say(opened.stdout.trimEnd());
341
+ const pointer = opened.stdout.split('\n').find((l) => l.startsWith('BRIEF ')) ?? '';
342
+ if (done('open')) return 0;
343
+
344
+ // 7 — the coder
345
+ step('code');
346
+ const logFile = path.join(copy, '.code-forge', 'runs', run, `${BLOCK}.log`);
347
+ mkdirSync(path.dirname(logFile), { recursive: true });
348
+ await runCoder({ coder, copy, run, pointer, logFile, cf, say, signal: abort.signal, timeoutMs: opts.coderTimeoutMs });
349
+ if (done('code')) return 0;
350
+
351
+ // 8 — one review per owned file; the approval is the signed row the gate reads
352
+ step('review');
353
+ summary.approvals = [];
354
+ for (const file of OWNED) {
355
+ const queued = lastJSON((await cf(['review-file', file, '--block', BLOCK, '--run', run])).stdout);
356
+ if (!queued?.ticket) throw new StepFailed(`review-file ${file}: ${queued?.status ?? 'no answer'}`);
357
+ const waited = lastJSON((await cf(['review-file', '--wait', queued.ticket, '--max', '600s'])).stdout);
358
+ const result = waited?.result ?? {};
359
+ say(`review ${file} · ${waited?.status} · round ${result.round ?? '?'} ${result.kind ?? ''} · approved ${result.approved === true}`);
360
+ if (waited?.status !== 'done' || result.approved !== true) {
361
+ throw new StepFailed(`review of ${file} is not approved (${waited?.status}; ${waited?.fix_list?.length ?? 0} finding(s)${waited?.stop ? `; stopped ${waited.stop}` : ''})`);
362
+ }
363
+ summary.approvals.push(file);
364
+ }
365
+ if (done('review')) return 0;
366
+
367
+ // 9 — proof, in the measurement export
368
+ step('proof');
369
+ const exp = lastJSON((await cf(['proof', 'export', BLOCK, '--run', run])).stdout);
370
+ summary.export = { restored: exp?.restored ?? [], untracked: exp?.untracked ?? [] };
371
+ say(`export ${exp?.dir} · restored ${summary.export.restored.join(', ')} · untracked ${summary.export.untracked.join(', ')}`);
372
+ const gate = lastJSON((await cf(['gates', 'run', '--cwd', exp.dir], { ok: [0, 1] })).stdout);
373
+ const gates = Array.isArray(gate?.results) ? gate.results.filter((/** @type {any} */ r) => r.skipped !== true) : [];
374
+ summary.export_gates_ok = gates.length > 0 && gates.every((/** @type {any} */ r) => r.ok === true);
375
+ say(`gates in the export · ${gates.map((/** @type {any} */ r) => `${r.gate} ${r.ok ? 'ok' : 'red'}`).join(' · ') || 'none ran'}`);
376
+ if (summary.export_gates_ok !== true) throw new StepFailed('the example suite does not pass in the measurement export');
377
+ summary.proofs = [];
378
+ for (const test of acceptanceTests(path.join(copy, 'plans', 'clamp.acceptance.yml'))) {
379
+ const res = await cf(['proof', 'red-green', BLOCK, '--run', run, '--test', test], { ok: [0, 1] });
380
+ const proof = lastJSON(res.stdout);
381
+ say(`${res.stderr.trimEnd()}\nproof ${test} · ${proof?.mechanism} · red ${proof?.red} (${proof?.red_kind}) · green ${proof?.green}`);
382
+ summary.proofs.push({ test, mechanism: proof?.mechanism, red_kind: proof?.red_kind, proven: proof?.proven === true });
383
+ if (proof?.proven !== true) throw new StepFailed(`red→green not proven for ${test}`);
384
+ }
385
+ await cf(['proof', 'export', BLOCK, '--run', run, '--remove']);
386
+ if (done('proof')) return 0;
387
+
388
+ // 10 — the gate, then what the completed block cost
389
+ step('close');
390
+ say((await cf(['block', 'close', BLOCK, '--run', run])).stdout.trimEnd());
391
+ blockOpen = false;
392
+ const rows = (await readAllRows(/** @type {string} */ (summary.slug))).filter((r) => r.run === run);
393
+ summary.cost = blockCost(rows, BLOCK);
394
+ const roles = Object.entries(summary.cost.by_role).map(([role, usd]) => `${role} $${usd}`).join(' · ');
395
+ say(
396
+ `cost per completed block: ${BLOCK} $${summary.cost.usd} (estimated from ${summary.cost.priced_rows} priced session row(s); ${roles || 'no priced rows'}) · tokens ${summary.cost.tokens_in} in / ${summary.cost.tokens_out} out` +
397
+ (coder === 'scripted' ? ' · coder: scripted, no session' : coder === 'harness' ? ' · coder: harness subagent, its tokens are not in the ledger' : ''),
398
+ );
399
+ if (done('close')) return 0;
400
+
401
+ // 11 — the report
402
+ step('report');
403
+ const report = await cf(['report', '--slug', /** @type {string} */ (summary.slug)]);
404
+ stdout.write(report.stdout);
405
+ summary.sections = report.stdout.split('\n').filter((l) => /^== [a-z0-9_]+ ==$/.test(l)).length;
406
+ say(`report sections: ${summary.sections}`);
407
+ return 0;
408
+ };
409
+
410
+ // SIGINT/SIGTERM while the run is active abort the current step (a wait ends at once, a CLI child
411
+ // gets the signal forwarded by `exec`), so the `finally` below still stops the block and ends the run.
412
+ const onSignal = (/** @type {NodeJS.Signals} */ signal) => abort.abort(`interrupted by ${signal}`);
413
+ process.on('SIGINT', onSignal);
414
+ process.on('SIGTERM', onSignal);
415
+ let code;
416
+ try {
417
+ code = await body();
418
+ } catch (err) {
419
+ if (!(err instanceof StepFailed)) throw err;
420
+ stderr.write(`first-use: FAILED at ${summary.steps.at(-1)}: ${err.message}\n`);
421
+ code = 1;
422
+ } finally {
423
+ if (started && blockOpen) {
424
+ // a failed run leaves no open block behind: `run end` refuses one, and the worker would outlive us
425
+ const stopped = await exec([process.execPath, BIN, 'block', 'stop', BLOCK, '--run', run, '--reason', `first-use failed at ${summary.steps.at(-1)}`], { cwd: copy, env: scrubbedEnv(), timeoutMs: 60000 });
426
+ say(`block stop · exit ${stopped.code}`);
427
+ }
428
+ if (started) {
429
+ const ended = await exec([process.execPath, BIN, 'run', 'end', '--run', run], { cwd: copy, env: scrubbedEnv(), timeoutMs: 60000 });
430
+ say(`run end · exit ${ended.code} · ${ended.stdout.trimEnd() || ended.stderr.trimEnd()}`);
431
+ summary.run_ended = ended.code === 0;
432
+ }
433
+ process.off('SIGINT', onSignal);
434
+ process.off('SIGTERM', onSignal);
435
+ }
436
+ summary.ok = code === 0;
437
+ say(`FIRST-USE ${JSON.stringify(summary)}`);
438
+ return code;
439
+ }
440
+
441
+ /**
442
+ * The test ids the acceptance file names, in order (`<file>::<case>` or `<file>`), deduplicated.
443
+ * @param {string} file
444
+ * @returns {string[]}
445
+ */
446
+ export function acceptanceTests(file) {
447
+ const clauses = parseYAML(readFileSync(file, 'utf8'));
448
+ if (!Array.isArray(clauses)) throw new StepFailed('the acceptance file is not a list of clauses');
449
+ return [...new Set(clauses.flatMap((c) => (Array.isArray(c?.tests) ? c.tests.map(String) : [])))];
450
+ }
451
+
452
+ /**
453
+ * The coder step.
454
+ * @param {{coder: string, copy: string, run: string, pointer: string, logFile: string, cf: (args: string[], o?: {cwd?: string, ok?: number[]}) => Promise<import('../src/util/exec.mjs').ExecResult>, say: (s: string) => void, signal: AbortSignal, timeoutMs: number}} o
455
+ */
456
+ async function runCoder({ coder, copy, run, pointer, logFile, cf, say, signal, timeoutMs }) {
457
+ const sha8 = /sha=([0-9a-f]{8})/.exec(pointer)?.[1] ?? '????????';
458
+ const lines = /lines=(\d+)/.exec(pointer)?.[1] ?? '?';
459
+ if (coder === 'scripted') {
460
+ appendFileSync(logFile, `ACK ${sha8} lines=${lines}\n`);
461
+ for (const file of OWNED) {
462
+ cpSync(path.join(INPUTS_DIR, 'coder', file), path.join(copy, file));
463
+ appendFileSync(logFile, `wrote ${file}\n`);
464
+ }
465
+ appendFileSync(logFile, `===BLOCK ${BLOCK} COMPLETE===\n`);
466
+ say(`scripted coder: ${OWNED.join(', ')} written · ACK ${sha8}`);
467
+ return;
468
+ }
469
+ if (coder === 'spawn') {
470
+ const res = await cf(['spawn', '--level', 'L1', '--role', 'coder', '--brief', 'plans/clamp.plan.md', '--run', run, '--block', BLOCK, '--cwd', copy, '--timeout', '1800'], { ok: [0, 1, 3] });
471
+ appendFileSync(logFile, `${res.stdout}${res.stderr}`);
472
+ if (res.code !== 0) throw new StepFailed(`the spawned coder exited ${res.code}`);
473
+ say(`spawned coder done · ${res.stdout.trim().split('\n').at(-1)}`);
474
+ return;
475
+ }
476
+ // harness: the orchestrator runs the Agent tool; this script waits for the sentinel in the log
477
+ say(`harness coder: run Agent(subagent_type: general-purpose, model: <alias of L1>, prompt: <the line below>) from the harness`);
478
+ say(`read the brief at the pointer; reply ACK ${sha8} lines=${lines} first; append a progress line to .code-forge/runs/${run}/${BLOCK}.log after every file, and your final ===BLOCK ${BLOCK} COMPLETE=== (or FAILED) line there too.`);
479
+ say(pointer);
480
+ say(`waiting up to ${timeoutMs / 1000}s for ===BLOCK ${BLOCK} COMPLETE=== in ${logFile} (Ctrl-C stops; the run is ended either way)`);
481
+ await waitForSentinel({ logFile, signal, timeoutMs });
482
+ }
483
+
484
+ /**
485
+ * Poll `logFile` until its last `===BLOCK B1 …===` line is COMPLETE (return) or anything else
486
+ * (throw). Throws `StepFailed` when `signal` aborts or `timeoutMs` passes. Each poll sleeps with
487
+ * `timers/promises` bound to `signal`, so no listener outlives its poll.
488
+ * @param {{logFile: string, signal: AbortSignal, timeoutMs: number, pollMs?: number}} o
489
+ * @returns {Promise<void>}
490
+ */
491
+ export async function waitForSentinel({ logFile, signal, timeoutMs, pollMs = 1000 }) {
492
+ const deadline = Date.now() + timeoutMs;
493
+ for (;;) {
494
+ if (signal.aborted) throw new StepFailed(String(signal.reason));
495
+ const log = existsSync(logFile) ? readFileSync(logFile, 'utf8') : '';
496
+ const sentinel = log.split('\n').filter((l) => l.startsWith(`===BLOCK ${BLOCK} `)).at(-1);
497
+ if (sentinel === `===BLOCK ${BLOCK} COMPLETE===`) return;
498
+ if (sentinel) throw new StepFailed(`the coder reported ${sentinel}`);
499
+ if (Date.now() >= deadline) throw new StepFailed(`no ===BLOCK ${BLOCK} COMPLETE=== line within ${timeoutMs / 1000}s`);
500
+ try {
501
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())), undefined, { signal });
502
+ } catch (err) {
503
+ if (/** @type {Error} */ (err)?.name !== 'AbortError') throw err;
504
+ // aborted: the check at the top of the loop throws StepFailed with the reason
505
+ }
506
+ }
507
+ }
508
+
509
+ const isMain = (() => {
510
+ try {
511
+ return process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
512
+ } catch {
513
+ return false;
514
+ }
515
+ })();
516
+
517
+ if (isMain) process.exitCode = await main(process.argv.slice(2));