@dzhechkov/keysarium 1.7.7 → 1.7.9

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.
@@ -3,7 +3,7 @@
3
3
  // Generalized from features/wave1-instrument-repair/check-plan-completeness.mjs (that copy is the
4
4
  // historical artifact of its run and stays untouched); this one is parameterized by feature dir.
5
5
  //
6
- // USAGE: node .claude/skills/feature-adr/scripts/check-plan-completeness.mjs [<feature-dir>] [--tier=M] [--acid=A1,A2] [--require-requirements]
6
+ // USAGE: node .claude/skills/feature-adr/scripts/check-plan-completeness.mjs [<feature-dir>] [--tier=M] [--acid=A1,A2] [--require-requirements] [--no-require-task-lines]
7
7
  // <feature-dir> defaults to the current working directory.
8
8
  // --tier=S|M|L|XL closes the ADR-less dodge (see S-TIER HONESTY); omitting it keeps the
9
9
  // heuristic, and the skip note then names the dodge out loud.
@@ -11,6 +11,12 @@
11
11
  // pipeline passes this flag, so the check is introduced two-shot (ADR-001 plan-inherits-
12
12
  // requirements): WARN first so the corpus can be measured without repainting every green
13
13
  // fixture red, FAIL once the planner prompt names the contract (feature-adr.js Step 6).
14
+ // C1 and C8 read the plan's TASK LINES by DEFAULT (C10 below; owner decision 2026-09-27,
15
+ // backlog 95565da0): an id cited only in prose, fenced code, an HTML comment, `## Amendments`
16
+ // or EXPECTED_CODE_TARGETS is not a task. --no-require-task-lines is the explicit way back
17
+ // to the raw-plan reader, where C10 only WARNs — for re-checking a plan written before the
18
+ // default changed. --require-task-lines is still accepted and means the default; passing
19
+ // BOTH is a contradiction and is refused (NOT-ESTABLISHED), never resolved silently.
14
20
  //
15
21
  // VERDICT CONTRACT (unchanged from the proven copy — never a silent pass):
16
22
  // PASS exit 0 last line: `K2 plan-completeness: PASS (...)`
@@ -20,16 +26,26 @@
20
26
  // ONE regex parses all three verdicts — the exit codes and their meanings are identical.)
21
27
  // Set difference over IDENTIFIERS, not text similarity.
22
28
  //
23
- // KNOWN LIMITATION (measured on the discrimination twin, 2026-08-19): C1 is a grep — a PROSE
24
- // mention of "ADR-002" satisfies it exactly like a task reference. The real N14 (backlog
25
- // 3dbd2851-adjacent) must parse task structure. Kept honest here: C1 catches "forgot entirely",
26
- // not "mentioned but not tasked".
29
+ // C1 AND C8 SHARE ONE PLAN-SIDE READER (k2-reads-masked-plan, backlog c03903eb, 2026-09-27).
30
+ // Until then both were a grep over the RAW plan: an id in prose, a fenced code block, an HTML
31
+ // comment, the `## Amendments` section or the EXPECTED_CODE_TARGETS block counted as a task
32
+ // (named here as a limit since 2026-08-19 for C1 and 2026-09-16 for C8). `planTaskText()` now
33
+ // answers "which plan text is a task" ONCE, for both checks — so one phrase can never pass one
34
+ // check and fail the other. Introduced WARN-first (C10), because the corpus said so; the FAIL
35
+ // half became the DEFAULT on 2026-09-27 by owner decision, with this measurement on the table:
36
+ // MEASURED 2026-09-27 over all 472 features/*/06_implementation_plan.md, pipeline mode
37
+ // (--require-requirements, tier read from 00): of 101 green plans, the task-line reader turns 55
38
+ // red (an earlier snapshot of the same day; the count the owner decided on, taken later over 473
39
+ // plans, was 56 of 102), and even mask-only (prose kept) turns 11 red — the SPARC-GOAP ```yaml goal-state block and
40
+ // «ADR-001 governs every task» prose are the corpus's own idioms, not forgeries. So a plan that
41
+ // was green before 2026-09-27 may be red on a re-check: that is the decision, not a regression —
42
+ // cite the id on a task line, or re-check that plan with --no-require-task-lines and say so.
43
+ // NAMED LIMIT: `planClaimsAdrWork` (S-tier honesty) and C2 still read the raw plan — a CLAIM of
44
+ // ADR work in prose is still a claim, and C2 names test paths, not ids.
27
45
  //
28
- // KNOWN LIMITATION (fix round 1, 2026-09-16, same class as C1 above): C8 is also a grep — a PROSE
29
- // mention of "FR-3" satisfies it exactly like a task reference. Kept honest here too: C8 catches
30
- // "forgot entirely", not "mentioned but not tasked". Masking the PLAN side (not just the 01/ADR
31
- // side) for C1 and C8 together, so a prose mention stops satisfying either check, is a separate
32
- // backlog item — filed by the lead, not chased here.
46
+ // KNOWN LIMITATION (C9): only byte-identical copies are visible before the edit. Already drifted
47
+ // or intentionally different pinned copies remain the identity tests' job; the frozen
48
+ // features/wave1-instrument-repair/check-plan-completeness.mjs is a real example C9 cannot see.
33
49
  //
34
50
  // Checks:
35
51
  // C1 every ADR file in 03_adr/ has >=1 task line in 06_implementation_plan.md citing it (ADR-00N)
@@ -37,6 +53,8 @@
37
53
  // C3 the plan carries an EXPECTED_CODE_TARGETS: block, non-empty, and EVERY line parses to a
38
54
  // plausible repo-relative path (no spaces unless quoted, no traversal, no markdown residue)
39
55
  // — SFDIPOT condition: line-level validation, reject-with-reason, not just block presence
56
+ // C9 every tracked byte-identical twin of a target is also listed or explicitly waived with a
57
+ // reason; size-first narrowing, fixed exclusions, one FAIL per missing (target, twin) pair
40
58
  // C4 the plan names the feature's OWN acid corpus (see "acid corpus" below)
41
59
  // C5 the plan has an 'Inputs read:' line naming 03_adr, 05_architecture (wave-2 seam, cheap here)
42
60
  // C8 every requirement id DECLARED in 01_requirements.md (FR-N, NFR-N, AC-N, C-N, with an optional
@@ -79,7 +97,9 @@
79
97
  // supplied explicitly with `--acid=T1,T2,…`. If neither establishes a corpus, C4 is SKIPPED-with-note
80
98
  // (a feature that declared no acid cases cannot be failed for not naming them).
81
99
  import { maskMarkdown } from './markdown-masker.mjs';
82
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
100
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
101
+ import { execFileSync } from 'node:child_process';
102
+ import { createHash } from 'node:crypto';
83
103
  import { isAbsolute, join, resolve } from 'node:path';
84
104
 
85
105
  const argv = process.argv.slice(2);
@@ -88,6 +108,8 @@ const tierArg = argv.find((a) => a.startsWith('--tier='));
88
108
  const TIER = tierArg ? tierArg.slice('--tier='.length).trim().toUpperCase() : null;
89
109
  const TIER_REQUIRES_ADR = TIER === 'M' || TIER === 'L' || TIER === 'XL';
90
110
  const REQUIRE_REQUIREMENTS = argv.includes('--require-requirements');
111
+ const NO_REQUIRE_TASK_LINES = argv.includes('--no-require-task-lines');
112
+ const REQUIRE_TASK_LINES = !NO_REQUIRE_TASK_LINES;
91
113
  const dirArg = argv.find((a) => !a.startsWith('--'));
92
114
  const FDIR = resolve(dirArg && dirArg !== '' ? (isAbsolute(dirArg) ? dirArg : join(process.cwd(), dirArg)) : process.cwd());
93
115
  const planPath = join(FDIR, '06_implementation_plan.md');
@@ -105,6 +127,7 @@ const safe = (v) => String(v)
105
127
  .slice(0, 300);
106
128
  const out = (s) => console.log(s);
107
129
  const notEstablished = (why) => { out(`K2 plan-completeness: NOT-ESTABLISHED — ${safe(why)}`); process.exit(3); };
130
+ if (NO_REQUIRE_TASK_LINES && argv.includes('--require-task-lines')) notEstablished('both --require-task-lines and --no-require-task-lines were passed — pick one reader');
108
131
  let failures = [], warnings = [], skips = [];
109
132
 
110
133
  // Lead delta after Codex rounds 2+3 (2026-09-16, plan-inherits-requirements): on the DECLARATION side
@@ -128,6 +151,54 @@ if (!existsSync(FDIR)) notEstablished(`feature dir absent: ${FDIR}`);
128
151
  if (!existsSync(planPath)) notEstablished('06_implementation_plan.md absent');
129
152
  const plan = readFileSync(planPath, 'utf-8');
130
153
  if (plan.trim().length < 200) notEstablished('plan suspiciously small (<200 chars)');
154
+ // The EXPECTED_CODE_TARGETS block as C3 reads it (C3 below reuses THIS match — one reader).
155
+ const blockM = plan.match(/EXPECTED_CODE_TARGETS:\s*\n((?:\s*[-*]\s*.+\n?)+)/);
156
+
157
+ // `## Amendments` as C6 reads it: up to three leading spaces, two to four hashes, trailing text
158
+ // allowed; the section ends at the next level-1..4 heading. Returned as LINE indices over masked
159
+ // lines — [headingLine, endLine). One definition for C6 and planTaskText, or the two drift.
160
+ function findAmendmentsSection(maskedLines) {
161
+ let head = -1, end = -1;
162
+ for (let i = 0; i < maskedLines.length; i++) {
163
+ const pl = maskedLines[i];
164
+ if (head < 0 && /^ {0,3}#{2,4}\s+Amendments\b/.test(pl)) head = i;
165
+ else if (head >= 0 && end < 0 && /^ {0,3}#{1,4}\s/.test(pl)) end = i;
166
+ }
167
+ if (head >= 0 && end < 0) end = maskedLines.length;
168
+ return { head, end };
169
+ }
170
+
171
+ // C10 — the TASK LINES of the plan, the text C1/C8 cite against (the default reader).
172
+ // Masked first (fences and HTML comments blanked, unclosed fence hidden — the K2 reader policy),
173
+ // then: drop the `## Amendments` section and the EXPECTED_CODE_TARGETS block, and keep only
174
+ // headings, list items and table rows. Table rows are kept deliberately, beyond the backlog's
175
+ // «headings/list items»: task tables (`| T1 | … |`) are a corpus form — MEASURED 2026-09-27,
176
+ // dropping them turns 12 more green plans red in pipeline mode (67 vs 55) and 26 more in WARN mode.
177
+ // NAMED LIMIT: a list item or table row that is itself prose («- Note: ADR-001 governs …») still
178
+ // counts — the reader tells structure from prose, not a task from a remark.
179
+ function planTaskText(planText) {
180
+ const lines = maskMarkdown(planText, { unclosed: 'mask' }).split('\n');
181
+ const am = findAmendmentsSection(lines);
182
+ let tFrom = -1, tTo = -1;
183
+ if (blockM) {
184
+ tFrom = planText.slice(0, blockM.index).split('\n').length - 1;
185
+ tTo = planText.slice(0, blockM.index + blockM[0].length - 1).split('\n').length - 1;
186
+ }
187
+ const keep = [];
188
+ for (let i = 0; i < lines.length; i++) {
189
+ if (am.head >= 0 && i >= am.head && i < am.end) continue;
190
+ if (tFrom >= 0 && i >= tFrom && i <= tTo) continue;
191
+ const l = lines[i];
192
+ if (/^ {0,3}#{1,6}\s/.test(l) || /^\s*(?:[-*+]|\d+[.)])\s+/.test(l) || /^\s*\|/.test(l)) keep.push(l);
193
+ }
194
+ return keep.join('\n');
195
+ }
196
+ const planTasks = planTaskText(plan);
197
+ // What C1/C8 cite against, and the ids that pass the raw read but have no task line (C10).
198
+ const citeText = REQUIRE_TASK_LINES ? planTasks : plan;
199
+ const outsideTasksOnly = { C1: [], C8: [] };
200
+ const citedOutsideOnly = (re) => re.test(plan) && !re.test(planTasks);
201
+
131
202
  const adrFiles = existsSync(adrDir) ? readdirSync(adrDir).filter(f => f.endsWith('.md')).sort() : [];
132
203
  const planClaimsAdrWork = /\bADR-\d+/.test(plan);
133
204
  if (adrFiles.length === 0 && planClaimsAdrWork) notEstablished('no ADR files under 03_adr/, yet the plan cites ADR-<n> — completeness cannot be established');
@@ -235,7 +306,8 @@ if (adrFiles.length === 0 && TIER_REQUIRES_ADR) {
235
306
  for (const n of headingNums) {
236
307
  const id = `ADR-${n}`;
237
308
  const re = new RegExp(`ADR-0*${Number(n)}\\b`);
238
- if (!re.test(plan)) failures.push(`C1: ${id} (${safe(f)}) has NO task in the plan referencing it`);
309
+ if (!re.test(citeText)) failures.push(`C1: ${id} (${safe(f)}) has NO task in the plan referencing it${citedOutsideOnly(re) ? ' (cited only outside task lines)' : ''}`);
310
+ else if (citedOutsideOnly(re)) outsideTasksOnly.C1.push(id);
239
311
  }
240
312
  continue;
241
313
  }
@@ -243,7 +315,8 @@ if (adrFiles.length === 0 && TIER_REQUIRES_ADR) {
243
315
  warnings.push(`C1: ${safe(f)} has no ADR-NNN heading — falling back to the filename prefix`);
244
316
  const id = `ADR-${m[1]}`;
245
317
  const re = new RegExp(`ADR-0*${Number(m[1])}\\b`);
246
- if (!re.test(plan)) failures.push(`C1: ${id} (${safe(f)}) has NO task in the plan referencing it`);
318
+ if (!re.test(citeText)) failures.push(`C1: ${id} (${safe(f)}) has NO task in the plan referencing it${citedOutsideOnly(re) ? ' (cited only outside task lines)' : ''}`);
319
+ else if (citedOutsideOnly(re)) outsideTasksOnly.C1.push(id);
247
320
  }
248
321
 
249
322
  // C2 — every Confirmation-listed test file path appears in the plan
@@ -334,7 +407,7 @@ function classifyTargetPath(path) {
334
407
  }
335
408
 
336
409
  // C3 — EXPECTED_CODE_TARGETS block, line-level validation
337
- const blockM = plan.match(/EXPECTED_CODE_TARGETS:\s*\n((?:\s*[-*]\s*.+\n?)+)/);
410
+ const listedTargets = new Set();
338
411
  if (!blockM) failures.push('C3: no EXPECTED_CODE_TARGETS: block in the plan');
339
412
  else {
340
413
  const lines = blockM[1].split('\n').map(s => s.trim()).filter(Boolean);
@@ -343,6 +416,81 @@ else {
343
416
  const path = ln.replace(/^[-*]\s*/, '').replace(/`/g, '').trim();
344
417
  const reasons = classifyTargetPath(path);
345
418
  if (reasons.length) failures.push(`C3: target line rejected: "${safe(ln)}" — ${reasons.join(', ')}`);
419
+ else listedTargets.add(path);
420
+ }
421
+ }
422
+
423
+ // C9 — every byte-identical copy is listed or carries a reasoned waiver (ADR-001).
424
+ const TWIN_EXCLUDED_PREFIXES = ['out/', '.claude/worktrees/', 'node_modules/'];
425
+ {
426
+ const excluded = (path) => TWIN_EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix) || path.includes('/' + prefix));
427
+ const waivers = [];
428
+ for (const line of maskMarkdown(plan, { unclosed: 'mask' }).split('\n')) {
429
+ if (!/^\s*(?:[-*]\s*)?TWIN_NOT_A_TARGET:/.test(line)) continue;
430
+ const match = line.match(/^\s*(?:[-*]\s*)?TWIN_NOT_A_TARGET:\s*`?([^\s`]+)`?\s*(?:—|--)\s*(\S.*)$/);
431
+ if (!match) {
432
+ failures.push(`C9: waiver "${safe(line.trim())}" carries no reason — a waiver without a reason is an allowlist entry`);
433
+ } else waivers.push({ path: match[1], used: false });
434
+ }
435
+
436
+ let tracked = null;
437
+ try {
438
+ tracked = execFileSync('git', ['ls-files', '-z'], {
439
+ cwd: process.cwd(), encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024,
440
+ stdio: ['ignore', 'pipe', 'pipe'],
441
+ }).split('\0').filter(Boolean);
442
+ } catch (error) {
443
+ warnings.push(`C9: twin scan had no input — git ls-files failed in ${safe(process.cwd())}: ${safe(error.message)}`);
444
+ }
445
+ if (tracked !== null) {
446
+ // Stat the universe first; only size matches ever reach readFileSync / md5.
447
+ const sizes = new Map();
448
+ for (const path of new Set([...tracked, ...listedTargets])) {
449
+ if (excluded(path)) continue;
450
+ try {
451
+ const stat = statSync(path);
452
+ if (stat.isFile()) sizes.set(path, stat.size);
453
+ } catch (error) {
454
+ // Missing targets are new files; tracked files can also have been deleted locally.
455
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') warnings.push(`C9: cannot stat ${safe(path)}: ${safe(error.message)}`);
456
+ }
457
+ }
458
+ const targetSizes = new Set([...listedTargets].map((path) => sizes.get(path)).filter((size) => size > 0));
459
+ const hashes = new Map();
460
+ const twinsByHash = new Map();
461
+ for (const [path, size] of sizes) {
462
+ if (!targetSizes.has(size)) continue;
463
+ try { hashes.set(path, createHash('md5').update(readFileSync(path)).digest('hex')); }
464
+ catch (error) { warnings.push(`C9: cannot hash ${safe(path)}: ${safe(error.message)}`); }
465
+ }
466
+ for (const path of tracked) {
467
+ const hash = hashes.get(path);
468
+ if (hash === undefined) continue;
469
+ if (!twinsByHash.has(hash)) twinsByHash.set(hash, []);
470
+ twinsByHash.get(hash).push(path);
471
+ }
472
+ let unlisted = 0;
473
+ for (const target of listedTargets) {
474
+ for (const twin of twinsByHash.get(hashes.get(target)) ?? []) {
475
+ if (twin === target) continue;
476
+ let waived = false;
477
+ for (const waiver of waivers) {
478
+ if (waiver.path.endsWith('/') ? twin.startsWith(waiver.path) : twin === waiver.path) {
479
+ waiver.used = true;
480
+ waived = true;
481
+ }
482
+ }
483
+ if (listedTargets.has(twin) || waived) continue;
484
+ unlisted++;
485
+ // Keep pairs separate: safe() truncates each echoed value at 300 characters.
486
+ failures.push(`C9: target ${safe(target)} has a byte-identical twin the plan does not list: ${safe(twin)} — list it or waive it (TWIN_NOT_A_TARGET: ${safe(twin)} — <why>)`);
487
+ }
488
+ }
489
+ for (const waiver of waivers) if (!waiver.used) warnings.push(`C9: unused waiver ${safe(waiver.path)}`);
490
+ if (unlisted === 0) {
491
+ const onDisk = [...listedTargets].filter((path) => sizes.has(path)).length;
492
+ out(`NOTE C9: twin scan over ${listedTargets.size} target(s), ${onDisk} on disk, 0 unlisted twins (byte-identical only; drifted copies need identity tests)`);
493
+ }
346
494
  }
347
495
  }
348
496
 
@@ -393,16 +541,14 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
393
541
  // phantom amendment or hand a real testless one someone else's marker. Third fence-blindness
394
542
  // found in a checker today, so it is closed here by construction rather than by care.
395
543
  const planLines = maskMarkdown(plan, { unclosed: 'mask' }).split('\n');
396
- let sectionStart = -1, sectionEnd = -1, cursor = 0;
397
- for (const pl of planLines) {
398
- // The SAME heading shape amendment-trace.ts accepts: up to three leading spaces, two to four
399
- // hashes, and trailing text allowed. C6 required exactly `##` with nothing after, so the two
400
- // tools disagreed about where the section even IS — the divergence this feature exists to end.
401
- if (sectionStart < 0 && /^ {0,3}#{2,4}\s+Amendments\b/.test(pl)) sectionStart = cursor + pl.length + 1;
402
- else if (sectionStart >= 0 && sectionEnd < 0 && /^ {0,3}#{1,4}\s/.test(pl)) sectionEnd = cursor;
403
- cursor += pl.length + 1;
404
- }
405
- if (sectionStart >= 0 && sectionEnd < 0) sectionEnd = plan.length;
544
+ // The SAME heading shape amendment-trace.ts accepts: up to three leading spaces, two to four
545
+ // hashes, and trailing text allowed. C6 required exactly `##` with nothing after, so the two
546
+ // tools disagreed about where the section even IS — the divergence this feature exists to end.
547
+ // The section boundary itself is `findAmendmentsSection` — shared with C10, one answer.
548
+ const amRange = findAmendmentsSection(planLines);
549
+ const lineOffset = (idx) => planLines.slice(0, idx).reduce((acc, l) => acc + l.length + 1, 0);
550
+ const sectionStart = amRange.head < 0 ? -1 : lineOffset(amRange.head + 1);
551
+ const sectionEnd = amRange.head < 0 ? -1 : (amRange.end < planLines.length ? lineOffset(amRange.end) : plan.length);
406
552
  // Sliced from the MASKED text so the offsets computed above line up with what is scanned.
407
553
  const maskedPlan = planLines.join('\n');
408
554
  const amSection = sectionStart >= 0 ? maskedPlan.slice(sectionStart, sectionEnd) : '';
@@ -417,7 +563,21 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
417
563
  // bullet-less row was invisible here while being a row there (so it evaded this check), and
418
564
  // this side lacked the range guard, so «AM-1..AM-4 are covered elsewhere» was falsely
419
565
  // reported as a definition. One shape, one meaning.
420
- const isDef = /^\s*(?:[-*|]\s*)?\*{0,2}AM-(?:CP-)?\d+\*{0,2}\b(?!\s*\.)/.test(pl);
566
+ // ЯЧЕЙКА ТАБЛИЦЫ ВНЕ СЕКЦИИ — НЕ ОПРЕДЕЛЕНИЕ. Внутри `## Amendments` строка таблицы
567
+ // законно объявляет поправку, поэтому там `|` остаётся допустимым началом строки. Снаружи
568
+ // же таблица — это СВОДКА, ссылающаяся на поправки, определённые в другом месте, и читать
569
+ // её как определение значит отказывать плану за оглавление.
570
+ // ИЗМЕРЕНО 2026-09-05: четыре ложных FAIL за один день на четырёх разных фичах
571
+ // (narrated-error-must-be-taught, fa-phase-statusline, core-boundary-guard,
572
+ // run-registry-liveness); каждый стоил ручной правки ведущего и перезапуска конвейера,
573
+ // то есть 5–10 минут и один цикл роутера. Один планировщик дошёл до того, что вписал в
574
+ // план оговорку «no line of this paragraph may begin with AM-» — прибор начал
575
+ // диктовать людям форму прозы, а это уже не проверка, а суеверие.
576
+ // ОДИН механизм, а не два: `|` УБРАН из класса начал строки. Первая редакция этой правки
577
+ // добавляла ещё и отдельную проверку `isTableRow`, и мутация, снимавшая только её,
578
+ // оставалась ЭКВИВАЛЕНТНОЙ — 77 тестов из 77 зелёные при «снятой» починке. Дублирующая
579
+ // защита не усиливает гейт, она прячет от мутационной пробы, что именно держит свойство.
580
+ const isDef = /^\s*(?:[-*]\s*)?\*{0,2}AM-(?:CP-)?\d+\*{0,2}\b(?!\s*\.)/.test(pl);
421
581
  const inSection = sectionStart >= 0 && cursor2 >= sectionStart && cursor2 < sectionEnd;
422
582
  if (isDef && !inSection) {
423
583
  const tok = (/AM-(?:CP-)?\d+/.exec(pl) || ['AM-?'])[0];
@@ -479,7 +639,10 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
479
639
  }
480
640
  if (superseded) break;
481
641
  }
482
- if (!hasTest && !superseded) failures.push(`C6: ${defs[k].id} carries neither \`\u2192 test <name>\` nor \`superseded by AM-N\` — an amendment without a confirmation is a wish, and a retracted one must say its successor`);
642
+ // Сообщение называет ТУ ЖЕ форму, которую требует MARK выше. Прежняя редакция обещала
643
+ // простое `→ test <name>`, а проверка требовала имя и файл в обратных кавычках — человек,
644
+ // написавший ровно то, что просило сообщение, получал отказ снова и не понимал, почему.
645
+ if (!hasTest && !superseded) failures.push(`C6: ${defs[k].id} carries neither \`\u2192 test \`<имя>\` in \`<файл>\`\` (обратные кавычки обязательны, как и слово in) nor \`superseded by AM-N\` — an amendment without a confirmation is a wish, and a retracted one must say its successor`);
483
646
  }
484
647
  }
485
648
  }
@@ -522,7 +685,9 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
522
685
  if (reqIds.length === 0) {
523
686
  warnings.push('C8: 01_requirements.md declares NO requirement ids in the contract shapes (FR-N / NFR-N / AC-N / C-N at line start) — nothing to cover');
524
687
  } else {
525
- const missing = reqIds.filter((id) => !new RegExp('\\b' + escapeReqId(id) + '\\b').test(plan));
688
+ const reqRe = (id) => new RegExp('\\b' + escapeReqId(id) + '\\b');
689
+ const missing = reqIds.filter((id) => !reqRe(id).test(citeText));
690
+ for (const id of reqIds) if (!missing.includes(id) && citedOutsideOnly(reqRe(id))) outsideTasksOnly.C8.push(id);
526
691
  if (missing.length === 0) {
527
692
  // Silence is not a verdict (K7): a check that ran and found nothing wrong must still print,
528
693
  // or a reader cannot tell "C8 ran clean" from "C8 never ran". Deliberately NOT a PASS/FAIL/
@@ -530,7 +695,7 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
530
695
  // mid-stream is exactly the G-F1 forgery class this script's `safe()` already defends against.
531
696
  out(`NOTE C8: all ${reqIds.length} requirement ids referenced`);
532
697
  } else if (REQUIRE_REQUIREMENTS) {
533
- for (const id of missing) failures.push(`C8: ${id} (01_requirements.md) is not referenced by the plan`);
698
+ for (const id of missing) failures.push(`C8: ${id} (01_requirements.md) is not referenced by the plan${citedOutsideOnly(reqRe(id)) ? ' (cited only outside task lines)' : ''}`);
534
699
  } else {
535
700
  const shown = missing.slice(0, 12);
536
701
  const more = missing.length > 12 ? `, …and ${missing.length - 12} more` : '';
@@ -540,6 +705,23 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
540
705
  }
541
706
  }
542
707
 
708
+ // C10 — ids that C1/C8 counted as covered, but whose ONLY citations sit outside task lines
709
+ // (prose, fenced code, an HTML comment, `## Amendments`, the EXPECTED_CODE_TARGETS block). Reached
710
+ // ONLY under --no-require-task-lines: by default C1/C8 already read task lines, so this list is
711
+ // empty by construction. One line per check, ids capped like C8's WARN.
712
+ for (const check of ['C1', 'C8']) {
713
+ const ids = [...new Set(outsideTasksOnly[check])];
714
+ if (ids.length === 0) continue;
715
+ const shown = ids.slice(0, 12);
716
+ const more = ids.length > 12 ? `, …and ${ids.length - 12} more` : '';
717
+ // Say what dropping the opt-out would do in THIS invocation (Codex astra r1 MINOR): C1 always
718
+ // FAILs by default; C8 FAILs only when --require-requirements is also passed — otherwise a WARN.
719
+ const consequence = check === 'C8' && !REQUIRE_REQUIREMENTS
720
+ ? 'without --no-require-task-lines this stays a C8 WARN (C8 is enforced only with --require-requirements; add that flag and the default reader fails it)'
721
+ : `these ids count as covered only because of --no-require-task-lines; the default makes this a ${check} FAIL`;
722
+ warnings.push(`C10: ${ids.length} ${check} id(s) are cited only outside task lines (prose, fenced code, HTML comment, ## Amendments, EXPECTED_CODE_TARGETS): ${shown.join(', ')}${more} — a mention is not a task; ${consequence}`);
723
+ }
724
+
543
725
  // C5 — Inputs read line
544
726
  if (!/Inputs read:/i.test(plan)) warnings.push('C5: no "Inputs read:" line (wave-2 seam, WARN only)');
545
727
  else for (const need of ['03_adr','05_architecture']) if (!plan.includes(need)) warnings.push(`C5: Inputs read line missing ${need}`);