@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,294 @@
1
+ /**
2
+ * `forge plan check` — the deterministic harden exit (plan §3.2, §3.8 rule 4, §10.1; block B9b).
3
+ *
4
+ * Reads a plan's block tables (any markdown table whose header has `id`, `owned_files` and
5
+ * `acceptance` columns; the wave is the nearest heading that says `Wave <n>`) and checks:
6
+ * - a facts sheet is present (pasted in the plan's §0 or given as `--facts`) and was built from
7
+ * the brief as it is now; a separate sheet's `brief_sha256` line must also appear in the plan;
8
+ * - every acceptance clause (the cell split on `;`) whose claim tokens the sheet does not mark
9
+ * VERIFIED is listed in the "… the facts sheet cannot back" section by a line that names the
10
+ * token, the block, and a tolerance — else `unbackable clause without tolerance: <block> <clause>`;
11
+ * - a caller map section exists and names every block (C9);
12
+ * - every block has a lane, owned files that pass B8's `assertOwned` (no `[ ] ( ) ! + @` in a
13
+ * glob, V6) and are disjoint from the other blocks of its wave (O26);
14
+ * - `depends_on` is acyclic and every path a block imports (`imports` column) that another block
15
+ * owns belongs to one of its (transitive) dependencies — a batch-mate arrives as a parameter,
16
+ * never as an import (C17);
17
+ * - every block has a `cases` and a `lines` forecast within `budget.block_cases` /
18
+ * `budget.block_lines` (V1);
19
+ * - no step of a wave's "Dispatch order" runs more than `caps.coders` blocks concurrently (`∥`, R10).
20
+ * Every failing row is reported by name; the verb exits 1 when there is any.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { assertOwned, findOverlap, ownsFile } from '../state/registry.mjs';
26
+ import { extractClaims, parseSheet, sha256 } from './facts.mjs';
27
+
28
+ /** Limits used when the config does not set them (plan v1.3 §10.7, R10). */
29
+ export const DEFAULT_LIMITS = Object.freeze({ coders: 2, blockCases: 80, blockLines: 2000 });
30
+
31
+ /**
32
+ * @typedef {object} PlanBlock
33
+ * @property {string} id @property {string} wave @property {string | null} level
34
+ * @property {string[]} dependsOn @property {string[]} owned @property {string[]} imports
35
+ * @property {string} acceptance @property {number | null} cases @property {number | null} lines
36
+ */
37
+
38
+ /** @param {string} s */
39
+ const plain = (s) => s.replace(/\*\*/g, '').trim();
40
+
41
+ /** @param {string} s */
42
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+
44
+ /** @param {string} id @returns {RegExp} `id` as a whole word (`B1` does not match inside `B1.1` or `B10`). */
45
+ const idRe = (id) => new RegExp(`(?<![\\w.])${escapeRe(id)}(?!\\w|\\.\\w)`);
46
+
47
+ /** @param {string} line @returns {string[]} the cells of a markdown table row (`\|` stays inside a cell). */
48
+ function cells(line) {
49
+ const parts = line.trim().split(/(?<!\\)\|/);
50
+ return parts.slice(1, parts.length - 1).map((c) => c.trim());
51
+ }
52
+
53
+ /** @param {string} cell @returns {string[]} code spans, else comma-separated words; `—`/`none` ⇒ []. */
54
+ function listCell(cell) {
55
+ const spans = [...cell.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim());
56
+ if (spans.length > 0) return spans;
57
+ return plain(cell)
58
+ .split(',')
59
+ .map((s) => s.trim())
60
+ .filter((s) => s.length > 0 && !['—', '-', 'none'].includes(s.toLowerCase()));
61
+ }
62
+
63
+ /** @param {string | undefined} raw @returns {number | null} */
64
+ function forecastNumber(raw) {
65
+ if (raw === undefined) return null;
66
+ const text = plain(raw).replace(/\(.*?\)/g, '').replace(/[\s   ]/g, '');
67
+ return /^\d+$/.test(text) ? Number(text) : null;
68
+ }
69
+
70
+ /**
71
+ * @param {string} text
72
+ * @returns {{blocks: PlanBlock[], sections: Array<{title: string, level: number, body: string}>, dispatch: Array<{wave: string, text: string}>}}
73
+ */
74
+ export function parsePlan(text) {
75
+ const lines = text.split(/\r?\n/);
76
+ /** @type {PlanBlock[]} */
77
+ const blocks = [];
78
+ /** @type {Array<{title: string, level: number, start: number, end: number}>} */
79
+ const heads = [];
80
+ /** @type {Array<{wave: string, text: string}>} */
81
+ const dispatch = [];
82
+ let wave = 'default';
83
+ for (let i = 0; i < lines.length; i += 1) {
84
+ const line = lines[i];
85
+ const head = /^(#{1,6})\s+(.*)$/.exec(line);
86
+ if (head) {
87
+ heads.push({ title: plain(head[2]), level: head[1].length, start: i + 1, end: lines.length });
88
+ const w = /\bWave\s+([\w.]+)/i.exec(head[2]);
89
+ if (w) wave = w[1];
90
+ continue;
91
+ }
92
+ if (/dispatch order[^:]*:/i.test(plain(line))) {
93
+ const after = plain(line).replace(/^.*?dispatch order[^:]*:/i, '');
94
+ dispatch.push({ wave, text: after });
95
+ continue;
96
+ }
97
+ if (!line.trim().startsWith('|') || i + 1 >= lines.length || !/^\s*\|[\s|:-]+\|\s*$/.test(lines[i + 1])) continue;
98
+ const header = cells(line).map((h) => plain(h).toLowerCase());
99
+ const col = (/** @type {string} */ name) => header.findIndex((h) => h === name || h.startsWith(`${name} `) || h.startsWith(`${name}(`));
100
+ const idCol = col('id');
101
+ const ownedCol = header.findIndex((h) => h.startsWith('owned_files'));
102
+ const accCol = header.findIndex((h) => h.startsWith('acceptance'));
103
+ if (idCol < 0 || ownedCol < 0 || accCol < 0) continue;
104
+ const forecastCol = header.findIndex((h) => /\bcases\b/.test(h) && h.includes('→'));
105
+ const parts = forecastCol >= 0 ? header[forecastCol].split('→').map((p) => p.trim()) : [];
106
+ let j = i + 2;
107
+ for (; j < lines.length && lines[j].trim().startsWith('|'); j += 1) {
108
+ const row = cells(lines[j]);
109
+ const at = (/** @type {number} */ c) => (c >= 0 ? row[c] ?? '' : '');
110
+ const id = plain(at(idCol));
111
+ if (id.length === 0) continue;
112
+ const forecast = forecastCol >= 0 ? at(forecastCol).split('→') : [];
113
+ const pick = (/** @type {string} */ name) => {
114
+ const own = col(name);
115
+ if (own >= 0 && own !== forecastCol) return forecastNumber(at(own));
116
+ const k = parts.indexOf(name);
117
+ return k >= 0 ? forecastNumber(forecast[k]) : null;
118
+ };
119
+ const level = /\bL[0-3]\b/.exec(plain(at(col('level'))));
120
+ blocks.push({
121
+ id,
122
+ wave,
123
+ level: level ? level[0] : null,
124
+ dependsOn: plain(at(col('depends_on')))
125
+ .split(/[,\s]+/)
126
+ .filter((s) => s.length > 0 && !['—', '-', 'none'].includes(s.toLowerCase())),
127
+ owned: listCell(at(ownedCol)),
128
+ imports: listCell(at(col('imports'))),
129
+ acceptance: at(accCol),
130
+ cases: pick('cases'),
131
+ lines: pick('lines'),
132
+ });
133
+ }
134
+ i = j - 1;
135
+ }
136
+ const sections = heads.map((h, k) => {
137
+ const next = heads.slice(k + 1).find((n) => n.level <= h.level);
138
+ return { title: h.title, level: h.level, body: lines.slice(h.start, next ? next.start - 1 : lines.length).join('\n') };
139
+ });
140
+ return { blocks, sections, dispatch };
141
+ }
142
+
143
+ /**
144
+ * @param {PlanBlock[]} blocks
145
+ * @returns {string[]} one message per cycle found in `depends_on` (among the plan's own blocks).
146
+ */
147
+ function dependencyCycles(blocks) {
148
+ const byId = new Map(blocks.map((b) => [b.id, b]));
149
+ const state = new Map();
150
+ /** @type {string[]} */
151
+ const out = [];
152
+ /** @param {string} id @param {string[]} trail */
153
+ const visit = (id, trail) => {
154
+ if (state.get(id) === 'done') return;
155
+ if (state.get(id) === 'open') {
156
+ out.push(`depends_on cycle: ${[...trail.slice(trail.indexOf(id)), id].join(' → ')}`);
157
+ return;
158
+ }
159
+ state.set(id, 'open');
160
+ for (const dep of byId.get(id)?.dependsOn ?? []) if (byId.has(dep)) visit(dep, [...trail, id]);
161
+ state.set(id, 'done');
162
+ };
163
+ for (const b of blocks) visit(b.id, []);
164
+ return out;
165
+ }
166
+
167
+ /** @param {PlanBlock[]} blocks @param {string} id @returns {Set<string>} every (transitive) dependency of `id`. */
168
+ function closure(blocks, id) {
169
+ const byId = new Map(blocks.map((b) => [b.id, b]));
170
+ const seen = new Set();
171
+ const stack = [...(byId.get(id)?.dependsOn ?? [])];
172
+ while (stack.length > 0) {
173
+ const dep = /** @type {string} */ (stack.pop());
174
+ if (seen.has(dep)) continue;
175
+ seen.add(dep);
176
+ stack.push(...(byId.get(dep)?.dependsOn ?? []));
177
+ }
178
+ return seen;
179
+ }
180
+
181
+ /** @param {string} body @returns {string[]} the list items of a section. */
182
+ const listItems = (body) =>
183
+ body
184
+ .split('\n')
185
+ .filter((l) => /^\s*(?:[-*]|\d+\.)\s+/.test(l))
186
+ .map((l) => l.trim());
187
+
188
+ /**
189
+ * @typedef {object} CheckOpts
190
+ * @property {string} [planPath] - where the plan lives (resolves the sheet's brief reference).
191
+ * @property {string} [factsText] @property {string} [factsPath] - a separate sheet (else the plan's own §0).
192
+ * @property {Record<string, any>} [cfg] - `caps.coders`, `budget.block_cases`, `budget.block_lines`.
193
+ */
194
+
195
+ /**
196
+ * @param {string} text - the plan.
197
+ * @param {CheckOpts} [opts]
198
+ * @returns {{ok: boolean, errors: string[], blocks: number}}
199
+ */
200
+ export function checkPlan(text, opts = {}) {
201
+ const limits = {
202
+ coders: opts.cfg?.caps?.coders ?? DEFAULT_LIMITS.coders,
203
+ blockCases: opts.cfg?.budget?.block_cases ?? DEFAULT_LIMITS.blockCases,
204
+ blockLines: opts.cfg?.budget?.block_lines ?? DEFAULT_LIMITS.blockLines,
205
+ };
206
+ const { blocks, sections, dispatch } = parsePlan(text);
207
+ /** @type {string[]} */
208
+ const errors = [];
209
+ if (blocks.length === 0) return { ok: false, errors: ['no block table found (a table with id, owned_files and acceptance columns)'], blocks: 0 };
210
+
211
+ // facts sheet: present, fresh, and (when separate) pasted into the plan
212
+ const sheetText = opts.factsText ?? text;
213
+ const sheet = parseSheet(sheetText);
214
+ if (!sheet) {
215
+ errors.push('facts sheet missing: run forge facts and paste the sheet into the plan §0');
216
+ } else {
217
+ if (opts.factsText !== undefined && !text.includes(`brief_sha256: \`${sheet.briefSha}\``)) errors.push('the plan §0 is not the facts sheet: its brief_sha256 is absent from the plan');
218
+ const base = opts.factsPath ?? opts.planPath;
219
+ if (base !== undefined) {
220
+ const brief = path.resolve(path.dirname(base), sheet.briefRef);
221
+ if (!existsSync(brief)) errors.push(`facts sheet brief not found: ${sheet.briefRef}`);
222
+ else if (sha256(readFileSync(brief)) !== sheet.briefSha) errors.push('facts sheet is stale: brief changed after it was built');
223
+ }
224
+ }
225
+ const verified = new Set((sheet?.facts ?? []).filter((f) => f.tag === 'VERIFIED').map((f) => f.claim));
226
+
227
+ // unbackable clauses
228
+ const unbackable = sections.find((s) => /cannot back/i.test(s.title));
229
+ if (!unbackable) errors.push('unbackable-clauses section missing (write "none" when there is none)');
230
+ const tolerances = unbackable ? listItems(unbackable.body).filter((l) => /\btolerance\b/i.test(l)) : [];
231
+ for (const b of blocks) {
232
+ for (const clause of b.acceptance.split(';').map((c) => plain(c)).filter((c) => c.length > 0)) {
233
+ const unbacked = extractClaims(clause).filter((c) => !verified.has(c.token));
234
+ if (unbacked.every((c) => tolerances.some((t) => t.includes(c.token) && idRe(b.id).test(t)))) continue;
235
+ errors.push(`unbackable clause without tolerance: ${b.id} ${clause}`);
236
+ }
237
+ }
238
+
239
+ // caller map
240
+ const callerMap = sections.find((s) => /caller map/i.test(s.title));
241
+ if (!callerMap) errors.push('caller map missing');
242
+ else for (const b of blocks) if (!idRe(b.id).test(callerMap.body)) errors.push(`caller map: block ${b.id} is absent`);
243
+
244
+ // lane, owned files, forecasts
245
+ for (const b of blocks) {
246
+ if (b.level === null) errors.push(`block ${b.id}: no lane (level L0–L3)`);
247
+ try {
248
+ assertOwned(b.owned);
249
+ } catch (err) {
250
+ errors.push(`block ${b.id}: ${/** @type {Error} */ (err).message}`);
251
+ }
252
+ if (b.cases === null) errors.push(`block ${b.id}: no cases forecast`);
253
+ else if (b.cases > limits.blockCases) errors.push(`block ${b.id}: cases forecast ${b.cases} exceeds budget.block_cases ${limits.blockCases} — split it`);
254
+ if (b.lines === null) errors.push(`block ${b.id}: no lines forecast`);
255
+ else if (b.lines > limits.blockLines) errors.push(`block ${b.id}: lines forecast ${b.lines} exceeds budget.block_lines ${limits.blockLines} — split it`);
256
+ }
257
+
258
+ // disjoint owned files per wave
259
+ for (let i = 0; i < blocks.length; i += 1) {
260
+ for (let j = i + 1; j < blocks.length; j += 1) {
261
+ const [a, b] = [blocks[i], blocks[j]];
262
+ if (a.wave !== b.wave) continue;
263
+ const hit = findOverlap(a.owned, b.owned);
264
+ if (hit) errors.push(`blocks ${a.id} and ${b.id} (wave ${a.wave}) overlap: ${hit[0]} / ${hit[1]}`);
265
+ }
266
+ }
267
+
268
+ // depends_on and the seam rule
269
+ errors.push(...dependencyCycles(blocks));
270
+ for (const b of blocks) {
271
+ const deps = closure(blocks, b.id);
272
+ for (const imported of b.imports) {
273
+ const owner = blocks.find((o) => o.id !== b.id && ownsFile(o.owned, imported));
274
+ if (owner && !deps.has(owner.id)) errors.push(`block ${b.id} imports ${imported} owned by ${owner.id}, which is not in its depends_on`);
275
+ }
276
+ }
277
+
278
+ // dispatch slots (R10)
279
+ const ids = blocks.map((b) => b.id);
280
+ for (const { wave, text: order } of dispatch) {
281
+ order.split('→').forEach((step, k) => {
282
+ const running = step
283
+ .split('∥')
284
+ .map((seg) => {
285
+ const hits = ids.map((id) => ({ id, at: seg.search(idRe(id)) })).filter((h) => h.at >= 0);
286
+ return hits.sort((x, y) => x.at - y.at)[0]?.id;
287
+ })
288
+ .filter((id) => id !== undefined);
289
+ if (running.length > limits.coders) errors.push(`dispatch step ${k + 1} (wave ${wave}) runs ${running.length} blocks concurrently (caps.coders ${limits.coders}): ${running.join(' ∥ ')}`);
290
+ });
291
+ }
292
+
293
+ return { ok: errors.length === 0, errors, blocks: blocks.length };
294
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * System 2 (plan §3.4; block B9a): a fresh L3 print-mode session, closed-book, empty cwd, the
3
+ * packet on stdin (V3; Grok reads it through `--prompt-file`), one turn, the answer schema
4
+ * compiled per provider (O36: OpenAI strict mode gets every property required + nullable).
5
+ *
6
+ * The prompt is bounded: `buildS2Prompt` keeps it at or under {@link S2_MAX_PROMPT_TOKENS}
7
+ * estimated tokens (bytes / 4) by cutting the context on a UTF-8 boundary; a question that alone
8
+ * does not fit is refused. `reason` is capped at 280 characters after parsing (not in the schema:
9
+ * string length keywords are not portable to every provider's structured-output mode).
10
+ *
11
+ * Cost guard: more than {@link S2_HEAVY_AFTER} non-fallback S2 calls in one block ⇒ `s2-heavy`
12
+ * (the orchestrator re-checks the decomposition); fallback calls do not count (O9).
13
+ */
14
+
15
+ import { randomBytes } from 'node:crypto';
16
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { currentRunRoot } from '../util/tmp.mjs';
19
+ import { estimateTokens, matchesSchema, SessionError, spawnSession } from './spawn.mjs';
20
+
21
+ /** Upper bound of the S2 prompt, in estimated tokens (bytes / 4). */
22
+ export const S2_MAX_PROMPT_TOKENS = 4000;
23
+
24
+ /** Maximum length of the answer's `reason`, in characters. */
25
+ export const S2_REASON_MAX = 280;
26
+
27
+ /** More than this many non-fallback S2 calls in a block raises `s2-heavy`. */
28
+ export const S2_HEAVY_AFTER = 6;
29
+
30
+ /** The S2 answer schema (source form; `compileSchema` makes the OpenAI strict variant). */
31
+ export const S2_SCHEMA = Object.freeze({
32
+ type: 'object',
33
+ properties: {
34
+ decision: { type: 'string' },
35
+ confidence: { type: 'number', minimum: 0, maximum: 1 },
36
+ reason: { type: 'string' },
37
+ overrule: { type: 'boolean' },
38
+ ask_human: { type: 'boolean' },
39
+ human_question: { type: ['string', 'null'] },
40
+ },
41
+ required: ['decision', 'confidence', 'reason', 'overrule', 'ask_human', 'human_question'],
42
+ additionalProperties: false,
43
+ });
44
+
45
+ /** The lens preamble (system prompt) of every S2 session. */
46
+ export const S2_LENS =
47
+ 'You are System 2 for code-forge: a careful, closed-book reviewer of one decision. ' +
48
+ 'You have no tools and no files; everything you may use is in the message. ' +
49
+ 'Answer once, with a JSON object that matches the schema. Never invent facts that are not in the message.';
50
+
51
+ const HEADER = [
52
+ '# System 2 decision',
53
+ '',
54
+ 'Decide the question below from the material given. Reply with ONE JSON object:',
55
+ '{"decision": string, "confidence": number 0..1, "reason": string of at most 280 characters,',
56
+ ' "overrule": boolean (true when you disagree with System 1), "ask_human": boolean,',
57
+ ' "human_question": string or null (the one question for the human when ask_human is true)}.',
58
+ '',
59
+ ].join('\n');
60
+
61
+ /**
62
+ * @typedef {object} S2Packet
63
+ * @property {string} question
64
+ * @property {string} [context]
65
+ * @property {string[]} [options]
66
+ * @property {{answer?: unknown, confidence?: number}} [s1]
67
+ */
68
+
69
+ /**
70
+ * Cut `text` to at most `maxBytes` UTF-8 bytes without splitting a character.
71
+ * @param {string} text @param {number} maxBytes
72
+ */
73
+ function cutBytes(text, maxBytes) {
74
+ const buf = Buffer.from(text, 'utf8');
75
+ if (buf.length <= maxBytes) return text;
76
+ let cut = Math.max(0, maxBytes);
77
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut -= 1;
78
+ return buf.subarray(0, cut).toString('utf8');
79
+ }
80
+
81
+ /**
82
+ * @param {S2Packet} packet
83
+ * @param {{maxTokens?: number}} [opts]
84
+ * @returns {string} the prompt, at most `maxTokens` estimated tokens.
85
+ * @throws {SessionError} `usage` when the packet has no question; `s2-too-large` when the fixed
86
+ * part (header, question, options, S1 answer) alone is over budget.
87
+ */
88
+ export function buildS2Prompt(packet, opts = {}) {
89
+ const maxTokens = opts.maxTokens ?? S2_MAX_PROMPT_TOKENS;
90
+ if (!packet || typeof packet.question !== 'string' || packet.question.trim().length === 0) {
91
+ throw new SessionError('usage', 's2: the packet needs a non-empty question');
92
+ }
93
+ let fixed = `${HEADER}## Question\n${packet.question.trim()}\n`;
94
+ if (Array.isArray(packet.options) && packet.options.length > 0) {
95
+ fixed += `\n## Options\n${packet.options.map((o) => `- ${String(o)}`).join('\n')}\n`;
96
+ }
97
+ if (packet.s1) fixed += `\n## System 1 answer\n${JSON.stringify(packet.s1)}\n`;
98
+ const maxBytes = maxTokens * 4;
99
+ const fixedBytes = Buffer.byteLength(fixed);
100
+ if (fixedBytes > maxBytes) {
101
+ throw new SessionError('s2-too-large', `s2: the question part is ${estimateTokens(fixedBytes)} tokens (max ${maxTokens})`);
102
+ }
103
+ const context = typeof packet.context === 'string' ? packet.context : '';
104
+ if (context.length === 0) return fixed;
105
+ const heading = '\n## Context\n';
106
+ const whole = `${heading}${context}\n`;
107
+ if (fixedBytes + Buffer.byteLength(whole) <= maxBytes) return fixed + whole;
108
+ const note = `\n[context cut: ${Buffer.byteLength(context)} bytes given]\n`;
109
+ const room = maxBytes - fixedBytes - Buffer.byteLength(heading) - Buffer.byteLength(note);
110
+ if (room <= 0) return fixed; // no space for any context once the heading and the note are counted
111
+ return `${fixed}${heading}${cutBytes(context, room)}${note}`;
112
+ }
113
+
114
+ /**
115
+ * Whether a block has made more than {@link S2_HEAVY_AFTER} non-fallback S2 calls.
116
+ * @param {ReadonlyArray<Record<string, any>>} rows - ledger rows
117
+ * @param {string} block
118
+ */
119
+ export function isS2Heavy(rows, block) {
120
+ return rows.filter((r) => r.event === 'session' && r.role === 's2' && r.block === block && r.source === 's2').length > S2_HEAVY_AFTER;
121
+ }
122
+
123
+ /**
124
+ * Run one S2 decision.
125
+ * @param {{cfg: Record<string, any>, packet: S2Packet, run?: string, block?: string, slug?: string, runRoot?: string, timeoutMs?: number}} opts
126
+ * @param {import('./spawn.mjs').SessionDeps} [deps]
127
+ */
128
+ export async function runS2(opts, deps = {}) {
129
+ const prompt = buildS2Prompt(opts.packet);
130
+ const root = opts.runRoot ?? currentRunRoot();
131
+ const dir = path.join(root, 's2', `${Date.now()}-${randomBytes(4).toString('hex')}`);
132
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
133
+ const packetPath = path.join(dir, 'packet.md');
134
+ try {
135
+ writeFileSync(packetPath, prompt, { mode: 0o600 });
136
+ const result = await spawnSession(
137
+ {
138
+ cfg: opts.cfg,
139
+ level: 'L3',
140
+ role: 's2',
141
+ promptPath: packetPath,
142
+ schema: S2_SCHEMA,
143
+ systemPromptText: S2_LENS,
144
+ runRoot: root,
145
+ run: opts.run,
146
+ block: opts.block,
147
+ slug: opts.slug,
148
+ timeoutMs: opts.timeoutMs,
149
+ },
150
+ deps,
151
+ );
152
+ if (result.status !== 'ok') return result;
153
+ // The OpenAI strict variant lets every field be null; the answer must still meet the source schema.
154
+ if (!matchesSchema(S2_SCHEMA, result.answer)) {
155
+ return { ...result, status: 'invalid-output', reason: 'answer does not match the S2 schema', answer: null };
156
+ }
157
+ if (result.answer.reason.length > S2_REASON_MAX) {
158
+ return { ...result, status: 'invalid-output', reason: `reason is longer than ${S2_REASON_MAX} characters`, answer: null };
159
+ }
160
+ return result;
161
+ } finally {
162
+ rmSync(dir, { recursive: true, force: true });
163
+ }
164
+ }