@event4u/agent-config 14.7.0 → 14.9.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +175 -390
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  6. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  7. package/dist/agent-src/commands/pr/create.md +14 -2
  8. package/dist/agent-src/commands/pr/merge.md +383 -0
  9. package/dist/agent-src/commands/review/changes.md +95 -14
  10. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  15. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  18. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  19. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  20. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  21. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  22. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  23. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  24. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  25. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  27. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  28. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  29. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  30. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  31. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  32. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  33. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  34. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  35. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  36. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  38. package/dist/agent-src/skills/security/SKILL.md +8 -0
  39. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/roadmaps.md +28 -2
  42. package/dist/cli/registry.js +3 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
  45. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  46. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
  47. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  48. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  49. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  50. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  51. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
  52. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  53. package/dist/cli-delegate/cmd_conformance.js +101 -25
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +7 -7
  56. package/dist/cli-delegate/cmd_export.js +6 -6
  57. package/dist/cli-delegate/cmd_handoff.js +7 -6
  58. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  59. package/dist/cli-delegate/cmd_migrate.js +2 -2
  60. package/dist/cli-delegate/cmd_preflight.js +5 -5
  61. package/dist/cli-delegate/cmd_prune.js +6 -6
  62. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  63. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  64. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  65. package/dist/cli-delegate/cmd_settings_get.js +4 -4
  66. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  67. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  68. package/dist/cli-delegate/cmd_sync.js +6 -6
  69. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +6 -6
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +83 -30
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +4 -4
  78. package/dist/discovery/dormancy-report.md +1 -1
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +11 -9
  81. package/dist/discovery/trust-report.md +2 -2
  82. package/dist/discovery/workspaces.json +9 -7
  83. package/dist/hooks/dispatch.js +1295 -812
  84. package/dist/install/globalRuleLayers.js +169 -0
  85. package/dist/install/globalRuleLayers.js.map +1 -0
  86. package/dist/install/partitionEligibility.js +156 -0
  87. package/dist/install/partitionEligibility.js.map +1 -1
  88. package/dist/install/ruleLayerPartition.js +157 -0
  89. package/dist/install/ruleLayerPartition.js.map +1 -0
  90. package/dist/mcp/registry-manifest.json +3 -3
  91. package/dist/mcp/server.json +2 -2
  92. package/dist/mcp/server.mjs +1358 -805
  93. package/dist/scripts/_lib/claude_plugin.js +162 -0
  94. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  95. package/dist/scripts/_lib/environment_detector.js +397 -0
  96. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  97. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  98. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  99. package/dist/scripts/_lib/spawn_env.js +129 -0
  100. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  101. package/dist/scripts/ai_council/modes.js +199 -0
  102. package/dist/scripts/ai_council/modes.js.map +1 -0
  103. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  104. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  105. package/dist/scripts/condense.js +52 -89
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/routes/wizard.js +3 -2
  108. package/dist/server/routes/wizard.js.map +1 -1
  109. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  110. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  111. package/dist/ui/index.html +1 -1
  112. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  113. package/docs/guidelines/agent-infra/false-green.md +47 -1
  114. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  115. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  116. package/package.json +7 -3
  117. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  118. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  119. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  120. package/src/config/ci-local-parity.yml +21 -0
  121. package/src/config/estate-count-budget.json +13 -471
  122. package/src/config/evaluator-budgets.json +4 -4
  123. package/src/config/gate-coverage.yml +458 -12
  124. package/src/config/gate-violation-baselines.json +18 -8
  125. package/src/config/gitignore-block.txt +15 -2
  126. package/src/config/hook-latency-budget.json +5 -0
  127. package/src/config/publish-surface.json +60 -0
  128. package/src/config/requirements-trace-budget.json +32 -0
  129. package/src/config/rule-stub-ceilings.json +25 -4
  130. package/src/install/globalRuleLayers.ts +197 -0
  131. package/src/install/partitionEligibility.ts +177 -0
  132. package/src/install/ruleLayerPartition.ts +212 -0
  133. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  134. package/src/scripts/_dispatch.bash +18 -0
  135. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  136. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  137. package/src/scripts/_lib/base_tree.ts +175 -0
  138. package/src/scripts/_lib/billing_grant.ts +165 -0
  139. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  140. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  141. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  142. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  143. package/src/scripts/_lib/host_capability.ts +59 -5
  144. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  145. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  146. package/src/scripts/_lib/quota_parked.ts +133 -0
  147. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  148. package/src/scripts/_lib/review_telemetry.ts +196 -0
  149. package/src/scripts/_lib/subagent_response.ts +92 -0
  150. package/src/scripts/adr/evidence_census.ts +899 -0
  151. package/src/scripts/adr/regenerate_index.ts +25 -38
  152. package/src/scripts/adr_cite_check.ts +540 -39
  153. package/src/scripts/ai_council/clients.ts +12 -1
  154. package/src/scripts/ai_council/config.ts +72 -115
  155. package/src/scripts/ai_council/fallback_config.ts +19 -7
  156. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  157. package/src/scripts/ai_council/orchestrator.ts +2 -2
  158. package/src/scripts/ai_council/py_format.ts +124 -0
  159. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  160. package/src/scripts/audit_adr_coverage.ts +134 -21
  161. package/src/scripts/bench_hook_latency.ts +204 -0
  162. package/src/scripts/build_archive_index.ts +34 -0
  163. package/src/scripts/check_adr_frontmatter.ts +574 -39
  164. package/src/scripts/check_council_pin_staleness.ts +300 -0
  165. package/src/scripts/check_estate_count.ts +420 -293
  166. package/src/scripts/check_gate_coverage.ts +91 -0
  167. package/src/scripts/check_hook_bundle_content.ts +219 -0
  168. package/src/scripts/check_new_adr_evidence.ts +529 -0
  169. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  170. package/src/scripts/check_pack_size.ts +234 -1
  171. package/src/scripts/check_publish_surface.ts +297 -0
  172. package/src/scripts/check_references.ts +18 -0
  173. package/src/scripts/check_requirements_trace.ts +378 -0
  174. package/src/scripts/check_rule_layer_partition.ts +531 -0
  175. package/src/scripts/check_secret_leak.ts +166 -12
  176. package/src/scripts/check_single_delivery.ts +137 -4
  177. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  178. package/src/scripts/code_graph/cli.ts +44 -5
  179. package/src/scripts/code_graph/extract.ts +87 -0
  180. package/src/scripts/condense.ts +61 -91
  181. package/src/scripts/council_cli.ts +12 -16
  182. package/src/scripts/detect_ai_tells.ts +87 -0
  183. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  184. package/src/scripts/external_sources_denylist.json +4 -1
  185. package/src/scripts/generate_capabilities_index.ts +41 -0
  186. package/src/scripts/git_authorization_hook.ts +54 -4
  187. package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
  188. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  189. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  190. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  191. package/src/scripts/hooks/host_semantics.ts +21 -0
  192. package/src/scripts/hooks/injection_budget.ts +13 -3
  193. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  194. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  195. package/src/scripts/hooks/state_io.ts +88 -1
  196. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  197. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  198. package/src/scripts/injection_scan_hook.ts +257 -15
  199. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  200. package/src/scripts/lint_hook_manifest.ts +49 -0
  201. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  202. package/src/scripts/lint_workflow_security.ts +53 -10
  203. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  204. package/src/scripts/report_envelope_rate.ts +166 -0
  205. package/src/scripts/review_axis_report.ts +218 -0
  206. package/src/scripts/routing_doctor.ts +5 -1
  207. package/src/scripts/run_supervise.ts +46 -1
  208. package/src/scripts/schemas/evals.schema.json +5 -0
  209. package/src/scripts/sync_pr_branch.ts +170 -20
  210. package/dist/cli/commands/commands.candidates.test.js +0 -257
  211. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  212. package/dist/cli/commands/profiles.test.js +0 -88
  213. package/dist/cli/commands/profiles.test.js.map +0 -1
  214. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  215. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  216. package/dist/cli/configRoot.test.js +0 -97
  217. package/dist/cli/configRoot.test.js.map +0 -1
  218. package/dist/cli/mcp/content.test.js +0 -54
  219. package/dist/cli/mcp/content.test.js.map +0 -1
  220. package/dist/cli/mcp/dispatch.test.js +0 -108
  221. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  222. package/dist/server/writeRoot.test.js +0 -91
  223. package/dist/server/writeRoot.test.js.map +0 -1
  224. package/dist/shared/capabilities.test.js +0 -39
  225. package/dist/shared/capabilities.test.js.map +0 -1
  226. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  227. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  228. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  229. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  230. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -0,0 +1,459 @@
1
+ /**
2
+ * One ADR frontmatter reader, for the three call sites that each had their own.
3
+ *
4
+ * Before this file the tree carried three independent parsers:
5
+ * `check_adr_frontmatter.parse_frontmatter` (folded indented continuations into
6
+ * the parent scalar), `adr_cite_check.parse_frontmatter` (the same, plus
7
+ * stripping a bare `>-` fold marker), and `adr/regenerate_index.fm` (a
8
+ * `^([a-z_]+):[ \t]*(.+?)[ \t]*$` regex, so a key with an empty value matched
9
+ * nothing and an indented line matched nothing either).
10
+ *
11
+ * Three readers were survivable while every field was a scalar. `provenance`
12
+ * and `evidence` are nested and carry lists, so the divergence stops being
13
+ * cosmetic: the regex reader would have read both as absent — silently, on the
14
+ * surface that renders the public index — while the other two would have folded
15
+ * them into one unusable string. Adding an axis to three parsers also pays for
16
+ * it three times, which is the reason this extraction lands BEFORE the axes
17
+ * rather than after.
18
+ *
19
+ * ## Scalar compatibility is the contract
20
+ *
21
+ * `scalars` reproduces the previous behaviour key-for-key, including the fold
22
+ * of an indented continuation into its parent and the `>-` strip. That is
23
+ * deliberate: the three call sites read `fm.status`, `fm.review_trigger`,
24
+ * `fm.superseded_by` and friends, and a reader that changed any of those
25
+ * values would be a behaviour change wearing a refactor's clothes.
26
+ *
27
+ * The one intentional difference: a key whose own value is empty and whose
28
+ * indented children look like `key: value` (or `- item`) becomes a **nested**
29
+ * entry and is kept out of `scalars` entirely, rather than folded into the
30
+ * garbage string the old readers produced. Nothing read those strings.
31
+ */
32
+
33
+ /** A frontmatter value that is not a scalar. */
34
+ export type AdrFrontmatterNode = string | string[] | { [key: string]: AdrFrontmatterNode };
35
+
36
+ export interface AdrFrontmatter {
37
+ /**
38
+ * Scalar keys, byte-identical to what the three previous parsers produced
39
+ * for every shape that existed before the nested axes.
40
+ */
41
+ scalars: Record<string, string>;
42
+ /** Keys whose value is a nested map or a list. */
43
+ nested: Record<string, AdrFrontmatterNode>;
44
+ /** Source order of every top-level key, for diagnostics. */
45
+ keyOrder: string[];
46
+ }
47
+
48
+ /** Fold indicators: the value is a multi-line scalar, never a nested map. */
49
+ const FOLD_MARKERS = new Set(['>', '>-', '>+', '|', '|-', '|+']);
50
+
51
+ /**
52
+ * Consume an indented block as prose, with no map/list discrimination.
53
+ *
54
+ * Used whenever the parent carried a fold marker. Routing a folded block
55
+ * through `parseBlock` looked equivalent and was not: real `review_trigger`
56
+ * prose contains colons ("Reopen when an `exec:` claim is found green"), so the
57
+ * map branch claimed the first line and then stopped at the first line without
58
+ * one — truncating four ADRs' triggers to their opening clause. The corpus
59
+ * equivalence test is what caught it; a single-line fixture round-tripped
60
+ * correctly and hid the bug.
61
+ */
62
+ function consumeFolded(lines: Line[], start: number, parentIndent: number): [string, number] {
63
+ const parts: string[] = [];
64
+ let i = start;
65
+ while (i < lines.length) {
66
+ const line = lines[i] as Line;
67
+ if (line.indent <= parentIndent) break;
68
+ parts.push(line.text);
69
+ i += 1;
70
+ }
71
+ return [parts.join(' ').trim(), i];
72
+ }
73
+
74
+ const QUOTED = /^["'](.*)["']$/;
75
+
76
+ function stripQuotes(value: string): string {
77
+ return value.replace(QUOTED, '$1');
78
+ }
79
+
80
+ /** `[a, b, c]` → `['a','b','c']`; a bare `[]` → `[]`. */
81
+ function parseInlineList(raw: string): string[] | null {
82
+ const trimmed = raw.trim();
83
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null;
84
+ const inner = trimmed.slice(1, -1).trim();
85
+ if (inner === '') return [];
86
+ return inner
87
+ .split(',')
88
+ .map((part) => stripQuotes(part.trim()))
89
+ .filter((part) => part !== '');
90
+ }
91
+
92
+ interface Line {
93
+ indent: number;
94
+ text: string;
95
+ }
96
+
97
+ /** Split the `---` block into significant lines, dropping blanks and comments. */
98
+ function significantLines(block: string): Line[] {
99
+ const out: Line[] = [];
100
+ for (const raw of block.split('\n')) {
101
+ const line = raw.replace(/\s+$/, '');
102
+ if (line === '') continue;
103
+ const trimmed = line.trimStart();
104
+ if (trimmed.startsWith('#')) continue;
105
+ out.push({ indent: line.length - trimmed.length, text: trimmed });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Parse one indented block into a map, a list, or a folded scalar.
112
+ *
113
+ * The discriminator is the block's own first line, not the parent key: `- x`
114
+ * opens a list, `k: v` opens a map, anything else is prose to fold. Keying on
115
+ * the parent name would have meant a hard-coded field list, which is the thing
116
+ * three divergent parsers already demonstrated does not survive a new axis.
117
+ */
118
+ function parseBlock(lines: Line[], start: number, parentIndent: number): [AdrFrontmatterNode, number] {
119
+ const first = lines[start];
120
+ if (first === undefined) return ['', start];
121
+
122
+ // A list.
123
+ if (first.text.startsWith('- ') || first.text === '-') {
124
+ const items: string[] = [];
125
+ let i = start;
126
+ while (i < lines.length) {
127
+ const line = lines[i] as Line;
128
+ if (line.indent <= parentIndent || !line.text.startsWith('-')) break;
129
+ items.push(stripQuotes(line.text.replace(/^-\s*/, '').trim()));
130
+ i += 1;
131
+ }
132
+ return [items, i];
133
+ }
134
+
135
+ // A map — every child line at this indent carries a `key:`.
136
+ const colon = first.text.indexOf(':');
137
+ if (colon > 0) {
138
+ const map: Record<string, AdrFrontmatterNode> = {};
139
+ let i = start;
140
+ while (i < lines.length) {
141
+ const line = lines[i] as Line;
142
+ if (line.indent <= parentIndent) break;
143
+ const idx = line.text.indexOf(':');
144
+ if (idx <= 0) break;
145
+ const key = line.text.slice(0, idx).trim();
146
+ const rest = line.text.slice(idx + 1).trim();
147
+ i += 1;
148
+ if (rest === '' || FOLD_MARKERS.has(rest)) {
149
+ const next = lines[i];
150
+ if (next !== undefined && next.indent > line.indent) {
151
+ if (FOLD_MARKERS.has(rest)) {
152
+ const [folded, consumed] = consumeFolded(lines, i, line.indent);
153
+ map[key] = folded;
154
+ i = consumed;
155
+ continue;
156
+ }
157
+ const [child, consumed] = parseBlock(lines, i, line.indent);
158
+ map[key] = child;
159
+ i = consumed;
160
+ continue;
161
+ }
162
+ map[key] = '';
163
+ continue;
164
+ }
165
+ const inline = parseInlineList(rest);
166
+ map[key] = inline ?? stripQuotes(rest);
167
+ }
168
+ return [map, i];
169
+ }
170
+
171
+ // Prose — fold it.
172
+ const parts: string[] = [];
173
+ let i = start;
174
+ while (i < lines.length) {
175
+ const line = lines[i] as Line;
176
+ if (line.indent <= parentIndent) break;
177
+ parts.push(line.text);
178
+ i += 1;
179
+ }
180
+ return [parts.join(' ').trim(), i];
181
+ }
182
+
183
+ /**
184
+ * Flatten a node back to the single string the old fold produced.
185
+ *
186
+ * A list renders as its inline literal (`[a, b]`) rather than as joined items,
187
+ * because that is what the previous parsers put in the scalar map for
188
+ * `protected_dimensions: [purpose]` and the corpus-equivalence test holds this
189
+ * reader to that byte-for-byte.
190
+ */
191
+ function foldToString(node: AdrFrontmatterNode): string {
192
+ if (typeof node === 'string') return node;
193
+ if (Array.isArray(node)) return `[${node.join(', ')}]`;
194
+ return Object.entries(node)
195
+ .map(([k, v]) => `${k}: ${foldToString(v)}`)
196
+ .join(' ')
197
+ .trim();
198
+ }
199
+
200
+ /**
201
+ * Read the leading `---` block of an ADR.
202
+ *
203
+ * Returns `null` when there is no frontmatter at all — which is the honest
204
+ * answer for the seven per-area records, whose metadata lives in a blockquote
205
+ * line instead. `adr_cite_check` already declares them partial coverage for
206
+ * exactly this reason; this reader does not paper over it.
207
+ */
208
+ export function readAdrFrontmatter(text: string): AdrFrontmatter | null {
209
+ if (!text.startsWith('---\n')) return null;
210
+ const end = text.indexOf('\n---\n', 4);
211
+ if (end === -1) return null;
212
+
213
+ const lines = significantLines(text.slice(4, end));
214
+ const scalars: Record<string, string> = {};
215
+ const nested: Record<string, AdrFrontmatterNode> = {};
216
+ const keyOrder: string[] = [];
217
+
218
+ let i = 0;
219
+ while (i < lines.length) {
220
+ const line = lines[i] as Line;
221
+ if (line.indent > 0) {
222
+ // Orphan indented line with no parent — the old readers dropped
223
+ // these too (no key to fold into). Skip rather than invent one.
224
+ i += 1;
225
+ continue;
226
+ }
227
+ const idx = line.text.indexOf(':');
228
+ if (idx <= 0) {
229
+ i += 1;
230
+ continue;
231
+ }
232
+ const key = line.text.slice(0, idx).trim();
233
+ const rest = line.text.slice(idx + 1).trim();
234
+ keyOrder.push(key);
235
+ i += 1;
236
+
237
+ const next = lines[i];
238
+ const hasBlock = next !== undefined && next.indent > 0;
239
+
240
+ if (FOLD_MARKERS.has(rest) && hasBlock) {
241
+ const [folded, consumed] = consumeFolded(lines, i, 0);
242
+ scalars[key] = folded;
243
+ i = consumed;
244
+ continue;
245
+ }
246
+
247
+ if (rest === '' && hasBlock) {
248
+ const [child, consumed] = parseBlock(lines, i, 0);
249
+ i = consumed;
250
+ if (typeof child === 'string') {
251
+ scalars[key] = child;
252
+ } else {
253
+ nested[key] = child;
254
+ }
255
+ continue;
256
+ }
257
+
258
+ const inline = parseInlineList(rest);
259
+ if (inline !== null) {
260
+ nested[key] = inline;
261
+ continue;
262
+ }
263
+
264
+ // A plain scalar with continuation lines — prose, as the old readers
265
+ // treated it. The same colon hazard applies here.
266
+ if (hasBlock) {
267
+ const [folded, consumed] = consumeFolded(lines, i, 0);
268
+ scalars[key] = `${stripQuotes(rest)} ${folded}`.trim();
269
+ i = consumed;
270
+ continue;
271
+ }
272
+ scalars[key] = stripQuotes(rest).replace(/^>-?$/, '');
273
+ }
274
+
275
+ return { scalars, nested, keyOrder };
276
+ }
277
+
278
+ /**
279
+ * The scalar-only view, for the call sites that want exactly what they had.
280
+ *
281
+ * A nested key is rendered back into its folded string here so a caller that
282
+ * only knows about scalars still sees *something* for `provenance` rather than
283
+ * `undefined` — which matters for the index reader, where an absent key and an
284
+ * empty key print differently.
285
+ */
286
+ export function readAdrFrontmatterScalars(text: string): Record<string, string> | null {
287
+ const parsed = readAdrFrontmatter(text);
288
+ if (parsed === null) return null;
289
+ const out: Record<string, string> = { ...parsed.scalars };
290
+ for (const [key, node] of Object.entries(parsed.nested)) {
291
+ out[key] = foldToString(node);
292
+ }
293
+ return out;
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Typed accessors for the two axes. Deliberately tolerant: a census proposal
298
+ // and a hand-authored record both flow through here, and a malformed value has
299
+ // to be reportable rather than fatal.
300
+ // ---------------------------------------------------------------------------
301
+
302
+ export const PROVENANCE_KINDS = ['human', 'agentic', 'mixed', 'unknown'] as const;
303
+ export type ProvenanceKind = (typeof PROVENANCE_KINDS)[number];
304
+
305
+ export const AGENTIC_MODES = ['single', 'council', 'delegated'] as const;
306
+ export const EVIDENCE_STRENGTHS = ['E0', 'E1', 'E2', 'E3', 'E4'] as const;
307
+ export type EvidenceStrength = (typeof EVIDENCE_STRENGTHS)[number];
308
+
309
+ export const DISCOVERY_STATES = ['complete', 'incomplete'] as const;
310
+ export const AUTHORITY_BASES = ['evidence', 'owner_intent'] as const;
311
+
312
+ export interface AdrProvenance {
313
+ kind: string | null;
314
+ decisionMakers: string[];
315
+ humanDirected: string | null;
316
+ agenticMode: string | null;
317
+ }
318
+
319
+ export interface AdrEvidence {
320
+ strength: string | null;
321
+ discovery: string | null;
322
+ basis: string[];
323
+ }
324
+
325
+ function asString(node: AdrFrontmatterNode | undefined): string | null {
326
+ if (typeof node === 'string') return node === '' ? null : node;
327
+ return null;
328
+ }
329
+
330
+ function asList(node: AdrFrontmatterNode | undefined): string[] {
331
+ if (Array.isArray(node)) return node;
332
+ if (typeof node === 'string' && node !== '') return [node];
333
+ return [];
334
+ }
335
+
336
+ export function provenanceOf(fm: AdrFrontmatter): AdrProvenance | null {
337
+ const node = fm.nested.provenance;
338
+ if (node === undefined || typeof node === 'string' || Array.isArray(node)) return null;
339
+ return {
340
+ kind: asString(node.kind),
341
+ decisionMakers: asList(node.decision_makers),
342
+ humanDirected: asString(node.human_directed),
343
+ agenticMode: asString(node.agentic_mode),
344
+ };
345
+ }
346
+
347
+ export function evidenceOf(fm: AdrFrontmatter): AdrEvidence | null {
348
+ const node = fm.nested.evidence;
349
+ if (node === undefined || typeof node === 'string' || Array.isArray(node)) return null;
350
+ return {
351
+ strength: asString(node.strength),
352
+ discovery: asString(node.discovery),
353
+ basis: asList(node.basis),
354
+ };
355
+ }
356
+
357
+ export function authorityBasisOf(fm: AdrFrontmatter): string | null {
358
+ return fm.scalars.authority_basis ?? null;
359
+ }
360
+
361
+ /**
362
+ * Does this record's own metadata say a cite-time reader must NOT treat it as
363
+ * establishing that the alternatives remain invalid?
364
+ *
365
+ * True for an accepted record graded E0 or E1, whatever its provenance.
366
+ *
367
+ * **Provenance was in this predicate and has been removed** (neutral review,
368
+ * 2026-08-21). It required `kind === 'agentic'`, so an identical E0 record made
369
+ * by a human printed no notice at all. That was wrong on the claim's own terms:
370
+ * "this record does not by itself establish that alternatives remain invalid"
371
+ * is a statement about the strength of the EVIDENCE, and a human snapshot is
372
+ * exactly as thin as an agent snapshot. Gating it on who decided also sat one
373
+ * step sideways from `adr-layout`'s own line that a record's historical
374
+ * decision-maker does not determine how it is treated on citation.
375
+ *
376
+ * `authority_basis: owner_intent` is the one exemption, and it is the RIGHT
377
+ * discriminator where provenance was the wrong one. An owner purpose statement
378
+ * is legitimately E0 — that is the honest form — and its alternatives ARE
379
+ * foreclosed, by ownership rather than by evidence. Printing "this does not
380
+ * establish that alternatives remain invalid" over it would be false. The
381
+ * declared field says which kind of authority a record rests on, so it is the
382
+ * field that decides, and the burden table already reads `owner_intent` as
383
+ * binding until the owner changes it.
384
+ *
385
+ * That is also why the earlier provenance gate was subtly wrong rather than
386
+ * simply wrong: it was reaching for this exemption and grabbing a correlate.
387
+ * Most owner-intent records are human-made, so `kind === 'agentic'` approximated
388
+ * it — and silently withheld the notice from every thin HUMAN record that
389
+ * carried no owner claim at all.
390
+ *
391
+ * The name says `lowEvidence` rather than `provisionalAuthority` for a second
392
+ * reason from the same review: ADR-239 and the roadmap both explicitly REJECT
393
+ * the word "provisional" for this output, because it asserts a permission that
394
+ * does not exist. Identifiers carrying the rejected vocabulary would have been
395
+ * the doctrine contradicted in its own implementation.
396
+ *
397
+ * What the caller prints is `authority_effect: disabled-shadow-mode` — a
398
+ * DISABLED effect, not a provisional permission. No grade authorizes anything.
399
+ */
400
+ export function isLowEvidenceAccepted(fm: AdrFrontmatter): boolean {
401
+ if ((fm.scalars.status ?? '') !== 'accepted') return false;
402
+ if (authorityBasisOf(fm) === 'owner_intent') return false;
403
+ const evidence = evidenceOf(fm);
404
+ return evidence?.strength === 'E0' || evidence?.strength === 'E1';
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Index cells. The two nested axes rendered as two flat table cells, in ONE
409
+ // place, because two generated tables print them (`adr/regenerate_index.ts`
410
+ // and `audit_adr_coverage.render_area_readme`) and both are byte-compared by a
411
+ // `--check` gate — a second copy of the em-dash-on-absent convention would be
412
+ // two gates disagreeing about one record.
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /** The placeholder both generated tables already use for an absent scalar. */
416
+ const CELL_ABSENT = '—';
417
+
418
+ /**
419
+ * Reserved row keys the index generators spread over their frontmatter scalars.
420
+ *
421
+ * Named apart from the frontmatter keys they derive from (`provenance`,
422
+ * `evidence`), which `readAdrFrontmatterScalars` also emits as folded strings —
423
+ * a folded `kind: agentic decision_makers: [...]` is not a table cell, and
424
+ * colliding with those keys would put it in one.
425
+ */
426
+ export interface AdrAxisCells {
427
+ provenance_kind: string;
428
+ evidence_grade: string;
429
+ }
430
+
431
+ /**
432
+ * `provenance.kind` and `evidence.strength` as table cells.
433
+ *
434
+ * `discovery` rides in the evidence cell rather than in a third column: a bare
435
+ * `E0` collapses the five states `adr-layout § evidence.discovery` separates,
436
+ * so an index that printed the grade alone would re-create exactly the
437
+ * conflation the axis exists to undo.
438
+ *
439
+ * Most of the corpus carries neither axis and is not expected to — the backfill
440
+ * is deliberately out of this roadmap — so `—` is the common answer and means
441
+ * "not assessed", never "assessed as weak".
442
+ */
443
+ export function readAdrAxisCells(text: string): AdrAxisCells {
444
+ const fm = readAdrFrontmatter(text);
445
+ if (fm === null) return { provenance_kind: CELL_ABSENT, evidence_grade: CELL_ABSENT };
446
+ const provenance = provenanceOf(fm);
447
+ const evidence = evidenceOf(fm);
448
+ const strength = evidence?.strength ?? null;
449
+ const discovery = evidence?.discovery ?? null;
450
+ return {
451
+ provenance_kind: provenance?.kind ?? CELL_ABSENT,
452
+ evidence_grade:
453
+ strength === null
454
+ ? CELL_ABSENT
455
+ : discovery === null
456
+ ? strength
457
+ : `${strength} (${discovery})`,
458
+ };
459
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Materialise one subtree of a git ref into a scratch directory, so a gate can
3
+ * MEASURE the base side with the same function it measures HEAD with.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * A ratchet needs a "before" number the change under review cannot rewrite. The
8
+ * cheapest way to get one is to commit it — and that is what
9
+ * `src/config/estate-count-budget.json` did until ADR-243. A committed number is
10
+ * also a file every branch edits, which made it the most-conflicted non-generated
11
+ * path in this repository: 7 of 7 `CONFLICTING` open PRs on 2026-08-21, and 39 of
12
+ * 43 non-merge commits in a 60-day window moving it.
13
+ *
14
+ * The number does not have to be stored. Where the metric is a function of the
15
+ * tree, the base ref's own tree IS the "before" side — it is committed, and the
16
+ * change under review cannot rewrite it either. What is missing is a way to run
17
+ * a filesystem parser over a ref without checking it out.
18
+ *
19
+ * WHY NOT `git archive`
20
+ * ---------------------
21
+ * Measured, not assumed: `git archive origin/main agents/roadmaps | tar -x`
22
+ * produces an EMPTY archive in this repository, because `.gitattributes` carries
23
+ * `/agents export-ignore` (line 15) and `git archive` honours it. That is correct
24
+ * for the npm tarball it was written for and fatal here, and it fails silently —
25
+ * an empty extraction reads as "the estate measured zero", which is the shape of
26
+ * a floor of 0 that passes everything.
27
+ *
28
+ * `git ls-tree` + `git cat-file --batch` reads the object store directly and
29
+ * ignores export-ignore entirely. Cost, measured on 703 files: 0.22 s, two
30
+ * spawns total.
31
+ *
32
+ * WHY NOT `git worktree add`
33
+ * --------------------------
34
+ * It checks out the whole tree — 9177 files here — for a subtree of 703, and it
35
+ * mutates `.git/worktrees`, so a crashed gate leaves administrative state behind.
36
+ *
37
+ * THE EMPTY CASE IS AN ERROR, NEVER A MEASUREMENT
38
+ * -----------------------------------------------
39
+ * `materialiseSubtree` returns the file count and callers MUST treat 0 as "the
40
+ * base side could not be established", never as a measurement. An absent subtree,
41
+ * a typo in the path, a ref that predates the directory and an export-ignore
42
+ * silently eating the content are indistinguishable from each other and all
43
+ * produce zero — and for a shrink-only gate, a floor of zero is a green light for
44
+ * unbounded growth. This is stated here because the caller cannot see which of
45
+ * those happened either, and the safe reading of all four is the same.
46
+ */
47
+ import { spawnSync } from 'node:child_process';
48
+ import * as fs from 'node:fs';
49
+ import * as os from 'node:os';
50
+ import * as path from 'node:path';
51
+
52
+ /** Ceiling on the blob stream. The roadmap corpus is ~2 MB; this is headroom. */
53
+ const BATCH_MAX_BUFFER = 256 * 1024 * 1024;
54
+
55
+ export interface MaterialisedTree {
56
+ /** Absolute path to a directory that now holds `<subtree>/...` from `ref`. */
57
+ root: string;
58
+ /** How many blobs were written. `0` means the base side is UNAVAILABLE. */
59
+ files: number;
60
+ /** Set when the read failed outright; `files` is then 0. */
61
+ error: string | null;
62
+ }
63
+
64
+ /** One entry of `git ls-tree -r -z`: `<mode> SP <type> SP <sha> TAB <path>`. */
65
+ interface TreeEntry {
66
+ sha: string;
67
+ rel: string;
68
+ }
69
+
70
+ /**
71
+ * Parse `git ls-tree -r -z` output.
72
+ *
73
+ * The `-z` form is used rather than `--format`, which needs git ≥ 2.36: this runs
74
+ * on contributor machines and on runners, and a gate that fails on an older git
75
+ * is a gate that gets turned off.
76
+ */
77
+ function parseLsTree(stdout: string): TreeEntry[] {
78
+ const out: TreeEntry[] = [];
79
+ for (const record of stdout.split('\0')) {
80
+ if (record === '') continue;
81
+ const tab = record.indexOf('\t');
82
+ if (tab === -1) continue;
83
+ const meta = record.slice(0, tab).split(' ');
84
+ const sha = meta[2];
85
+ const type = meta[1];
86
+ const rel = record.slice(tab + 1);
87
+ // Blobs only. A submodule entry is a `commit` and has no content here;
88
+ // writing its sha as a file would be a silent corruption of the measured
89
+ // tree rather than a missing file the caller could notice.
90
+ if (type !== 'blob' || sha === undefined || sha === '' || rel === '') continue;
91
+ out.push({ sha, rel });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Walk a `git cat-file --batch` stream, invoking `sink` per object.
98
+ *
99
+ * Wire format per object: `<sha> SP <type> SP <size> LF`, then exactly `size`
100
+ * bytes, then one LF. Sizes are read from the header rather than inferred from
101
+ * the next header, so a blob containing a line that looks like a header cannot
102
+ * desynchronise the parse — the failure mode that makes a line-oriented reading
103
+ * of this stream wrong.
104
+ */
105
+ function walkBatch(buf: Buffer, sink: (sha: string, body: Buffer) => void): void {
106
+ let pos = 0;
107
+ while (pos < buf.length) {
108
+ const nl = buf.indexOf('\n', pos);
109
+ if (nl === -1) return;
110
+ const header = buf.subarray(pos, nl).toString('utf-8');
111
+ const parts = header.split(' ');
112
+ if (parts.length < 3) return; // `<sha> missing` — a bad ref; stop cleanly.
113
+ const size = Number.parseInt(parts[2] ?? '', 10);
114
+ if (!Number.isFinite(size) || size < 0) return;
115
+ const start = nl + 1;
116
+ sink(parts[0] ?? '', buf.subarray(start, start + size));
117
+ pos = start + size + 1;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Write `<subtree>` as it exists at `ref` into a fresh temp directory.
123
+ *
124
+ * The returned `root` is the caller's to remove — callers hold it in a `finally`.
125
+ * `subtree` is a repo-relative POSIX path (`agents/roadmaps`), because that is
126
+ * what git pathspecs take; a `path.sep`-joined value works on POSIX and silently
127
+ * matches nothing on Windows, so callers pass the POSIX form explicitly.
128
+ */
129
+ export function materialiseSubtree(repoRoot: string, ref: string, subtree: string): MaterialisedTree {
130
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'base-tree-'));
131
+ const list = spawnSync('git', ['ls-tree', '-r', '-z', ref, '--', subtree], {
132
+ cwd: repoRoot,
133
+ encoding: 'utf-8',
134
+ maxBuffer: BATCH_MAX_BUFFER,
135
+ });
136
+ if (list.status !== 0) {
137
+ return { root, files: 0, error: `git ls-tree ${ref} -- ${subtree} failed: ${(list.stderr ?? '').trim()}` };
138
+ }
139
+ const entries = parseLsTree(list.stdout ?? '');
140
+ if (entries.length === 0) {
141
+ return { root, files: 0, error: `${subtree} holds no blobs at ${ref}` };
142
+ }
143
+
144
+ const batch = spawnSync('git', ['cat-file', '--batch'], {
145
+ cwd: repoRoot,
146
+ input: `${entries.map((e) => e.sha).join('\n')}\n`,
147
+ maxBuffer: BATCH_MAX_BUFFER,
148
+ });
149
+ if (batch.status !== 0 || batch.stdout === null) {
150
+ return { root, files: 0, error: `git cat-file --batch failed: ${String(batch.stderr ?? '').trim()}` };
151
+ }
152
+
153
+ // One sha may appear under several paths (identical file content), so the
154
+ // stream is keyed by sha and every path carrying that sha is written. Keying
155
+ // by position would break on exactly that case, and it is not hypothetical:
156
+ // roadmap stubs share boilerplate.
157
+ const bySha = new Map<string, Buffer>();
158
+ walkBatch(batch.stdout, (sha, body) => {
159
+ if (!bySha.has(sha)) bySha.set(sha, body);
160
+ });
161
+
162
+ let files = 0;
163
+ for (const entry of entries) {
164
+ const body = bySha.get(entry.sha);
165
+ if (body === undefined) continue;
166
+ const dest = path.join(root, ...entry.rel.split('/'));
167
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
168
+ fs.writeFileSync(dest, body);
169
+ files += 1;
170
+ }
171
+ if (files !== entries.length) {
172
+ return { root, files: 0, error: `wrote ${String(files)} of ${String(entries.length)} blobs from ${ref}` };
173
+ }
174
+ return { root, files, error: null };
175
+ }