@codedology/code-forge 0.3.1 → 0.4.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 (67) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +153 -1
  6. package/docs/reference/config.md +8 -1
  7. package/package.json +4 -2
  8. package/schema/code-forge.schema.json +15 -4
  9. package/skill/SKILL.md +7 -7
  10. package/skill/references/adapters/claude-code.md +1 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +8 -2
  17. package/skill/references/plan.md +3 -3
  18. package/skill/references/review.md +8 -5
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/jev.mjs +114 -17
  25. package/src/cli/ledger.mjs +99 -8
  26. package/src/cli/logs.mjs +193 -58
  27. package/src/cli/plan.mjs +27 -11
  28. package/src/cli/report.mjs +15 -1
  29. package/src/cli/resolve.mjs +16 -1
  30. package/src/cli/review-file.mjs +10 -5
  31. package/src/cli/run.mjs +109 -8
  32. package/src/config/closed-book.mjs +75 -0
  33. package/src/config/validate.mjs +128 -7
  34. package/src/decide/escalation.mjs +112 -11
  35. package/src/decide/jev-client.mjs +1 -1
  36. package/src/decide/questions.mjs +39 -3
  37. package/src/doctor/probes.mjs +48 -6
  38. package/src/engines/builders/claude.mjs +4 -1
  39. package/src/engines/builders/codex.mjs +26 -11
  40. package/src/engines/builders/grok.mjs +3 -3
  41. package/src/engines/efforts.mjs +109 -0
  42. package/src/engines/sentinel.mjs +34 -0
  43. package/src/keys/onepassword.mjs +3 -0
  44. package/src/ledger/report.mjs +66 -29
  45. package/src/ledger/spend.mjs +171 -0
  46. package/src/review/engine.mjs +65 -23
  47. package/src/review/fixloop.mjs +29 -6
  48. package/src/review/session-retry.mjs +69 -0
  49. package/src/review/triage.mjs +26 -1
  50. package/src/review/validate-review.mjs +21 -6
  51. package/src/session/author.mjs +6 -2
  52. package/src/session/plan-check.mjs +71 -6
  53. package/src/session/plan-sections.mjs +98 -0
  54. package/src/session/spawn.mjs +217 -8
  55. package/src/state/block.mjs +13 -4
  56. package/src/state/config-snapshot.mjs +150 -0
  57. package/src/state/run.mjs +86 -0
  58. package/src/util/breadcrumbs.mjs +145 -0
  59. package/src/util/error-hint.mjs +3 -2
  60. package/src/util/error-kind.mjs +2 -2
  61. package/src/util/error-log.mjs +109 -18
  62. package/src/util/forbidden.mjs +3 -1
  63. package/src/util/known-fixes.json +1 -0
  64. package/src/util/known-fixes.mjs +153 -0
  65. package/src/worker/engine.mjs +13 -8
  66. package/src/worker/loop.mjs +135 -39
  67. package/src/worker/queue.mjs +68 -7
@@ -0,0 +1,69 @@
1
+ /**
2
+ * One review session with ONE automatic retry on `timeout` (block B30, issue #2).
3
+ *
4
+ * Field data: a reviewer CLI can hang for the whole `review.session_timeout_s` with almost no
5
+ * output, while the same packet reviews in seconds on a second try. So a session whose status is
6
+ * `timeout` is spawned once more with the SAME options (same packet file, same level, same lens);
7
+ * only when the second attempt times out too does the review report `unavailable: timeout`. Any
8
+ * other failure (exit, schema, unavailable…) is never retried here.
9
+ *
10
+ * Both attempts are recorded: `spawnSession` writes one ledger `session` row per spawn as always,
11
+ * and every timed-out attempt adds a `review.session_timeout` note `{lens, role, level, provider,
12
+ * model, attempt, retried, duration_ms, stderr_tail, tail_note?, stdout_bytes, stdout_events}`:
13
+ * the last 4 KB of the session's stderr, packet-free and redacted (`stderrTail`; null with a
14
+ * `tail_note` when withheld), and stdout only as its byte count and last event types
15
+ * (`stdoutEvents`) — never stdout text. The helper never throws: a throwing spawn is returned as
16
+ * `res: null` and a failed note is ignored. `onAttempt(n)` is told each attempt as it starts.
17
+ */
18
+
19
+ import { logWarning } from '../util/error-log.mjs';
20
+
21
+ /** How many times a timed-out session is spawned again. */
22
+ export const TIMEOUT_RETRIES = 1;
23
+
24
+ /**
25
+ * @param {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn
26
+ * @param {Record<string, any>} opts - the spawn options; passed unchanged to every attempt.
27
+ * @param {(row: Record<string, any>) => Promise<unknown>} note - best-effort ledger note.
28
+ * @param {(attempt: number) => void} [onAttempt] - told the attempt number as each one starts.
29
+ * @returns {Promise<{res: Record<string, any> | null, attempts: number}>} the last attempt's result
30
+ * (`null` when the spawn threw) and how many attempts ran.
31
+ */
32
+ export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () => {}) {
33
+ let attempts = 0;
34
+ for (;;) {
35
+ attempts += 1;
36
+ onAttempt(attempts);
37
+ /** @type {Record<string, any> | null} */
38
+ let res;
39
+ try {
40
+ res = await spawn(opts);
41
+ } catch {
42
+ return { res: null, attempts }; // a throw is an `exit` failure, never retried
43
+ }
44
+ if (res?.status !== 'timeout') return { res, attempts };
45
+ const retried = attempts <= TIMEOUT_RETRIES;
46
+ try {
47
+ await note({
48
+ event: 'review.session_timeout',
49
+ lens: opts?.rowExtra?.lens ?? null,
50
+ role: opts?.role ?? null,
51
+ level: opts?.level ?? null,
52
+ provider: res.provider ?? null,
53
+ model: res.model ?? null,
54
+ attempt: attempts,
55
+ retried,
56
+ duration_ms: typeof res.duration_ms === 'number' ? res.duration_ms : null,
57
+ stderr_tail: typeof res.stderr_tail === 'string' ? res.stderr_tail : null,
58
+ ...(typeof res.tail_note === 'string' ? { tail_note: res.tail_note } : {}),
59
+ stdout_bytes: typeof res.stdout_bytes === 'number' ? res.stdout_bytes : null,
60
+ stdout_events: Array.isArray(res.stdout_events) ? res.stdout_events : [],
61
+ });
62
+ } catch {
63
+ // a failed note never stops the retry or the result
64
+ }
65
+ if (!retried) return { res, attempts };
66
+ // B37: the retry is a recoverable problem: one warning line in the local error log
67
+ await logWarning({ warning: 'review_retry', message: 'a review session timed out; it was retried once' }).catch(() => null);
68
+ }
69
+ }
@@ -18,6 +18,7 @@
18
18
  import { buildQuestionPayload, isQuestionDisabled } from '../decide/questions.mjs';
19
19
  import { bandFinding, bandResolved } from '../decide/thresholds.mjs';
20
20
  import { alwaysResidue } from '../decide/fallback-rules.mjs';
21
+ import { logWarning } from '../util/error-log.mjs';
21
22
 
22
23
  /**
23
24
  * @typedef {{id: string, file?: string, line_start: number, line_end: number, severity: 'critical' | 'warning' | 'nit', category?: string, claim?: string, evidence?: string, fix?: string}} Finding
@@ -37,6 +38,27 @@ async function note(writeRow, row) {
37
38
  }
38
39
  }
39
40
 
41
+ /**
42
+ * The reasons a fallback warning may name: Jev client failure kinds (`jev-client.mjs`) plus
43
+ * `error` (the call threw) and `invalid_answer` (ok, but no probability). Anything else is `other`.
44
+ */
45
+ const FALLBACK_REASONS = new Set(['unauthorized', 'invalid_request', 'rate_limited', 'unavailable', 'network', 'timeout', 'unexpected_status', 'invalid_response', 'error', 'invalid_answer']);
46
+
47
+ /**
48
+ * B37: Jev was asked and gave no usable answer, so §3.5's fallback decides: one warning line in the
49
+ * local error log (once per reason per process). The reason is a word from a fixed list, never
50
+ * Jev's text. Never throws: a logging failure cannot change the fallback.
51
+ * @param {unknown} reason
52
+ */
53
+ async function fellBack(reason) {
54
+ const word = typeof reason === 'string' && FALLBACK_REASONS.has(reason) ? reason : 'other';
55
+ try {
56
+ await logWarning({ warning: 's1_fallback', message: `System 1 (Jev) gave no answer (${word}); fell back to the rules` }).catch(() => null);
57
+ } catch {
58
+ // the fallback stands without its warning line
59
+ }
60
+ }
61
+
40
62
  /** @param {Finding} f @returns {'fix_now' | 'nit'} the verdict a judge's severity means. */
41
63
  export const severityVerdict = (f) => (f.severity === 'nit' ? 'nit' : 'fix_now');
42
64
 
@@ -53,10 +75,13 @@ export async function askNoul(id, state, { jev, cfg = {} }) {
53
75
  try {
54
76
  res = await jev({ state, questions: buildQuestionPayload([id], cfg) });
55
77
  } catch {
78
+ await fellBack('error');
56
79
  return null;
57
80
  }
58
81
  const p = res?.ok === true ? res.answers?.[id]?.noul : undefined;
59
- return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1 ? p : null;
82
+ if (typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1) return p;
83
+ await fellBack(res?.ok === true ? 'invalid_answer' : res?.kind);
84
+ return null;
60
85
  }
61
86
 
62
87
  /**
@@ -7,17 +7,27 @@
7
7
  * 3. `reviewed_hunks` equals the packet's hunk headers EXACTLY and IN ORDER;
8
8
  * 4. `tokens_out ≥ review.min_tokens_out` (120).
9
9
  * Anything else is `unavailable` with one reason — `timeout`, `exit`, `schema`,
10
- * `hunks_mismatch`, `too_short` — and is never approval. There is no "approve on missing output":
10
+ * `hunks_mismatch`, `too_short`, or `budget` (B33: the spawner refused at `budget.usd`) — and is
11
+ * never approval. There is no "approve on missing output":
11
12
  * no answer is a `schema` failure, no session is an `exit` failure.
12
13
  */
13
14
 
14
15
  import { readFileSync } from 'node:fs';
15
16
  import { Ajv2020 } from 'ajv/dist/2020.js';
16
17
 
17
- export const UNAVAILABLE_REASONS = Object.freeze(['exit', 'schema', 'hunks_mismatch', 'too_short', 'timeout']);
18
+ export const UNAVAILABLE_REASONS = Object.freeze(['exit', 'schema', 'hunks_mismatch', 'too_short', 'timeout', 'budget']);
18
19
 
19
20
  export const DEFAULT_MIN_TOKENS_OUT = 120;
20
21
 
22
+ /**
23
+ * A clean pass (`passed: true`, no findings, every hunk acknowledged) on a SMALL diff legitimately
24
+ * needs few words: a 3-line placeholder reviewed by GPT-6 Astra produced 108 tokens and was refused
25
+ * as `too_short`. For such answers the floor drops to this value; any finding keeps the full floor.
26
+ */
27
+ export const CLEAN_PASS_MIN_TOKENS_OUT = 40;
28
+ /** A diff with at most this many added lines is "small" for the clean-pass floor. */
29
+ export const SMALL_DIFF_ADDED_LINES = 20;
30
+
21
31
  /** The source answer schema (compiled per provider by the spawner). */
22
32
  export const FINDING_SCHEMA = Object.freeze(JSON.parse(readFileSync(new URL('./finding.schema.json', import.meta.url), 'utf8')));
23
33
 
@@ -39,18 +49,20 @@ export function minTokensOut(cfg) {
39
49
  * @property {boolean} ok
40
50
  * @property {Record<string, any>} [review]
41
51
  * @property {number} [tokens_out]
42
- * @property {'exit' | 'schema' | 'hunks_mismatch' | 'too_short' | 'timeout'} [reason]
52
+ * @property {'exit' | 'schema' | 'hunks_mismatch' | 'too_short' | 'timeout' | 'budget'} [reason]
43
53
  * @property {string} [detail]
44
54
  */
45
55
 
46
56
  /**
47
57
  * @param {Record<string, any> | null | undefined} session - a `spawnSession` result.
48
- * @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number}} expect
58
+ * @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number, smallDiff?: boolean}} expect - `smallDiff` lowers the floor to `CLEAN_PASS_MIN_TOKENS_OUT` for a clean pass only
49
59
  * @returns {Verdict}
50
60
  */
51
- export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT }) {
61
+ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT, smallDiff = false }) {
52
62
  if (!session || typeof session !== 'object') return { ok: false, reason: 'exit', detail: 'no session result' };
53
63
  if (session.status === 'timeout') return { ok: false, reason: 'timeout', detail: 'killed at the session timeout' };
64
+ // B33: the spawner refused the session because the run reached `budget.usd` — nothing ran
65
+ if (session.status === 'unavailable' && session.reason === 'budget') return { ok: false, reason: 'budget', detail: String(session.message ?? 'budget.usd reached') };
54
66
  const exitCode = session.exit_code;
55
67
  if (session.status === 'failed' || session.status === 'unavailable' || (exitCode !== undefined && exitCode !== 0)) {
56
68
  return { ok: false, reason: 'exit', detail: `session ${session.status}${exitCode === undefined ? '' : ` (exit ${exitCode})`}` };
@@ -65,6 +77,9 @@ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAU
65
77
  return { ok: false, reason: 'hunks_mismatch', detail: `reviewed_hunks has ${got.length} entries; the packet has ${hunkHeaders.length} hunks` };
66
78
  }
67
79
  const tokensOut = Number(session.usage?.tokens_out ?? session.row?.tokens_out ?? 0);
68
- if (!(tokensOut >= min)) return { ok: false, reason: 'too_short', detail: `tokens_out ${tokensOut} < ${min}` };
80
+ const review = /** @type {{passed: boolean, findings: unknown[]}} */ (session.answer);
81
+ const cleanPass = review.passed === true && Array.isArray(review.findings) && review.findings.length === 0;
82
+ const floor = smallDiff && cleanPass ? Math.min(min, CLEAN_PASS_MIN_TOKENS_OUT) : min;
83
+ if (!(tokensOut >= floor)) return { ok: false, reason: 'too_short', detail: `tokens_out ${tokensOut} < ${floor}` };
69
84
  return { ok: true, review: session.answer, tokens_out: tokensOut };
70
85
  }
@@ -20,6 +20,7 @@ import { estimateCostUsd } from '../ledger/prices.mjs';
20
20
  import { writeSafe } from '../util/redact.mjs';
21
21
  import { currentRunRoot } from '../util/tmp.mjs';
22
22
  import { checkSheetFresh } from './facts.mjs';
23
+ import { authorHeadingRules } from './plan-sections.mjs';
23
24
  import { spawnSession } from './spawn.mjs';
24
25
 
25
26
  export const JOBS = Object.freeze(['plan', 'harden']);
@@ -58,8 +59,11 @@ export const AUTHOR_LENS =
58
59
  const JOB_RULES = {
59
60
  plan: [
60
61
  'Write the plan draft in markdown. Its §0 is the facts sheet below, VERBATIM (header lines included).',
61
- 'Include a section titled "Acceptance clauses the facts sheet cannot back": every clause that cites a claim the sheet',
62
- 'marks NOT-FOUND or UNVERIFIABLE, each with a tolerance naming the block; write `none` when there is none.',
62
+ ...authorHeadingRules(),
63
+ 'The level column of the block table is the lane recorded for each block by `forge jev ask lane --block <id> --plan <plan>`',
64
+ '(or `--rules`, the deterministic fallback, when Jev is unavailable): L0 trivial · L1 a plain feature following a pattern ·',
65
+ 'L2 money, dates, tenancy, migrations, concurrency. It is never a block category. Leave it as `L?` when no lane is given in',
66
+ 'the message; plan check refuses a level that differs from the recorded lane.',
63
67
  ],
64
68
  harden: [
65
69
  'Harden the draft: close every gap you can from the material given; for each decision only the human can make,',
@@ -9,6 +9,10 @@
9
9
  * VERIFIED is listed in the "… the facts sheet cannot back" section by a line that names the
10
10
  * token, the block, and a tolerance — else `unbackable clause without tolerance: <block> <clause>`;
11
11
  * - a caller map section exists and names every block (C9);
12
+ * - the required sections are found by their exact headings (`plan-sections.mjs`); a near miss at
13
+ * the expected position is accepted with a WARN naming the exact heading (B36);
14
+ * - every block's level is the lane recorded for it in the ledger by `jev ask lane --block <id>`
15
+ * (Jev, or `--rules`, the deterministic fallback) — none, or a different one, is refused (B36);
12
16
  * - every block has a lane, owned files that pass B8's `assertOwned` (no `[ ] ( ) ! + @` in a
13
17
  * glob, V6) and are disjoint from the other blocks of its wave (O26);
14
18
  * - `depends_on` is acyclic and every path a block imports (`imports` column) that another block
@@ -24,6 +28,7 @@ import { existsSync, readFileSync } from 'node:fs';
24
28
  import path from 'node:path';
25
29
  import { assertOwned, findOverlap, ownsFile } from '../state/registry.mjs';
26
30
  import { extractClaims, parseSheet, sha256 } from './facts.mjs';
31
+ import { findSection, nearMissWarning, REQUIRED_SECTIONS } from './plan-sections.mjs';
27
32
 
28
33
  /** Limits used when the config does not set them (plan v1.3 §10.7, R10). */
29
34
  export const DEFAULT_LIMITS = Object.freeze({ coders: 2, blockCases: 80, blockLines: 2000 });
@@ -185,17 +190,57 @@ const listItems = (body) =>
185
190
  .filter((l) => /^\s*(?:[-*]|\d+\.)\s+/.test(l))
186
191
  .map((l) => l.trim());
187
192
 
193
+ /** Where a lane may come from: Jev's `lane` answer, or the deterministic fallback rule (`--rules`). */
194
+ export const LANE_SOURCES = Object.freeze(['jev', 'rules']);
195
+
196
+ /**
197
+ * @typedef {{lane: string, source: string, decision_id: string | null}} RecordedLane
198
+ */
199
+
200
+ /**
201
+ * A recorded lane answer, normalised: trimmed, upper-cased, the first `L0`–`L3` token (`" l1 "` ⇒
202
+ * `L1`); an answer with no such token stays as written, trimmed (`split`).
203
+ * @param {string} answer @returns {string}
204
+ */
205
+ export function normaliseLane(answer) {
206
+ const token = /\bL[0-3]\b/.exec(answer.trim().toUpperCase());
207
+ return token ? token[0] : answer.trim();
208
+ }
209
+
210
+ /**
211
+ * The lane recorded per block by `forge jev ask lane --block <id>` (or `--rules`): the latest
212
+ * `decision` row for `lane` whose `block` is set and whose `source` is Jev or the fallback rules.
213
+ * A row that names a plan (`--plan`) counts only for the plan with that base name (both sides are
214
+ * compared as `path.basename`); with no plan name given, only untagged rows count.
215
+ * @param {Array<Record<string, any>>} rows - ledger rows, oldest first.
216
+ * @param {string} [planName] - the plan file's base name.
217
+ * @returns {Map<string, RecordedLane>}
218
+ */
219
+ export function recordedLanes(rows, planName) {
220
+ /** @type {Map<string, RecordedLane>} */
221
+ const out = new Map();
222
+ for (const row of rows) {
223
+ if (row?.event !== 'decision' || row.question !== 'lane' || typeof row.block !== 'string') continue;
224
+ if (!LANE_SOURCES.includes(row.source) || typeof row.answer !== 'string') continue;
225
+ if (row.plan !== undefined && (planName === undefined || typeof row.plan !== 'string' || path.basename(row.plan) !== path.basename(planName))) continue;
226
+ out.set(row.block, { lane: normaliseLane(row.answer), source: row.source, decision_id: typeof row.decision_id === 'string' ? row.decision_id : null });
227
+ }
228
+ return out;
229
+ }
230
+
188
231
  /**
189
232
  * @typedef {object} CheckOpts
190
233
  * @property {string} [planPath] - where the plan lives (resolves the sheet's brief reference).
191
234
  * @property {string} [factsText] @property {string} [factsPath] - a separate sheet (else the plan's own §0).
192
235
  * @property {Record<string, any>} [cfg] - `caps.coders`, `budget.block_cases`, `budget.block_lines`.
236
+ * @property {Array<Record<string, any>>} [decisions] - the project's ledger rows (the recorded lanes);
237
+ * none given ⇒ no block has a recorded lane.
193
238
  */
194
239
 
195
240
  /**
196
241
  * @param {string} text - the plan.
197
242
  * @param {CheckOpts} [opts]
198
- * @returns {{ok: boolean, errors: string[], blocks: number}}
243
+ * @returns {{ok: boolean, errors: string[], warnings: string[], blocks: number, lanes: Array<{block: string, level: string | null, lane: string | null, source: string | null}>}}
199
244
  */
200
245
  export function checkPlan(text, opts = {}) {
201
246
  const limits = {
@@ -206,7 +251,7 @@ export function checkPlan(text, opts = {}) {
206
251
  const { blocks, sections, dispatch } = parsePlan(text);
207
252
  /** @type {string[]} */
208
253
  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 };
254
+ if (blocks.length === 0) return { ok: false, errors: ['no block table found (a table with id, owned_files and acceptance columns)'], warnings: [], blocks: 0, lanes: [] };
210
255
 
211
256
  // facts sheet: present, fresh, and (when separate) pasted into the plan
212
257
  const sheetText = opts.factsText ?? text;
@@ -224,8 +269,18 @@ export function checkPlan(text, opts = {}) {
224
269
  }
225
270
  const verified = new Set((sheet?.facts ?? []).filter((f) => f.tag === 'VERIFIED').map((f) => f.claim));
226
271
 
272
+ // the required sections, by their exact headings (B36: a near miss at the expected position is a WARN)
273
+ /** @type {string[]} */
274
+ const warnings = [];
275
+ /** @param {import('./plan-sections.mjs').RequiredSection} spec */
276
+ const required = (spec) => {
277
+ const hit = findSection(sections, spec);
278
+ if (hit && !hit.exact) warnings.push(nearMissWarning(hit.section.title, spec));
279
+ return hit?.section;
280
+ };
281
+
227
282
  // unbackable clauses
228
- const unbackable = sections.find((s) => /cannot back/i.test(s.title));
283
+ const unbackable = required(REQUIRED_SECTIONS.unbackable);
229
284
  if (!unbackable) errors.push('unbackable-clauses section missing (write "none" when there is none)');
230
285
  const tolerances = unbackable ? listItems(unbackable.body).filter((l) => /\btolerance\b/i.test(l)) : [];
231
286
  for (const b of blocks) {
@@ -237,13 +292,17 @@ export function checkPlan(text, opts = {}) {
237
292
  }
238
293
 
239
294
  // caller map
240
- const callerMap = sections.find((s) => /caller map/i.test(s.title));
295
+ const callerMap = required(REQUIRED_SECTIONS.callerMap);
241
296
  if (!callerMap) errors.push('caller map missing');
242
297
  else for (const b of blocks) if (!idRe(b.id).test(callerMap.body)) errors.push(`caller map: block ${b.id} is absent`);
243
298
 
244
- // lane, owned files, forecasts
299
+ // lane (B36: the level is the lane recorded for the block), owned files, forecasts
300
+ const lanes = recordedLanes(opts.decisions ?? [], opts.planPath === undefined ? undefined : path.basename(opts.planPath));
245
301
  for (const b of blocks) {
302
+ const recorded = lanes.get(b.id);
246
303
  if (b.level === null) errors.push(`block ${b.id}: no lane (level L0–L3)`);
304
+ else if (!recorded) errors.push(`block ${b.id}: level ${b.level} has no recorded lane decision — run forge jev ask lane --block ${b.id} --state <file> (or --rules when Jev is unavailable)`);
305
+ else if (recorded.lane !== b.level) errors.push(`block ${b.id}: level ${b.level} differs from the recorded lane ${recorded.lane} (${recorded.source})`);
247
306
  try {
248
307
  assertOwned(b.owned);
249
308
  } catch (err) {
@@ -290,5 +349,11 @@ export function checkPlan(text, opts = {}) {
290
349
  });
291
350
  }
292
351
 
293
- return { ok: errors.length === 0, errors, blocks: blocks.length };
352
+ return {
353
+ ok: errors.length === 0,
354
+ errors,
355
+ warnings,
356
+ blocks: blocks.length,
357
+ lanes: blocks.map((b) => ({ block: b.id, level: b.level, lane: lanes.get(b.id)?.lane ?? null, source: lanes.get(b.id)?.source ?? null })),
358
+ };
294
359
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The plan sections `forge plan check` requires, by their exact headings (B36). One source for
3
+ * the author's prompt (`author.mjs` lists these headings in the Plan job's rules), the check
4
+ * (`plan-check.mjs` finds the sections by them) and the template (`skill/templates/plan.md`
5
+ * carries them; a test keeps the three in step). Callers address a section by its id, never by
6
+ * its place in a list.
7
+ *
8
+ * A section is found by its exact heading: the heading's text after its number equals `heading`
9
+ * (case-insensitive, trimmed). A heading that misses the wording is accepted, with a WARN naming
10
+ * the exact heading, only when its number is the section's expected one (`position`), its text
11
+ * names one of the section's distinctive `keywords`, and its text is not another required
12
+ * section's heading. Anything else is a missing section.
13
+ */
14
+
15
+ /**
16
+ * @typedef {'unbackable' | 'callerMap'} SectionId
17
+ * @typedef {object} RequiredSection
18
+ * @property {SectionId} id
19
+ * @property {string} heading - the exact heading text (without its number).
20
+ * @property {string} label - the expected position as the template numbers it.
21
+ * @property {RegExp} position - matches the heading's number (`§0.x`, `0.6` ⇒ `0.x`, `0.6`).
22
+ * @property {string[]} keywords - distinctive lowercase words a near-miss heading must name one of.
23
+ * @property {string} purpose - what the author writes under it (the prompt's one line per section).
24
+ */
25
+
26
+ /** @type {Readonly<Record<SectionId, Readonly<RequiredSection>>>} */
27
+ export const REQUIRED_SECTIONS = Object.freeze({
28
+ unbackable: Object.freeze({
29
+ id: 'unbackable',
30
+ heading: 'Acceptance clauses the facts sheet cannot back',
31
+ label: '§0.x',
32
+ position: /^0\.\w+$/,
33
+ // distinctive words only: a plain '§0.1 Facts sheet' heading must not pass as this section
34
+ keywords: ['unbackable', 'cannot back'],
35
+ purpose: 'every clause that cites a claim the sheet marks NOT-FOUND or UNVERIFIABLE, each with a tolerance naming the block; `none` when there is none',
36
+ }),
37
+ callerMap: Object.freeze({
38
+ id: 'callerMap',
39
+ heading: 'Caller map',
40
+ label: '§3',
41
+ position: /^3$/,
42
+ keywords: ['caller map', 'callers'],
43
+ purpose: 'mechanism → the block that ships it → the block that calls it; every block id appears',
44
+ }),
45
+ });
46
+
47
+ /** @returns {Readonly<RequiredSection>[]} every required section, in template order. */
48
+ export const requiredSections = () => Object.values(REQUIRED_SECTIONS);
49
+
50
+ /**
51
+ * @param {string} title - a heading's text (markdown emphasis already stripped).
52
+ * @returns {{number: string | null, text: string}} its leading number (`§0.x`, `0.6.`, `3.` ⇒
53
+ * `0.x`, `0.6`, `3`) and the rest.
54
+ */
55
+ export function splitHeading(title) {
56
+ const m = /^§?\s*(\d+(?:\.\w+)*)\.?\s+(.*)$/.exec(title.trim());
57
+ return m ? { number: m[1], text: m[2].trim() } : { number: null, text: title.trim() };
58
+ }
59
+
60
+ /**
61
+ * @template {{title: string}} S
62
+ * @param {S[]} sections - the plan's headings, in order.
63
+ * @param {RequiredSection} spec
64
+ * @returns {{section: S, exact: boolean} | null} the first exact match, else the first near miss
65
+ * at the expected position, else null.
66
+ */
67
+ export function findSection(sections, spec) {
68
+ const textOf = (/** @type {S} */ s) => splitHeading(s.title).text.toLowerCase();
69
+ const want = spec.heading.toLowerCase();
70
+ const exact = sections.find((s) => textOf(s) === want);
71
+ if (exact) return { section: exact, exact: true };
72
+ const others = requiredSections()
73
+ .filter((o) => o.id !== spec.id)
74
+ .map((o) => o.heading.toLowerCase());
75
+ const near = sections.find((s) => {
76
+ const { number } = splitHeading(s.title);
77
+ const text = textOf(s);
78
+ return number !== null && spec.position.test(number) && !others.includes(text) && spec.keywords.some((k) => text.includes(k));
79
+ });
80
+ return near ? { section: near, exact: false } : null;
81
+ }
82
+
83
+ /**
84
+ * @param {string} found - the near-miss heading as written.
85
+ * @param {RequiredSection} spec
86
+ * @returns {string} the WARN line `plan check` prints for it.
87
+ */
88
+ export function nearMissWarning(found, spec) {
89
+ return `WARN section "${found}" at ${spec.label} read as "${spec.heading}" — use the exact heading "${spec.heading}"`;
90
+ }
91
+
92
+ /** @returns {string[]} the Plan job's section rule lines for the author packet: each heading once. */
93
+ export function authorHeadingRules() {
94
+ return [
95
+ 'Use these section headings exactly, word for word, at these positions (plan check finds the sections by them):',
96
+ ...requiredSections().map((s) => `- "${s.label} ${s.heading}" — ${s.purpose}.`),
97
+ ];
98
+ }