@event4u/agent-config 9.10.0 → 9.12.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +111 -0
  3. package/README.md +20 -16
  4. package/agents/templates/.ai-council.yml.example +481 -0
  5. package/agents/templates/.ai-video.xml.example +247 -0
  6. package/dist/agent-src/commands/fix/ci.md +29 -0
  7. package/dist/agent-src/commands/pr/create.md +60 -0
  8. package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
  9. package/dist/agent-src/rules/design-fidelity.md +2 -2
  10. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
  11. package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
  12. package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
  13. package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
  14. package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
  15. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
  16. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  17. package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
  18. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
  21. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  22. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
  23. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
  24. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
  25. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
  26. package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
  27. package/dist/cli/initRouting.js +74 -11
  28. package/dist/cli/initRouting.js.map +1 -1
  29. package/dist/cli/main.js +14 -3
  30. package/dist/cli/main.js.map +1 -1
  31. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  32. package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
  33. package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
  34. package/dist/cli-delegate/chunk-A3HJ6NTI.js +1084 -0
  35. package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
  36. package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
  37. package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
  38. package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
  39. package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
  40. package/dist/cli-delegate/chunk-HYWSRNUX.js +179 -0
  41. package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
  42. package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
  43. package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
  44. package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
  45. package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
  46. package/dist/cli-delegate/chunk-QFKQLQ7X.js +10835 -0
  47. package/dist/cli-delegate/chunk-TEGJU2JX.js +6908 -0
  48. package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
  49. package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
  50. package/dist/cli-delegate/cmd_analyze_session.js +294 -0
  51. package/dist/cli-delegate/cmd_conformance.js +643 -0
  52. package/dist/cli-delegate/cmd_converge.js +326 -0
  53. package/dist/cli-delegate/cmd_doctor.js +142 -0
  54. package/dist/cli-delegate/cmd_explain.js +2576 -0
  55. package/dist/cli-delegate/cmd_export.js +298 -0
  56. package/dist/cli-delegate/cmd_fleet.js +333 -0
  57. package/dist/cli-delegate/cmd_memory_get.js +3878 -0
  58. package/dist/cli-delegate/cmd_migrate.js +11 -0
  59. package/dist/cli-delegate/cmd_preflight.js +153 -0
  60. package/dist/cli-delegate/cmd_prune.js +446 -0
  61. package/dist/cli-delegate/cmd_refresh.js +28 -0
  62. package/dist/cli-delegate/cmd_settings_check.js +574 -0
  63. package/dist/cli-delegate/cmd_settings_migrate.js +225 -0
  64. package/dist/cli-delegate/cmd_sync.js +266 -0
  65. package/dist/cli-delegate/cmd_uninstall.js +657 -0
  66. package/dist/cli-delegate/cmd_update.js +425 -0
  67. package/dist/cli-delegate/cmd_upgrade.js +444 -0
  68. package/dist/cli-delegate/cmd_validate.js +307 -0
  69. package/dist/cli-delegate/cmd_versions.js +370 -0
  70. package/dist/discovery/deprecation-report.md +1 -1
  71. package/dist/discovery/discovery-manifest.json +45 -16
  72. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  73. package/dist/discovery/discovery-manifest.summary.md +3 -3
  74. package/dist/discovery/orphan-report.md +1 -1
  75. package/dist/discovery/packs.json +13 -7
  76. package/dist/discovery/trust-report.md +2 -2
  77. package/dist/discovery/workspaces.json +6 -4
  78. package/dist/install/emit_host_rules_cli.js +2 -1
  79. package/dist/install/emit_host_rules_cli.js.map +1 -1
  80. package/dist/install/install.mjs +634 -409
  81. package/dist/install/ruleInScope.js +123 -0
  82. package/dist/install/ruleInScope.js.map +1 -0
  83. package/dist/install/rule_scope.js +7 -3
  84. package/dist/install/rule_scope.js.map +1 -1
  85. package/dist/install/toolDetection.js +40 -12
  86. package/dist/install/toolDetection.js.map +1 -1
  87. package/dist/install/wizard-plan.js +12 -3
  88. package/dist/install/wizard-plan.js.map +1 -1
  89. package/dist/mcp/registry-manifest.json +3 -3
  90. package/dist/mcp/server.json +2 -2
  91. package/dist/mcp/server.mjs +1553 -2728
  92. package/dist/router.json +1 -1
  93. package/dist/scripts/condense.js +6 -22
  94. package/dist/scripts/condense.js.map +1 -1
  95. package/docs/guidelines/design-fidelity-mechanics.md +14 -0
  96. package/package.json +4 -2
  97. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
  98. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  99. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
  100. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
  101. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
  102. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
  103. package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
  104. package/src/config/discovery/packs.yml +7 -0
  105. package/src/config/evaluator-budgets.json +1 -1
  106. package/src/config/pack-size-budget.json +27 -0
  107. package/src/install/emit_host_rules_cli.ts +2 -1
  108. package/src/install/ruleInScope.ts +133 -0
  109. package/src/install/rule_scope.ts +7 -3
  110. package/src/install/toolDetection.ts +39 -8
  111. package/src/install/wizard-plan.ts +13 -3
  112. package/src/scripts/_cli/cmd_conformance.ts +2 -1
  113. package/src/scripts/_cli/cmd_converge.ts +2 -1
  114. package/src/scripts/_cli/cmd_doctor.ts +214 -13
  115. package/src/scripts/_cli/cmd_export.ts +2 -1
  116. package/src/scripts/_cli/cmd_fleet.ts +2 -1
  117. package/src/scripts/_cli/cmd_migrate.ts +2 -1
  118. package/src/scripts/_cli/cmd_preflight.ts +2 -1
  119. package/src/scripts/_cli/cmd_refresh.ts +2 -1
  120. package/src/scripts/_cli/cmd_update.ts +2 -1
  121. package/src/scripts/_cli/cmd_upgrade.ts +2 -1
  122. package/src/scripts/_cli/cmd_versions.ts +2 -1
  123. package/src/scripts/_cli/detection_report.ts +480 -0
  124. package/src/scripts/_dispatch.bash +52 -14
  125. package/src/scripts/_lib/changelog_eras.ts +103 -9
  126. package/src/scripts/_lib/environment_detector.ts +441 -0
  127. package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
  128. package/src/scripts/_lib/package_root.ts +72 -0
  129. package/src/scripts/ai_council/config.ts +15 -7
  130. package/src/scripts/ai_council/modes.ts +68 -2
  131. package/src/scripts/ai_council/transport_resolver.ts +331 -0
  132. package/src/scripts/check_bundle_path_leakage.ts +10 -4
  133. package/src/scripts/check_dependency_floors.ts +153 -0
  134. package/src/scripts/check_installer_import_purity.ts +295 -0
  135. package/src/scripts/check_pack_size.ts +188 -0
  136. package/src/scripts/condense.ts +7 -28
  137. package/src/scripts/council_cli.ts +6 -2
  138. package/src/scripts/evaluator_umbrella.sh +34 -1
  139. package/src/scripts/install-hooks.sh +36 -4
  140. package/src/scripts/install.ts +210 -5
  141. package/src/scripts/lint_eval_fixture_citations.ts +113 -0
  142. package/src/scripts/lint_ui_stack_bundles.ts +201 -0
@@ -69,8 +69,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
69
69
  import * as cli_wrapper from '../_lib/cli_wrapper.js';
70
70
  import * as sync_gitignore from '../sync_gitignore.js';
71
71
  import * as sync_gitattributes from '../sync_gitattributes.js';
72
+ import { resolvePackageRoot } from '../_lib/package_root.js';
72
73
 
73
- const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
74
+ const PACKAGE_ROOT = resolvePackageRoot(import.meta.url);
74
75
 
75
76
  // ---------------------------------------------------------------------------
76
77
  // Parity primitives (ADR-200).
@@ -67,6 +67,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
67
67
 
68
68
  import * as installed_lock from '../_lib/installed_lock.js';
69
69
  import * as update_check from '../_lib/update_check.js';
70
+ import { resolvePackageRoot } from '../_lib/package_root.js';
70
71
  import {
71
72
  DEFAULT_PROJECT_FILE,
72
73
  LOCAL_PROJECT_FILE,
@@ -550,7 +551,7 @@ function _refresh_global_lockfile(version: string, out: OutSink): void {
550
551
 
551
552
  /** Read `version` from the package's own `package.json`. */
552
553
  function _detect_installed_version(): string {
553
- const pkg_json = path.join(path.resolve(_HERE_DIR, '..', '..', '..'), 'package.json');
554
+ const pkg_json = path.join(resolvePackageRoot(import.meta.url), 'package.json');
554
555
  try {
555
556
  const data = JSON.parse(fs.readFileSync(pkg_json, { encoding: 'utf-8' })) as Record<
556
557
  string,
@@ -93,9 +93,10 @@ import * as claude_plugin from '../_lib/claude_plugin.js';
93
93
  import { event4u_root, write_target } from '../_lib/user_global_paths.js';
94
94
  import { project_settings_path } from '../_lib/agent_settings.js';
95
95
  import { _is_source_repo } from './cmd_refresh.js';
96
+ import { resolvePackageRoot } from '../_lib/package_root.js';
96
97
 
97
98
  const PACKAGE_NAME = '@event4u/agent-config';
98
- const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
99
+ const PACKAGE_ROOT = resolvePackageRoot(import.meta.url);
99
100
 
100
101
  // ---------------------------------------------------------------------------
101
102
  // Parity primitives (ADR-200).
@@ -47,6 +47,7 @@ import { spawnSync } from 'node:child_process';
47
47
  import { fileURLToPath, pathToFileURL } from 'node:url';
48
48
 
49
49
  import { project_settings_path, resolve_project_root } from '../_lib/agent_settings.js';
50
+ import { resolvePackageRoot } from '../_lib/package_root.js';
50
51
 
51
52
  const PACKAGE_NAME = '@event4u/agent-config';
52
53
 
@@ -171,7 +172,7 @@ function _project_root(): string {
171
172
  /** Return `version` from the local `package.json`, or `""` if absent. */
172
173
  function _local_package_version(): string {
173
174
  const candidates = [
174
- path.join(path.resolve(_HERE_DIR, '..', '..', '..'), 'package.json'),
175
+ path.join(resolvePackageRoot(import.meta.url), 'package.json'),
175
176
  path.join(_project_root(), 'package.json'),
176
177
  ];
177
178
  for (const p of candidates) {
@@ -0,0 +1,480 @@
1
+ /**
2
+ * The detection report — one builder, one renderer, three surfaces (Phases 3
3
+ * and 4 of `road-to-zero-ceremony-detection`).
4
+ *
5
+ * `doctor`'s detection section, its `--json` payload, and the first-invocation
6
+ * spend disclosure are all produced from the SAME rows built here. That is the
7
+ * point: a disclosure rendered by its own code path drifts from the report the
8
+ * user just read, and then the number they were shown is not the number that
9
+ * governs the call.
10
+ *
11
+ * ## What a row says — and what it deliberately does not
12
+ *
13
+ * Per provider: `detected` · `authenticated` · `authSource` · `billing` ·
14
+ * `enabledInConfig`, plus the transport `auto` WOULD select and, when the
15
+ * provider is unavailable, a one-line reason and a one-line fix.
16
+ *
17
+ * The gap between "could work" and "allowed to spend" is reported, never
18
+ * closed. `enabledInConfig: false` on a fully detected provider is not a
19
+ * defect — it is a recorded consent decision, and this module's job is to make
20
+ * that visible instead of mysterious. See the roadmap's
21
+ * `council-availability-semantics` blocker for why detection does not confer
22
+ * permission.
23
+ *
24
+ * ## Purity
25
+ *
26
+ * `buildDetectionReport` is pure over its inputs: an `EnvironmentReport` from
27
+ * `_lib/environment_detector.ts` plus a `CouncilFacts` struct. The impure read
28
+ * lives in `readCouncilFacts`, and the CLI-call counters are injected by the
29
+ * caller — so this module never imports the council client layer and cannot
30
+ * reach a billable call.
31
+ */
32
+
33
+ import * as path from 'node:path';
34
+
35
+ import {
36
+ classifyBilling,
37
+ knownProviders,
38
+ strongestAuth,
39
+ PROVIDER_CLI_META,
40
+ type AuthSource,
41
+ type BillingClass,
42
+ type DetectedHost,
43
+ type EnvironmentReport,
44
+ } from '../_lib/environment_detector.js';
45
+ import { resolveTransport, type Transport } from '../ai_council/transport_resolver.js';
46
+
47
+ export const DETECTION_SCHEMA_VERSION = 1;
48
+
49
+ /** The council-config facts a row needs. Read separately so the builder is pure. */
50
+ export interface CouncilFacts {
51
+ /** Resolved user-global config path, whether or not it exists. */
52
+ readonly configPath: string;
53
+ readonly configPresent: boolean;
54
+ /** Council master switch. `false` when no config exists. */
55
+ readonly enabled: boolean;
56
+ /** Effective `defaults.mode`, or `null` when no config exists. */
57
+ readonly defaultsMode: string | null;
58
+ readonly members: Readonly<Record<string, CouncilMemberFacts>>;
59
+ /** `cli_call_budget.max_calls_per_day` per provider. */
60
+ readonly cliCaps: Readonly<Record<string, number>>;
61
+ /** `cost_budget.max_total_usd`, or `null` when no config exists. */
62
+ readonly costBudgetMaxUsd: number | null;
63
+ }
64
+
65
+ export interface CouncilMemberFacts {
66
+ readonly enabled: boolean;
67
+ /** Per-member `mode:` override, or `null`. */
68
+ readonly mode: string | null;
69
+ /** Per-member `binary:` override, or `null`. */
70
+ readonly binary: string | null;
71
+ readonly apiKeyRef: string | null;
72
+ }
73
+
74
+ export interface ProviderRow {
75
+ readonly provider: string;
76
+ /** A CLI binary resolves OR any credential is present. */
77
+ readonly detected: boolean;
78
+ /** Any credential is present. Presence — never a claim that it works. */
79
+ readonly authenticated: boolean;
80
+ readonly authSource: AuthSource | null;
81
+ readonly billing: BillingClass;
82
+ /** The transport `auto` would select right now, or `null` when none can be. */
83
+ readonly transport: Transport | null;
84
+ /**
85
+ * The mode this member is actually CONFIGURED with — per-member override,
86
+ * else `defaults.mode`. Distinct from `transport`: that answers "what could
87
+ * work", this answers "what will run". A member pinned to `manual` makes no
88
+ * provider call no matter what `auto` could have reached.
89
+ */
90
+ readonly configuredMode: string | null;
91
+ readonly available: boolean;
92
+ readonly enabledInConfig: boolean;
93
+ /** Non-null exactly when `available` is false. */
94
+ readonly reason: string | null;
95
+ /** One-line fix. Non-null whenever the capability is unusable as configured. */
96
+ readonly fix: string | null;
97
+ /** Per-day CLI cap, or `null` when unset (unlimited from the loader's view). */
98
+ readonly cliCap: number | null;
99
+ /** CLI calls already spent today, when the caller supplied counters. */
100
+ readonly cliUsed: number | null;
101
+ }
102
+
103
+ export interface DetectionReport {
104
+ readonly schema_version: number;
105
+ readonly council_config_path: string;
106
+ readonly council_config_present: boolean;
107
+ readonly council_enabled: boolean;
108
+ readonly defaults_mode: string | null;
109
+ readonly hosts: readonly DetectedHost[];
110
+ readonly providers: readonly ProviderRow[];
111
+ readonly budgets: {
112
+ readonly cost_budget_max_total_usd: number | null;
113
+ readonly cli_call_budget: Readonly<Record<string, number>>;
114
+ };
115
+ }
116
+
117
+ export interface BuildDetectionReportOptions {
118
+ readonly environment: EnvironmentReport;
119
+ readonly council: CouncilFacts;
120
+ /** `provider -> calls spent today`. Injected so this module stays client-free. */
121
+ readonly cliCallsUsed?: Readonly<Record<string, number>>;
122
+ /** Environment map for resolving `env:` key refs. Defaults to `process.env`. */
123
+ readonly env?: Readonly<Record<string, string | undefined>>;
124
+ }
125
+
126
+ /**
127
+ * True when `ref` is an `env:<VAR>` reference whose variable actually holds a
128
+ * value. A `file:` ref is deliberately NOT resolved here — the detector already
129
+ * probes the standard key-file names, and re-resolving arbitrary paths would be
130
+ * the third auth-probe shape this work exists to avoid.
131
+ */
132
+ function envRefResolves(
133
+ ref: string | null,
134
+ env?: Readonly<Record<string, string | undefined>>,
135
+ ): boolean {
136
+ if (ref === null || !ref.startsWith('env:')) return false;
137
+ const name = ref.slice('env:'.length).trim();
138
+ if (name === '') return false;
139
+ return ((env ?? process.env)[name] ?? '').trim() !== '';
140
+ }
141
+
142
+ /**
143
+ * The one-line edit that grants a detected provider permission to spend.
144
+ *
145
+ * This is an EDIT, not an invented command: no settings-mutation CLI exists
146
+ * (`settings` is a GUI alias; a `settings set` verb is greenfield), so emitting
147
+ * `agent-config settings set …` would print a command that does not run. The
148
+ * file and the exact key path are what remove the work of finding it, which is
149
+ * the actual goal — consent stays an explicit act either way.
150
+ */
151
+ export function enableInstruction(provider: string, configPath: string): string {
152
+ return `set \`members.${provider}.enabled: true\` in ${configPath}`;
153
+ }
154
+
155
+ /** Build every row. Pure over `opts`. */
156
+ export function buildDetectionReport(opts: BuildDetectionReportOptions): DetectionReport {
157
+ const { environment, council } = opts;
158
+ const used = opts.cliCallsUsed ?? {};
159
+ const providers: ProviderRow[] = [];
160
+
161
+ for (const provider of knownProviders()) {
162
+ const member = council.members[provider];
163
+ const enabledInConfig = member?.enabled ?? false;
164
+ const auth = strongestAuth(environment, provider);
165
+ const authSource = auth?.source ?? null;
166
+ const binaryOverride = member?.binary ?? null;
167
+
168
+ // What `auto` would pick — the honest "could this work right now"
169
+ // answer, independent of the mode actually configured.
170
+ //
171
+ // A CONFIGURED `api_key_ref` is not a RESOLVED key: every member in the
172
+ // shipped template carries one (`env:XAI_API_KEY` and friends) whether
173
+ // or not the variable is set. Treating configuration as presence made
174
+ // every provider report `auto→api` while showing `detected ❌`. So the
175
+ // detector's records decide, plus the one case configuration does prove:
176
+ // an `env:` ref whose variable actually holds a value.
177
+ const resolved = resolveTransport({
178
+ provider,
179
+ mode: 'auto',
180
+ report: environment,
181
+ binaryOverride,
182
+ ...(envRefResolves(member?.apiKeyRef ?? null, opts.env)
183
+ ? { apiKeyPresent: true }
184
+ : {}),
185
+ });
186
+
187
+ const binaryPresent = cliBinaryPresent(environment, provider, binaryOverride);
188
+ const detected = binaryPresent || authSource !== null;
189
+ const capRaw = council.cliCaps[provider];
190
+
191
+ providers.push({
192
+ provider,
193
+ detected,
194
+ authenticated: authSource !== null,
195
+ authSource,
196
+ billing: classifyBilling(provider, authSource),
197
+ transport: resolved.transport,
198
+ configuredMode: member?.mode ?? council.defaultsMode,
199
+ available: resolved.available,
200
+ enabledInConfig,
201
+ reason: resolved.reason,
202
+ fix: fixFor({
203
+ provider,
204
+ detected,
205
+ available: resolved.available,
206
+ configuredMode: member?.mode ?? council.defaultsMode,
207
+ enabledInConfig,
208
+ councilEnabled: council.enabled,
209
+ configPresent: council.configPresent,
210
+ configPath: council.configPath,
211
+ reason: resolved.reason,
212
+ }),
213
+ cliCap: capRaw ?? null,
214
+ cliUsed: Object.prototype.hasOwnProperty.call(used, provider)
215
+ ? (used[provider] ?? 0)
216
+ : null,
217
+ });
218
+ }
219
+
220
+ return {
221
+ schema_version: DETECTION_SCHEMA_VERSION,
222
+ council_config_path: council.configPath,
223
+ council_config_present: council.configPresent,
224
+ council_enabled: council.enabled,
225
+ defaults_mode: council.defaultsMode,
226
+ hosts: environment.hosts,
227
+ providers,
228
+ budgets: {
229
+ cost_budget_max_total_usd: council.costBudgetMaxUsd,
230
+ cli_call_budget: council.cliCaps,
231
+ },
232
+ };
233
+ }
234
+
235
+ function cliBinaryPresent(
236
+ environment: EnvironmentReport,
237
+ provider: string,
238
+ binaryOverride: string | null,
239
+ ): boolean {
240
+ if (binaryOverride !== null && binaryOverride.trim() !== '') return true;
241
+ const expected = PROVIDER_CLI_META[provider]?.[0];
242
+ if (expected === undefined) return false;
243
+ return environment.hosts.some(
244
+ (h) => h.binary !== null && stripExe(path.basename(h.binary)) === expected,
245
+ );
246
+ }
247
+
248
+ function stripExe(name: string): string {
249
+ return name.replace(/\.(exe|cmd)$/i, '');
250
+ }
251
+
252
+ /**
253
+ * One-line fix for whatever makes this provider unusable, most-blocking first:
254
+ * no config file → no master switch → nothing detected → detected-but-not-
255
+ * enabled → detected-and-enabled-but-no-transport. `null` when it works.
256
+ */
257
+ function fixFor(o: {
258
+ provider: string;
259
+ detected: boolean;
260
+ available: boolean;
261
+ configuredMode: string | null;
262
+ enabledInConfig: boolean;
263
+ councilEnabled: boolean;
264
+ configPresent: boolean;
265
+ configPath: string;
266
+ reason: string | null;
267
+ }): string | null {
268
+ if (!o.configPresent) {
269
+ return (
270
+ `no council config — copy \`agents/templates/.ai-council.yml.example\` ` +
271
+ `to ${o.configPath}`
272
+ );
273
+ }
274
+ if (!o.councilEnabled) {
275
+ return `set \`enabled: true\` in ${o.configPath}`;
276
+ }
277
+ // A manual member needs neither a binary nor a key — the human is the
278
+ // transport. Telling them to install a CLI would be advice for a transport
279
+ // they explicitly opted out of.
280
+ if (o.configuredMode === 'manual') {
281
+ return o.enabledInConfig ? null : enableInstruction(o.provider, o.configPath);
282
+ }
283
+ if (!o.detected) {
284
+ const bin = PROVIDER_CLI_META[o.provider]?.[0] ?? o.provider;
285
+ return (
286
+ `nothing detected — install the \`${bin}\` CLI and log in, or add an ` +
287
+ `\`api_key_ref\` for ${o.provider}`
288
+ );
289
+ }
290
+ if (!o.enabledInConfig) {
291
+ // Detected but not permitted: the gap this report exists to surface.
292
+ return enableInstruction(o.provider, o.configPath);
293
+ }
294
+ if (!o.available) {
295
+ return o.reason;
296
+ }
297
+ return null;
298
+ }
299
+
300
+ // ── rendering ───────────────────────────────────────────────────────────
301
+
302
+ const GLYPH_OK = '✅';
303
+ const GLYPH_NO = '❌';
304
+ const GLYPH_WARN = '⚠️';
305
+
306
+ /** `detected · authenticated · source · billing · enabled` for one provider. */
307
+ export function renderProviderLine(row: ProviderRow, configPath: string): string {
308
+ const parts = [
309
+ `detected ${row.detected ? GLYPH_OK : GLYPH_NO}`,
310
+ `authenticated ${row.authenticated ? GLYPH_OK : GLYPH_NO}`,
311
+ `source ${row.authSource ?? '—'}`,
312
+ `billing ${row.billing}`,
313
+ `enabled-in-config ${row.enabledInConfig ? GLYPH_OK : GLYPH_NO}`,
314
+ ];
315
+ parts.push(`mode ${row.configuredMode ?? '—'}`);
316
+ if (row.transport !== null) {
317
+ parts.push(`auto→${row.transport}`);
318
+ }
319
+ if (row.cliCap !== null) {
320
+ const used = row.cliUsed ?? 0;
321
+ const glyph = used >= row.cliCap ? GLYPH_WARN : GLYPH_OK;
322
+ parts.push(`cli-quota ${used}/${row.cliCap} ${glyph}`);
323
+ }
324
+ // A detected-but-unpermitted provider is the case the report exists for —
325
+ // name the consent gap explicitly rather than leaving a bare ❌.
326
+ if (row.detected && !row.enabledInConfig) {
327
+ parts.push(`not allowed to spend — ${enableInstruction(row.provider, configPath)}`);
328
+ }
329
+ return `${row.provider}: ${parts.join(' · ')}`;
330
+ }
331
+
332
+ /**
333
+ * The full detection section, as lines. Hosts + versions, the provider →
334
+ * transport → billing table, budgets, and a reason + fix per unavailable
335
+ * capability.
336
+ */
337
+ export function renderDetectionLines(report: DetectionReport): string[] {
338
+ const lines: string[] = ['detection:'];
339
+
340
+ const installed = report.hosts.filter((h) => h.installed);
341
+ lines.push(` hosts: ${installed.length}/${report.hosts.length} installed`);
342
+ for (const h of installed) {
343
+ const version = h.version === null ? 'no binary' : h.version;
344
+ lines.push(` ${h.id}: ${version}${h.binary === null ? '' : ` (${h.binary})`}`);
345
+ }
346
+
347
+ lines.push(
348
+ ` council config: ${report.council_config_present ? report.council_config_path : `absent (${report.council_config_path})`}` +
349
+ `${report.council_config_present ? ` · enabled ${report.council_enabled ? GLYPH_OK : GLYPH_NO} · defaults.mode ${report.defaults_mode ?? '—'}` : ''}`,
350
+ );
351
+
352
+ lines.push(' providers:');
353
+ for (const row of report.providers) {
354
+ lines.push(` ${renderProviderLine(row, report.council_config_path)}`);
355
+ if (row.fix !== null) {
356
+ lines.push(` fix: ${row.fix}`);
357
+ }
358
+ }
359
+
360
+ const caps = Object.entries(report.budgets.cli_call_budget);
361
+ lines.push(
362
+ ` budgets: cost_budget.max_total_usd ${report.budgets.cost_budget_max_total_usd ?? '—'}` +
363
+ ` · cli_call_budget ${caps.length === 0 ? 'unset' : caps.map(([p, c]) => `${p}=${c}`).join(', ')}`,
364
+ );
365
+ return lines;
366
+ }
367
+
368
+ /**
369
+ * The first-invocation spend disclosure — built from the SAME rows as the table
370
+ * above, so the two can never disagree about which members spend money.
371
+ *
372
+ * Only enabled AND available members can spend, so only they appear. A member
373
+ * that is detected but not enabled is named as explicitly excluded, because
374
+ * "why is my provider not being consulted" is the question this answers.
375
+ */
376
+ export function renderSpendDisclosure(report: DetectionReport): string {
377
+ // Bucket by the CONFIGURED mode, not by what `auto` could reach: a member
378
+ // pinned to `manual` makes no provider call regardless of what is installed,
379
+ // and telling the user it will hit an API would be exactly the drift this
380
+ // single-renderer design exists to prevent.
381
+ const spending = report.providers.filter(
382
+ (r) => r.enabledInConfig && (r.available || r.configuredMode === 'manual'),
383
+ );
384
+ const excludedTail = renderExcludedTail(report);
385
+ if (spending.length === 0) {
386
+ // The excluded list matters MOST here: "no call will be made" without it
387
+ // leaves the obvious next question ("but my provider IS installed?")
388
+ // unanswered.
389
+ return (
390
+ 'council spend disclosure · no member is both enabled and available — ' +
391
+ `no call will be made.${excludedTail}`
392
+ );
393
+ }
394
+ const free = spending.filter((r) => r.configuredMode === 'manual');
395
+ const billed = spending.filter((r) => r.configuredMode !== 'manual');
396
+ const metered = billed.filter((r) => r.billing === 'per-token');
397
+ const subscription = billed.filter((r) => r.billing === 'subscription');
398
+
399
+ const segments: string[] = [];
400
+ if (metered.length > 0) {
401
+ segments.push(
402
+ `${metered.length} metered (per-token): ` +
403
+ metered.map((r) => `${r.provider}→${r.transport}`).join(', '),
404
+ );
405
+ }
406
+ if (subscription.length > 0) {
407
+ segments.push(
408
+ `${subscription.length} on subscription quota: ` +
409
+ subscription
410
+ .map(
411
+ (r) =>
412
+ `${r.provider}→${r.transport}` +
413
+ (r.cliCap === null ? '' : ` (${r.cliUsed ?? 0}/${r.cliCap} today)`),
414
+ )
415
+ .join(', '),
416
+ );
417
+ }
418
+ if (free.length > 0) {
419
+ segments.push(`${free.length} manual (no provider call): ${free.map((r) => r.provider).join(', ')}`);
420
+ }
421
+
422
+ return `council spend disclosure · ${segments.join(' · ')}${excludedTail}`;
423
+ }
424
+
425
+ /** The `detected but not allowed to spend` tail, or `''` when there is none. */
426
+ function renderExcludedTail(report: DetectionReport): string {
427
+ const excluded = report.providers.filter((r) => r.detected && !r.enabledInConfig);
428
+ if (excluded.length === 0) return '';
429
+ return ` · detected but not allowed to spend: ${excluded.map((r) => r.provider).join(', ')}`;
430
+ }
431
+
432
+ /** The `--json` payload for the detection section. Stable field order. */
433
+ export function detectionJson(report: DetectionReport): Record<string, unknown> {
434
+ return {
435
+ schema_version: report.schema_version,
436
+ council_config_path: report.council_config_path,
437
+ council_config_present: report.council_config_present,
438
+ council_enabled: report.council_enabled,
439
+ defaults_mode: report.defaults_mode,
440
+ hosts: report.hosts.map((h) => ({
441
+ id: h.id,
442
+ installed: h.installed,
443
+ binary: h.binary,
444
+ version: h.version,
445
+ })),
446
+ providers: report.providers.map((r) => ({
447
+ provider: r.provider,
448
+ detected: r.detected,
449
+ authenticated: r.authenticated,
450
+ auth_source: r.authSource,
451
+ billing: r.billing,
452
+ transport: r.transport,
453
+ configured_mode: r.configuredMode,
454
+ available: r.available,
455
+ enabled_in_config: r.enabledInConfig,
456
+ reason: r.reason,
457
+ fix: r.fix,
458
+ cli_cap: r.cliCap,
459
+ cli_used: r.cliUsed,
460
+ })),
461
+ budgets: {
462
+ cost_budget_max_total_usd: report.budgets.cost_budget_max_total_usd,
463
+ cli_call_budget: report.budgets.cli_call_budget,
464
+ },
465
+ spend_disclosure: renderSpendDisclosure(report),
466
+ };
467
+ }
468
+
469
+ /** Facts for a machine with no council config at all. */
470
+ export function absentCouncilFacts(configPath: string): CouncilFacts {
471
+ return {
472
+ configPath,
473
+ configPresent: false,
474
+ enabled: false,
475
+ defaultsMode: null,
476
+ members: {},
477
+ cliCaps: {},
478
+ costBudgetMaxUsd: null,
479
+ };
480
+ }
@@ -346,16 +346,20 @@ print_version() {
346
346
  fi
347
347
  }
348
348
 
349
- # Resolve the tsx runner (Python→TypeScript migration). tsx ships as a
350
- # RUNTIME dependency of the package, so the package-local install is the
351
- # canonical source — first via node_modules/.bin, then via the module's CLI
352
- # entry (covers installs where .bin shims are missing). `npx tsx` is a last
353
- # resort only: npx runs against the CONSUMER project's cwd, so its npm config
354
- # and devEngines/engines constraints apply — a consumer pinning e.g.
355
- # `node <24` hard-fails every hook and TS command with EBADDEVENGINES (the
356
- # 8.1.0 regression that silently broke hooks + roadmap:progress in consumer
357
- # projects). Exits 127 when nothing works — the runtime is Node/tsx, there is
358
- # no Python fallback.
349
+ # Resolve the tsx runner (Python→TypeScript migration). tsx is a DEV-ONLY
350
+ # dependency since the road-to-credible-install Phase 1 flip — the consumer
351
+ # runtime tree ships no tsx, so every consumer-reachable surface must resolve
352
+ # a precompiled bundle instead (hooks → dist/hooks/dispatch.js, mcp:run →
353
+ # dist/mcp/server.mjs, the `_cli` delegate commands → dist/cli-delegate/,
354
+ # see exec_ts below). This resolver therefore only ever fires in a DEV tree
355
+ # (node_modules/.bin/tsx present) or as a genuine last-resort fallback.
356
+ # `npx tsx` is that last resort: npx runs against the CONSUMER project's cwd,
357
+ # so its npm config and devEngines/engines constraints apply — a consumer
358
+ # pinning e.g. `node <24` hard-fails with EBADDEVENGINES (the 8.1.0
359
+ # regression that silently broke hooks + roadmap:progress in consumer
360
+ # projects). Reaching it from a consumer-tier command is a packaging bug, not
361
+ # a supported path. Exits 127 when nothing works — the runtime is Node/tsx,
362
+ # there is no Python fallback.
359
363
  require_tsx() {
360
364
  if [[ -x "$PACKAGE_ROOT/node_modules/.bin/tsx" ]]; then
361
365
  TSX_BIN="$PACKAGE_ROOT/node_modules/.bin/tsx"
@@ -378,14 +382,48 @@ require_tsx() {
378
382
  exit 127
379
383
  }
380
384
 
381
- # Run an absolute script path via tsx. The argument is an absolute path that
382
- # may carry a `.py` (legacy) or `.ts` extension; we resolve the `.ts` twin and
383
- # exec it through tsx. Argv/stdin/stdout/stderr/exit-code pass through
384
- # unchanged — mirrors src/scripts/run.ts resolution.
385
+ # Map a `src/scripts/_cli/<name>.ts` path to its precompiled bundle entry
386
+ # under `dist/cli-delegate/<name>.js`, echoing the bundle path when it exists
387
+ # and node is available. Empty output means "no bundle — use the tsx path".
388
+ #
389
+ # Scoped to `_cli/` on purpose: that directory is the consumer-tier delegate
390
+ # command surface (`sync`, `doctor`, `validate`, `upgrade`, …) and its
391
+ # basenames are unique, so the mapping is collision-free. Other exec_ts
392
+ # callers (resolve_script results, the work engine, …) are unaffected.
393
+ cli_delegate_bundle() {
394
+ local ts_abs="$1"
395
+ case "$ts_abs" in
396
+ "$PACKAGE_ROOT/src/scripts/_cli/"*) ;;
397
+ *) return 0 ;;
398
+ esac
399
+ local name
400
+ name="$(basename "${ts_abs%.ts}")"
401
+ local bundle="$PACKAGE_ROOT/dist/cli-delegate/$name.js"
402
+ if [[ -f "$bundle" ]] && command -v node >/dev/null 2>&1; then
403
+ printf '%s' "$bundle"
404
+ fi
405
+ }
406
+
407
+ # Run an absolute script path. The argument is an absolute path that may carry
408
+ # a `.py` (legacy) or `.ts` extension; we resolve the `.ts` twin.
409
+ #
410
+ # A precompiled `dist/cli-delegate/` bundle wins when present — the consumer
411
+ # tree ships no tsx (see require_tsx), and the bundle is also ~5.7x faster
412
+ # than the npx path it replaces (p50 56-71 ms vs 346-392 ms, measured
413
+ # 2026-07-31; see docs/decisions/ADR-204). The tsx path stays as the dev-tree
414
+ # route and as the fallback for every non-`_cli` caller.
415
+ #
416
+ # Argv/stdin/stdout/stderr/exit-code pass through unchanged in both routes —
417
+ # mirrors src/scripts/run.ts resolution.
385
418
  exec_ts() {
386
419
  local script="$1"; shift
387
420
  local ts_abs="${script%.py}"
388
421
  ts_abs="${ts_abs%.ts}.ts"
422
+ local bundle
423
+ bundle="$(cli_delegate_bundle "$ts_abs")"
424
+ if [[ -n "$bundle" ]]; then
425
+ exec node "$bundle" "$@"
426
+ fi
389
427
  if [[ ! -f "$ts_abs" ]]; then
390
428
  echo "❌ agent-config: script not found: $ts_abs" >&2
391
429
  exit 127