@event4u/agent-config 9.10.0 → 9.11.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 (121) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +59 -0
  3. package/README.md +12 -15
  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/rules/design-fidelity.md +1 -1
  7. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
  8. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +30 -30
  11. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  12. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +20 -0
  13. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +20 -1
  14. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +179 -0
  15. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +155 -4
  16. package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
  17. package/dist/cli/initRouting.js +74 -11
  18. package/dist/cli/initRouting.js.map +1 -1
  19. package/dist/cli/main.js +14 -3
  20. package/dist/cli/main.js.map +1 -1
  21. package/dist/cli-delegate/chunk-4SV3XGFA.js +6910 -0
  22. package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
  23. package/dist/cli-delegate/chunk-7DSAMZFU.js +356 -0
  24. package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
  25. package/dist/cli-delegate/chunk-BJGXZNPM.js +266 -0
  26. package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
  27. package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
  28. package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
  29. package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
  30. package/dist/cli-delegate/chunk-IR7P7JWA.js +872 -0
  31. package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
  32. package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
  33. package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
  34. package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
  35. package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
  36. package/dist/cli-delegate/chunk-RDGRHAAB.js +259 -0
  37. package/dist/cli-delegate/chunk-TFG57JJM.js +1969 -0
  38. package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
  39. package/dist/cli-delegate/chunk-YMFO6FDN.js +10603 -0
  40. package/dist/cli-delegate/cmd_analyze_session.js +294 -0
  41. package/dist/cli-delegate/cmd_conformance.js +646 -0
  42. package/dist/cli-delegate/cmd_converge.js +326 -0
  43. package/dist/cli-delegate/cmd_doctor.js +144 -0
  44. package/dist/cli-delegate/cmd_explain.js +2508 -0
  45. package/dist/cli-delegate/cmd_export.js +296 -0
  46. package/dist/cli-delegate/cmd_fleet.js +332 -0
  47. package/dist/cli-delegate/cmd_memory_get.js +3878 -0
  48. package/dist/cli-delegate/cmd_migrate.js +11 -0
  49. package/dist/cli-delegate/cmd_preflight.js +270 -0
  50. package/dist/cli-delegate/cmd_prune.js +444 -0
  51. package/dist/cli-delegate/cmd_refresh.js +28 -0
  52. package/dist/cli-delegate/cmd_settings_check.js +574 -0
  53. package/dist/cli-delegate/cmd_settings_migrate.js +223 -0
  54. package/dist/cli-delegate/cmd_sync.js +264 -0
  55. package/dist/cli-delegate/cmd_uninstall.js +656 -0
  56. package/dist/cli-delegate/cmd_update.js +425 -0
  57. package/dist/cli-delegate/cmd_upgrade.js +444 -0
  58. package/dist/cli-delegate/cmd_validate.js +305 -0
  59. package/dist/cli-delegate/cmd_versions.js +370 -0
  60. package/dist/discovery/deprecation-report.md +1 -1
  61. package/dist/discovery/discovery-manifest.json +14 -7
  62. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  63. package/dist/discovery/discovery-manifest.summary.md +2 -2
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +12 -7
  66. package/dist/discovery/trust-report.md +1 -1
  67. package/dist/discovery/workspaces.json +4 -3
  68. package/dist/install/emit_host_rules_cli.js +2 -1
  69. package/dist/install/emit_host_rules_cli.js.map +1 -1
  70. package/dist/install/install.mjs +410 -378
  71. package/dist/install/rule_scope.js +5 -1
  72. package/dist/install/rule_scope.js.map +1 -1
  73. package/dist/install/toolDetection.js +40 -12
  74. package/dist/install/toolDetection.js.map +1 -1
  75. package/dist/install/wizard-plan.js +2 -2
  76. package/dist/install/wizard-plan.js.map +1 -1
  77. package/dist/mcp/registry-manifest.json +1 -1
  78. package/dist/mcp/server.json +2 -2
  79. package/dist/mcp/server.mjs +1614 -765
  80. package/dist/router.json +1 -1
  81. package/package.json +4 -2
  82. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +30 -30
  83. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  84. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +20 -0
  85. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +20 -1
  86. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +179 -0
  87. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +155 -4
  88. package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
  89. package/src/config/discovery/packs.yml +7 -0
  90. package/src/config/evaluator-budgets.json +1 -1
  91. package/src/config/pack-size-budget.json +27 -0
  92. package/src/install/emit_host_rules_cli.ts +2 -1
  93. package/src/install/rule_scope.ts +5 -1
  94. package/src/install/toolDetection.ts +39 -8
  95. package/src/install/wizard-plan.ts +2 -2
  96. package/src/scripts/_cli/cmd_conformance.ts +2 -1
  97. package/src/scripts/_cli/cmd_converge.ts +2 -1
  98. package/src/scripts/_cli/cmd_doctor.ts +214 -13
  99. package/src/scripts/_cli/cmd_export.ts +2 -1
  100. package/src/scripts/_cli/cmd_fleet.ts +2 -1
  101. package/src/scripts/_cli/cmd_migrate.ts +2 -1
  102. package/src/scripts/_cli/cmd_preflight.ts +2 -1
  103. package/src/scripts/_cli/cmd_refresh.ts +2 -1
  104. package/src/scripts/_cli/cmd_update.ts +2 -1
  105. package/src/scripts/_cli/cmd_upgrade.ts +2 -1
  106. package/src/scripts/_cli/cmd_versions.ts +2 -1
  107. package/src/scripts/_cli/detection_report.ts +480 -0
  108. package/src/scripts/_dispatch.bash +52 -14
  109. package/src/scripts/_lib/changelog_eras.ts +103 -9
  110. package/src/scripts/_lib/environment_detector.ts +441 -0
  111. package/src/scripts/_lib/package_root.ts +72 -0
  112. package/src/scripts/ai_council/config.ts +15 -7
  113. package/src/scripts/ai_council/modes.ts +68 -2
  114. package/src/scripts/ai_council/transport_resolver.ts +331 -0
  115. package/src/scripts/check_bundle_path_leakage.ts +10 -4
  116. package/src/scripts/check_dependency_floors.ts +153 -0
  117. package/src/scripts/check_pack_size.ts +188 -0
  118. package/src/scripts/council_cli.ts +6 -2
  119. package/src/scripts/evaluator_umbrella.sh +34 -1
  120. package/src/scripts/lint_eval_fixture_citations.ts +113 -0
  121. package/src/scripts/lint_ui_stack_bundles.ts +170 -0
@@ -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;
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_dependency_floors — keep every runtime dependency floor at a settled
4
+ * minor, so consumers on `prefer-offline=true` or a lagging registry mirror
5
+ * cannot hit `ETARGET — No matching version found`
6
+ * (road-to-zero-ceremony-install § Phase 2).
7
+ *
8
+ * Why a gate and not a retry: `dependencies` are resolved on the CONSUMER's
9
+ * machine by npm, BEFORE our `bin` is executed. When resolution fails, npx
10
+ * aborts and no code of ours ever runs — so this failure mode cannot be
11
+ * caught, retried, or explained from inside the CLI. It can only be made
12
+ * unreachable at publish time, which is what this gate does. CONTRIBUTING
13
+ * § "Runtime dependency floors" states the rule in prose; this is its teeth.
14
+ *
15
+ * The rule: a caret floor pins the minimum compatible version, so its patch
16
+ * component is `.0` (`^9.5.0`, never `^9.6.1`). A floor pinned to a
17
+ * just-published patch fails against cached registry metadata that predates
18
+ * it, even though the version exists on public npm.
19
+ *
20
+ * Exact pins (no range operator) are a different, deliberate mechanism — an
21
+ * ABI lock — and are allowed only when listed in EXACT_PIN_EXCEPTIONS with a
22
+ * reason, so a drive-by pin cannot hide among them.
23
+ *
24
+ * SECURITY FLOORS OUTRANK THIS RULE. A floor whose patch component is non-zero
25
+ * *because everything below it carries an advisory* must never be lowered to
26
+ * satisfy resolvability — that trades a rare install failure for a live CVE.
27
+ * Those live in SECURITY_FLOOR_EXCEPTIONS with the advisory and the vulnerable
28
+ * range, and the gate then enforces the floor rather than fighting it.
29
+ *
30
+ * Usage:
31
+ * ./scripts-run src/scripts/check_dependency_floors [--json]
32
+ *
33
+ * Exit codes: 0 green · 1 a floor violates the rule · 2 misuse / unreadable manifest.
34
+ */
35
+
36
+ import * as fs from 'node:fs';
37
+ import * as path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
41
+
42
+ /**
43
+ * Dependencies deliberately pinned to an exact version, with the reason. An
44
+ * exact pin cannot suffer the freshest-patch failure (the version is either
45
+ * cached or it is not), but it also forfeits patch fixes — so each one is
46
+ * named here rather than silently tolerated.
47
+ */
48
+ export const EXACT_PIN_EXCEPTIONS: Readonly<Record<string, string>> = {
49
+ 'web-tree-sitter': 'ABI-locked against tree-sitter-wasms; see code-graph engine pin',
50
+ 'tree-sitter-wasms': 'ABI-locked against web-tree-sitter; see code-graph engine pin',
51
+ };
52
+
53
+ /**
54
+ * Floors that are non-settled ON PURPOSE, because every version below them is
55
+ * vulnerable. The required range is pinned here so the gate ENFORCES the floor
56
+ * instead of asking for it to be lowered — lowering one trades a rare install
57
+ * failure for a live advisory, which is never the right trade.
58
+ */
59
+ export const SECURITY_FLOOR_EXCEPTIONS: Readonly<Record<string, { required: string; reason: string }>> = {
60
+ '@fastify/static': {
61
+ required: '^10.1.2',
62
+ reason:
63
+ 'high-severity route-guard bypass via path traversal (CVSS 7.5) affects <=10.1.1; ' +
64
+ '10.1.2 is the fix. Verified with npm audit on a clean 10.1.0 install, 2026-07-31.',
65
+ },
66
+ };
67
+
68
+ /** A settled caret floor: `^X.Y.0`. */
69
+ const SETTLED_CARET = /^\^\d+\.\d+\.0$/;
70
+
71
+ /** An exact version with no range operator: `1.2.3`. */
72
+ const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
73
+
74
+ /**
75
+ * Every violation in `dependencies`, as human-readable lines. Empty array
76
+ * means the manifest cannot produce a freshest-patch `ETARGET` for a consumer.
77
+ */
78
+ export function evaluate(dependencies: Readonly<Record<string, string>>): string[] {
79
+ const errors: string[] = [];
80
+ for (const [name, range] of Object.entries(dependencies)) {
81
+ const security = SECURITY_FLOOR_EXCEPTIONS[name];
82
+ if (security !== undefined) {
83
+ // The floor is the control. Enforce it; never ask for it to settle.
84
+ if (range !== security.required) {
85
+ errors.push(
86
+ `${name}@${range}: security floor must stay at ${security.required} — ${security.reason}`,
87
+ );
88
+ }
89
+ continue;
90
+ }
91
+ if (EXACT_VERSION.test(range)) {
92
+ if (!(name in EXACT_PIN_EXCEPTIONS)) {
93
+ errors.push(
94
+ `${name}@${range}: exact pin without an entry in EXACT_PIN_EXCEPTIONS — ` +
95
+ 'add the reason there, or use a settled caret floor (^X.Y.0)',
96
+ );
97
+ }
98
+ continue;
99
+ }
100
+ if (SETTLED_CARET.test(range)) continue;
101
+ if (range.startsWith('^')) {
102
+ errors.push(
103
+ `${name}@${range}: caret floor is not a settled minor — lower the patch to ` +
104
+ `.0 (e.g. ${range.replace(/\.\d+$/, '.0')}) so a lagging registry mirror ` +
105
+ 'can still resolve it',
106
+ );
107
+ continue;
108
+ }
109
+ errors.push(
110
+ `${name}@${range}: unsupported range shape — use a settled caret floor (^X.Y.0), ` +
111
+ 'or an exact pin listed in EXACT_PIN_EXCEPTIONS',
112
+ );
113
+ }
114
+ return errors;
115
+ }
116
+
117
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
118
+ const asJson = argv.includes('--json');
119
+ const manifestPath = path.join(REPO_ROOT, 'package.json');
120
+ let dependencies: Record<string, string>;
121
+ try {
122
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as {
123
+ dependencies?: Record<string, string>;
124
+ };
125
+ dependencies = raw.dependencies ?? {};
126
+ } catch (err) {
127
+ process.stderr.write(`❌ dependency floors: cannot read ${manifestPath}: ${String(err)}\n`);
128
+ return 2;
129
+ }
130
+ if (Object.keys(dependencies).length === 0) {
131
+ process.stderr.write('❌ dependency floors: no runtime dependencies found — gate would pass vacuously\n');
132
+ return 2;
133
+ }
134
+ const errors = evaluate(dependencies);
135
+ if (asJson) {
136
+ process.stdout.write(`${JSON.stringify({ checked: Object.keys(dependencies).length, errors }, null, 2)}\n`);
137
+ return errors.length > 0 ? 1 : 0;
138
+ }
139
+ if (errors.length > 0) {
140
+ for (const e of errors) process.stderr.write(`❌ dependency floor: ${e}\n`);
141
+ return 1;
142
+ }
143
+ process.stdout.write(
144
+ `✅ dependency floors settled (${Object.keys(dependencies).length} runtime deps) — ` +
145
+ 'no freshest-patch ETARGET reachable for a consumer\n',
146
+ );
147
+ return 0;
148
+ }
149
+
150
+ const _selfPath = fileURLToPath(import.meta.url);
151
+ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(_selfPath)) {
152
+ process.exit(main());
153
+ }