@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
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Layout-independent package-root resolution for the `_cli` delegate commands.
3
+ *
4
+ * `src/scripts/_cli/*.ts` sits three levels below the package root, so every
5
+ * delegate command derived the root with three hard-coded parent hops. Then the
6
+ * delegate precompile (`fa51c5a54`) started bundling those same modules into
7
+ * `dist/cli-delegate/*.js` — **two** levels below the root. Fixed hop counts are
8
+ * correct in exactly one of the two layouts, and nothing failed loudly: three
9
+ * hops from the bundle lands on `<pkg>/..`, which for a scoped install is
10
+ * `node_modules/@event4u`. `conformance` then reported `dist/router.json` and
11
+ * `src/scripts/hook_manifest.yaml` as missing — files that ship fine — and the
12
+ * 9.11.0 release PR's `tarball E2E` went red on both Node majors.
13
+ *
14
+ * The fix is to stop counting hops. Anchor on the marker that actually defines
15
+ * the root: the `package.json` whose `name` is this package. That answer holds
16
+ * for the source tree, for any bundle depth, for npm-global, npx, and
17
+ * vendor/bin symlink invocations — and it keeps holding if either directory
18
+ * ever moves.
19
+ */
20
+
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ /** `name` in this package's own package.json — the root marker. */
26
+ export const PACKAGE_NAME = '@event4u/agent-config';
27
+
28
+ /**
29
+ * Bound on the upward walk. Deep enough for any real install path, finite so a
30
+ * malformed start value can never spin.
31
+ */
32
+ const MAX_ASCENT = 16;
33
+
34
+ /**
35
+ * Absolute package root for the module at `fromUrlOrPath` (pass
36
+ * `import.meta.url`).
37
+ *
38
+ * Walks up looking for the package.json whose `name` is {@link PACKAGE_NAME}.
39
+ * A consumer's own package.json encountered on the way is skipped by that name
40
+ * check, so an install nested under another project resolves correctly.
41
+ *
42
+ * `legacyHops` is the pre-existing hop count for the *source* layout and is
43
+ * used only if no marker is found. Falling back beats throwing during CLI
44
+ * startup: a wrong path yields one actionable "missing file" message, while an
45
+ * exception takes down every command in the delegate.
46
+ */
47
+ export function resolvePackageRoot(fromUrlOrPath: string, legacyHops = 3): string {
48
+ const start = fromUrlOrPath.startsWith('file:') ? fileURLToPath(fromUrlOrPath) : fromUrlOrPath;
49
+ const startDir = path.dirname(start);
50
+
51
+ let dir = startDir;
52
+ for (let i = 0; i < MAX_ASCENT; i++) {
53
+ const manifest = path.join(dir, 'package.json');
54
+ if (fs.existsSync(manifest)) {
55
+ try {
56
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8')) as { name?: unknown };
57
+ if (parsed.name === PACKAGE_NAME) {
58
+ return dir;
59
+ }
60
+ } catch {
61
+ // Unreadable / unparseable manifest — not our marker; keep ascending.
62
+ }
63
+ }
64
+ const parent = path.dirname(dir);
65
+ if (parent === dir) {
66
+ break; // filesystem root
67
+ }
68
+ dir = parent;
69
+ }
70
+
71
+ return path.resolve(startDir, ...(Array.from({ length: legacyHops }, () => '..') as string[]));
72
+ }
@@ -14,12 +14,16 @@
14
14
  * Validation contract (8 rules, all enforced at load time):
15
15
  *
16
16
  * 1. `enabled` is a bool.
17
- * 2. `defaults.mode` ∈ {`api`, `manual`, `cli`}; per-member mode same
18
- * set. Semantics: `api` = SDK call against a stored key (billable);
17
+ * 2. `defaults.mode` ∈ {`api`, `manual`, `cli`, `auto`}; per-member mode
18
+ * same set. Semantics: `api` = SDK call against a stored key (billable);
19
19
  * `manual` = copy & paste — human transports prompt + reply between
20
20
  * the agent and an external chat surface (free); `cli` = shell out to
21
21
  * a locally-installed CLI under subscription auth (free for
22
- * first-party CLIs, billable for community wrappers).
22
+ * first-party CLIs, billable for community wrappers); `auto` = pick
23
+ * per provider per invocation (cli → api → unavailable), resolved by
24
+ * `transport_resolver.ts`. `auto` is opt-in — it is never the shipped
25
+ * default, because flipping it would move an existing user's spend
26
+ * from per-token dollars onto subscription quota with no config edit.
23
27
  * 3. `members.<name>` keys are restricted to the known provider set.
24
28
  * 4. `cost_budget.*` numeric fields are >= 0.
25
29
  * 5. Enabled members carry a non-empty `model` and `api_key_ref` when
@@ -30,7 +34,8 @@
30
34
  * even if syntactically plausible.
31
35
  * 7. Resolved `file:` key paths must have mode 0o600 (delegated to
32
36
  * `resolve_api_key`; runs at use-time, not parse-time).
33
- * 8. `binary:` is only valid when the member's effective mode is `cli`;
37
+ * 8. `binary:` is only valid when the member's effective mode is `cli` or
38
+ * `auto` (auto may resolve to the cli rung);
34
39
  * `cli_call_budget.max_calls_per_day.<provider>` keys must be valid
35
40
  * providers.
36
41
  *
@@ -60,7 +65,7 @@ const _VALID_PROVIDERS: ReadonlySet<string> = new Set([
60
65
  'xai',
61
66
  'perplexity',
62
67
  ]);
63
- const _VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli']);
68
+ const _VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli', 'auto']);
64
69
 
65
70
  /**
66
71
  * Prefixes that signal "this is a raw API key" so we refuse it loudly
@@ -1655,10 +1660,13 @@ function _build_member(
1655
1660
  `members.${name}.binary must be a non-empty string when set.`,
1656
1661
  );
1657
1662
  }
1658
- if (effective_mode !== 'cli') {
1663
+ // `auto` may resolve to the cli rung, so a binary override is
1664
+ // legitimate there too — rejecting it would make `auto` unusable for
1665
+ // anyone whose CLI is not on `$PATH` under its default name.
1666
+ if (effective_mode !== 'cli' && effective_mode !== 'auto') {
1659
1667
  throw new CouncilConfigError(
1660
1668
  `members.${name}.binary is only valid when the member's ` +
1661
- `effective mode is 'cli' (got ${_pyRepr(effective_mode)}). Set ` +
1669
+ `effective mode is 'cli' or 'auto' (got ${_pyRepr(effective_mode)}). Set ` +
1662
1670
  `\`mode: cli\` on the member or \`defaults.mode: cli\` to use ` +
1663
1671
  `this field.`,
1664
1672
  );
@@ -12,17 +12,43 @@
12
12
  * - `manual` — copy-paste loop with the user as transport (free).
13
13
  * - `cli` — shell out to a locally-installed provider CLI under the
14
14
  * user's subscription auth. `billable=False`.
15
+ * - `auto` — resolve per provider per invocation (cli → api → unavailable).
16
+ * Accepted here as a VALUE; the concrete transport is chosen by
17
+ * `transport_resolver.ts`, which owns the chain and the billing
18
+ * classification. `manual` is never part of the auto chain.
15
19
  *
16
20
  * Resolution precedence — first non-empty wins:
17
21
  *
18
22
  * 1. Invocation flag e.g. `/council mode:manual`
19
23
  * 2. Per-member setting `ai_council.members.<name>.mode`
20
- * 3. Global setting `ai_council.mode`
24
+ * 3. Global setting `ai_council.mode` OR `ai_council.defaults.mode`
25
+ * (see `resolve_global_mode` — both shapes reach
26
+ * this resolver, and they must agree)
21
27
  * 4. Built-in default `manual`
28
+ *
29
+ * ## Two distinct defaults — the reconciliation (road-to-zero-ceremony-detection)
30
+ *
31
+ * The contract used to describe a single fallback (`"api"`) while this module
32
+ * implemented `manual`. Both were right about different layers, and the docs
33
+ * conflated them. They are now named separately:
34
+ *
35
+ * - **Loader default** — when a config file omits `defaults.mode` entirely,
36
+ * `config.ts::_build_defaults` fills `api`. Unchanged; this is what every
37
+ * real config observes, because the loader always populates the key.
38
+ * - **Built-in fallback** — when NO layer supplies a mode at all (a settings
39
+ * dict handed straight to the resolver, no config file involved), the answer
40
+ * is `manual`, below. That is the fail-closed direction the no-silent-spend
41
+ * Iron Law requires: a caller who supplied no transport preference has not
42
+ * asked to spend money, so the free transport wins.
22
43
  */
23
44
 
24
- export const VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli']);
45
+ export const VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli', 'auto']);
25
46
 
47
+ /**
48
+ * Built-in fallback when no layer supplies a mode. `manual` — free, no key,
49
+ * no spend. NOT the same as the loader's `defaults.mode` default (`api`); see
50
+ * the module header.
51
+ */
26
52
  export const DEFAULT_MODE = 'manual';
27
53
 
28
54
  /** Raised when a configured / invoked mode is not in `VALID_MODES`. */
@@ -72,6 +98,46 @@ function _pyReprList(items: string[]): string {
72
98
  return `[${items.map((i) => _pyRepr(i)).join(', ')}]`;
73
99
  }
74
100
 
101
+ /**
102
+ * Extract the global transport mode from an `ai_council` block, accepting BOTH
103
+ * shapes the block legitimately arrives in.
104
+ *
105
+ * `council_cli.ts::_synthesize_ai_council_block` FLATTENS the loader's
106
+ * `defaults.mode` onto a top-level `mode` key, so the normal path presents the
107
+ * flat shape. But `build_members` is on the exported surface, and callers that
108
+ * hand it a RAW `.ai-council.yml`-shaped dict present the nested shape. Reading
109
+ * only the flat key silently dropped the configured default on that path and
110
+ * fell through to `DEFAULT_MODE`.
111
+ *
112
+ * Flat wins when both are present — it is the synthesized, already-resolved
113
+ * value. Returns `null` when neither shape carries a usable string, letting the
114
+ * precedence chain continue to the built-in.
115
+ */
116
+ export function resolve_global_mode(
117
+ aiBlock: ReadonlyMap<string, unknown> | Record<string, unknown> | null | undefined,
118
+ ): string | null {
119
+ if (aiBlock === null || aiBlock === undefined) {
120
+ return null;
121
+ }
122
+ const flat = _normalise(_settingsGet(aiBlock, 'mode'));
123
+ if (flat !== null) {
124
+ return flat;
125
+ }
126
+ const defaults = _settingsGet(aiBlock, 'defaults');
127
+ if (defaults === null || defaults === undefined) {
128
+ return null;
129
+ }
130
+ if (!(defaults instanceof Map) && typeof defaults !== 'object') {
131
+ return null;
132
+ }
133
+ if (Array.isArray(defaults)) {
134
+ return null;
135
+ }
136
+ return _normalise(
137
+ _settingsGet(defaults as ReadonlyMap<string, unknown> | Record<string, unknown>, 'mode'),
138
+ );
139
+ }
140
+
75
141
  export interface ResolveModeOptions {
76
142
  invocationMode?: string | null;
77
143
  memberSettings?: ReadonlyMap<string, unknown> | Record<string, unknown> | null;
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Transport resolution for `mode: auto`, plus the two guards that keep it
3
+ * honest (Phase 2 of `road-to-zero-ceremony-detection`).
4
+ *
5
+ * `modes.ts` answers "which mode did the config ask for" — a pure precedence
6
+ * walk over four layers. This module answers the next question: when that
7
+ * answer is `auto`, which concrete transport does THIS machine support right
8
+ * now, how is it billed, and what happens when the chosen rung fails
9
+ * mid-invocation.
10
+ *
11
+ * ## The `auto` chain
12
+ *
13
+ * cli — the provider's CLI binary resolves AND a credential is present
14
+ * api — else a key resolves
15
+ * ∅ — else unavailable, with a one-line reason
16
+ *
17
+ * `manual` is deliberately NOT in the chain. It is free and always "available"
18
+ * (the human is the transport), so an availability-ranked chain would always
19
+ * end there and `auto` would silently become "ask the human to copy-paste".
20
+ * Manual stays an explicit opt-in.
21
+ *
22
+ * ## The billing invariant
23
+ *
24
+ * Billing is classified from (provider, detected auth source) by
25
+ * `classifyBilling` in `_lib/environment_detector.ts` — never from the
26
+ * transport this module picked. That is the property that keeps the existing
27
+ * per-provider rules intact under `auto`:
28
+ *
29
+ * - a VENDOR-OFFICIAL CLI under a subscription login is unmetered, so the USD
30
+ * cost gate stays off and `cli_call_budget` guards the plan quota instead;
31
+ * - a COMMUNITY wrapper (`grok`, `perplexity`) shells out to the same paid API
32
+ * and stays metered even though the transport is also spelled `cli`;
33
+ * - an unknown source is metered — deliberately over-gated.
34
+ *
35
+ * `makesProviderCall` below IS transport-derived, and is not a billing term:
36
+ * it records that `manual` performs no provider call at all, which is why no
37
+ * amount of billing classification applies to it.
38
+ *
39
+ * @see docs/contracts/ai-council-config.md § Transport modes
40
+ */
41
+
42
+ import {
43
+ classifyBilling,
44
+ isCommunityCli,
45
+ strongestAuth,
46
+ PROVIDER_CLI_META,
47
+ type AuthSource,
48
+ type BillingClass,
49
+ type EnvironmentReport,
50
+ } from '../_lib/environment_detector.js';
51
+
52
+ /** A concrete transport a member can actually run on. `auto` is not one. */
53
+ export type Transport = 'api' | 'manual' | 'cli';
54
+
55
+ /** The accepted values of the `mode` key, including the resolver-only `auto`. */
56
+ export const VALID_TRANSPORT_MODES: ReadonlySet<string> = new Set([
57
+ 'api',
58
+ 'manual',
59
+ 'cli',
60
+ 'auto',
61
+ ]);
62
+
63
+ export interface ResolvedTransport {
64
+ /** False when `auto` found no usable rung; `reason` then says why. */
65
+ readonly available: boolean;
66
+ /** The concrete transport, or `null` when unavailable. */
67
+ readonly transport: Transport | null;
68
+ /** How a provider call is paid for. From auth source only — never from `transport`. */
69
+ readonly billing: BillingClass;
70
+ /** The auth source the classification used, or `null` when none was detected. */
71
+ readonly authSource: AuthSource | null;
72
+ /** Transport-derived, NOT a billing term: `manual` performs no provider call. */
73
+ readonly makesProviderCall: boolean;
74
+ /** One-line reason. Non-empty exactly when `available` is false. */
75
+ readonly reason: string | null;
76
+ }
77
+
78
+ export interface ResolveTransportOptions {
79
+ readonly provider: string;
80
+ /** The mode `resolve_mode` returned — `api` | `manual` | `cli` | `auto`. */
81
+ readonly mode: string;
82
+ /** The single read-only environment report. */
83
+ readonly report: EnvironmentReport;
84
+ /** Member-level `binary:` override, when set. Counts as a resolved binary. */
85
+ readonly binaryOverride?: string | null;
86
+ /**
87
+ * Whether the member's `api_key_ref` resolves. Defaults to "a key-file or
88
+ * env-key auth record exists for this provider" from the report.
89
+ */
90
+ readonly apiKeyPresent?: boolean;
91
+ }
92
+
93
+ /** Auth sources that mean "the provider CLI can authenticate". */
94
+ const CLI_AUTH_SOURCES: ReadonlySet<AuthSource> = new Set(['cli-subscription', 'cli-api-key']);
95
+
96
+ /** Auth sources that mean "a stored key exists for the api transport". */
97
+ const KEY_AUTH_SOURCES: ReadonlySet<AuthSource> = new Set(['key-file', 'env-key']);
98
+
99
+ function hasSource(
100
+ report: EnvironmentReport,
101
+ provider: string,
102
+ allowed: ReadonlySet<AuthSource>,
103
+ ): boolean {
104
+ return report.auth.some((a) => a.provider === provider && allowed.has(a.source));
105
+ }
106
+
107
+ /** Resolved CLI binary for `provider` from the report, or `null`. */
108
+ function cliBinary(report: EnvironmentReport, provider: string): string | null {
109
+ const meta = PROVIDER_CLI_META[provider];
110
+ if (meta === undefined) return null;
111
+ const binaryName = meta[0];
112
+ const host = report.hosts.find(
113
+ (h) => h.binary !== null && stripExeSuffix(basename(h.binary)) === binaryName,
114
+ );
115
+ return host?.binary ?? null;
116
+ }
117
+
118
+ /** Last path segment, separator-agnostic (no `node:path` import needed). */
119
+ function basename(p: string): string {
120
+ const parts = p.split(/[/\\]/);
121
+ return parts[parts.length - 1] ?? p;
122
+ }
123
+
124
+ /** Strip a Windows executable suffix so `claude.cmd` matches `claude`. */
125
+ function stripExeSuffix(name: string): string {
126
+ return name.replace(/\.(exe|cmd)$/i, '');
127
+ }
128
+
129
+ /**
130
+ * Resolve one member's transport.
131
+ *
132
+ * Explicit `api` / `manual` / `cli` pass through unchanged — this function
133
+ * never overrides a mode the user or a per-member override asked for. Only
134
+ * `auto` walks the chain.
135
+ */
136
+ export function resolveTransport(opts: ResolveTransportOptions): ResolvedTransport {
137
+ const { provider, mode, report } = opts;
138
+ const auth = strongestAuth(report, provider);
139
+ const authSource = auth?.source ?? null;
140
+ const billing = classifyBilling(provider, authSource);
141
+
142
+ if (mode === 'manual') {
143
+ return {
144
+ available: true,
145
+ transport: 'manual',
146
+ // Classified from the auth source like every other member; it is
147
+ // simply never consulted because no provider call happens.
148
+ billing,
149
+ authSource,
150
+ makesProviderCall: false,
151
+ reason: null,
152
+ };
153
+ }
154
+
155
+ if (mode === 'api' || mode === 'cli') {
156
+ return {
157
+ available: true,
158
+ transport: mode,
159
+ billing,
160
+ authSource,
161
+ makesProviderCall: true,
162
+ reason: null,
163
+ };
164
+ }
165
+
166
+ if (mode !== 'auto') {
167
+ return unavailable(
168
+ provider,
169
+ billing,
170
+ `mode=${JSON.stringify(mode)} is not one of ` +
171
+ `${[...VALID_TRANSPORT_MODES].sort().join(', ')}`,
172
+ );
173
+ }
174
+
175
+ // ── the auto chain ──────────────────────────────────────────────────
176
+ const override = (opts.binaryOverride ?? null)?.trim() ?? null;
177
+ const binary = override !== null && override !== '' ? override : cliBinary(report, provider);
178
+ const cliAuthed = hasSource(report, provider, CLI_AUTH_SOURCES);
179
+
180
+ if (binary !== null && cliAuthed) {
181
+ return {
182
+ available: true,
183
+ transport: 'cli',
184
+ billing,
185
+ authSource,
186
+ makesProviderCall: true,
187
+ reason: null,
188
+ };
189
+ }
190
+
191
+ const keyPresent = opts.apiKeyPresent ?? hasSource(report, provider, KEY_AUTH_SOURCES);
192
+ if (keyPresent) {
193
+ return {
194
+ available: true,
195
+ transport: 'api',
196
+ billing,
197
+ authSource,
198
+ makesProviderCall: true,
199
+ reason: null,
200
+ };
201
+ }
202
+
203
+ return unavailable(provider, billing, autoUnavailableReason(provider, binary, cliAuthed));
204
+ }
205
+
206
+ function unavailable(
207
+ _provider: string,
208
+ billing: BillingClass,
209
+ reason: string,
210
+ ): ResolvedTransport {
211
+ return {
212
+ available: false,
213
+ transport: null,
214
+ // Unavailable members never spend, but the class stays over-gated so a
215
+ // caller that ignores `available` cannot read this as "free".
216
+ billing,
217
+ authSource: null,
218
+ makesProviderCall: false,
219
+ reason,
220
+ };
221
+ }
222
+
223
+ /** The one-line reason `auto` prints when no rung resolved. */
224
+ function autoUnavailableReason(
225
+ provider: string,
226
+ binary: string | null,
227
+ cliAuthed: boolean,
228
+ ): string {
229
+ const expected = PROVIDER_CLI_META[provider]?.[0] ?? provider;
230
+ if (binary === null && !cliAuthed) {
231
+ return (
232
+ `no transport available: \`${expected}\` is not on PATH and no key resolves ` +
233
+ `— install the CLI and log in, or add an api_key_ref`
234
+ );
235
+ }
236
+ if (binary === null) {
237
+ return (
238
+ `no transport available: a credential is present but \`${expected}\` is not ` +
239
+ `on PATH and no key resolves — install the CLI, or add an api_key_ref`
240
+ );
241
+ }
242
+ return (
243
+ `no transport available: \`${expected}\` is on PATH but not authenticated and no ` +
244
+ `key resolves — log in to the CLI, or add an api_key_ref`
245
+ );
246
+ }
247
+
248
+ // ── mid-flight fallback ─────────────────────────────────────────────────
249
+
250
+ /**
251
+ * Why a CLI call failed, normalised from `CliClient._classify_stderr` and the
252
+ * `binary_missing` skip reason.
253
+ */
254
+ export type CliFailureClass =
255
+ | 'binary_missing'
256
+ | 'auth_rejected'
257
+ | 'cli_unsupported'
258
+ | 'timeout'
259
+ | 'server_error'
260
+ | 'quota_exhausted'
261
+ | 'other';
262
+
263
+ /**
264
+ * Failure classes that may fall through to the `api` rung.
265
+ *
266
+ * All three share one property: the CLI provably never reached the provider,
267
+ * so retrying on another transport cannot double-charge. Everything absent
268
+ * from this set — notably `timeout` and `server_error` — may have left a
269
+ * half-completed call behind, and falling through would risk paying twice for
270
+ * one question. `quota_exhausted` is excluded for a different reason: the
271
+ * quota is a cap the user deliberately set, so routing around it would defeat
272
+ * the guard rather than recover from a fault.
273
+ */
274
+ const FALLBACK_ELIGIBLE: ReadonlySet<CliFailureClass> = new Set([
275
+ 'binary_missing',
276
+ 'auth_rejected',
277
+ 'cli_unsupported',
278
+ ]);
279
+
280
+ /** Normalise a raw client error / skip reason into a `CliFailureClass`. */
281
+ export function classifyCliFailure(raw: string): CliFailureClass {
282
+ const s = raw.trim().toLowerCase();
283
+ if (s === 'binary_missing' || s === 'file_not_found') return 'binary_missing';
284
+ if (s === 'auth_expired' || s === 'auth_rejected' || s === 'unauthorized') {
285
+ return 'auth_rejected';
286
+ }
287
+ if (s === 'cli_unsupported' || s === 'unsupported' || s === 'parse_failed') {
288
+ return 'cli_unsupported';
289
+ }
290
+ if (s === 'timeout') return 'timeout';
291
+ if (s === 'cli_quota_exhausted' || s === 'quota_exhausted') return 'quota_exhausted';
292
+ if (/^exit_5\d\d$/.test(s) || s === 'server_error' || s === 'os') return 'server_error';
293
+ return 'other';
294
+ }
295
+
296
+ /** True when `failure` is eligible to fall through to the `api` rung. */
297
+ export function isFallbackEligible(failure: CliFailureClass): boolean {
298
+ return FALLBACK_ELIGIBLE.has(failure);
299
+ }
300
+
301
+ /**
302
+ * One-shot, per-provider fallback ledger for a single invocation.
303
+ *
304
+ * Construct one per council run. `attempt` returns `'api'` at most ONCE per
305
+ * provider, and only for a fallback-eligible failure class; every later call
306
+ * for that provider returns `'stop'`. This is the double-spend guard: a
307
+ * timeout never falls through, and an eligible failure never falls through
308
+ * twice.
309
+ */
310
+ export class MidFlightFallback {
311
+ private readonly used = new Set<string>();
312
+
313
+ /** `'api'` to retry on the api rung, `'stop'` to surface the failure. */
314
+ attempt(provider: string, failure: CliFailureClass): 'api' | 'stop' {
315
+ if (!isFallbackEligible(failure)) return 'stop';
316
+ if (this.used.has(provider)) return 'stop';
317
+ this.used.add(provider);
318
+ return 'api';
319
+ }
320
+
321
+ /** Providers that have already spent their one fallback. */
322
+ spent(): readonly string[] {
323
+ return [...this.used].sort();
324
+ }
325
+ }
326
+
327
+ /**
328
+ * True when `provider`'s CLI transport is a community wrapper — re-exported
329
+ * so callers reasoning about `auto` do not have to reach into the detector.
330
+ */
331
+ export { isCommunityCli };
@@ -62,10 +62,16 @@ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
62
62
 
63
63
  // Tracked directories that may hold a build-generated bundle. Only
64
64
  // `dist/install/` is force-included in `.gitignore` today (`!/dist/install/`);
65
- // `dist/hooks/` and `dist/mcp/` are listed defensively in case a future
66
- // change tracks them too — `git ls-files` on an untracked directory returns
67
- // nothing, so listing an as-yet-untracked root here is always safe.
68
- const BUNDLE_ROOTS: readonly string[] = ['dist/install', 'dist/hooks', 'dist/mcp'];
65
+ // `dist/hooks/`, `dist/mcp/` and `dist/cli-delegate/` are listed defensively
66
+ // in case a future change tracks them too — `git ls-files` on an untracked
67
+ // directory returns nothing, so listing an as-yet-untracked root here is
68
+ // always safe.
69
+ const BUNDLE_ROOTS: readonly string[] = [
70
+ 'dist/install',
71
+ 'dist/hooks',
72
+ 'dist/mcp',
73
+ 'dist/cli-delegate',
74
+ ];
69
75
 
70
76
  interface LeakPattern {
71
77
  readonly name: string;