@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,165 @@
1
+ /**
2
+ * billing_grant — the run-scoped answer to "plan quota is gone, may I spend?".
3
+ *
4
+ * The problem this closes is a billing-class switch nobody chose at the moment
5
+ * it happened. A council seat on a vendor CLI under a subscription login is
6
+ * unmetered; its api twin is metered USD. Until `api_on_quota: 'ask'` existed
7
+ * that switch was decided once, at configuration time, and then applied
8
+ * silently mid-round — see `transport_resolver.ts` § `FallbackPolicy`.
9
+ *
10
+ * A grant is the human's yes, and it is scoped to ONE run for two reasons.
11
+ *
12
+ * The first is that a run is the unit the question is answerable in. "May I
13
+ * spend money finishing this?" needs the remaining scope in view; a standing
14
+ * setting has no scope to show. The second is the failure a wall-clock TTL
15
+ * produces: the git-guard grant expired after 30 minutes, so a drain outliving
16
+ * its own grant re-asked mid-run — which is the interruption this whole
17
+ * mechanism exists to remove. So the TTL here is the RUN, and `revoke` is
18
+ * called from the path that already clears run-continuation state. A wall
19
+ * clock appears only as a backstop for a run that died without cleaning up.
20
+ *
21
+ * Transport is an env var (`AC_BILLING_GRANT`) plus a file under
22
+ * `agents/runtime/`. Both are needed and neither is redundant: the env var is
23
+ * what a spawned subagent can read without knowing the repo root, and the file
24
+ * is what makes the grant survive a subprocess boundary in the other
25
+ * direction and gives `revoke` something to delete. `hardenedSpawnEnv()`
26
+ * passes the `AC_` family through — verified, and pinned by a test in
27
+ * `tests/scripts/_lib/spawn_env.test.ts`, because a future deny-family entry
28
+ * would otherwise break the chain silently.
29
+ *
30
+ * PII-exclusion-by-construction: a grant record holds a run id, an ISO
31
+ * timestamp, and nothing else. There is no field capable of carrying a prompt,
32
+ * a path, or a credential, so there is no scrubbing pass here that could fail.
33
+ */
34
+
35
+ import * as fs from 'node:fs';
36
+ import * as path from 'node:path';
37
+
38
+ /** Env var the main session sets and every spawned worker reads. */
39
+ export const BILLING_GRANT_ENV = 'AC_BILLING_GRANT';
40
+
41
+ /** Directory, relative to the repo root, holding one file per live grant. */
42
+ export const BILLING_GRANT_DIR_REL = path.join('agents', 'runtime', 'billing-grants');
43
+
44
+ /**
45
+ * Backstop only. A run that exits through its normal path calls `revoke`; this
46
+ * bounds the damage of one that does not, and is deliberately far longer than
47
+ * any run so it can never be the thing that ends a live grant.
48
+ */
49
+ export const BILLING_GRANT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
50
+
51
+ export interface BillingGrant {
52
+ readonly run_id: string;
53
+ readonly issued_at: string;
54
+ }
55
+
56
+ /** Filesystem-safe filename for a run id. Mirrors `run_checkpoint.checkpointFile`. */
57
+ export function grantFile(repoRoot: string, runId: string): string {
58
+ return path.join(
59
+ repoRoot,
60
+ BILLING_GRANT_DIR_REL,
61
+ `${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`,
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Record the human's yes for `runId` and return the env pair to hand to
67
+ * subagents. Idempotent: re-issuing keeps the original `issued_at`, so a
68
+ * second yes inside one run cannot extend the backstop window.
69
+ */
70
+ export function issueBillingGrant(
71
+ repoRoot: string,
72
+ runId: string,
73
+ now: () => Date = () => new Date(),
74
+ ): BillingGrant {
75
+ const file = grantFile(repoRoot, runId);
76
+ const existing = _read(file);
77
+ if (existing !== null && existing.run_id === runId) return existing;
78
+ const grant: BillingGrant = { run_id: runId, issued_at: now().toISOString() };
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ fs.writeFileSync(file, `${JSON.stringify(grant, null, 2)}\n`, 'utf8');
81
+ return grant;
82
+ }
83
+
84
+ /**
85
+ * Is a grant in force for `runId`?
86
+ *
87
+ * Both halves must agree, and the AND is the security property rather than
88
+ * belt-and-braces: an env var alone is settable by anything in the process
89
+ * tree, and a file alone would let a grant from a previous run authorise this
90
+ * one. Requiring the env var to NAME the run whose file exists is what makes
91
+ * a different run's grant unusable.
92
+ */
93
+ export function isBillingGrantActive(
94
+ repoRoot: string,
95
+ runId: string,
96
+ env: NodeJS.ProcessEnv = process.env,
97
+ now: () => Date = () => new Date(),
98
+ ): boolean {
99
+ if ((env[BILLING_GRANT_ENV] ?? '').trim() !== runId) return false;
100
+ const grant = _read(grantFile(repoRoot, runId));
101
+ if (grant === null || grant.run_id !== runId) return false;
102
+ const age = now().getTime() - Date.parse(grant.issued_at);
103
+ return Number.isFinite(age) && age >= 0 && age <= BILLING_GRANT_MAX_AGE_MS;
104
+ }
105
+
106
+ /**
107
+ * Ambient check for a caller that does not carry a run id — the council CLI
108
+ * reading config, for instance. The env var names the run, so the file it
109
+ * names is the one that must exist; there is no "any grant will do" branch.
110
+ */
111
+ export function hasBillingGrant(
112
+ repoRoot: string,
113
+ env: NodeJS.ProcessEnv = process.env,
114
+ now: () => Date = () => new Date(),
115
+ ): boolean {
116
+ const runId = (env[BILLING_GRANT_ENV] ?? '').trim();
117
+ if (runId === '') return false;
118
+ return isBillingGrantActive(repoRoot, runId, env, now);
119
+ }
120
+
121
+ /**
122
+ * Remove the grant for `runId`. Safe to call when none exists.
123
+ *
124
+ * Called from `run_continuation_hook.clearRunState` — the one place a run is
125
+ * declared over, and therefore the grant's real TTL. `clearRunState` is
126
+ * exported for that reason: reaching it through the concern chain would mean
127
+ * standing up the whole dispatch harness to assert one unlink.
128
+ */
129
+ export function revokeBillingGrant(repoRoot: string, runId: string): void {
130
+ try {
131
+ fs.unlinkSync(grantFile(repoRoot, runId));
132
+ } catch {
133
+ // Already gone is the success case, not an error worth surfacing.
134
+ }
135
+ }
136
+
137
+ function _read(file: string): BillingGrant | null {
138
+ try {
139
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as unknown;
140
+ if (raw === null || typeof raw !== 'object') return null;
141
+ const o = raw as Record<string, unknown>;
142
+ if (typeof o.run_id !== 'string' || typeof o.issued_at !== 'string') return null;
143
+ return { run_id: o.run_id, issued_at: o.issued_at };
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The id of the run a grant would be scoped to.
151
+ *
152
+ * Resolution order, first non-empty wins: `AC_BILLING_GRANT` (a grant already
153
+ * in force names its own run), then `AC_RUN_ID` (what an orchestrating session
154
+ * sets), then the host's session id. Returns `null` when none resolves — and
155
+ * `null` is rendered as a literal placeholder rather than a fabricated id,
156
+ * because a grant command carrying an invented run id would be a command that
157
+ * silently authorises nothing.
158
+ */
159
+ export function currentRunId(env: NodeJS.ProcessEnv = process.env): string | null {
160
+ for (const key of [BILLING_GRANT_ENV, 'AC_RUN_ID', 'CLAUDE_CODE_SESSION_ID']) {
161
+ const v = (env[key] ?? '').trim();
162
+ if (v !== '') return v;
163
+ }
164
+ return null;
165
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The `council:{grant-billing,revoke-billing}` verbs, and the round's closing
3
+ * Human Gate line.
4
+ *
5
+ * Lives here rather than in `council_cli.ts` for the reason that file's other
6
+ * extractions give: it is ~2,500 lines over the source ceiling and the
7
+ * documented remedy is extraction, not a raised baseline
8
+ * (`gate-violation-baselines.json` § check_source_size_budget).
9
+ *
10
+ * IO is injected rather than imported so this module stays testable without a
11
+ * process: `council_cli.ts` owns the repo root and the stdout/stderr pair.
12
+ */
13
+
14
+ import {
15
+ BILLING_GRANT_ENV,
16
+ currentRunId,
17
+ hasBillingGrant,
18
+ issueBillingGrant,
19
+ revokeBillingGrant,
20
+ } from './billing_grant.js';
21
+ import { renderBillingGateLines } from './council_fallback_posture.js';
22
+ import { writeQuotaParked } from './quota_parked.js';
23
+
24
+ export interface BillingCliDeps {
25
+ readonly repoRoot: string;
26
+ readonly stdout: (s: string) => void;
27
+ readonly stderr: (s: string) => void;
28
+ }
29
+
30
+ /** The verbs this module owns, spread into `council_cli`'s subcommand list. */
31
+ export const BILLING_SUBCOMMANDS = ['grant-billing', 'revoke-billing'] as const;
32
+
33
+ /**
34
+ * The arg spec for a billing verb, or `null` when `cmd` is not one.
35
+ *
36
+ * Consulted from `council_cli`'s `default:` arm rather than added as two more
37
+ * `case` labels there: the verb list already lives in this module, and a
38
+ * second copy of it in a switch is a copy that can drift.
39
+ */
40
+ export function billingArgSpec(
41
+ cmd: string,
42
+ ): { positionals: string[]; opts: never[]; requiredOpts: never[] } | null {
43
+ return (BILLING_SUBCOMMANDS as readonly string[]).includes(cmd)
44
+ ? { positionals: ['run_id'], opts: [], requiredOpts: [] }
45
+ : null;
46
+ }
47
+
48
+ /**
49
+ * Dispatch a billing verb, or return `null` when `cmd` is not one.
50
+ *
51
+ * A `null` return rather than a thrown or a boolean out-param: the caller's
52
+ * dispatch chain is a run of `if (cmd === x) return f()`, and this has to slot
53
+ * into it as two lines without teaching the caller which verbs exist.
54
+ */
55
+ export function handleBillingCommand(
56
+ cmd: string,
57
+ runId: unknown,
58
+ d: BillingCliDeps,
59
+ ): number | null {
60
+ if (cmd === 'grant-billing') return grantBilling(String(runId ?? ''), d);
61
+ if (cmd === 'revoke-billing') return revokeBilling(String(runId ?? ''), d);
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Record the human's yes for one run.
67
+ *
68
+ * Writes the grant file and then prints the export line, because the file
69
+ * alone is not the grant: a grant is in force only when `AC_BILLING_GRANT`
70
+ * NAMES the run whose file exists (see `isBillingGrantActive`), and that AND
71
+ * is what stops a previous run's grant from authorising this one. Printing the
72
+ * export rather than setting it is not a limitation being apologised for — a
73
+ * process cannot set its parent's environment, and a command claiming to would
74
+ * be lying.
75
+ */
76
+ export function grantBilling(runId: string, d: BillingCliDeps): number {
77
+ if (runId.trim() === '') {
78
+ d.stderr('❌ council:grant-billing: a run id is required.\n');
79
+ return 2;
80
+ }
81
+ const g = issueBillingGrant(d.repoRoot, runId.trim());
82
+ d.stdout(`✅ billing grant recorded for run ${g.run_id} (issued ${g.issued_at})\n`);
83
+ d.stdout(` export ${BILLING_GRANT_ENV}=${g.run_id}\n`);
84
+ const live = hasBillingGrant(d.repoRoot) ? 'yes' : 'not until the export above is set';
85
+ d.stdout(` in force: ${live}\n`);
86
+ return 0;
87
+ }
88
+
89
+ /**
90
+ * End the grant. Exit 0 on an absent one: "there is no grant for this run" is
91
+ * the state the caller asked for, and failing on it would make the run-end
92
+ * cleanup path noisy on exactly the runs that never needed a grant.
93
+ */
94
+ export function revokeBilling(runId: string, d: BillingCliDeps): number {
95
+ if (runId.trim() === '') {
96
+ d.stderr('❌ council:revoke-billing: a run id is required.\n');
97
+ return 2;
98
+ }
99
+ revokeBillingGrant(d.repoRoot, runId.trim());
100
+ d.stdout(`✅ billing grant revoked for run ${runId.trim()}\n`);
101
+ return 0;
102
+ }
103
+
104
+ /**
105
+ * Print the round's closing question when `api_on_quota: 'ask'` parked seats.
106
+ * Silent when nothing parked, so the caller needs no branch.
107
+ */
108
+ export function printBillingGate(parked: readonly string[], d: BillingCliDeps): void {
109
+ const runId = currentRunId();
110
+ // Record before rendering. A parked round is the one moment this repository
111
+ // KNOWS plan quota is exhausted without needing a host signal, and the
112
+ // printed line reaches whoever is watching the terminal — which for an
113
+ // autonomous drain is nobody. The marker is what survives to the next
114
+ // `run:supervise` report. Skipped when no run id resolves: a marker with a
115
+ // fabricated key is worse than none, because nothing would ever match it.
116
+ if (parked.length > 0 && runId !== null) writeQuotaParked(d.repoRoot, runId, parked);
117
+ for (const line of renderBillingGateLines(parked, {
118
+ runId: runId ?? '<run-id>',
119
+ estimatedUsd: null,
120
+ })) {
121
+ d.stdout(`${line}\n`);
122
+ }
123
+ }
@@ -13,6 +13,8 @@
13
13
  * `council:status` is the one command that must stay free of side effects.
14
14
  */
15
15
 
16
+ import type { ApiOnQuota } from '../ai_council/transport_resolver.js';
17
+
16
18
  export type FallbackPosture = 'api' | 'none' | 'n/a';
17
19
 
18
20
  export interface PostureInput {
@@ -59,7 +61,7 @@ export function fallbackPostureFor(input: PostureInput): FallbackPosture {
59
61
  */
60
62
  export function renderPostureLines(
61
63
  postures: ReadonlyArray<readonly [string, FallbackPosture]>,
62
- apiOnQuota: boolean,
64
+ apiOnQuota: ApiOnQuota,
63
65
  ): string[] {
64
66
  const live = postures.filter(([, p]) => p !== 'n/a');
65
67
  if (live.length === 0) return [];
@@ -72,10 +74,55 @@ export function renderPostureLines(
72
74
  );
73
75
  // Separate from the per-seat lines: not a per-seat property but the one
74
76
  // billing-class decision, governing every seat at once.
75
- lines.push(
76
- ` fallback quota api_on_quota: ${apiOnQuota ? 'on' : 'off'}` +
77
- ` — an exhausted cli quota ${apiOnQuota ? 'MAY' : 'may not'}` +
78
- ' retry on the metered rung',
79
- );
77
+ // Three postures, rendered by name rather than by truthiness. `'ask'` is
78
+ // truthy, so an `apiOnQuota ? 'on' : 'off'` reading would print `on` for
79
+ // it — telling the operator that quota fall-through happens silently when
80
+ // the whole point of the value is that it does not.
81
+ const word = apiOnQuota === 'ask' ? 'ask' : apiOnQuota ? 'on' : 'off';
82
+ const consequence =
83
+ apiOnQuota === 'ask'
84
+ ? ' — an exhausted cli quota PARKS the seat and the round closes with a question,' +
85
+ ' unless a run-scoped billing grant is already in force'
86
+ : apiOnQuota
87
+ ? ' — an exhausted cli quota MAY retry on the metered rung'
88
+ : ' — an exhausted cli quota may not retry on the metered rung';
89
+ lines.push(` fallback quota api_on_quota: ${word}${consequence}`);
80
90
  return lines;
81
91
  }
92
+
93
+ /**
94
+ * The Human Gate a round closes with when `api_on_quota: 'ask'` parked seats.
95
+ *
96
+ * A correction to this roadmap step, recorded rather than quietly absorbed:
97
+ * the step said to render this "through the existing `renderPostureLines`
98
+ * path". That path is `council:status`, which runs before any round and can
99
+ * therefore never know which seats a round parked. So this is a sibling in the
100
+ * same module rather than a branch inside that function — same place, same
101
+ * vocabulary, different moment.
102
+ *
103
+ * Returns `[]` when nothing parked, so a caller may render unconditionally
104
+ * without a heading over an empty list.
105
+ */
106
+ export function renderBillingGateLines(
107
+ parked: readonly string[],
108
+ opts: { readonly runId: string; readonly estimatedUsd: number | null },
109
+ ): string[] {
110
+ if (parked.length === 0) return [];
111
+ const seats = [...new Set(parked)].sort();
112
+ const cost =
113
+ opts.estimatedUsd === null
114
+ ? 'unknown — no priced estimate for the retry'
115
+ : `~$${opts.estimatedUsd.toFixed(2)}`;
116
+ return [
117
+ '',
118
+ 'HUMAN GATE — plan quota exhausted, metered spend not authorised',
119
+ ` parked seats ${seats.join(', ')}`,
120
+ ` retry cost ${cost}`,
121
+ // The command is printed rather than described because the whole
122
+ // mechanism fails if answering it takes a lookup: an operator who has
123
+ // to go find the syntax is an operator who says yes tomorrow, on a run
124
+ // that has already ended.
125
+ ` to authorise agent-config council:grant-billing ${opts.runId}`,
126
+ ' to decline do nothing — the grant is never implied by silence',
127
+ ];
128
+ }
@@ -13,13 +13,18 @@
13
13
  */
14
14
 
15
15
  import type { CliFallbackOptions, FallbackEvent } from '../ai_council/mid_flight_fallback.js';
16
+ import type { ApiOnQuota } from '../ai_council/transport_resolver.js';
17
+ import { parseApiOnQuota } from '../ai_council/transport_resolver.js';
18
+ import { hasBillingGrant } from './billing_grant.js';
16
19
  import { CouncilConfigError } from '../ai_council/config.js';
17
20
 
18
21
  type Dict = Record<string, unknown>;
19
22
 
20
23
  /** Anything the twin factory needs that `council_cli.ts` owns. */
21
24
  export interface FallbackWiringDeps {
22
- readonly apiOnQuota: boolean;
25
+ readonly apiOnQuota: ApiOnQuota;
26
+ /** Run-scoped billing grant in force. Read only under `'ask'`. */
27
+ readonly billingGrant?: boolean;
23
28
  /** Does this provider have an api constructor at all? */
24
29
  readonly hasApiRung: (provider: string) => boolean;
25
30
  /** This provider's `members.<name>` block, or `{}`. */
@@ -38,6 +43,7 @@ export interface FallbackWiringDeps {
38
43
  ) => unknown;
39
44
  /** The events-log appender. */
40
45
  readonly emit: (record: Dict) => unknown;
46
+
41
47
  }
42
48
 
43
49
  /**
@@ -77,8 +83,15 @@ function isRefusal(exc: unknown): boolean {
77
83
  }
78
84
 
79
85
  export function buildFallbackOptions(deps: FallbackWiringDeps): CliFallbackOptions {
86
+ // Owned here, not passed in. `establishTwin` emits `awaiting_grant` and
87
+ // this is the nearest thing to it that outlives one call, so a caller
88
+ // wanting the closing question reads `options.parked` instead of
89
+ // threading a collector through the factory.
90
+ const parked: string[] = [];
80
91
  return {
81
92
  api_on_quota: deps.apiOnQuota,
93
+ billing_grant: deps.billingGrant === true,
94
+ parked,
82
95
  // The orchestrator is a pure library and holds no sink; this is it.
83
96
  // One line per establishing escalation, so attendance analysis can
84
97
  // tell a seat SAVED by the fallback from one that was natively api.
@@ -93,6 +106,7 @@ export function buildFallbackOptions(deps: FallbackWiringDeps): CliFallbackOptio
93
106
  // during a fallback kills the whole round, and the mechanism whose
94
107
  // entire purpose is to SAVE a seat becomes the thing that loses
95
108
  // every seat. Losing the log line is the strictly smaller failure.
109
+ if (e.outcome === 'awaiting_grant') parked.push(e.provider);
96
110
  try {
97
111
  deps.emit({
98
112
  action: 'transport_fallback',
@@ -124,3 +138,49 @@ export function buildFallbackOptions(deps: FallbackWiringDeps): CliFallbackOptio
124
138
  },
125
139
  };
126
140
  }
141
+
142
+ /** Everything `wireCouncilFallback` needs that `council_cli.ts` owns. */
143
+ export interface CallSiteDeps {
144
+ readonly repoRoot: string;
145
+ readonly isDict: (v: unknown) => v is Dict;
146
+ readonly membersCfg: Dict;
147
+ readonly overrides: Dict;
148
+ readonly hasApiRung: (provider: string) => boolean;
149
+ readonly constructApi: FallbackWiringDeps['constructApi'];
150
+ readonly emit: (record: Dict) => unknown;
151
+ }
152
+
153
+ /**
154
+ * Build the fallback factory from the raw `ai_council` block and hand it back
155
+ * through the out-param `build_members` threads.
156
+ *
157
+ * Extracted from `council_cli.ts` when the billing-grant read joined the two
158
+ * config reads already here: that file is ~2,500 lines over the source ceiling
159
+ * and the documented remedy is extraction rather than a raised baseline
160
+ * (`gate-violation-baselines.json` § check_source_size_budget). Keeping the
161
+ * derivation next to the factory it feeds is the better shape anyway — the
162
+ * caller had to know that an absent `fallback` block means `false` and that
163
+ * the grant is read per-run, neither of which is the caller's business.
164
+ */
165
+ export function wireCouncilFallback(
166
+ out: { options: CliFallbackOptions | null } | null,
167
+ ai: Dict,
168
+ d: CallSiteDeps,
169
+ ): void {
170
+ // The null check lives here, not at the call site: `fallback_out` is an
171
+ // optional out-param and "the caller passed no ref" is this function's
172
+ // business, not a branch every caller has to remember.
173
+ if (out === null) return;
174
+ // Absent/malformed block means `false`, never a throw. Why quota
175
+ // fall-through is opt-in: `FallbackPolicy.apiOnQuota`.
176
+ const cfg = d.isDict(ai['fallback']) ? ai['fallback'] : {};
177
+ out.options = buildFallbackOptions({
178
+ apiOnQuota: parseApiOnQuota(cfg['api_on_quota']),
179
+ billingGrant: hasBillingGrant(d.repoRoot),
180
+ hasApiRung: d.hasApiRung,
181
+ memberConfig: (p) => ((d.membersCfg[p] as Dict) || {}) as Dict,
182
+ modelOverride: (p) => (d.overrides[p] as string | undefined) ?? null,
183
+ constructApi: d.constructApi,
184
+ emit: d.emit,
185
+ });
186
+ }
@@ -160,7 +160,17 @@ export function readLastVerify(root: string, session_id: string | null): string
160
160
  const command = typeof rec['command'] === 'string' ? rec['command'] : null;
161
161
  if (!command) return null;
162
162
  const at = typeof rec['at'] === 'string' ? rec['at'] : 'unknown time';
163
- return `${command} @ ${at} (no exit status is recorded anywhere)`.slice(0, MAX_FIELD_CHARS);
163
+ // Collapse whitespace BEFORE slicing. A recorded command is whatever
164
+ // the operator ran, and a heredoc (`git commit -F - <<'MSG'`) records a
165
+ // multi-line string — so slicing to 200 chars kept the newlines, and
166
+ // `validateRecycleEnvelope`'s `isShortLine` rejects any value containing
167
+ // one. The result was that `session:recycle` refused its OWN
168
+ // machine-collected field: composing a valid envelope was impossible
169
+ // whenever the previous verification command spanned more than one line,
170
+ // and the error named `last_verify` without hinting that the composer
171
+ // had not supplied it. Reproduced 2026-08-21 on a 512-char heredoc.
172
+ const flat = `${command} @ ${at} (no exit status is recorded anywhere)`.replace(/\s+/g, ' ').trim();
173
+ return flat.slice(0, MAX_FIELD_CHARS);
164
174
  } catch {
165
175
  return null;
166
176
  }
@@ -120,11 +120,65 @@ export function normalizeHostManifest(input: unknown): HostCapabilityManifest {
120
120
  * host identity a hook/dispatch envelope carries).
121
121
  *
122
122
  * Each row is partial: an omitted field stays at the `SAFE_DEFAULT` `false`
123
- * when the row is applied. Claude Code is the one host this roadmap's
124
- * transcript evidence measured spawning and running subagents concurrently
125
- * (`Agent` tool dispatch, parallel tool-use blocks); polling and a separate
126
- * quota pool were never observed, so they stay `false` — add a field only
127
- * once it is itself observed, never by inference.
123
+ * when the row is applied — and an ABSENT field is the record for "never
124
+ * looked", which is a different state from a field written as `false`
125
+ * deliberately. Add a field only once it is itself observed, never by
126
+ * inference.
127
+ *
128
+ * ## Rows are written under the observation protocol
129
+ *
130
+ * `contexts/execution/host-capability-manifest.md` § Observation protocol states
131
+ * the per-field criterion and the four-part citation (host · host version ·
132
+ * transcript or artefact reference · date). A row without all four is not
133
+ * admissible, and no row may be filled from a host's documentation.
134
+ *
135
+ * ## REACHABILITY, 2026-08-22 — one of eight, and the other seven are recorded
136
+ *
137
+ * `hook_manifest.yaml` declares eight platform keys. Reachable means a real
138
+ * session can be started on the host, not that it is installable in principle.
139
+ *
140
+ * - `claude` — REACHABLE. Row below, re-cited under the protocol.
141
+ * - `augment` — not reachable from this session: a different editor host, no
142
+ * session available to the running agent.
143
+ * - `cowork` — not reachable from this session, same reason.
144
+ * - `cursor` — not reachable from this session, same reason.
145
+ * - `cline` — not reachable from this session, same reason.
146
+ * - `windsurf` — not reachable from this session, same reason.
147
+ * - `gemini` — not reachable from this session, same reason.
148
+ * - `copilot` — not reachable from this session, same reason; additionally
149
+ * `fallback_only` in the manifest.
150
+ *
151
+ * The seven have **no row**, so every field resolves to the safe default and
152
+ * `describeHostCapabilities` reports its source as `default`. That is the
153
+ * "never looked" state, and it is the honest one — a recorded silence rather
154
+ * than a table with gaps that reads as a table of negatives.
155
+ *
156
+ * ## The `claude` row, RE-CITED rather than grandfathered
157
+ *
158
+ * The row predated the protocol. Both its fields are now carried on transcript
159
+ * evidence, so it is brought up to the new shape rather than marked
160
+ * grandfathered — which was the other permitted disposition and would have left
161
+ * it looking like it passed a protocol it never ran.
162
+ *
163
+ * `subagent_spawn: true` — OBSERVED. claude (Claude Code, Opus 5 1M session,
164
+ * 2026-08-22). `agents/runtime/state/subagent-ledger/2026-08.jsonl`: **445**
165
+ * `subagent_start` records, and **420** stops carrying a measured
166
+ * `duration_ms` — i.e. 420 child legs whose start and stop pair, which is the
167
+ * criterion (a measurable child duration, not an inferred one).
168
+ *
169
+ * `parallel_spawn: true` — OBSERVED. Same host, session and artefact.
170
+ * **6,168** of 6,388 stop records carry `concurrent_open >= 2`, with a
171
+ * maximum of **30** concurrently open children. Overlapping intervals at one
172
+ * instant, which is the criterion — not two children in sequence.
173
+ *
174
+ * `status_polling`, `separate_quota_pool`, `worker_respawn` — **absent from the
175
+ * row, and that is deliberate.** The ledger does not instrument any of the
176
+ * three, so this is "never looked", NOT "observed absent". They stay at the
177
+ * safe default and report `default` provenance. Writing any of them as an
178
+ * explicit `false` would claim a measurement nobody took.
179
+ *
180
+ * `agent_teams` — out of scope for a row by construction; see its own field
181
+ * doc comment and the protocol section.
128
182
  */
129
183
  const HOST_CAPABILITY_REGISTRY: Readonly<Record<string, Partial<HostCapabilityManifest>>> = {
130
184
  claude: { subagent_spawn: true, parallel_spawn: true },
@@ -28,12 +28,21 @@ import * as path from 'node:path';
28
28
  /**
29
29
  * Artefact types both host layers can carry.
30
30
  *
31
- * All four, matching `check_single_delivery` (R2 finding): this notice read three
31
+ * All five, matching `check_single_delivery` (R2 finding): this notice read three
32
32
  * where the gate reads four, so a subagent duplicated across layers was invisible
33
33
  * on one surface and reported on the other — two surfaces disagreeing about the
34
34
  * same question is worse than one surface being silent.
35
+ *
36
+ * `personas` was added 2026-08-21. Both lists had read four types while the
37
+ * installer's `_CLAUDE_SKILL_BUNDLE` (`install.ts:1916-1921`) ships
38
+ * rules · skills · commands · **personas** — so the one family this repository
39
+ * actually delivered from both layers was the one neither surface looked at
40
+ * (29 shared names on a freshly regenerated tree). The two lists stay
41
+ * hand-written and independent of the installer's on purpose: deriving the
42
+ * verifier's scope from the producer would make the check inherit the producer's
43
+ * omissions, which is the defect being closed here.
35
44
  */
36
- const TYPES = ['rules', 'skills', 'commands', 'agents'] as const;
45
+ const TYPES = ['rules', 'skills', 'commands', 'personas', 'user-types', 'agents'] as const;
37
46
 
38
47
  /**
39
48
  * Count shared entry names per artefact type between the two layers.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Remove empty directories under a root, deepest first.
3
+ *
4
+ * ## Why this exists as a shared helper
5
+ *
6
+ * A generator that stops writing into a tree leaves the tree's DIRECTORIES
7
+ * behind. Measured 2026-08-21 while extending the ADR-236 partition to the
8
+ * colon-form commands: after the generator was gated, `.claude/commands/`
9
+ * held **0 `.md` files and 40 empty cluster directories** — and
10
+ * `check_single_delivery` counts directory NAMES, so it still reported 40
11
+ * overlapping commands against a layer that delivered none. A partition that
12
+ * stops the writes and leaves the shape is invisible to the gate that is
13
+ * supposed to confirm it.
14
+ *
15
+ * `condense.ts` already carried this loop inline for the skills tree. It is
16
+ * lifted here rather than copied because the second caller is what turns an
17
+ * inline block into a shape two producers must agree on — and because
18
+ * `condense.ts` sits above `check_source_size_budget`'s ceiling, where a
19
+ * shrink-only ratchet refuses even a small addition.
20
+ *
21
+ * Best-effort by construction: a race or a permission error on one directory is
22
+ * swallowed, matching the inline original. Pruning is cleanup, never a gate.
23
+ *
24
+ * ## Symlinks: MEASURED, because a neutral review split on it
25
+ *
26
+ * One seat called an unguarded recursion a merge blocker (a directory symlink
27
+ * pointing at an ancestor would loop forever); the other called the finding
28
+ * false, on the grounds that `Dirent.isDirectory()` does not follow the target.
29
+ * A vote cannot settle that, so it was probed — `readdirSync(dir, {withFileTypes:
30
+ * true})` over a real symlink-to-directory on darwin, Node 26:
31
+ *
32
+ * link isDirectory=false isSymbolicLink=true
33
+ * real isDirectory=true isSymbolicLink=false
34
+ *
35
+ * `Dirent` carries `lstat` semantics, so the walk below cannot descend into a
36
+ * symlink at all and no loop is reachable — and no inode set is needed. The
37
+ * second seat was right. Recorded rather than fixed, so the next reader does not
38
+ * re-raise it: a defensive `!e.isSymbolicLink()` here would be dead code
39
+ * asserting something the type already guarantees.
40
+ *
41
+ * The same property is why this is safe on a tree of symlinks: `.claude/commands`
42
+ * holds symlinks into `src/domains/**`, and pruning must not follow one out of
43
+ * the tree it was pointed at.
44
+ */
45
+ import * as fs from 'node:fs';
46
+ import * as path from 'node:path';
47
+
48
+ /** @returns the number of directories removed. */
49
+ export function pruneEmptyDirs(root: string): number {
50
+ let removed = 0;
51
+ const walk = (dir: string): void => {
52
+ let entries: fs.Dirent[];
53
+ try {
54
+ entries = fs.readdirSync(dir, { withFileTypes: true });
55
+ } catch {
56
+ return;
57
+ }
58
+ for (const e of entries) {
59
+ if (e.isDirectory()) walk(path.join(dir, e.name));
60
+ }
61
+ try {
62
+ if (fs.readdirSync(dir).length === 0) {
63
+ fs.rmdirSync(dir);
64
+ removed += 1;
65
+ }
66
+ } catch {
67
+ /* race or permission — cleanup is best-effort, mirrors the inline original */
68
+ }
69
+ };
70
+ let top: fs.Dirent[];
71
+ try {
72
+ top = fs.readdirSync(root, { withFileTypes: true });
73
+ } catch {
74
+ return 0;
75
+ }
76
+ for (const e of top) {
77
+ if (e.isDirectory()) walk(path.join(root, e.name));
78
+ }
79
+ return removed;
80
+ }