@event4u/agent-config 17.0.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +157 -468
  5. package/README.md +4 -3
  6. package/dist/agent-src/commands/project/analyze.md +9 -6
  7. package/dist/agent-src/commands/project/map.md +58 -0
  8. package/dist/agent-src/commands/project.md +5 -3
  9. package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
  10. package/dist/agent-src/skills/code-intelligence/SKILL.md +6 -0
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/agent-src/templates/roadmaps.md +5 -1
  13. package/dist/cli/registry.js +1 -1
  14. package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
  15. package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
  16. package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
  17. package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
  18. package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
  19. package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
  20. package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
  21. package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
  22. package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +32 -47
  24. package/dist/cli-delegate/cmd_doctor.js +4 -4
  25. package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
  26. package/dist/cli-delegate/cmd_explain.js +1 -1
  27. package/dist/cli-delegate/cmd_export.js +6 -6
  28. package/dist/cli-delegate/cmd_fleet.js +3 -3
  29. package/dist/cli-delegate/cmd_preflight.js +3 -3
  30. package/dist/cli-delegate/cmd_prune.js +6 -6
  31. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  32. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  33. package/dist/cli-delegate/cmd_sync.js +6 -6
  34. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  35. package/dist/cli-delegate/cmd_update.js +9 -13
  36. package/dist/cli-delegate/cmd_validate.js +6 -6
  37. package/dist/discovery/deprecation-report.md +1 -1
  38. package/dist/discovery/discovery-manifest.json +41 -15
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +3 -3
  41. package/dist/discovery/dormancy-report.md +1 -1
  42. package/dist/discovery/orphan-report.md +1 -1
  43. package/dist/discovery/packs.json +6 -5
  44. package/dist/discovery/trust-report.md +2 -2
  45. package/dist/discovery/workspaces.json +5 -4
  46. package/dist/hooks/dispatch.js +163 -148
  47. package/dist/hooks/dispatch.sha256 +1 -1
  48. package/dist/hooks/instructions_observer.chunk.cjs +301 -0
  49. package/dist/hooks/instructions_observer.chunk.cjs.sha256 +1 -0
  50. package/dist/install/claudeRuleRewrite.js +10 -3
  51. package/dist/install/conflict.js +24 -2
  52. package/dist/install/conflictTracker.js +14 -3
  53. package/dist/install/install.mjs +222 -117
  54. package/dist/install/installThinLayer.js +1 -0
  55. package/dist/install/loggedWrites.js +142 -0
  56. package/dist/install/paths.js +12 -2
  57. package/dist/install/preserve.js +25 -4
  58. package/dist/install/txlog.js +10 -6
  59. package/dist/mcp/registry-manifest.json +3 -3
  60. package/dist/mcp/server.json +2 -2
  61. package/dist/mcp/server.mjs +33595 -22916
  62. package/dist/scripts/_lib/egress_rules.js +4 -1
  63. package/dist/scripts/_lib/identifier_redaction.js +35 -2
  64. package/dist/scripts/ai_council/seat_policy.js +7 -1
  65. package/dist/server/schemas/settings.js +1 -1
  66. package/package.json +4 -1
  67. package/src/config/ci-local-parity.yml +1 -1
  68. package/src/config/continuity-surface.json +7 -0
  69. package/src/config/evaluator-budgets.json +4 -4
  70. package/src/config/gate-coverage.yml +27 -7
  71. package/src/config/gate-violation-baselines.json +9 -3
  72. package/src/config/gitignore-block.txt +6 -0
  73. package/src/config/hook-bundle-budget.json +19 -4
  74. package/src/config/hook-token-budget.json +1 -1
  75. package/src/config/packed-binary-manifest.json +27 -0
  76. package/src/config/production-entries.json +18 -0
  77. package/src/config/release-gate-locality.yml +0 -7
  78. package/src/config/skill-body-budget.json +7 -1
  79. package/src/install/claudeRuleRewrite.ts +10 -1
  80. package/src/install/conflict.ts +24 -1
  81. package/src/install/conflictTracker.ts +18 -2
  82. package/src/install/installThinLayer.ts +6 -0
  83. package/src/install/loggedWrites.ts +148 -0
  84. package/src/install/paths.ts +11 -2
  85. package/src/install/preserve.ts +40 -4
  86. package/src/install/txlog.ts +10 -6
  87. package/src/scripts/_cli/cmd_conformance.ts +3 -2
  88. package/src/scripts/_cli/cmd_update.ts +15 -17
  89. package/src/scripts/_lib/bench_ab_complexity.ts +14 -2
  90. package/src/scripts/_lib/collector_record.ts +1 -0
  91. package/src/scripts/_lib/council_settings_block.ts +9 -2
  92. package/src/scripts/_lib/egress_rules.ts +4 -1
  93. package/src/scripts/_lib/host_capability.ts +10 -5
  94. package/src/scripts/_lib/identifier_redaction.ts +38 -2
  95. package/src/scripts/_lib/price_freshness.ts +112 -0
  96. package/src/scripts/_lib/production_reach.ts +194 -0
  97. package/src/scripts/_lib/quota_parked.ts +17 -28
  98. package/src/scripts/_lib/release_findings_ingest.ts +13 -335
  99. package/src/scripts/_lib/runtime_journal.ts +7 -7
  100. package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
  101. package/src/scripts/ai_council/chairman.ts +5 -2
  102. package/src/scripts/ai_council/clients.ts +4 -3
  103. package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
  104. package/src/scripts/ai_council/free_route_catalog.ts +142 -5
  105. package/src/scripts/ai_council/free_route_client.ts +4 -0
  106. package/src/scripts/ai_council/hand_payload.ts +115 -7
  107. package/src/scripts/ai_council/orchestrator.ts +7 -10
  108. package/src/scripts/ai_council/route_chooser.ts +1 -1
  109. package/src/scripts/ai_council/route_member.ts +66 -4
  110. package/src/scripts/ai_council/seat_policy.ts +8 -1
  111. package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
  112. package/src/scripts/ai_council/status_surface.ts +2 -0
  113. package/src/scripts/ask_transport.ts +137 -15
  114. package/src/scripts/check_finding_dispositions.ts +25 -71
  115. package/src/scripts/check_release_pr_shape.ts +3 -3
  116. package/src/scripts/check_step_production_reach.ts +257 -0
  117. package/src/scripts/code_graph/build.ts +210 -10
  118. package/src/scripts/code_graph/cli.ts +168 -46
  119. package/src/scripts/code_graph/detect.ts +5 -4
  120. package/src/scripts/code_graph/docs_layer.ts +57 -0
  121. package/src/scripts/code_graph/extract.ts +58 -13
  122. package/src/scripts/code_graph/extract_more.ts +314 -0
  123. package/src/scripts/code_graph/laravel_overlay.ts +454 -0
  124. package/src/scripts/code_graph/map.ts +846 -0
  125. package/src/scripts/code_graph/map_model.ts +977 -0
  126. package/src/scripts/code_graph/map_verify.ts +140 -0
  127. package/src/scripts/code_graph/map_viewer/viewer.css +278 -0
  128. package/src/scripts/code_graph/map_viewer/viewer.js +756 -0
  129. package/src/scripts/code_graph/scip_reader.ts +201 -0
  130. package/src/scripts/code_graph/sqlite_store.ts +8 -1
  131. package/src/scripts/code_graph/types.ts +81 -15
  132. package/src/scripts/code_graph/validate.ts +16 -26
  133. package/src/scripts/code_graph/verbs.ts +7 -0
  134. package/src/scripts/council_cli.ts +10 -8
  135. package/src/scripts/hook_chunks.mjs +7 -0
  136. package/src/scripts/hook_manifest.json +1 -1
  137. package/src/scripts/hook_manifest.yaml +23 -0
  138. package/src/scripts/hooks/dispatch_hook.ts +4 -4
  139. package/src/scripts/hooks/instructions_observer_hook.ts +25 -6
  140. package/src/scripts/hooks/lazy_concern.ts +4 -0
  141. package/src/scripts/hooks/payload_stub.ts +27 -0
  142. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
  143. package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
  144. package/src/scripts/install-hooks.sh +5 -0
  145. package/src/scripts/install.ts +2 -2
  146. package/src/scripts/lint_agents_layout.ts +2 -0
  147. package/src/scripts/lint_decision_classes.ts +34 -0
  148. package/src/scripts/lint_hook_manifest.ts +25 -0
  149. package/src/scripts/lint_roadmap_blockers.ts +52 -3
  150. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -6
  151. package/src/scripts/mcp_server/graph_tools.ts +73 -1
  152. package/src/scripts/mcp_server/tools.ts +19 -36
  153. package/src/scripts/release.ts +5 -9
  154. package/src/scripts/release_drill.ts +11 -165
  155. package/src/scripts/release_publication.ts +0 -9
  156. package/src/scripts/report_production_reach.ts +35 -0
  157. package/src/scripts/worker_reuse_shadow_report.ts +399 -1
  158. package/src/server/schemas/settings.ts +1 -1
  159. package/src/vendor/grammars/README.md +10 -5
  160. package/src/vendor/grammars/tree-sitter-go.wasm +0 -0
  161. package/src/vendor/grammars/tree-sitter-java.wasm +0 -0
  162. package/src/vendor/grammars/tree-sitter-python.wasm +0 -0
  163. package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
  164. /package/dist/cli-delegate/{chunk-E6XNBVE4.js → chunk-KR5ZIFPO.js} +0 -0
@@ -11,7 +11,7 @@
11
11
  * mutable and unaudited; it is transport, not a record). Every blocking
12
12
  * finding in the ledger (see `isBlocking`) must carry a complete disposition
13
13
  * `{status: fixed|false_positive|accepted_risk, commit (when fixed),
14
- * rationale, verified_by}`; the release workflow is red while one does not.
14
+ * rationale, verified_by}` when the gate is run.
15
15
  *
16
16
  * Modes:
17
17
  * --release X.Y.Z [--dir <ledger dir>] validate the committed ledger
@@ -24,50 +24,28 @@
24
24
  * self_review_gate --findings-out) into the ledger with an empty
25
25
  * disposition, so validation is red until a human fills it.
26
26
  *
27
- * ABSENCE IS NOT EVIDENCE OF ZERO. An absent ledger used to exit 0 for any
28
- * version, so a RELEASED version whose findings were never ingested read as
29
- * "no findings" — the failure this paragraph exists to stop. When the ledger
30
- * file is missing the gate now asks whether the version has SHIPPED:
27
+ * AN ABSENT LEDGER IS NOT A FAILURE (owner, 2026-10-22). A release waits for
28
+ * no review ledger, because every commit on main is already reviewed and
29
+ * merged; the 17.0.0 cut stopped for a day on a ledger whose self-review never
30
+ * finished. No release workflow waits for this check, and an absent ledger
31
+ * passes, shipped or not. The consistency coverage guard still executes it,
32
+ * so any committed ledger must remain valid.
31
33
  *
32
- * released a tag for the version exists locally, or the remote says
33
- * the release exists -> an absent ledger is a FAILURE (1)
34
- * unreleased the local tag list is COMPLETE and lacks it, or the remote
35
- * says so -> normal in-flight state, absent ledger is fine
36
- * undeterminable the tag list cannot settle it and no remote answered ->
37
- * exit 2. Refusing beats passing: a silent green here is the
38
- * very inversion this split exists to remove
39
- *
40
- * THE SOURCE IS THE TAG / RELEASE, AND DELIBERATELY NOT `CHANGELOG.md`. The
41
- * changelog heading is written by the release PR itself — gh pr diff 1836 shows
42
- * "+## [14.16.0](...)" inside the release commit — so on branch release/X the
43
- * changelog claims X is released before X exists. Reading it would redden every
44
- * in-flight release branch over a file nobody could yet have written, which is
45
- * the exact behaviour this split is here to preserve. The tag is created at
46
- * publish, after merge, so it cannot make that mistake.
47
- *
48
- * A local tag MISS is not authoritative on its own: actions/checkout fetches
49
- * depth 1 and no tags by default, and the one workflow that runs this gate does
50
- * not override either, so in CI the local tag list is EMPTY. See releaseStatus
51
- * for the asymmetry and the remote fallback.
52
- *
53
- * AN EMPTY LEDGER MUST SAY WHY IT IS EMPTY. The released/unreleased split above
54
- * closed the missing-file half; it left the other half open, because
55
- * `findings: []` still passed while asserting nothing. A writer could satisfy
34
+ * AN EMPTY LEDGER MUST SAY WHY IT IS EMPTY. A ledger that exists is a record,
35
+ * and `findings: []` used to pass while asserting nothing. A writer could satisfy
56
36
  * the gate by creating an empty file, which is the same silence wearing the
57
37
  * shape of a record. `no_findings_reason` is now required whenever the file
58
38
  * exists and the array is empty — see empty_ledger_problem for what the reason
59
39
  * has to distinguish.
60
40
  *
61
41
  * A future reader changing allowEmpty below is the person this is addressed to:
62
- * the EMPTY_VALID waiver now covers only an EXPLAINED empty ledger and an
63
- * unreleased version. It covers neither a released version with no ledger nor an
64
- * empty ledger with no reason.
42
+ * the EMPTY_VALID waiver covers an absent ledger and an EXPLAINED empty one. It
43
+ * does not cover an empty ledger with no reason.
65
44
  *
66
- * Exit codes: 0 complete · 1 missing/incomplete dispositions (an absent ledger
67
- * for a RELEASED version and an unexplained empty ledger are both among them) ·
68
- * 2 usage error, schema error, or an
69
- * undeterminable release status. Deliberately NOT fail-open: this script never calls a model;
70
- * a broken ledger file is a hard error, not a neutral skip.
45
+ * Exit codes: 0 complete or no ledger · 1 missing/incomplete dispositions (an
46
+ * unexplained empty ledger among them) · 2 usage error or schema error.
47
+ * Deliberately NOT fail-open: this script never calls a model; a broken ledger
48
+ * file is a hard error, not a neutral skip.
71
49
  */
72
50
  import { spawnSync } from 'node:child_process';
73
51
  import * as fs from 'node:fs';
@@ -861,7 +839,7 @@ function main(argv: readonly string[]): number {
861
839
  fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2) + '\n');
862
840
  process.stdout.write(
863
841
  `📥 ingested ${added} new finding(s) into ${path.relative(REPO_ROOT, ledgerPath)} — ` +
864
- 'fill dispositions before the release validation goes green\n',
842
+ 'fill dispositions by hand; no release waits for them\n',
865
843
  );
866
844
  if (carried.length > 0) {
867
845
  process.stdout.write(` carried integrity fields: ${carried.join(', ')}\n`);
@@ -883,30 +861,9 @@ function main(argv: readonly string[]): number {
883
861
  return 0;
884
862
  }
885
863
 
886
- if (!fs.existsSync(ledgerPath)) {
887
- const status = resolve_release_status(release);
888
- if (status === 'released') {
889
- process.stderr.write(
890
- `❌ ${release} has shipped and carries no findings ledger at `
891
- + `${path.relative(REPO_ROOT, ledgerPath)}.\n`
892
- + ' Absence is not evidence of zero — the release PR\'s self-review may have\n'
893
- + ' reported findings that were never ingested. Recover the machine block and:\n'
894
- + ` ./scripts-run src/scripts/check_finding_dispositions --ingest <findings.json> --release ${release}\n`,
895
- );
896
- return 1;
897
- }
898
- if (status === 'undeterminable') {
899
- process.stderr.write(
900
- `❌ cannot determine whether ${release} has shipped: this checkout tag list is`
901
- + ' incomplete (shallow or tagless) and no remote answered.\n'
902
- + ' Refusing to pass on an absent ledger without knowing — that silent green is\n'
903
- + ' the defect this check replaces. Fetch tags (actions/checkout fetch-tags: true)\n'
904
- + ' or make the gh CLI available.\n',
905
- );
906
- return 2;
907
- }
908
- }
909
-
864
+ // An absent ledger is not a failure, shipped or not: a release waits for no
865
+ // review ledger, because everything on main is already reviewed and merged
866
+ // (owner, 2026-10-22). It reads as an empty ledger below.
910
867
  let ledger: Ledger = { schema_version: 1, release, findings: [] };
911
868
  if (fs.existsSync(ledgerPath)) {
912
869
  try {
@@ -965,15 +922,12 @@ function main(argv: readonly string[]): number {
965
922
  units: 'recorded finding(s)',
966
923
  roots: [path.relative(REPO_ROOT, ledgerPath)],
967
924
  allowEmpty:
968
- 'EMPTY_VALID: zero recorded findings IS the pass state for a release whose '
969
- + 'self-review reported none — the ledger file is created on first --ingest, so a '
970
- + 'clean release legitimately has no <version>.json. NARROWED TWICE: it no longer '
971
- + 'covers a RELEASED version with no ledger, which returns 1 above on the '
972
- + 'released/unreleased split, and it no longer covers an empty ledger that does '
973
- + 'not say why it is empty, which returns 1 above via empty_ledger_problem. What '
974
- + 'remains is an EXPLAINED empty ledger and an unreleased version. Un-ingested '
975
- + 'findings on an in-flight release are caught by --pr (unrecorded_findings), '
976
- + 'never by this count.',
925
+ 'EMPTY_VALID: zero recorded findings is a pass. A version with no ledger reads '
926
+ + 'as empty, shipped or not, because a release waits for no review ledger '
927
+ + '(owner, 2026-10-22). It does not cover an existing empty ledger that does not '
928
+ + 'say why it is empty, which returns 1 above via empty_ledger_problem. '
929
+ + 'Un-ingested findings are caught by --pr (unrecorded_findings), never by this '
930
+ + 'count.',
977
931
  });
978
932
  } catch (exc) {
979
933
  if (exc instanceof DeadScopeError) {
@@ -41,9 +41,9 @@ const ALLOWLIST_GLOBS = [
41
41
  'src/domains/*/pack.yaml',
42
42
  'src/domains/*/README.md',
43
43
  'docs/archive/CHANGELOG-pre-*.md',
44
- // Finding-disposition ledger (release-truth Phase 3) — a release PR must
45
- // be able to carry the dispositions of its own self-review findings; the
46
- // finding-dispositions gate is red until blocking ones are recorded here.
44
+ // Finding-disposition ledger — optional, committed by hand; a release PR
45
+ // may carry one. No release workflow waits for it (2026-10-22); the
46
+ // consistency coverage guard still requires any committed ledger to be valid.
47
47
  'agents/evidence/release-findings/*.json',
48
48
  // Evidence-temperature census — `taskfiles/content.yml` § release-prepare
49
49
  // writes one per release, so without this entry the pipeline generates a
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * A step a pull request ticks reaches production (road-to-a-tick-the-shipped-path-earns 3.2).
4
+ *
5
+ * Reads only the PR's own new ticks, the `check_step_receipts` diff walk. For each,
6
+ * the step's SUBJECT modules are the `src/` paths its block names (its receipt's
7
+ * `production code touched:` bound included), and its tests are the test files it
8
+ * names plus each subject's conventional test. Every public symbol such a test
9
+ * takes from a subject must be imported by name somewhere in the production
10
+ * closure (`_lib/production_reach.ts`), or the subject is itself an entry. A
11
+ * `_`-prefixed name is a testing seam and is not counted.
12
+ *
13
+ * A step whose code is built but not wired stays `[ ]` with `built, not wired`; a
14
+ * ticked step carrying that phrase is refused outright. `production entry: <path>`
15
+ * in the block adds an entry for that step. Report mode exits 0 (3.3: one release
16
+ * of shadow first); `--enforce` exits 1 on a finding.
17
+ *
18
+ * Usage:
19
+ * ./scripts-run src/scripts/check_step_production_reach [--base <ref>] [--head <ref>] [--enforce] [--json]
20
+ * ./scripts-run src/scripts/check_step_production_reach --self-test
21
+ *
22
+ * Exit 0 clean or report-only · 1 a finding while enforcing · 2 usage or git error.
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+ import * as path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import {
29
+ commitTreeOf,
30
+ ENTRIES_CONFIG_REL,
31
+ moduleImports,
32
+ productionReach,
33
+ type Reach,
34
+ type ReachTree,
35
+ worktreeOf,
36
+ } from './_lib/production_reach.js';
37
+ import { GateLedger } from './_lib/gate_ledger.js';
38
+ import { reportScanned } from './_lib/scan_scope.js';
39
+
40
+ const _HERE = fileURLToPath(import.meta.url);
41
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
42
+ const ROADMAP_ROOT = 'agents/roadmaps';
43
+ const TICK = /^- \[x\] \*\*([^\s*]+)/;
44
+
45
+ export interface NewTick {
46
+ readonly file: string;
47
+ readonly step: string;
48
+ }
49
+
50
+ /** Steps an added line ticks that no removed line of the same file ticked. */
51
+ export function newTicks(diff: string): NewTick[] {
52
+ const perFile = new Map<string, { added: string[]; removed: Set<string> }>();
53
+ let cur: { added: string[]; removed: Set<string> } | null = null;
54
+ for (const line of diff.split('\n')) {
55
+ if (line.startsWith('+++ ')) {
56
+ const target = line.slice(4).replace(/^b\//, '');
57
+ cur = target === '/dev/null' ? null : { added: [], removed: new Set() };
58
+ if (cur !== null) perFile.set(target, cur);
59
+ continue;
60
+ }
61
+ if (cur === null || line.startsWith('--- ')) continue;
62
+ const id = TICK.exec(line.slice(1))?.[1];
63
+ if (id === undefined) continue;
64
+ if (line.startsWith('+')) cur.added.push(id);
65
+ else if (line.startsWith('-')) cur.removed.add(id);
66
+ }
67
+ const out: NewTick[] = [];
68
+ for (const [file, { added, removed }] of perFile) {
69
+ for (const step of added) if (!removed.has(step)) out.push({ file, step });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** The step's block: its tick line through the line before the next checkbox or heading. */
75
+ export function stepBlock(text: string, step: string): string | null {
76
+ const lines = text.split('\n');
77
+ const start = lines.findIndex((l) => TICK.exec(l)?.[1] === step);
78
+ if (start < 0) return null;
79
+ let end = start + 1;
80
+ while (end < lines.length && !/^\s*- \[[ x~-]\]/.test(lines[end] as string) && !/^#{1,6}\s/.test(lines[end] as string)) end += 1;
81
+ return lines.slice(start, end).join('\n');
82
+ }
83
+
84
+ const PATH_RE = /(?:^|[\s`(:,])((?:src\/|tests\/|_lib\/)[A-Za-z0-9_/.-]+\.ts)\b/g;
85
+
86
+ /** `src/` subjects and test files a block names; `_lib/x.ts` means `src/scripts/_lib/x.ts`. */
87
+ export function namedPaths(tree: ReachTree, block: string): { subjects: string[]; tests: string[]; entries: string[] } {
88
+ const subjects = new Set<string>();
89
+ const tests = new Set<string>();
90
+ for (const m of block.matchAll(PATH_RE)) {
91
+ let p = m[1] as string;
92
+ if (p.startsWith('_lib/')) p = `src/scripts/${p}`;
93
+ if (tree.read(p) === null) continue;
94
+ if (/\.test\.ts$/.test(p)) tests.add(p);
95
+ else if (p.startsWith('src/')) subjects.add(p);
96
+ }
97
+ const entries = [...block.matchAll(/production entry:\s*`?([A-Za-z0-9_/.-]+\.ts)`?/g)].map((m) => m[1] as string);
98
+ // A named test's own module is a subject too: `tests/scripts/_lib_x.test.ts` → `src/scripts/_lib/x.ts`.
99
+ for (const t of tests) {
100
+ const stem = path.posix.basename(t, '.test.ts');
101
+ const own = t.startsWith('src/')
102
+ ? t.replace(/\.test\.ts$/, '.ts')
103
+ : `src/scripts/${stem.startsWith('_lib_') ? `_lib/${stem.slice(5)}` : stem}.ts`;
104
+ if (tree.read(own) !== null) subjects.add(own);
105
+ }
106
+ for (const s of [...subjects]) {
107
+ const dir = path.posix.dirname(s);
108
+ const base = path.posix.basename(s, '.ts');
109
+ const rel = s.replace(/^src\/scripts\//, '').replace(/\.ts$/, '').replace(/\//g, '_');
110
+ for (const c of [`${dir}/${base}.test.ts`, `tests/scripts/${base}.test.ts`, `tests/scripts/${rel}.test.ts`]) {
111
+ if (tree.read(c) !== null) tests.add(c);
112
+ }
113
+ }
114
+ return { subjects: [...subjects].sort(), tests: [...tests].sort(), entries };
115
+ }
116
+
117
+ export interface Finding {
118
+ readonly file: string;
119
+ readonly step: string;
120
+ readonly reason: string;
121
+ }
122
+
123
+ /** The unreached public symbols this block's tests take from its subjects. */
124
+ export function checkStep(tree: ReachTree, reach: Reach, file: string, step: string, block: string): Finding | null {
125
+ // Quoted, the phrase is talk about the rule; bare, it is the step's own status.
126
+ if (/built, not wired/i.test(block.replace(/`[^`]*`/g, ''))) {
127
+ return { file, step, reason: 'ticked while it says `built, not wired` — such a step stays `[ ]`' };
128
+ }
129
+ const { subjects, tests, entries } = namedPaths(tree, block);
130
+ const r = entries.length ? productionReach(tree, entries) : reach;
131
+ const reached = (key: string, mod: string): boolean =>
132
+ r.entries.has(mod) || r.symbols.has(key) || r.symbols.has(`${mod}#*`);
133
+ const missing: string[] = [];
134
+ for (const t of tests) {
135
+ for (const imp of moduleImports(tree, t)) {
136
+ if (!subjects.includes(imp.target)) continue;
137
+ for (const n of imp.names) {
138
+ if (n === '*' || n.startsWith('_')) continue;
139
+ if (!reached(`${imp.target}#${n}`, imp.target)) missing.push(`${imp.target}#${n}`);
140
+ }
141
+ }
142
+ }
143
+ if (missing.length === 0) return null;
144
+ return {
145
+ file,
146
+ step,
147
+ reason: `tested but unreached from any production entry: ${[...new Set(missing)].join(', ')}`,
148
+ };
149
+ }
150
+
151
+ function git(root: string, args: string[]): string | null {
152
+ const res = spawnSync('git', args, { cwd: root, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 });
153
+ return res.status === 0 ? res.stdout : null;
154
+ }
155
+
156
+ export function run(
157
+ root: string,
158
+ base: string,
159
+ head: string | null,
160
+ ledger: GateLedger = new GateLedger('check_step_production_reach'),
161
+ ): { scanned: number; findings: Finding[] } | null {
162
+ const range = head === null ? `${base}...HEAD` : `${base}...${head}`;
163
+ const diff = git(root, ['diff', '-U0', '--find-renames', '--no-color', range, '--', `${ROADMAP_ROOT}/`]);
164
+ if (diff === null) return null;
165
+ const ticks = newTicks(diff);
166
+ // A replayed commit predates the entries config, so the checkout's copy is read over it.
167
+ const config = worktreeOf(root).read(ENTRIES_CONFIG_REL);
168
+ const at = head === null ? worktreeOf(root) : commitTreeOf(root, head);
169
+ const tree: ReachTree = { list: at.list, read: (rel) => (rel === ENTRIES_CONFIG_REL ? config : at.read(rel)) };
170
+ const reach = productionReach(tree);
171
+ const findings: Finding[] = [];
172
+ for (const { file, step } of ticks) {
173
+ const target = `${file}#${step}`;
174
+ ledger.plan(target);
175
+ const block = stepBlock(tree.read(file) ?? '', step);
176
+ if (block === null) {
177
+ ledger.skip(target, 'precondition_unmet');
178
+ continue;
179
+ }
180
+ const f = checkStep(tree, reach, file, step, block);
181
+ if (f === null) ledger.complete(target);
182
+ else {
183
+ ledger.fail(target, f.reason);
184
+ findings.push(f);
185
+ }
186
+ }
187
+ return { scanned: ticks.length, findings };
188
+ }
189
+
190
+ function selfTest(): number {
191
+ // Assembled at run time, so prepack's import scan does not read fixture text as a shipped import.
192
+ const from = (spec: string): string => `from '${spec}';`;
193
+ const files: Record<string, string> = {
194
+ 'src/config/production-entries.json': JSON.stringify({ entries: ['src/main.ts'], entry_sources: [] }),
195
+ 'src/main.ts': `import { wired } ${from('./lib.js')}\nwired();\n`,
196
+ 'src/lib.ts': 'export function wired() {}\nexport function unwired() {}\n',
197
+ 'src/cli.ts': `import { unwired } ${from('./lib.js')}\nunwired();\n`,
198
+ 'tests/lib.test.ts': `import { wired, unwired } ${from('../src/lib.js')}\n`,
199
+ 'tests/wired.test.ts': `import { wired } ${from('../src/lib.js')}\n`,
200
+ };
201
+ const tree: ReachTree = { list: () => Object.keys(files), read: (rel) => files[rel] ?? null };
202
+ const reach = productionReach(tree);
203
+ const failures: string[] = [];
204
+ const unwired = checkStep(tree, reach, 'r.md', '1.1', '- [x] **1.1 x** `src/lib.ts`\n verify: `tests/lib.test.ts`');
205
+ if (unwired === null || !unwired.reason.includes('src/lib.ts#unwired')) failures.push('an unwired symbol did not red');
206
+ if (checkStep(tree, reach, 'r.md', '1.2', '- [x] **1.2 x** `src/lib.ts`\n verify: `tests/wired.test.ts`') !== null) {
207
+ failures.push('a wired symbol red');
208
+ }
209
+ const declared = '- [x] **1.4 x** `src/lib.ts`, production entry: `src/cli.ts`\n verify: `tests/lib.test.ts`';
210
+ if (checkStep(tree, reach, 'r.md', '1.4', declared) !== null) failures.push('a declared production entry was not honoured');
211
+ if (checkStep(tree, reach, 'r.md', '1.3', '- [x] **1.3 x** built, not wired') === null) {
212
+ failures.push('a ticked built-not-wired step did not red');
213
+ }
214
+ if (newTicks('+++ b/r.md\n-- [ ] **1.1 x**\n+- [x] **1.1 x**\n').length !== 1) failures.push('a new tick was not read');
215
+ if (newTicks('+++ b/r.md\n-- [x] **1.1 x**\n+- [x] **1.1 y**\n').length !== 0) failures.push('a reworded tick read as new');
216
+ for (const f of failures) process.stderr.write(`❌ check_step_production_reach --self-test: ${f}\n`);
217
+ if (failures.length === 0) process.stdout.write('✅ check_step_production_reach --self-test: both directions hold\n');
218
+ return failures.length ? 1 : 0;
219
+ }
220
+
221
+ export function main(argv: readonly string[]): number {
222
+ if (argv.includes('--self-test')) return selfTest();
223
+ const value = (flag: string): string | undefined => {
224
+ const i = argv.indexOf(flag);
225
+ return i >= 0 ? argv[i + 1] : undefined;
226
+ };
227
+ const ledger = new GateLedger('check_step_production_reach');
228
+ const result = run(REPO_ROOT, value('--base') ?? 'origin/main', value('--head') ?? null, ledger);
229
+ if (result === null) {
230
+ process.stderr.write('check_step_production_reach: cannot diff the range\n');
231
+ return 2;
232
+ }
233
+ reportScanned({
234
+ gate: 'check_step_production_reach',
235
+ scanned: result.scanned,
236
+ units: 'newly ticked step(s)',
237
+ roots: [`git diff ${value('--base') ?? 'origin/main'}... -- ${ROADMAP_ROOT}/`],
238
+ allowEmpty: 'EMPTY_VALID: the scan root is a DIFF, and most pull requests tick no roadmap step.',
239
+ });
240
+ if (!argv.includes('--json')) ledger.report();
241
+ else ledger.finalize();
242
+ if (argv.includes('--json')) {
243
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
244
+ } else if (result.findings.length === 0) {
245
+ process.stdout.write('✅ check_step_production_reach: every newly ticked step reaches a production entry\n');
246
+ }
247
+ const enforce = argv.includes('--enforce');
248
+ if (!argv.includes('--json')) {
249
+ for (const f of result.findings) process.stdout.write(`${enforce ? '❌' : '⚠️ '} ${f.file}: step ${f.step} — ${f.reason}\n`);
250
+ if (result.findings.length && !enforce) process.stdout.write(' Report only (shadow, step 3.3): nothing is refused yet.\n');
251
+ }
252
+ return enforce && result.findings.length ? 1 : 0;
253
+ }
254
+
255
+ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
256
+ process.exit(main(process.argv.slice(2)));
257
+ }