@dzhechkov/keysarium 1.7.6 → 1.7.8

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/.dz-manifest.json CHANGED
@@ -3,9 +3,13 @@
3
3
  "version": 3,
4
4
  "pack": "keysarium",
5
5
  "files": [
6
+ {
7
+ "path": "LICENSE",
8
+ "sha256": "3b93f7070f6843f37e0cae255f7caff22b466a4017fd2020d7d3216237e5a701"
9
+ },
6
10
  {
7
11
  "path": "README.md",
8
- "sha256": "3b4f6570d8cebd26429409ed23da99ddd082c378946cf722fe62d5b405e288c2"
12
+ "sha256": "384c02be209742869d2a88e87746abad359d4099acb0146a68b9b86dd13d47dd"
9
13
  },
10
14
  {
11
15
  "path": "README.ru.md",
@@ -17,7 +21,7 @@
17
21
  },
18
22
  {
19
23
  "path": "package.json",
20
- "sha256": "67d60a0ad58316ec7c0cd264683d6d17e5fe7f33c746f151b95c0def597037d6"
24
+ "sha256": "1dd2e14eb1a9f3108c5bf607490bf36127a655cfc8aa89136a2f164332757c6c"
21
25
  },
22
26
  {
23
27
  "path": "src/cli.js",
@@ -345,7 +349,7 @@
345
349
  },
346
350
  {
347
351
  "path": "templates/.claude/skills/feature-adr/SKILL.md",
348
- "sha256": "67eb730c815e6b8f9b9b61084c67ceb85440548f00b21f52187d16e81e3d7af8"
352
+ "sha256": "28c265603481f976a52ea4c627e11194f3c0283801ea93d02cbb9f4248b43c68"
349
353
  },
350
354
  {
351
355
  "path": "templates/.claude/skills/feature-adr/examples/sample-feature-output.md",
@@ -381,15 +385,15 @@
381
385
  },
382
386
  {
383
387
  "path": "templates/.claude/skills/feature-adr/modules/06-implementation-plan.md",
384
- "sha256": "5c8d4c79d5329702b8c75b9afb36ae335aa63c99e74045c0afa98e12658ee975"
388
+ "sha256": "d7a2a4e56b24451da234b1ac4ef440ba9b0370617e1c0cde8ad5f3d575a53209"
385
389
  },
386
390
  {
387
391
  "path": "templates/.claude/skills/feature-adr/modules/07-code.md",
388
- "sha256": "fb054591e86d55cee184eab96b5f0440053ada8fa9edf619e4a66ec5ac371587"
392
+ "sha256": "db79f8d026cc47edd1e5d2f30d0e1455c55c570dd022d549d440985a3e5940a5"
389
393
  },
390
394
  {
391
395
  "path": "templates/.claude/skills/feature-adr/modules/08-qe.md",
392
- "sha256": "7973676cc9a5a6d8429c0bcdbe163bb5dc16e2d2f28a72698e44ad72d234e199"
396
+ "sha256": "d7a967926feec9b0ec173ac7042ae3abd368644d8e1dba9a81a770794417fcb3"
393
397
  },
394
398
  {
395
399
  "path": "templates/.claude/skills/feature-adr/modules/09-fleet-qe.md",
@@ -479,9 +483,13 @@
479
483
  "path": "templates/.claude/skills/feature-adr/references/qe-checklist.md",
480
484
  "sha256": "238d8896996dc53559f58b24aa7eca966cc8845c3b9dda6d982c717c657b91e4"
481
485
  },
486
+ {
487
+ "path": "templates/.claude/skills/feature-adr/scripts/build-coder-context.mjs",
488
+ "sha256": "19c3991bfb5e88205c9ff6d4d44037c136a190581bab199d3928196e4616fe15"
489
+ },
482
490
  {
483
491
  "path": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
484
- "sha256": "08e69d2e63350fe2dc64483226fcd30901b77a8e930ca6280ef71181c67675b4"
492
+ "sha256": "8b93949ce4f671d932c5389050db3a3e2750efcbed69681d69f392ccf4d2a168"
485
493
  },
486
494
  {
487
495
  "path": "templates/.claude/skills/feature-adr/scripts/markdown-masker.mjs",
@@ -989,5 +997,5 @@
989
997
  }
990
998
  ]
991
999
  },
992
- "signature": "Ld7xkqWgfHmJp0Xv+wKYPylP5opGZmCfibsIDam0NS90aZ6R9oLfJVeRljITB25umc7/zHZMW1MWLbMornUgDQ=="
1000
+ "signature": "fTGSL3MpU5nWefu5K27ZehkXuMSbnESMohNdhhWggVsNtHkbsRP+fYgSdHRJEK7Qx86dOYvgywTLcveS5vjZCA=="
993
1001
  }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dzhechko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,9 +1,26 @@
1
1
  # @dzhechkov/keysarium
2
2
 
3
+ Current package version: `1.7.8`. <!-- dz:version -->
4
+
5
+ Site: https://aicoding.space · Source: https://github.com/djd1m/dz-harness/tree/main/packages/@dzhechkov/keysarium
6
+
3
7
  **AI Case Research Toolkit for Claude Code**
4
8
 
5
9
  Full 7-phase pipeline for AI case studies, hackathons, and casariums. Provides skills, commands, rules, shards, agent templates, and brain portability for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
6
10
 
11
+ Bundled feature-adr usage guidance records observed stage metadata/source IDs through existing
12
+ writers and reads explicit Wf/FA project evidence. Unknown dimensions and full prices stay null;
13
+ independent verification, family estimates and billing absence remain visible.
14
+
15
+ The bundled feature-adr Step 8 now carries explicit existing round/run/task identity through its plain
16
+ Claude bridge contract using the execution project. Preflight conflicts refuse before review; matching
17
+ and legacy-window assurance remain distinct. Native Workflow QE does not add a duplicate bridge review.
18
+
19
+ The bundled feature-adr Code module now ships the shared `scripts/build-coder-context.mjs` helper.
20
+ It supplies bounded literal requirements, plan tasks and ADR Decision/Confirmation; plain coding
21
+ reads or embeds its successful block, and Workflow fingerprints current inputs before code reuse.
22
+ Required missing or incomplete context refuses coding. This helper is separate from advisory recall.
23
+
7
24
  ---
8
25
 
9
26
 
@@ -473,3 +490,22 @@ harness-core's `src/markdown-masker.ts`. It runs without a core build. Amendment
473
490
  and K2 share the parser while retaining their existing unclosed-block and indentation policies.
474
491
  The four-space indented-code gap remains open for amendment checks and K2; swarm briefs retain their
475
492
  existing masking of indented code. Versions are unchanged in this staged change.
493
+
494
+ ### Codex companion for feature-adr
495
+
496
+ `dz statusline --watch --project "/path/to/worktree" --brain "/path/to/shared-brain"
497
+ --slug "feature-slug" --run-id "stable-run-id"` adds an explicitly launched adjacent terminal
498
+ companion, including Plain runs. Until installed, invoke the worktree-built
499
+ `node packages/@dzhechkov/harness-cli/dist/bin.js statusline --watch ...`. This extends the existing
500
+ command inventory. Canonical feature-adr guidance supplies the quoted producer/observer recipe: record
501
+ a stable run ID and actual tier at the START of each step, then `done` on real completion; recall/teach
502
+ remain scoped to the shared brain. Project run state and brain counts are separate. One slug retains
503
+ one latest run; report freshness is not process liveness and stage position is not passed gates.
504
+
505
+ The readonly companion requires a dedicated stdout TTY, uses serial 2-second refreshes (0.25–60
506
+ allowed), sanitizes/clips text and handles resize. Below 40x8 it shows a size warning. Ctrl-C/SIGTERM
507
+ exit 0; output failure 1; invalid/piped watch 2. Failed/absent counts and optional values are explicit;
508
+ watch v1 ETA is unavailable and global source inventory omitted. No stdin/raw mode, models, logical
509
+ store writes or automatic terminal/settings changes. Normal SQLite ephemeral WAL/SHM sidecars are
510
+ permitted. One-shot Claude text/JSON/ETA remain unchanged. Codex native footer capability is not
511
+ asserted; parity names manual `dz statusline --watch` access.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/keysarium",
3
- "version": "1.7.6",
3
+ "version": "1.7.8",
4
4
  "scripts": {},
5
5
  "description": "AI Case Research Toolkit for Claude Code — full 7-phase pipeline with feature-adr, dream cycles, background workers, multi-platform support, reward learning, and Cloud.ru AI Factory mapping (--ai-factory flag)",
6
6
  "main": "src/cli.js",
@@ -38,7 +38,7 @@
38
38
  "url": "git+https://github.com/djd1m/dz-harness.git",
39
39
  "directory": "packages/@dzhechkov/keysarium"
40
40
  },
41
- "homepage": "https://github.com/djd1m/dz-harness/tree/main/packages/@dzhechkov/keysarium#readme",
41
+ "homepage": "https://aicoding.space",
42
42
  "bugs": {
43
43
  "url": "https://github.com/djd1m/dz-harness/issues"
44
44
  },
package/sbom.json CHANGED
@@ -9,13 +9,23 @@
9
9
  }
10
10
  },
11
11
  "components": [
12
+ {
13
+ "type": "file",
14
+ "name": "LICENSE",
15
+ "hashes": [
16
+ {
17
+ "alg": "SHA-256",
18
+ "content": "3b93f7070f6843f37e0cae255f7caff22b466a4017fd2020d7d3216237e5a701"
19
+ }
20
+ ]
21
+ },
12
22
  {
13
23
  "type": "file",
14
24
  "name": "README.md",
15
25
  "hashes": [
16
26
  {
17
27
  "alg": "SHA-256",
18
- "content": "3b4f6570d8cebd26429409ed23da99ddd082c378946cf722fe62d5b405e288c2"
28
+ "content": "384c02be209742869d2a88e87746abad359d4099acb0146a68b9b86dd13d47dd"
19
29
  }
20
30
  ]
21
31
  },
@@ -49,7 +59,7 @@
49
59
  },
50
60
  {
51
61
  "name": "dz:canonical-json-sha256-v2",
52
- "value": "67d60a0ad58316ec7c0cd264683d6d17e5fe7f33c746f151b95c0def597037d6"
62
+ "value": "1dd2e14eb1a9f3108c5bf607490bf36127a655cfc8aa89136a2f164332757c6c"
53
63
  }
54
64
  ]
55
65
  },
@@ -869,7 +879,7 @@
869
879
  "hashes": [
870
880
  {
871
881
  "alg": "SHA-256",
872
- "content": "67eb730c815e6b8f9b9b61084c67ceb85440548f00b21f52187d16e81e3d7af8"
882
+ "content": "28c265603481f976a52ea4c627e11194f3c0283801ea93d02cbb9f4248b43c68"
873
883
  }
874
884
  ]
875
885
  },
@@ -959,7 +969,7 @@
959
969
  "hashes": [
960
970
  {
961
971
  "alg": "SHA-256",
962
- "content": "5c8d4c79d5329702b8c75b9afb36ae335aa63c99e74045c0afa98e12658ee975"
972
+ "content": "d7a2a4e56b24451da234b1ac4ef440ba9b0370617e1c0cde8ad5f3d575a53209"
963
973
  }
964
974
  ]
965
975
  },
@@ -969,7 +979,7 @@
969
979
  "hashes": [
970
980
  {
971
981
  "alg": "SHA-256",
972
- "content": "fb054591e86d55cee184eab96b5f0440053ada8fa9edf619e4a66ec5ac371587"
982
+ "content": "db79f8d026cc47edd1e5d2f30d0e1455c55c570dd022d549d440985a3e5940a5"
973
983
  }
974
984
  ]
975
985
  },
@@ -979,7 +989,7 @@
979
989
  "hashes": [
980
990
  {
981
991
  "alg": "SHA-256",
982
- "content": "7973676cc9a5a6d8429c0bcdbe163bb5dc16e2d2f28a72698e44ad72d234e199"
992
+ "content": "d7a967926feec9b0ec173ac7042ae3abd368644d8e1dba9a81a770794417fcb3"
983
993
  }
984
994
  ]
985
995
  },
@@ -1203,13 +1213,23 @@
1203
1213
  }
1204
1214
  ]
1205
1215
  },
1216
+ {
1217
+ "type": "file",
1218
+ "name": "templates/.claude/skills/feature-adr/scripts/build-coder-context.mjs",
1219
+ "hashes": [
1220
+ {
1221
+ "alg": "SHA-256",
1222
+ "content": "19c3991bfb5e88205c9ff6d4d44037c136a190581bab199d3928196e4616fe15"
1223
+ }
1224
+ ]
1225
+ },
1206
1226
  {
1207
1227
  "type": "file",
1208
1228
  "name": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
1209
1229
  "hashes": [
1210
1230
  {
1211
1231
  "alg": "SHA-256",
1212
- "content": "08e69d2e63350fe2dc64483226fcd30901b77a8e930ca6280ef71181c67675b4"
1232
+ "content": "8b93949ce4f671d932c5389050db3a3e2750efcbed69681d69f392ccf4d2a168"
1213
1233
  }
1214
1234
  ]
1215
1235
  },
@@ -562,13 +562,91 @@ only the statusline:
562
562
 
563
563
  **Record the panel at the START of every step, not only at Steps 0/8/9.** The panel shows the last step
564
564
  that reported; a pipeline that reports three times per run shows a stale step for most of its life. Emit
565
- `dz statusline --fa-record --slug <slug> --step "<Step N Name>" --recalled <n> --stored <n>` as the first
565
+ `dz statusline --fa-record --project "<worktree>" --slug "<slug>" --run-id "<stable-run-id>" --tier M --step "<Step N Name>" --recalled <n> --stored <n>` as the first
566
566
  action of each step. The recall/teach counts only change at Steps 0/8/9; the *step label* changes at every
567
567
  one of them.
568
568
 
569
569
  *Honesty note:* the panel is live only insofar as the pipeline records state — it reflects what the
570
570
  pipeline actually did with the loop (recalls that ran, stores that landed), not an aspirational count.
571
571
 
572
+ ### Plain observed usage receipts
573
+
574
+ Use the existing witnessed writer for usage you actually observed. At a real stage boundary,
575
+ capture the execution project, stable run/task, verbatim stage, actual model/family/role, attempt,
576
+ tier/mode and source window/IDs available to this host. Missing fields remain null with their reason;
577
+ do not infer input/cache from total, price from a model family, or tokens from invocation budgets.
578
+
579
+ ```bash
580
+ dz feature-adr-record --kind ledger --stage "$CURRENT_STAGE" --project "$EXECUTION_PROJECT" \
581
+ --row "$OBSERVED_STAGE_ROW_JSON" --rollout-id "$ACTUAL_SESSION_ID" --turn-id "$ACTUAL_TURN_ID" --json
582
+ dz usage --by-stage --project "$EXECUTION_PROJECT" --source fa-ledger --run "$STABLE_RUN_ID" --json
583
+ ```
584
+
585
+ `OBSERVED_STAGE_ROW_JSON` is real host metadata: `runId`, `taskId`, `stage`, `model`, `family`, `role`,
586
+ `attempt`, `tier`, `mode`, `tokens`/dimensions when observed, and optional separate `estimate` with
587
+ tokens/costUsd/method/source/capturedAt. Omit selectors not known; window/cwd/model-only correlation
588
+ is labelled legacy-window and cannot claim exact source verification. Source roots can be named with
589
+ `--codex-sessions`. Exact IDs are validated against existing receipts; no new IDs or recall/teach occur.
590
+ The source receipt subset is captured once; later source append cannot enlarge the old row.
591
+ Captured payload integrity and every pricing-bearing dimension must match the original scoped source.
592
+ A reported monetary amount belongs to one observation, not each expanded token receipt. Preserve an
593
+ actual observation ID/scope/basis in optional `reportedCostObservation: { id, scope, basis }` within
594
+ `--row` when known; otherwise a captured source scope supplies a stable identity and unrelated money
595
+ attribution stays unavailable. Reimports of the same observation count once; conflicting amounts are
596
+ diagnosed. Missing money differs from zero. Exports must avoid every selected authoritative source,
597
+ including custom run directories and symlink aliases. Invalid Claude counters retain nulls/diagnostics;
598
+ a declared invalid total cannot derive a replacement, and accounting validity does not rewrite the
599
+ actual generation outcome.
600
+
601
+ For Wf, use `--source workflow-budget --run <id>` and optional `--run-dir <dir>` for its existing
602
+ budget/trace/state. Auto source collisions require an explicit source; joined Wf summary projections
603
+ never add another cost. Preserve reported total basis and cache/reasoning subsets. Wf budget.spent
604
+ counts dispatch units; native Workflow's existing budget delta is output-only. Neither is raw total.
605
+
606
+ Reports separate conservation, expected inventory and independent amount verification. Without
607
+ a same-scope witness, verified totals are null even when reported values conserve. Unknown rates or
608
+ split keep primary estimated USD null; static family estimates, provider-reported USD and caller
609
+ pre-run estimates are separate, never current exact prices or billed amounts. Billing remains unobserved.
610
+
611
+ ### Codex companion terminal (Plain included)
612
+
613
+ Open an adjacent terminal or a manual tmux split and launch the observer explicitly. Codex does not
614
+ have a dz native command-provider footer. Keep the producer's project, slug and stable run ID equal to
615
+ the observer's; use the actual complexity tier at the START of every active step. Brain is the shared
616
+ learning store for recall/teach, while project is the worktree containing run slots and branch.
617
+
618
+ ```bash
619
+ # Set these to your actual absolute paths. Use the worktree build until the change is installed.
620
+ PANEL_CLI="/path/to/worktree/packages/@dzhechkov/harness-cli/dist/bin.js"
621
+ PANEL_PROJECT="/path/to/worktree"
622
+ PANEL_BRAIN="/path/to/canonical-brain"
623
+ PANEL_SLUG="feature-slug"
624
+ PANEL_RUN="feature-20261002-1" # choose once per invocation, retain at every step
625
+ node "$PANEL_CLI" statusline --watch --project "$PANEL_PROJECT" --brain "$PANEL_BRAIN" \
626
+ --slug "$PANEL_SLUG" --run-id "$PANEL_RUN" --interval 2
627
+ # In the producer terminal, at the START of each real step (example: tier M):
628
+ node "$PANEL_CLI" statusline --fa-record --project "$PANEL_PROJECT" --slug "$PANEL_SLUG" \
629
+ --run-id "$PANEL_RUN" --tier M --step "Step 7 Code" --recalled 3 --stored 0
630
+ # Only after the run actually completes; this is not a claim that QE passed:
631
+ node "$PANEL_CLI" statusline --fa-record --project "$PANEL_PROJECT" --slug "$PANEL_SLUG" \
632
+ --run-id "$PANEL_RUN" --tier M --step "done" --recalled 3 --stored 0
633
+ ```
634
+
635
+ Use `dz recall ... --project "$PANEL_BRAIN"` and `dz teach ... --project "$PANEL_BRAIN"` for actual
636
+ learning. Supply measured cumulative counters, not the example numbers. The observer counts its brain
637
+ source directly; the slot's producer pool is not a shared-brain inventory. One slug holds one latest
638
+ run, so a replacement makes an exact old selection missing. No selectors means visibly automatic
639
+ selection. Freshness measures producer-report age, not process liveness: fresh <30 minutes, stale
640
+ 30–<90, expired >=90; completed remains completed. Stage position is not completed gates.
641
+
642
+ Watch v1 displays ETA unavailable, unknown optional values and unavailable failed/absent learning
643
+ sources, and omits ambiguous global source inventory. It uses escaped ASCII text, a dedicated stdout
644
+ TTY, 0.25–60 second intervals (default 2), and a size warning below 40 columns/8 rows. Ctrl-C/SIGTERM
645
+ exit 0; output failures exit 1; piped output and watch+JSON/install/record combinations exit 2.
646
+ Observation never reads stdin, runs models or writes logical store state. SQLite-managed ephemeral
647
+ WAL/SHM files are permitted; no application locks, repair or schema changes occur. One-shot Claude
648
+ statusline/JSON and its ETA keep their existing behavior. Do not start a terminal automatically.
649
+
572
650
  ### What changes with `--full-qe`
573
651
 
574
652
  Full agentic-qe protocols for the same 9 core skills. No new agents, just deeper methodology.
@@ -235,6 +235,16 @@ C2 recognises JS/TS, pytest, Go, Rust, JVM and .NET test paths, extensible per p
235
235
 
236
236
  Never proceed on a non-zero exit, and never treat empty output as a pass — the last line
237
237
  (`K2 plan-completeness: PASS|FAIL|NOT-ESTABLISHED`) is the verdict, and its absence is not one.
238
+
239
+ **Where an id counts (the default since 2026-09-27, owner decision).** C1 and C8 read the plan's
240
+ TASK LINES only: a heading, a list item or a table row. An `ADR-<n>` or `FR-<n>` that appears only
241
+ in a prose paragraph, a fenced code block (the SPARC-GOAP ```yaml goal state included), an HTML
242
+ comment, the `## Amendments` section or the `EXPECTED_CODE_TARGETS:` block is a mention, not a task,
243
+ and the gate FAILs it with `(cited only outside task lines)`. Write the id on the FIRST line of the
244
+ task that implements it: a wrapped continuation line of a list item is not read either. Measured on the archive when the default changed: 56 of 102 plans that had
245
+ passed would fail this reader, so a plan written before that date may be red on a re-check — move
246
+ the citation onto a task line, or re-check that one plan with `--no-require-task-lines` and say so
247
+ in the checkpoint banner.
238
248
  What it checks: C1 every ADR **decision** (a `# ADR-NNN` / `## ADR-NNN` heading INSIDE the file, not
239
249
  just the filename prefix — a file with several headings owes several plan citations) has a plan task
240
250
  citing it · C2 every ADR Confirmation test path is named in the plan · C3 the `EXPECTED_CODE_TARGETS:`
@@ -26,6 +26,35 @@ opus (complex code generation)
26
26
 
27
27
  ### 1. Pre-Implementation Checklist
28
28
 
29
+ ### Current literal context (plain and delegated coding)
30
+
31
+ Before coding or delegating, run the installed helper beside this module. Set `CONTEXT_HELPER` to
32
+ the absolute `scripts/build-coder-context.mjs` path of the skill installation you are reading;
33
+ set `FEATURE_DIR` to the absolute target `features/<slug>` directory and use the actual tier:
34
+
35
+ ```bash
36
+ node "$CONTEXT_HELPER" "$FEATURE_DIR" --tier=M
37
+ ```
38
+
39
+ The command emits exactly one JSON envelope and exits 0 only for `status: "complete"`. On any
40
+ nonzero exit, unavailable/incomplete status, malformed JSON or required missing/empty section,
41
+ stop before coding and repair the named input. Never paste a partial result as complete. A missing
42
+ helper requires restoring this skill installation, not inventing a replacement block.
43
+
44
+ For every delegated coder assignment, paste the successful envelope's literal `promptBlock` into
45
+ the actual prompt, then append the existing advisory decision-recall block once. Keep the source
46
+ paths below for deeper reading. For single-agent in-session coding, read this generated block
47
+ directly before implementing. Requirements and plan are included in full; each ADR supplies its
48
+ Decision and Confirmation with source labels. M/L/XL require at least one ADR; S can have none.
49
+
50
+ The same canonical helper is used by the programmatic Workflow before code checkpoint lookup.
51
+ It fingerprints full current inputs and binds the prompt separately, so changed documents cannot
52
+ reuse old code. The plain mode boundary is an executable helper command plus these required
53
+ read/embedding instructions; there is no separately automated plain dispatcher. Pure/fixture tests
54
+ do not establish a live model relay's authenticity. Helper bounds are 64 documents, 256 KiB/file,
55
+ 1 MiB read and 96 KiB UTF-8 for the entire labelled block; exceeded bounds refuse without trimming.
56
+ These are document limits, not a new limit on the existing coder wrapper's final prompt.
57
+
29
58
  Before writing any code:
30
59
  - [ ] Read existing similar implementations in codebase
31
60
  - [ ] Identify naming conventions (files, classes, functions, variables)
@@ -23,6 +23,30 @@ sonnet (analytical evaluation)
23
23
 
24
24
  ## Protocol
25
25
 
26
+ ### Bridge identity for plain Step 8
27
+
28
+ When the plain host invokes the existing Claude review bridge, take round number, pipeline run and
29
+ task from the current round receipt/state in the execution project. Pass the fields actually present:
30
+
31
+ ```bash
32
+ dz qe-bridge --family claude --slug "$FEATURE_SLUG" --project "$EXECUTION_PROJECT" \
33
+ --round "$CURRENT_ROUND" --round-run "$CURRENT_ROUND_RUN" --task "$CURRENT_TASK" \
34
+ --coder-family codex
35
+ ```
36
+
37
+ Do not mint missing identifiers or point `--project` at a separate learning brain. Explicit identity
38
+ must agree with one readable open round before any probe or review child; conflicts refuse with exit 2.
39
+ The bridge freezes that snapshot for the signoff. Signoff `roundRun` is the pipeline run; its existing
40
+ `runId` identifies the separate bridge invocation and audit filenames. No-flags standalone reviews
41
+ retain best-effort lookup and visibly unbound provenance when authority is absent or unavailable.
42
+
43
+ Round close validates every present identity field before selection. Full, partial and genuinely
44
+ identity-free window assurance appear as `round-run-task`, `partial-identity` and `legacy-window`.
45
+ Malformed or foreign claims cannot become a manual-close fallback. This contract governs the
46
+ existing plain bridge and control-review caller; native Workflow QE already runs its own review and
47
+ must not invoke a second bridge to manufacture this receipt. Local fake-child tests do not establish
48
+ a live Claude model roundtrip or secure every native Codex QE receipt.
49
+
26
50
  ### 1. Smoke Tests (All tiers)
27
51
 
28
52
  ```
@@ -205,7 +229,9 @@ list), grep for unfinished-stub markers: `TODO` / `FIXME` / `HACK` / `XXX` / `PL
205
229
  file:line, UNLESS the line carries an inline `no-stubs: <reason>` waiver WITH a non-empty reason, or
206
230
  `.dz/guard.json` `stubWaivers` lists the path WITH a reason. A REASONLESS waiver is itself a HIGH gap,
207
231
  never an exemption. Cross-check mechanically: `dz guard check --op publish --json` runs the same scan
208
- as the SOFT `no-stubs` rule over the working-tree diff. When you QUOTE a marker in `08_qe_report.md`,
232
+ as the SOFT `no-stubs` rule over the working-tree diff.
233
+ After the Step-7 code has landed, run `dz guard check --op code --json` and treat a HARD `block` verdict
234
+ as a HIGH finding naming the drifted file. When you QUOTE a marker in `08_qe_report.md`,
209
235
  backtick it so the report itself scans clean (the claim-check forbidden-phrase convention). Record the
210
236
  verdict in the ADR Fitness section.
211
237
 
@@ -311,6 +337,9 @@ Compile all findings into a structured report:
311
337
  ✅ READY FOR MERGE | ❌ NEEDS FIXES | ⚠️ CONDITIONAL APPROVAL
312
338
  ```
313
339
 
340
+ Settle a cross-family re-QE debt with `dz reqe --slug <s> --done --report <08b>`.
341
+ For `dz reqe --done`, exit 3 means the review settled but named BLOCKER/HIGH findings — stop and surface them to the owner.
342
+
314
343
  ### 7.1 Findings ledger (machine-readable)
315
344
 
316
345
  Prose is for people; `dz score`/`dz recap` need a machine-readable surface too (qe-findings-record,
@@ -356,6 +385,45 @@ An empty (header-only) table is read as `hollow: true` — worse than no table a
356
385
  claims a ledger exists and says nothing. If there are no findings, omit the section entirely rather
357
386
  than writing an empty table.
358
387
 
388
+ ### 7.2 Ledger row for this QE step (mandatory)
389
+
390
+ Plain mode (this SKILL, not the ultracode `Workflow`) has no in-process `appendRunCostRow` — without
391
+ this step the QE instrument's own pass leaves ZERO trace in the run-cost ledger (aqe-ledger-row,
392
+ owner audit 17.09, decision #3).
393
+
394
+ **Resume guard — check this FIRST, before writing anything.** If QE was RESTORED from a checkpoint
395
+ for THIS run (`features/<slug>/.fa-state/checkpoints.jsonl` already contains a line for stage `qe`
396
+ that matches this run) — the row for this review was already written by the run that actually did
397
+ it. Do **NOT** run the command below in that case; instead write exactly this line into
398
+ `08_qe_report.md`: `qe ledger row skipped — stage resumed`. Writing the row anyway would double-pay
399
+ the same review in the ledger (aqe-ledger-row fix-round-1/#3, Codex r1 HIGH #3 — plain mode had no
400
+ resume guard at all before this).
401
+
402
+ Otherwise — QE ran fresh in this pass — after `08_qe_report.md` is written and the grade is final, run:
403
+
404
+ ```bash
405
+ dz feature-adr-record --kind ledger --stage qe --slug <slug> --row '<json>' --json
406
+ ```
407
+
408
+ fix-round-1 (Codex r1 HIGH finding 4, ADR-001 D5): no `--auto` here. `--auto` is the trusted marker
409
+ of an AUTOMATED pipeline run and requires an experiment envelope that the manual path does not have;
410
+ this plain-mode row is written without it.
411
+
412
+ `<json>` carries the same fields the ultracode pipeline writes for this row: `reviewer` (the model
413
+ that reviewed, or `null` if unknown — never guessed), `reviewerFamily` (`claude`|`codex`|`null` when
414
+ the reviewer identity is not one of the two known families — never guessed as `claude`), `qeRole`
415
+ (`qe-code-reviewer` for a Claude reviewer; `codex-review`/`codex-exec` for a codex reviewer by scope
416
+ mode; `null` otherwise), `grade`, `gradeSource` (if known), `findings` (gap count), `findingsBySeverity`
417
+ (normalized `sev` -> count, unknown severities counted as `other`, never dropped), `findingsSource`
418
+ (`gaps` or `no-gaps-array`), `claimCheck` (if run), `crossFamily` (bool: reviewer family != coder
419
+ family — `null` when either family is unknown, since a same/cross-family verdict cannot be asserted
420
+ without both), `qeScope` (if the reviewer ran scoped, e.g. codex `mode`/`ref`/`files`). Insert the
421
+ writer's verdict (`{verdict:"written"|...}`) verbatim into `08_qe_report.md` so the record is
422
+ auditable from the artifact itself. NAMED LIMIT (layer 4 of the cost-of-detection ladder, said
423
+ honestly): this step is a skill instruction, not a code gate — nothing forces the agent to run it or
424
+ to check the resume guard first; only its PRESENCE in this module is deterministically pinned (grep
425
+ for the command above and for the resume-guard sentence).
426
+
359
427
  ### 8. QE Pattern Store (Direct Mode only)
360
428
 
361
429
  When `{AGENTIC_QE_MODE}` = `direct` | `direct-extended`, after the gap loop is closed and the verdict is set:
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ // One parser for workflow and plain Step 7. Importing this module performs no IO.
3
+ import { createHash } from 'node:crypto';
4
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync } from 'node:fs';
5
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const SCHEMA = 'fa-coder-context-1';
9
+ const MAX_DOCUMENTS = 64;
10
+ const MAX_FILE_BYTES = 256 * 1024;
11
+ const MAX_TOTAL_BYTES = 1024 * 1024;
12
+ const MAX_PROMPT_BYTES = 96 * 1024;
13
+ const sha = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
14
+ const kindOf = (path) => path === '01_requirements.md' ? 'requirements'
15
+ : path === '06_implementation_plan.md' ? 'plan'
16
+ : /^03_adr\/[0-9]{3}-[^/\\\x00-\x1f]+\.md$/.test(path) ? 'adr' : null;
17
+ const diagnostic = (source, reason, section) => ({ source, reason, severity: 'error', ...(section ? { section } : {}) });
18
+
19
+ // One lexical pass recognizes comments only outside Markdown code/escapes, retaining offsets.
20
+ function markdown(text) {
21
+ const headings = [];
22
+ const visible = [];
23
+ let fence = null;
24
+ let comment = false;
25
+ let inlineEnd = -1;
26
+ let offset = 0;
27
+ for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
28
+ const clean = line.replace(/\r?\n$/, '').replace(/^\uFEFF/, '');
29
+ const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(clean);
30
+ if (fence) {
31
+ if (delimiter && delimiter[1][0] === fence.char && delimiter[1].length >= fence.length && delimiter[2].trim() === '') fence = null;
32
+ else visible.push(line);
33
+ } else if (!comment && inlineEnd <= offset && delimiter && !(delimiter[1][0] === '`' && delimiter[2].includes('`'))) {
34
+ fence = { char: delimiter[1][0], length: delimiter[1].length };
35
+ } else {
36
+ const wasComment = comment;
37
+ let masked = '';
38
+ for (let i = 0; i < line.length;) {
39
+ if (comment) {
40
+ if (line.startsWith('-->', i)) { masked += ' '; i += 3; comment = false; }
41
+ else { masked += /[\r\n]/.test(line[i]) ? line[i] : ' '; i++; }
42
+ continue;
43
+ }
44
+ if (offset + i < inlineEnd) { masked += line[i++]; continue; }
45
+ // An escaped '<' cannot start a comment; paired backslashes still allow the next opener.
46
+ if (line[i] === '\\' && i + 1 < line.length && /[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]/.test(line[i + 1])) {
47
+ masked += line.slice(i, i + 2); i += 2; continue;
48
+ }
49
+ if (line[i] === '`') {
50
+ const run = /^`+/.exec(line.slice(i))[0];
51
+ const rest = text.slice(offset + i + run.length);
52
+ // Code spans can wrap within a paragraph, but cannot hide a subsequent block heading/fence.
53
+ const boundary = /\n[ \t]*(?:\r?\n|#{1,6}[ \t]|`{3,}|~{3,})/.exec(rest);
54
+ const paragraph = boundary ? rest.slice(0, boundary.index) : rest;
55
+ const close = [...paragraph.matchAll(/`+/g)].find((match) => match[0].length === run.length);
56
+ if (close) inlineEnd = offset + i + run.length + close.index + run.length;
57
+ masked += run; i += run.length; continue;
58
+ }
59
+ if (line.startsWith('<!--', i)) { comment = true; masked += ' '; i += 4; continue; }
60
+ masked += line[i++];
61
+ }
62
+ const heading = !wasComment && /^ {0,3}(#{1,6})[ \t]+(.+?)\s*$/.exec(masked.replace(/\r?\n$/, '').replace(/^\uFEFF/, ''));
63
+ if (heading) headings.push({ depth: heading[1].length, title: heading[2].replace(/[ \t]+#+[ \t]*$/, '').trim(), start: offset, body: offset + line.length });
64
+ else visible.push(masked);
65
+ }
66
+ offset += line.length;
67
+ }
68
+ return { headings, unclosed: fence !== null, unclosedComment: comment, substantive: visible.join('').trim() !== '' };
69
+ }
70
+
71
+ /** Pure: consumes already-read documents only, and hashes the same snapshot used for the block. */
72
+ export function buildCoderContext({ tier, documents }) {
73
+ const diagnostics = [];
74
+ const sources = [];
75
+ const blocks = [];
76
+ let bounded = true;
77
+ if (!['S', 'M', 'L', 'XL'].includes(tier)) diagnostics.push(diagnostic('tier', 'invalid-tier'));
78
+ if (!Array.isArray(documents)) documents = [];
79
+ if (documents.length > MAX_DOCUMENTS) { diagnostics.push(diagnostic('documents', 'document-limit')); bounded = false; }
80
+ const sorted = [...documents].sort((a, b) => String(a?.path).localeCompare(String(b?.path), 'en'));
81
+ const seen = new Set();
82
+ let total = 0;
83
+ for (const doc of sorted.slice(0, MAX_DOCUMENTS)) {
84
+ const path = doc?.path;
85
+ const kind = typeof path === 'string' && path.length <= 512 ? kindOf(path) : null;
86
+ if (!kind || typeof doc.text !== 'string') { diagnostics.push(diagnostic(String(path ?? 'documents'), 'invalid-source')); bounded = false; continue; }
87
+ if (seen.has(path)) { diagnostics.push(diagnostic(path, 'duplicate-source')); bounded = false; continue; }
88
+ seen.add(path);
89
+ const bytes = Buffer.byteLength(doc.text, 'utf8'); total += bytes;
90
+ const status = bytes > MAX_FILE_BYTES ? 'oversized' : 'read';
91
+ sources.push({ path, kind, bytes, digest: status === 'read' ? sha(doc.text) : null, status });
92
+ if (status !== 'read') { diagnostics.push(diagnostic(path, 'file-limit')); bounded = false; continue; }
93
+ const parsed = markdown(doc.text);
94
+ if (parsed.unclosed) diagnostics.push(diagnostic(path, 'unclosed-fence'));
95
+ if (parsed.unclosedComment) diagnostics.push(diagnostic(path, 'unclosed-comment'));
96
+ if (kind !== 'adr') {
97
+ if (!parsed.substantive) diagnostics.push(diagnostic(path, 'empty-section', 'body'));
98
+ blocks.push('\n\n### Source: ' + path + ' — body\n' + doc.text);
99
+ continue;
100
+ }
101
+ for (const section of ['Decision', 'Confirmation']) {
102
+ const matches = parsed.headings.filter((h) => h.depth >= 2 && h.title.toLowerCase() === section.toLowerCase());
103
+ if (matches.length !== 1) { diagnostics.push(diagnostic(path, matches.length ? 'duplicate-section' : 'missing-section', section)); continue; }
104
+ const heading = matches[0];
105
+ const next = parsed.headings.find((h) => h.start > heading.start && h.depth <= heading.depth);
106
+ const body = doc.text.slice(heading.body, next ? next.start : doc.text.length);
107
+ if (!markdown(body).substantive) diagnostics.push(diagnostic(path, 'empty-section', section));
108
+ blocks.push('\n\n### Source: ' + path + ' — ' + section + '\n' + body);
109
+ }
110
+ }
111
+ if (total > MAX_TOTAL_BYTES) { diagnostics.push(diagnostic('documents', 'aggregate-limit')); bounded = false; }
112
+ for (const path of ['01_requirements.md', '06_implementation_plan.md']) {
113
+ if (!seen.has(path)) {
114
+ sources.push({ path, kind: kindOf(path), bytes: 0, digest: null, status: 'missing' });
115
+ diagnostics.push(diagnostic(path, 'missing-source'));
116
+ }
117
+ }
118
+ if (tier !== 'S' && !sources.some((s) => s.kind === 'adr')) diagnostics.push(diagnostic('03_adr', 'missing-source'));
119
+ let promptBlock = '\n\n## Current coder context (literal source snapshot)' + blocks.join('');
120
+ if (tier === 'S' && !sources.some((s) => s.kind === 'adr')) promptBlock += '\n\nADR sources: none selected (tier S).';
121
+ if (Buffer.byteLength(promptBlock, 'utf8') > MAX_PROMPT_BYTES) {
122
+ diagnostics.push(diagnostic('promptBlock', 'prompt-limit')); promptBlock = '';
123
+ }
124
+ return { schema: SCHEMA, status: diagnostics.length ? 'incomplete' : 'complete', promptBlock,
125
+ digest: bounded ? sha(JSON.stringify([SCHEMA, tier, sorted.map((d) => [d.path, d.text])])) : null,
126
+ sources: sources.sort((a, b) => a.path.localeCompare(b.path, 'en')), diagnostics };
127
+ }
128
+
129
+ function hostContext(feature, tier) {
130
+ const failures = [];
131
+ const documents = [];
132
+ const failedSources = [];
133
+ let root;
134
+ const fail = (path, reason, status = 'unreadable', bytes = 0) => {
135
+ failures.push(diagnostic(path, reason));
136
+ failedSources.push({ path, kind: kindOf(path) ?? 'adr', bytes, digest: null, status });
137
+ };
138
+ try {
139
+ if (lstatSync(feature).isSymbolicLink() || !lstatSync(feature).isDirectory()) throw new Error('invalid-root');
140
+ root = realpathSync(feature);
141
+ } catch { return { schema: SCHEMA, status: 'unavailable', promptBlock: '', digest: null, sources: [], diagnostics: [diagnostic(feature, 'invalid-root')] }; }
142
+ let paths = ['01_requirements.md', '06_implementation_plan.md'];
143
+ const adrDir = join(root, '03_adr');
144
+ try {
145
+ const stat = lstatSync(adrDir);
146
+ if (stat.isSymbolicLink() || !stat.isDirectory()) fail('03_adr', 'invalid-file', 'invalid');
147
+ else paths.push(...readdirSync(adrDir).filter((name) => /^[0-9]{3}-.+\.md$/.test(name)).sort().map((name) => '03_adr/' + name));
148
+ } catch (error) { if (error.code !== 'ENOENT') fail('03_adr', 'read-failure'); }
149
+ if (paths.length > MAX_DOCUMENTS) return { schema: SCHEMA, status: 'incomplete', promptBlock: '', digest: null, sources: [], diagnostics: [diagnostic('documents', 'document-limit')] };
150
+ let total = 0;
151
+ for (const path of paths) {
152
+ let fd;
153
+ try {
154
+ const file = join(root, path);
155
+ const stat = lstatSync(file);
156
+ if (stat.isSymbolicLink() || !stat.isFile()) { fail(path, 'invalid-file', 'invalid'); continue; }
157
+ const actual = realpathSync(file);
158
+ const rel = relative(root, actual);
159
+ if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) { fail(path, 'outside-root', 'invalid'); continue; }
160
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
161
+ const opened = fstatSync(fd);
162
+ // Compare the opened descriptor with the contained file checked before open.
163
+ if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev || realpathSync(file) !== actual) { fail(path, 'invalid-file', 'invalid'); continue; }
164
+ const chunks = []; let bytes = 0;
165
+ while (true) {
166
+ const chunk = Buffer.alloc(Math.min(16384, MAX_FILE_BYTES + 1 - bytes));
167
+ const count = readSync(fd, chunk, 0, chunk.length, null);
168
+ if (count === 0) break;
169
+ bytes += count; total += count; chunks.push(chunk.subarray(0, count));
170
+ if (bytes > MAX_FILE_BYTES || total > MAX_TOTAL_BYTES) break;
171
+ }
172
+ if (bytes > MAX_FILE_BYTES) { fail(path, 'file-limit', 'oversized', bytes); continue; }
173
+ if (total > MAX_TOTAL_BYTES) { fail(path, 'aggregate-limit', 'oversized', bytes); break; }
174
+ try { documents.push({ path, text: new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)) }); }
175
+ catch { fail(path, 'invalid-utf8', 'invalid', bytes); }
176
+ } catch (error) { fail(path, error.code === 'ENOENT' ? 'missing-source' : 'read-failure', error.code === 'ENOENT' ? 'missing' : 'unreadable'); }
177
+ finally { if (fd !== undefined) closeSync(fd); }
178
+ }
179
+ const result = buildCoderContext({ tier, documents });
180
+ if (failures.length) {
181
+ const failed = new Set(failedSources.map((s) => s.path));
182
+ result.sources = result.sources.filter((s) => !failed.has(s.path)).concat(failedSources).sort((a, b) => a.path.localeCompare(b.path, 'en'));
183
+ result.diagnostics = result.diagnostics.filter((d) => !(failed.has(d.source) && d.reason === 'missing-source')).concat(failures);
184
+ result.status = failures.every((d) => ['file-limit', 'aggregate-limit'].includes(d.reason)) ? 'incomplete' : 'unavailable';
185
+ result.digest = null;
186
+ }
187
+ return result;
188
+ }
189
+
190
+ function main(argv) {
191
+ const feature = argv[0];
192
+ const tier = argv.find((arg) => arg.startsWith('--tier='))?.slice(7);
193
+ let result;
194
+ if (!feature || !['S', 'M', 'L', 'XL'].includes(tier) || argv.some((arg, i) => i > 0 && arg !== '--tier=' + tier)) {
195
+ result = { schema: SCHEMA, status: 'unavailable', promptBlock: '', digest: null, sources: [], diagnostics: [diagnostic('arguments', 'invalid-arguments')] };
196
+ } else result = hostContext(resolve(feature), tier);
197
+ console.log(JSON.stringify(result));
198
+ return result.status === 'complete' ? 0 : 1;
199
+ }
200
+
201
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) process.exitCode = main(process.argv.slice(2));
@@ -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}`);