@codedology/code-forge 0.3.2 → 0.5.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.
- package/CHANGELOG.md +85 -0
- package/README.md +88 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +236 -1
- package/docs/reference/config.md +13 -1
- package/package.json +6 -2
- package/schema/code-forge.schema.json +26 -4
- package/skill/SKILL.md +8 -7
- package/skill/references/adapters/claude-code.md +5 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +10 -2
- package/skill/references/plan.md +12 -3
- package/skill/references/review.md +13 -6
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/facts.mjs +16 -1
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/review.mjs +40 -9
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/questions.mjs +27 -4
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +288 -0
- package/src/review/engine.mjs +57 -16
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +6 -3
- package/src/session/author.mjs +6 -2
- package/src/session/facts.mjs +218 -53
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +373 -20
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +122 -40
- package/src/worker/loop.mjs +326 -78
- package/src/worker/queue.mjs +68 -7
package/src/session/facts.mjs
CHANGED
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
* private B0.1 root in `os.tmpdir()` (`factsTmpRoot`) — and is removed when the verb ends;
|
|
16
16
|
* `spawnSession` refuses a facts cwd below any `.git`. {@link pathViolation} still refuses
|
|
17
17
|
* `~`, `..` and absolute paths outside the tool roots, so the delegate cannot leave it.
|
|
18
|
-
* Step 3 (deterministic): the answer is validated against `schema/facts.schema.json
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Step 3 (deterministic): the answer is validated against `schema/facts.schema.json` (only a
|
|
19
|
+
* schema-invalid answer refuses the sheet); rows match claims by their echoed claim
|
|
20
|
+
* ({@link echoedClaim}). A row whose `command` is not an allowed read-only form
|
|
21
|
+
* ({@link readOnlyViolation}'s allow-list; env claims only `printenv NAME >/dev/null`, their excerpt
|
|
22
|
+
* always blanked), or a VERIFIED row with an empty excerpt or from a silent `test`, is downgraded
|
|
23
|
+
* to UNVERIFIABLE on its own (B39) and never counted; every claim, command, excerpt and reason
|
|
24
|
+
* passes through B0 `redact`. The
|
|
22
25
|
* sheet records the brief's sha256 and mtime in its header, both taken from the ONE read of the
|
|
23
26
|
* brief that also feeds the extractor; {@link checkSheetFresh} is what `author` and `plan check` use
|
|
24
27
|
* to refuse a stale one.
|
|
@@ -220,14 +223,20 @@ const GLOB_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
|
|
|
220
223
|
* secret-path check and could name what the check never saw): the allow-list never needs one
|
|
221
224
|
* (fail closed).
|
|
222
225
|
* @param {string} command
|
|
223
|
-
* @returns {{segments: string[][], refused: string | null}}
|
|
226
|
+
* @returns {{segments: string[][], separators: string[], pipedOut?: boolean[], refused: string | null}}
|
|
227
|
+
* `separators`: every separator met, in order (`|`, `||`, `&&`, `;`, `&`, newline, CR);
|
|
228
|
+
* `pipedOut[i]`: segment i feeds a single `|` (its output is not the command's output).
|
|
224
229
|
*/
|
|
225
230
|
function splitCommand(command) {
|
|
226
|
-
if (/[`]/.test(command)) return { segments: [], refused: 'backticks are not allowed' };
|
|
227
|
-
if (/\$/.test(command)) return { segments: [], refused: '$ expansion is not allowed' };
|
|
228
|
-
if (/[<>]/.test(command)) return { segments: [], refused: 'redirection is not allowed' };
|
|
231
|
+
if (/[`]/.test(command)) return { segments: [], separators: [], refused: 'backticks are not allowed' };
|
|
232
|
+
if (/\$/.test(command)) return { segments: [], separators: [], refused: '$ expansion is not allowed' };
|
|
233
|
+
if (/[<>]/.test(command)) return { segments: [], separators: [], refused: 'redirection is not allowed' };
|
|
229
234
|
/** @type {string[][]} */
|
|
230
235
|
const segments = [[]];
|
|
236
|
+
/** @type {string[]} */
|
|
237
|
+
const separators = [];
|
|
238
|
+
/** @type {boolean[]} */
|
|
239
|
+
const piped = [false];
|
|
231
240
|
let word = '';
|
|
232
241
|
let inWord = false;
|
|
233
242
|
const flush = () => {
|
|
@@ -239,29 +248,37 @@ function splitCommand(command) {
|
|
|
239
248
|
const c = command[i];
|
|
240
249
|
if (c === "'" || c === '"') {
|
|
241
250
|
const end = command.indexOf(c, i + 1);
|
|
242
|
-
if (end < 0) return { segments: [], refused: 'unbalanced quote' };
|
|
251
|
+
if (end < 0) return { segments: [], separators: [], refused: 'unbalanced quote' };
|
|
243
252
|
word += command.slice(i + 1, end);
|
|
244
253
|
inWord = true;
|
|
245
254
|
i = end;
|
|
246
255
|
} else if (c === '\\') {
|
|
247
|
-
if (i + 1 >= command.length || command[i + 1] === '\n' || command[i + 1] === '\r') return { segments: [], refused: 'line continuation is not allowed' };
|
|
256
|
+
if (i + 1 >= command.length || command[i + 1] === '\n' || command[i + 1] === '\r') return { segments: [], separators: [], refused: 'line continuation is not allowed' };
|
|
248
257
|
word += command[i + 1];
|
|
249
258
|
inWord = true;
|
|
250
259
|
i += 1;
|
|
251
260
|
} else if (SEPARATORS.has(c)) {
|
|
252
261
|
flush();
|
|
253
|
-
if ((c === '&' || c === '|') && command[i + 1] === c)
|
|
262
|
+
if ((c === '&' || c === '|') && command[i + 1] === c) {
|
|
263
|
+
separators.push(c + c);
|
|
264
|
+
i += 1;
|
|
265
|
+
} else {
|
|
266
|
+
separators.push(c);
|
|
267
|
+
if (c === '|') piped[piped.length - 1] = true;
|
|
268
|
+
}
|
|
254
269
|
segments.push([]);
|
|
270
|
+
piped.push(false);
|
|
255
271
|
} else if (c === ' ' || c === '\t') {
|
|
256
272
|
flush();
|
|
257
273
|
} else {
|
|
258
|
-
if (GLOB_CHARS.has(c)) return { segments: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
|
|
274
|
+
if (GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
|
|
259
275
|
word += c;
|
|
260
276
|
inWord = true;
|
|
261
277
|
}
|
|
262
278
|
}
|
|
263
279
|
flush();
|
|
264
|
-
|
|
280
|
+
const kept = segments.map((s, i) => /** @type {[string[], boolean]} */ ([s, piped[i]])).filter(([s]) => s.length > 0);
|
|
281
|
+
return { segments: kept.map(([s]) => s), separators, pipedOut: kept.map(([, p]) => p), refused: null };
|
|
265
282
|
}
|
|
266
283
|
|
|
267
284
|
/** A token that names a secret-looking file (read refused, whatever the command). */
|
|
@@ -414,17 +431,67 @@ function segmentViolation(argv) {
|
|
|
414
431
|
}
|
|
415
432
|
}
|
|
416
433
|
|
|
434
|
+
/** A bare CLI name (never a path) for the `<cli> … --help` forms. */
|
|
435
|
+
const BARE_CLI = /^[a-z0-9][a-z0-9._-]*$/;
|
|
436
|
+
/** A subcommand word of the `<cli> <subcommand> --help | grep -c -- <flag>` form. */
|
|
437
|
+
const SUBCOMMAND_WORD = /^[a-z][a-z0-9-]*$/;
|
|
438
|
+
/** The flag a help page is searched for (`grep -c -- <flag>`). */
|
|
439
|
+
const HELP_FLAG = /^--?[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Never a `<cli>` of the subcommand-help form, even when the brief names it: interpreters and shells
|
|
443
|
+
* run a project file (`node scripts --help`), and BSD file tools take `--help` as an operand after
|
|
444
|
+
* one (`rm foo --help`). git keeps its own read list.
|
|
445
|
+
*/
|
|
446
|
+
export const SUBCOMMAND_HELP_DENY = Object.freeze([
|
|
447
|
+
'node', 'deno', 'bun', 'sh', 'bash', 'zsh', 'fish', 'dash', 'python', 'python3', 'ruby', 'perl', 'php',
|
|
448
|
+
'rm', 'mv', 'cp', 'kill', 'pkill', 'chmod', 'chown', 'ln', 'dd', 'tee', 'git',
|
|
449
|
+
// launchers that run their subcommand as a program
|
|
450
|
+
'npx', 'pnpx', 'bunx', 'uvx', 'pipx', 'npm', 'pnpm', 'yarn', 'env', 'sudo', 'doas', 'xargs', 'exec', 'nice', 'nohup', 'timeout', 'time', 'watch', 'command', 'builtin', 'eval', 'source',
|
|
451
|
+
]);
|
|
452
|
+
const SUBCOMMAND_HELP_DENIED = new Set(SUBCOMMAND_HELP_DENY);
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The CLIs the subcommand-help form may name: the first word of every `command` claim of the brief.
|
|
456
|
+
* @param {ReadonlyArray<Claim>} claims
|
|
457
|
+
* @returns {Set<string>}
|
|
458
|
+
*/
|
|
459
|
+
export function claimClis(claims) {
|
|
460
|
+
return new Set(claims.filter((c) => c.kind === 'command').map((c) => c.token.split(' ')[0]));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Is `segments` (split at `separators`) exactly `<cli> <subcommand> --help | grep -c -- <flag>`?
|
|
465
|
+
* One pipe and nothing else; `<cli>` a bare name that a `command` claim of the brief starts with
|
|
466
|
+
* (`clis`), never a build runner nor one of {@link SUBCOMMAND_HELP_DENY}; a plain lowercase
|
|
467
|
+
* subcommand word; a flag-shaped pattern. The two-token `<cli> --help` form stays a segment of its
|
|
468
|
+
* own ({@link segmentViolation}).
|
|
469
|
+
* @param {string[][]} segments @param {string[]} separators @param {ReadonlySet<string>} clis
|
|
470
|
+
* @returns {boolean}
|
|
471
|
+
*/
|
|
472
|
+
function isSubcommandHelpGrep(segments, separators, clis) {
|
|
473
|
+
if (segments.length !== 2 || separators.length !== 1 || separators[0] !== '|') return false;
|
|
474
|
+
const [help, grep] = segments;
|
|
475
|
+
const cli = help[0];
|
|
476
|
+
if (help.length !== 3 || help[2] !== '--help' || !BARE_CLI.test(cli) || !clis.has(cli) || BUILD_RUNNERS.has(cli) || SUBCOMMAND_HELP_DENIED.has(cli) || !SUBCOMMAND_WORD.test(help[1])) return false;
|
|
477
|
+
return grep.length === 4 && grep[0] === 'grep' && grep[1] === '-c' && grep[2] === '--' && HELP_FLAG.test(grep[3]);
|
|
478
|
+
}
|
|
479
|
+
|
|
417
480
|
/**
|
|
418
481
|
* Why a delegate's `command` is not an allowed read-only command, or null when it is. An
|
|
419
482
|
* ALLOW-LIST (fail closed): every segment between `;`, `&&`, `||`, `|`, `&`, newline or CR must
|
|
420
|
-
* be one of the forms in {@link segmentViolation}
|
|
483
|
+
* be one of the forms in {@link segmentViolation}, or the whole command is exactly
|
|
484
|
+
* `<cli> <subcommand> --help | grep -c -- <flag>` for a `<cli>` in `opts.clis`
|
|
485
|
+
* ({@link isSubcommandHelpGrep}); `$`, backticks and redirections are refused.
|
|
421
486
|
* @param {string} command
|
|
487
|
+
* @param {{clis?: ReadonlySet<string>}} [opts] - `clis`: {@link claimClis} of the brief (none ⇒ the subcommand form is never admitted).
|
|
422
488
|
* @returns {string | null}
|
|
423
489
|
*/
|
|
424
|
-
export function readOnlyViolation(command) {
|
|
425
|
-
const { segments, refused } = splitCommand(command);
|
|
490
|
+
export function readOnlyViolation(command, opts = {}) {
|
|
491
|
+
const { segments, separators, refused } = splitCommand(command);
|
|
426
492
|
if (refused) return refused;
|
|
427
493
|
if (segments.length === 0) return 'empty command';
|
|
494
|
+
if (isSubcommandHelpGrep(segments, separators, opts.clis ?? new Set()) && !segments.flat().some((t) => SECRET_PATH.some((re) => re.test(t)))) return null;
|
|
428
495
|
for (const argv of segments) {
|
|
429
496
|
const why = segmentViolation(argv);
|
|
430
497
|
if (why) return why;
|
|
@@ -435,12 +502,90 @@ export function readOnlyViolation(command) {
|
|
|
435
502
|
/** The one form an env claim may be checked with: existence only, the value never printed. */
|
|
436
503
|
const ENV_CHECK = /^printenv ([A-Z][A-Z0-9_]*) >\/dev\/null$/;
|
|
437
504
|
|
|
505
|
+
/** @param {string} text @returns {string} `text` trimmed, without one pair of surrounding backtick runs. */
|
|
506
|
+
function unquoteBackticks(text) {
|
|
507
|
+
const t = text.trim();
|
|
508
|
+
const m = /^(`+)([\s\S]*?)(`+)$/.exec(t);
|
|
509
|
+
return m && t.length > 1 ? m[2].trim() : t;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The claim token a delegate echoed back: the packet lists claims as `- F<n> <kind>: <token>`, so
|
|
514
|
+
* the delegate may copy `<kind>: <token>`, with or without backticks. Only a leading `<kind>: ` and
|
|
515
|
+
* surrounding backticks are stripped; anything else stays (and then does not match).
|
|
516
|
+
* @param {string} claim @param {string} kind
|
|
517
|
+
* @returns {string}
|
|
518
|
+
*/
|
|
519
|
+
export function echoedClaim(claim, kind) {
|
|
520
|
+
let t = unquoteBackticks(String(claim));
|
|
521
|
+
if (t.startsWith(`${kind}: `)) t = t.slice(kind.length + 2);
|
|
522
|
+
return unquoteBackticks(t);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Does this allow-listed segment print nothing on success? From {@link segmentViolation}'s list:
|
|
527
|
+
* `test …`; `grep`/`rg` with `-q`/`--quiet`/`--silent` (a short cluster holding `q` counts, operands
|
|
528
|
+
* after `--` do not); `which -s` (a short cluster holding `s`). `[ … ]` is never allowed.
|
|
529
|
+
* @param {string[]} argv
|
|
530
|
+
* @returns {boolean}
|
|
531
|
+
*/
|
|
532
|
+
function isSilentSegment(argv) {
|
|
533
|
+
const [verb, ...rest] = argv;
|
|
534
|
+
if (verb === 'test') return true;
|
|
535
|
+
const end = rest.indexOf('--');
|
|
536
|
+
const opts = end < 0 ? rest : rest.slice(0, end);
|
|
537
|
+
if (verb === 'grep' || verb === 'rg') return opts.some((t) => /^-[a-zA-Z]*q/.test(t) || t === '--quiet' || t === '--silent');
|
|
538
|
+
if (verb === 'which') return opts.some((t) => /^-[a-zA-Z]*s/.test(t));
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Does the command print nothing on success? Only a segment not piped into another one prints to
|
|
544
|
+
* the command's output; the command is silent when every such segment is silent.
|
|
545
|
+
* @param {string} command
|
|
546
|
+
* @returns {boolean}
|
|
547
|
+
*/
|
|
548
|
+
export function isSilentCommand(command) {
|
|
549
|
+
const { segments, pipedOut = [] } = splitCommand(command);
|
|
550
|
+
return segments.length > 0 && segments.every((argv, i) => pipedOut[i] || isSilentSegment(argv));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** @typedef {{fact_id: string, reason: string}} Downgrade */
|
|
554
|
+
|
|
438
555
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
556
|
+
* Which check rule a counted row (VERIFIED or NOT-FOUND) breaks, or null. A row the delegate
|
|
557
|
+
* already tagged UNVERIFIABLE is never counted, so it keeps its own `why`.
|
|
558
|
+
* @param {Fact} row @param {Claim} claim @param {ReadonlySet<string>} clis - {@link claimClis}.
|
|
559
|
+
* @returns {string | null}
|
|
560
|
+
*/
|
|
561
|
+
function ruleBroken(row, claim, clis) {
|
|
562
|
+
if (row.tag === 'UNVERIFIABLE') return null;
|
|
563
|
+
if (row.command.trim().length === 0) return `${row.tag} without a check command; not counted`;
|
|
564
|
+
if (claim.kind === 'env') {
|
|
565
|
+
const env = ENV_CHECK.exec(row.command);
|
|
566
|
+
if (!env || env[1] !== claim.token) return `an env claim may only be checked with printenv ${claim.token} >/dev/null; not counted`;
|
|
567
|
+
} else {
|
|
568
|
+
const why = readOnlyViolation(row.command, { clis });
|
|
569
|
+
if (why) return `check command not allowed: ${why}; not counted`;
|
|
570
|
+
if (row.tag === 'VERIFIED' && isSilentCommand(row.command)) return 'silent command; excerpt cannot be its output; not counted';
|
|
571
|
+
}
|
|
572
|
+
if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) return 'VERIFIED with an empty output excerpt; not counted';
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Validate the delegate's answer (step 3). Only a schema-invalid answer is refused. A row answers
|
|
578
|
+
* claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
|
|
579
|
+
* the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
|
|
580
|
+
* equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
|
|
581
|
+
* delegate left out are kept as UNVERIFIABLE rows ("no answer") — a missing answer is never read
|
|
582
|
+
* as a fact. A VERIFIED or NOT-FOUND row that breaks a check rule (a command off the read-only
|
|
583
|
+
* allow-list, an env claim not checked with `printenv NAME >/dev/null`, a VERIFIED row with an
|
|
584
|
+
* empty excerpt or from a silent `test`) becomes UNVERIFIABLE with `why` = the rule and is listed
|
|
585
|
+
* in `downgraded`. Every row shows the CANONICAL claim token and kind.
|
|
441
586
|
* @param {unknown} answer @param {ReadonlyArray<Claim>} claims
|
|
442
|
-
* @returns {Fact[]} the rows
|
|
443
|
-
* @throws {FactsError} `refused`
|
|
587
|
+
* @returns {{facts: Fact[], downgraded: Downgrade[]}} the rows in claim order, and the downgrades.
|
|
588
|
+
* @throws {FactsError} `refused` when the answer does not match the schema.
|
|
444
589
|
*/
|
|
445
590
|
export function validateFacts(answer, claims) {
|
|
446
591
|
if (!validateFile(answer)) {
|
|
@@ -448,33 +593,46 @@ export function validateFacts(answer, claims) {
|
|
|
448
593
|
throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
|
|
449
594
|
}
|
|
450
595
|
const rows = /** @type {{facts: Fact[]}} */ (answer).facts;
|
|
451
|
-
const problems = [];
|
|
452
|
-
for (const row of rows) {
|
|
453
|
-
if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) problems.push(`${row.fact_id}: VERIFIED with an empty output excerpt`);
|
|
454
|
-
if (row.command.trim().length === 0) {
|
|
455
|
-
if (row.tag !== 'UNVERIFIABLE') problems.push(`${row.fact_id}: ${row.tag} without a command`);
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
if (row.kind === 'env') {
|
|
459
|
-
const env = ENV_CHECK.exec(row.command);
|
|
460
|
-
if (!env || env[1] !== row.claim) problems.push(`${row.fact_id}: an env claim may only be checked with printenv ${row.claim} >/dev/null`);
|
|
461
|
-
continue;
|
|
462
|
-
}
|
|
463
|
-
const why = readOnlyViolation(row.command);
|
|
464
|
-
if (why) problems.push(`${row.fact_id}: command is not read-only: ${why}`);
|
|
465
|
-
}
|
|
466
|
-
if (problems.length > 0) throw new FactsError('refused', `facts sheet refused: ${problems.join('; ')}`);
|
|
467
596
|
/** @type {Fact[]} */
|
|
468
|
-
const
|
|
597
|
+
const facts = [];
|
|
598
|
+
/** @type {Downgrade[]} */
|
|
599
|
+
const downgraded = [];
|
|
600
|
+
const clis = claimClis(claims);
|
|
601
|
+
// each row answers at most ONE claim: pass 1 anchors rows by fact_id (with the kind, else with the
|
|
602
|
+
// echoed claim); pass 2 gives the rows left over to claims still open, by kind + echoed claim
|
|
603
|
+
/** @type {Set<Fact>} */
|
|
604
|
+
const taken = new Set();
|
|
605
|
+
/** @type {Array<Fact | undefined>} */
|
|
606
|
+
const chosen = claims.map((claim, i) => {
|
|
607
|
+
const id = `F${i + 1}`;
|
|
608
|
+
const row = rows.find((r) => !taken.has(r) && r.fact_id === id && r.kind === claim.kind) ?? rows.find((r) => !taken.has(r) && r.fact_id === id && echoedClaim(r.claim, claim.kind) === claim.token);
|
|
609
|
+
if (row) taken.add(row);
|
|
610
|
+
return row;
|
|
611
|
+
});
|
|
469
612
|
claims.forEach((claim, i) => {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
: { fact_id: `F${i + 1}`, claim: redact(claim.token), kind: claim.kind, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: 'no answer from the delegate' },
|
|
475
|
-
);
|
|
613
|
+
if (chosen[i]) return;
|
|
614
|
+
const row = rows.find((r) => !taken.has(r) && r.kind === claim.kind && echoedClaim(r.claim, claim.kind) === claim.token);
|
|
615
|
+
if (row) taken.add(row);
|
|
616
|
+
chosen[i] = row;
|
|
476
617
|
});
|
|
477
|
-
|
|
618
|
+
claims.forEach((claim, i) => {
|
|
619
|
+
const id = `F${i + 1}`;
|
|
620
|
+
const row = chosen[i];
|
|
621
|
+
if (!row) {
|
|
622
|
+
facts.push({ fact_id: id, claim: redact(claim.token), kind: claim.kind, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: 'no answer from the delegate' });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
|
|
626
|
+
const broken = ruleBroken(row, claim, clis);
|
|
627
|
+
if (broken) {
|
|
628
|
+
const reason = redact(broken);
|
|
629
|
+
downgraded.push({ fact_id: id, reason });
|
|
630
|
+
facts.push({ ...base, tag: 'UNVERIFIABLE', why: reason });
|
|
631
|
+
} else {
|
|
632
|
+
facts.push({ ...base, tag: row.tag, why: row.why === null ? null : redact(row.why) });
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
return { facts, downgraded };
|
|
478
636
|
}
|
|
479
637
|
|
|
480
638
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -560,20 +718,27 @@ export function checkSheetFresh(sheetText, briefPath) {
|
|
|
560
718
|
const RULE = [
|
|
561
719
|
'# Facts delegate',
|
|
562
720
|
'',
|
|
563
|
-
'For each claim below, run the cheapest READ-ONLY check (`<cli> --help | grep -c <flag>`,
|
|
564
|
-
'`<cli>
|
|
721
|
+
'For each claim below, run the cheapest READ-ONLY check (`<cli> --help | grep -c -- <flag>`,',
|
|
722
|
+
'`<cli> <subcommand> --help | grep -c -- <flag>`, `which <cli>`, `ls <path>`, `<cli> --version`,',
|
|
723
|
+
'`npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
|
|
565
724
|
'characters). Tag each claim VERIFIED (the output shows it), NOT-FOUND (the check ran and it is absent) or',
|
|
566
|
-
'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess
|
|
567
|
-
'
|
|
725
|
+
'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess: the excerpt is the',
|
|
726
|
+
"command's literal output, never your reading of it. For a path use `ls <path>` (it prints the path); `test` prints",
|
|
727
|
+
'nothing, so a `test` check can never be VERIFIED. Only these forms are accepted, joined by `|` if needed: which,',
|
|
728
|
+
'command -v, ls, cat, head, tail, wc, grep (no -r), rg,',
|
|
568
729
|
'find (only -name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin',
|
|
569
730
|
'-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, `<cli> --help|-h|--version` (a bare CLI name, no path, no build runner), git',
|
|
570
|
-
'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, node --version, test -e|-f|-d|-n, `curl -sI <one URL
|
|
731
|
+
'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, node --version, test -e|-f|-d|-n, `curl -sI <one URL>`;',
|
|
732
|
+
'and, as a whole command only, `<cli> <subcommand> --help | grep -c -- <flag>` (`<cli>` the first word of a',
|
|
733
|
+
'command claim below, never an interpreter, shell or file tool; a lowercase subcommand word; no other pipe or',
|
|
734
|
+
'argument). A check that breaks these rules is not counted.',
|
|
571
735
|
'No `$`, no backticks, no redirection, no unquoted glob, no `~`, no absolute path outside /usr, /opt/homebrew or',
|
|
572
736
|
'/bin, no secret files.',
|
|
573
737
|
'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
|
|
574
738
|
'',
|
|
575
739
|
'Reply with ONE JSON object: {"facts": [{"fact_id", "claim", "kind", "command", "output_excerpt", "tag", "why"}]} —',
|
|
576
|
-
'one row per claim
|
|
740
|
+
'one row per claim: `fact_id` (F<n>) and `kind` copied from the list, `claim` = the token alone (the text after',
|
|
741
|
+
'`<kind>: `), `why` null unless UNVERIFIABLE.',
|
|
577
742
|
'',
|
|
578
743
|
'## Claims',
|
|
579
744
|
'',
|
|
@@ -797,7 +962,7 @@ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run =
|
|
|
797
962
|
* Build the facts sheet (steps 1–3) and write it to `outPath`.
|
|
798
963
|
* @param {BuildFactsOpts} opts
|
|
799
964
|
* @param {import('./spawn.mjs').SessionDeps} [deps]
|
|
800
|
-
* @returns {Promise<{status: string, reason?: string | null, outPath?: string, claims: Claim[], facts?: Fact[]}>}
|
|
965
|
+
* @returns {Promise<{status: string, reason?: string | null, outPath?: string, claims: Claim[], facts?: Fact[], downgraded?: Downgrade[]}>}
|
|
801
966
|
*/
|
|
802
967
|
export async function buildFacts(opts, deps = {}) {
|
|
803
968
|
const brief = readBriefOnce(opts.briefPath);
|
|
@@ -831,7 +996,7 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
831
996
|
deps,
|
|
832
997
|
);
|
|
833
998
|
if (result.status !== 'ok') return { status: result.status, reason: result.reason ?? null, claims };
|
|
834
|
-
const facts = validateFacts(result.answer, claims);
|
|
999
|
+
const { facts, downgraded } = validateFacts(result.answer, claims);
|
|
835
1000
|
const now = (opts.now ?? (() => new Date()))();
|
|
836
1001
|
const rel = path.relative(path.dirname(opts.outPath), opts.briefPath).split(path.sep).join('/');
|
|
837
1002
|
const text = renderSheet({
|
|
@@ -847,7 +1012,7 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
847
1012
|
if (writeRow) {
|
|
848
1013
|
await writeRow({ event: 'facts.built', role: 'facts', run: opts.run ?? null, claims: claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length, tokens_in: result.row?.tokens_in ?? null, tokens_out: result.row?.tokens_out ?? null });
|
|
849
1014
|
}
|
|
850
|
-
return { status: 'ok', outPath: opts.outPath, claims, facts };
|
|
1015
|
+
return { status: 'ok', outPath: opts.outPath, claims, facts, downgraded };
|
|
851
1016
|
} finally {
|
|
852
1017
|
removeSnapshot(snapDir);
|
|
853
1018
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -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 =
|
|
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 =
|
|
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 {
|
|
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
|
+
}
|