@event4u/agent-config 14.6.0 → 14.8.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 (258) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +255 -145
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  5. package/dist/agent-src/commands/analyze.md +1 -1
  6. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  7. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  8. package/dist/agent-src/commands/pr/create.md +14 -2
  9. package/dist/agent-src/commands/pr/merge.md +379 -0
  10. package/dist/agent-src/commands/roadmap/process-full.md +272 -32
  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/roadmap-execution-contract.md +95 -2
  15. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
  16. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  17. package/dist/agent-src/rules/context-hygiene.md +2 -0
  18. package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
  19. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  20. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  21. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  22. package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
  23. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  24. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  25. package/dist/agent-src/rules/skill-quality.md +0 -2
  26. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  27. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  28. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  29. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  30. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  31. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  32. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  33. package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
  34. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  35. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  36. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  37. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  38. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  39. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  40. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  41. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  42. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  43. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  44. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  45. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  46. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  47. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  48. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  49. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  50. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  51. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  52. package/dist/agent-src/skills/security/SKILL.md +8 -0
  53. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  54. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  55. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  56. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  57. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  58. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  59. package/dist/agent-src/templates/roadmaps.md +65 -4
  60. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  61. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  62. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  63. package/dist/cli/registry.js +2 -1
  64. package/dist/cli/registry.js.map +1 -1
  65. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  66. package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
  67. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  68. package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
  69. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
  70. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  71. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
  72. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  73. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
  74. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  75. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  76. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
  77. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  78. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  79. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  80. package/dist/cli-delegate/cmd_conformance.js +466 -716
  81. package/dist/cli-delegate/cmd_converge.js +3 -3
  82. package/dist/cli-delegate/cmd_doctor.js +10 -10
  83. package/dist/cli-delegate/cmd_explain.js +1 -1
  84. package/dist/cli-delegate/cmd_export.js +10 -9
  85. package/dist/cli-delegate/cmd_handoff.js +10 -9
  86. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  89. package/dist/cli-delegate/cmd_preflight.js +5 -5
  90. package/dist/cli-delegate/cmd_prune.js +11 -10
  91. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  92. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  93. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  94. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  95. package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
  96. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  97. package/dist/cli-delegate/cmd_sync.js +11 -10
  98. package/dist/cli-delegate/cmd_uninstall.js +13 -11
  99. package/dist/cli-delegate/cmd_update.js +4 -4
  100. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  101. package/dist/cli-delegate/cmd_validate.js +11 -10
  102. package/dist/cli-delegate/cmd_versions.js +3 -3
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +111 -37
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +5 -5
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +15 -12
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +11 -8
  112. package/dist/hooks/dispatch.js +3165 -2192
  113. package/dist/install/claudePathsPlan.js +187 -0
  114. package/dist/install/claudePathsPlan.js.map +1 -0
  115. package/dist/install/globalRuleLayers.js +169 -0
  116. package/dist/install/globalRuleLayers.js.map +1 -0
  117. package/dist/install/hostLayerFingerprint.js +136 -0
  118. package/dist/install/hostLayerFingerprint.js.map +1 -0
  119. package/dist/install/install.mjs +553 -425
  120. package/dist/install/partitionEligibility.js +432 -0
  121. package/dist/install/partitionEligibility.js.map +1 -0
  122. package/dist/install/ruleLayerPartition.js +157 -0
  123. package/dist/install/ruleLayerPartition.js.map +1 -0
  124. package/dist/mcp/registry-manifest.json +3 -3
  125. package/dist/mcp/server.json +2 -2
  126. package/dist/mcp/server.mjs +1863 -1234
  127. package/dist/router.json +1 -1
  128. package/dist/scripts/_lib/agent_src.js +41 -0
  129. package/dist/scripts/_lib/agent_src.js.map +1 -1
  130. package/dist/scripts/_lib/install_layout.js +83 -0
  131. package/dist/scripts/_lib/install_layout.js.map +1 -0
  132. package/dist/scripts/_lib/installed_lock.js +444 -0
  133. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  134. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  135. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  136. package/dist/scripts/condense.js +63 -251
  137. package/dist/scripts/condense.js.map +1 -1
  138. package/dist/server/schemas/settings.js +4 -0
  139. package/dist/server/schemas/settings.js.map +1 -1
  140. package/docs/contracts/settings-classes.md +12 -6
  141. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  142. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  143. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  144. package/docs/guidelines/agent-infra/false-green.md +47 -1
  145. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  146. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  147. package/package.json +1 -1
  148. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  149. package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  150. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  151. package/src/agent-src/scripts/gate_budget.ts +282 -0
  152. package/src/agent-src/scripts/gate_execute.ts +72 -31
  153. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  154. package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
  155. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  156. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  157. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  158. package/src/config/agent-settings.template.yml +27 -2
  159. package/src/config/ci-local-parity.yml +39 -0
  160. package/src/config/estate-count-budget.json +612 -3
  161. package/src/config/evaluator-budgets.json +3 -3
  162. package/src/config/gate-coverage.yml +220 -12
  163. package/src/config/gate-violation-baselines.json +73 -63
  164. package/src/config/gitignore-block.txt +15 -2
  165. package/src/config/hook-latency-budget.json +5 -0
  166. package/src/config/rule-activation-census.json +14 -6
  167. package/src/config/rule-stub-ceilings.json +34 -7
  168. package/src/install/claudePathsPlan.ts +196 -0
  169. package/src/install/globalRuleLayers.ts +197 -0
  170. package/src/install/hostLayerFingerprint.ts +145 -0
  171. package/src/install/partitionEligibility.ts +502 -0
  172. package/src/install/ruleLayerPartition.ts +212 -0
  173. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  174. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  175. package/src/scripts/_dispatch.bash +17 -1
  176. package/src/scripts/_lib/ac_heading.ts +30 -0
  177. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  178. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  179. package/src/scripts/_lib/agent_src.ts +42 -0
  180. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  181. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  182. package/src/scripts/_lib/envelope_grounding.ts +49 -9
  183. package/src/scripts/_lib/installed_lock.ts +50 -5
  184. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  185. package/src/scripts/_lib/md_section.ts +129 -0
  186. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  187. package/src/scripts/_lib/scope_guard.sh +26 -3
  188. package/src/scripts/_lib/self_repair.ts +19 -0
  189. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  190. package/src/scripts/adr/evidence_census.ts +899 -0
  191. package/src/scripts/adr/regenerate_index.ts +12 -32
  192. package/src/scripts/adr_cite_check.ts +540 -39
  193. package/src/scripts/ai_council/cli_help.ts +55 -0
  194. package/src/scripts/audit_adr_coverage.ts +103 -17
  195. package/src/scripts/before_complete_hook.ts +153 -28
  196. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  197. package/src/scripts/bench_hook_latency.ts +204 -0
  198. package/src/scripts/build_archive_index.ts +34 -0
  199. package/src/scripts/check_adr_frontmatter.ts +574 -39
  200. package/src/scripts/check_hook_bundle_content.ts +219 -0
  201. package/src/scripts/check_new_adr_evidence.ts +529 -0
  202. package/src/scripts/check_references.ts +78 -0
  203. package/src/scripts/check_release_published.ts +32 -11
  204. package/src/scripts/check_rule_layer_partition.ts +531 -0
  205. package/src/scripts/check_single_delivery.ts +194 -17
  206. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  207. package/src/scripts/ci_settle.ts +170 -0
  208. package/src/scripts/condense.ts +79 -259
  209. package/src/scripts/council_cli.ts +3 -3
  210. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  211. package/src/scripts/external_sources_denylist.json +4 -1
  212. package/src/scripts/hook_manifest.json +1 -1
  213. package/src/scripts/hook_manifest.yaml +77 -14
  214. package/src/scripts/hooks/block_no_verify.ts +61 -10
  215. package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
  216. package/src/scripts/hooks/concern_registry.ts +4 -0
  217. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  218. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  219. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  220. package/src/scripts/hooks/host_semantics.ts +21 -0
  221. package/src/scripts/hooks/injection_budget.ts +13 -3
  222. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  223. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  224. package/src/scripts/hooks/run_continuation_hook.ts +8 -5
  225. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  226. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  227. package/src/scripts/hooks/state_io.ts +870 -21
  228. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  229. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  230. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  231. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  232. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  233. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  234. package/src/scripts/injection_scan_hook.ts +137 -5
  235. package/src/scripts/install.ts +16 -11
  236. package/src/scripts/language_mirror_hook.ts +561 -25
  237. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  238. package/src/scripts/lint_hook_manifest.ts +49 -0
  239. package/src/scripts/lint_plan_risk_register.ts +4 -3
  240. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  241. package/src/scripts/lint_skill_router_head.ts +4 -4
  242. package/src/scripts/mcp_server/tools.ts +2 -2
  243. package/src/scripts/new_roadmap.ts +136 -0
  244. package/src/scripts/pack_mcp_content.ts +20 -5
  245. package/src/scripts/release.ts +37 -38
  246. package/src/scripts/report_layer_overlap.ts +6 -0
  247. package/src/scripts/routing_doctor.ts +5 -1
  248. package/src/scripts/self_repair_hook.ts +37 -5
  249. package/src/scripts/session_register_hook.ts +11 -0
  250. package/src/scripts/skill_usage_collect.ts +11 -1
  251. package/src/scripts/skill_usage_report.ts +278 -8
  252. package/src/scripts/sync_pr_branch.ts +170 -20
  253. package/src/server/schemas/settings.ts +8 -0
  254. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  255. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  256. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  257. package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
  258. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.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
+ }
@@ -277,10 +277,49 @@ export function _domains_command_logical(p: string): string | null {
277
277
  return `commands/${subpath}.md`;
278
278
  }
279
279
 
280
+ /**
281
+ * Is this domains path inside a `__`-prefixed SCRATCH pack?
282
+ *
283
+ * ## Why this exists — a measured cross-test contamination
284
+ *
285
+ * A pack id is a lowercase slug (`slug_prefix` is validated against
286
+ * `^[a-z][a-z0-9-]*$`), so a directory whose name starts with `__` is not a
287
+ * pack that can ship. It is a test scratch marker, and the walkers must not
288
+ * count it.
289
+ *
290
+ * The defect this closes, measured 2026-08-20 on a full local suite run:
291
+ * `check_artefact_count_messaging`'s "live gate — real repo surfaces" test
292
+ * PASSED in isolation and FAILED in the suite, reporting
293
+ * `commands says 200, expected 207`. The +7 is exact and traceable:
294
+ * `lint_originality.test.ts` writes seven `command.md` files into the REAL tree
295
+ * at `src/domains/__origtest_batch/c1..c7/` (it must — that gate classifies an
296
+ * artefact by its PATH, so a tmpdir would not be a "command" at all), and
297
+ * removes them in `afterEach`. Vitest runs test files in parallel workers, so
298
+ * while those seven exist a concurrent worker counting the live tree sees 207.
299
+ *
300
+ * This is a SCOPE defect, not a flaky test: a gate asserting over the developer's
301
+ * live tree is not hermetic, and the remedy is a fixture boundary rather than a
302
+ * relaxed expectation. The boundary is placed here, at the point where a path
303
+ * becomes a counted artefact, because that is the one place that fixes it for
304
+ * every counter at once — and it weakens nothing: the count of real artefacts is
305
+ * still asserted exactly.
306
+ *
307
+ * Checked before shipping: no pack under `src/domains/` uses a `__` prefix, and
308
+ * `lint_originality` does not consume these walkers (it reads its corpus from
309
+ * the `--changed` paths it is handed), so its own batch test is unaffected.
310
+ */
311
+ function _isScratchPack(domains_root: string, full: string): boolean {
312
+ const rel = path.relative(domains_root, full);
313
+ const first = rel.split(path.sep)[0];
314
+ return first !== undefined && first.startsWith('__');
315
+ }
316
+
280
317
  /**
281
318
  * Yield `[physical_path, logical_relpath]` for every domains command, in
282
319
  * deterministic order. Naturally inert until `src/domains/*\/**\/command.md`
283
320
  * files exist. Mirrors `_iter_domains_commands`.
321
+ *
322
+ * Skips `__`-prefixed scratch packs — see `_isScratchPack`.
284
323
  */
285
324
  export function* _iter_domains_commands(): Generator<[string, string]> {
286
325
  const dom = _roots.SRC_DOMAINS;
@@ -291,6 +330,9 @@ export function* _iter_domains_commands(): Generator<[string, string]> {
291
330
  if (!_isFile(p)) {
292
331
  continue;
293
332
  }
333
+ if (_isScratchPack(dom, p)) {
334
+ continue;
335
+ }
294
336
  const logical = _domains_command_logical(p);
295
337
  if (logical !== null) {
296
338
  yield [p, logical];
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Delta #9 — a corpus task may name an EXTERNAL repository pinned at a SHA.
3
+ *
4
+ * Until this module, every v2 task resolved to a self-contained fixture tree
5
+ * under `internal/bench/ab/`, so the harness could only measure trap tasks this
6
+ * repository authored. Phase 3 of `road-to-solution-minimalism` asks for the
7
+ * opposite: a public repo nobody chose for the purpose, so a size effect cannot
8
+ * be an artefact of fixtures written by the same hand as the rule.
9
+ *
10
+ * ## Why a SHA and not a ref
11
+ *
12
+ * `sha` is a full 40-hex commit id, and a branch or tag name is REFUSED rather
13
+ * than resolved. A ref moves, so a report pinned to one records which repository
14
+ * answered but not which tree — and F7 of that roadmap forbids exactly that
15
+ * class of unreproducible number. The refusal is loud (a throw) because the
16
+ * quiet alternative, resolving the ref at run time, produces a report that looks
17
+ * pinned and is not.
18
+ *
19
+ * ## Materialisation, and what needs the network
20
+ *
21
+ * `ensure_pinned_tree` warms a cache ONCE per SHA — `git init` + a `--depth 1`
22
+ * fetch of that exact commit + `git archive` into a `.git`-free tree. Every
23
+ * per-trial workspace is then a local copy of that tree, so a sweep touches the
24
+ * network once per pinned task and never on the metered path. `is_materialised`
25
+ * answers the warm/cold question without touching the network, which is what
26
+ * lets `--mode selftest` and the offline complexity re-scorer report an honest
27
+ * "not materialised locally" instead of either hanging or scoring zero.
28
+ *
29
+ * The tree is `.git`-free on purpose. `changed_files` already skips `.git`, so
30
+ * carrying it would not corrupt a diff — it would just copy a repository per
31
+ * trial, and a sweep is 5 arms x 3 seeds per task.
32
+ *
33
+ * ## What this module deliberately does NOT do
34
+ *
35
+ * No sub-directory scoping, no submodules, no LFS, no authenticated remotes:
36
+ * `repo` must be an `https://` URL. Each of those is a real feature and none is
37
+ * needed to pin one task to one commit, which is the whole of delta #9.
38
+ */
39
+ import { execFileSync } from 'node:child_process';
40
+ import * as fs from 'node:fs';
41
+ import * as os from 'node:os';
42
+ import * as path from 'node:path';
43
+
44
+ /** A task's pinned-repo coordinates, already validated. */
45
+ export interface PinnedSpec {
46
+ repo: string;
47
+ sha: string;
48
+ }
49
+
50
+ /** A malformed or contradictory pin. Never swallowed into a fixture fallback. */
51
+ export class PinnedRepoError extends Error {}
52
+
53
+ const SHA_RE = /^[0-9a-f]{40}$/;
54
+ const REPO_URL_RE = /^https:\/\/[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+$/;
55
+ const CACHE_ROOT = path.join(os.tmpdir(), 'agent-config-bench-v2-repos');
56
+ /** Written into a warm cache entry so a half-finished fetch never reads as warm. */
57
+ const STAMP = '.pinned-repo.json';
58
+
59
+ type Dict = Record<string, unknown>;
60
+
61
+ /**
62
+ * The pin declared by `task`, or `null` when it is an ordinary fixture task.
63
+ *
64
+ * Throws rather than returning `null` on a task that is *trying* to be pinned
65
+ * and failing: a half-declared pin silently treated as a fixture task is how a
66
+ * sweep would run the wrong tree and report it as the right one.
67
+ */
68
+ export function pinnedSpecFor(task: Dict): PinnedSpec | null {
69
+ const repoRaw = task['repo'];
70
+ const shaRaw = task['sha'];
71
+ const id = task['id'] === undefined ? '<unknown>' : String(task['id']);
72
+ if (repoRaw === undefined && shaRaw === undefined) return null;
73
+ if (repoRaw === undefined || shaRaw === undefined) {
74
+ throw new PinnedRepoError(`task ${id}: \`repo\` and \`sha\` must be declared together`);
75
+ }
76
+ if (task['fixture'] !== undefined) {
77
+ throw new PinnedRepoError(`task ${id}: a pinned task must not also declare \`fixture\``);
78
+ }
79
+ const repo = String(repoRaw);
80
+ const sha = String(shaRaw);
81
+ if (!REPO_URL_RE.test(repo)) {
82
+ throw new PinnedRepoError(`task ${id}: \`repo\` must be an https:// URL, got ${repo}`);
83
+ }
84
+ if (!SHA_RE.test(sha)) {
85
+ throw new PinnedRepoError(
86
+ `task ${id}: \`sha\` must be a full 40-hex commit id, got ${sha} — ` +
87
+ 'a branch or tag is refused because it moves and the report would not be reproducible',
88
+ );
89
+ }
90
+ return { repo, sha };
91
+ }
92
+
93
+ /** Cache directory for one pin. Keyed by the SHA — a commit id is content. */
94
+ export function pinned_tree_dir(spec: PinnedSpec): string {
95
+ return path.join(CACHE_ROOT, spec.sha, 'tree');
96
+ }
97
+
98
+ /** True when this pin's tree is already on disk and complete. */
99
+ export function is_materialised(spec: PinnedSpec): boolean {
100
+ const stampPath = path.join(CACHE_ROOT, spec.sha, STAMP);
101
+ if (!fs.existsSync(stampPath) || !fs.existsSync(pinned_tree_dir(spec))) return false;
102
+ try {
103
+ const stamp = JSON.parse(fs.readFileSync(stampPath, 'utf-8')) as Dict;
104
+ return String(stamp['repo']) === spec.repo && String(stamp['sha']) === spec.sha;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function _git(cwd: string, args: readonly string[]): void {
111
+ execFileSync('git', [...args], { cwd, stdio: 'pipe' });
112
+ }
113
+
114
+ /**
115
+ * Materialise the pinned tree, fetching over the network only on a cold cache.
116
+ *
117
+ * Returns the tree path. A failed fetch leaves NO stamp, so the next call
118
+ * retries from scratch rather than reading a partial checkout as warm.
119
+ */
120
+ export function ensure_pinned_tree(spec: PinnedSpec): string {
121
+ const tree = pinned_tree_dir(spec);
122
+ if (is_materialised(spec)) return tree;
123
+ const base = path.join(CACHE_ROOT, spec.sha);
124
+ fs.rmSync(base, { recursive: true, force: true });
125
+ const gitdir = path.join(base, 'src');
126
+ fs.mkdirSync(gitdir, { recursive: true });
127
+ fs.mkdirSync(tree, { recursive: true });
128
+ _git(gitdir, ['init', '-q', '.']);
129
+ _git(gitdir, ['remote', 'add', 'origin', spec.repo]);
130
+ _git(gitdir, ['fetch', '-q', '--depth', '1', 'origin', spec.sha]);
131
+ const tar = execFileSync('git', ['archive', '--format=tar', 'FETCH_HEAD'], {
132
+ cwd: gitdir,
133
+ maxBuffer: 512 * 1024 * 1024,
134
+ });
135
+ const tarPath = path.join(base, 'tree.tar');
136
+ fs.writeFileSync(tarPath, tar);
137
+ execFileSync('tar', ['-x', '-f', tarPath, '-C', tree], { stdio: 'pipe' });
138
+ fs.rmSync(tarPath, { force: true });
139
+ fs.rmSync(gitdir, { recursive: true, force: true });
140
+ fs.writeFileSync(path.join(base, STAMP), JSON.stringify({ repo: spec.repo, sha: spec.sha }), 'utf-8');
141
+ return tree;
142
+ }
143
+
144
+ /**
145
+ * Absolute path to a task's pristine tree — fixture or pinned repo.
146
+ *
147
+ * `offlineOnly` is for readers that must not reach the network (the offline
148
+ * re-scorer, `--mode selftest`): a cold cache returns `null` there, which every
149
+ * caller reports as "not measured" rather than as a zero.
150
+ */
151
+ export function pristine_tree_for(
152
+ task: Dict,
153
+ opts: { fixturesRoot: string; offlineOnly?: boolean },
154
+ ): string | null {
155
+ const spec = pinnedSpecFor(task);
156
+ if (spec !== null) {
157
+ if (opts.offlineOnly === true) return is_materialised(spec) ? pinned_tree_dir(spec) : null;
158
+ return ensure_pinned_tree(spec);
159
+ }
160
+ if (task['fixture'] === undefined) return null;
161
+ return path.join(opts.fixturesRoot, String(task['fixture']));
162
+ }