@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +111 -0
- package/README.md +20 -16
- package/agents/templates/.ai-council.yml.example +481 -0
- package/agents/templates/.ai-video.xml.example +247 -0
- package/dist/agent-src/commands/fix/ci.md +29 -0
- package/dist/agent-src/commands/pr/create.md +60 -0
- package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
- package/dist/agent-src/rules/design-fidelity.md +2 -2
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
- package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
- package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
- package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
- package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
- package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
- package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
- package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
- package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
- package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
- package/dist/cli/initRouting.js +74 -11
- package/dist/cli/initRouting.js.map +1 -1
- package/dist/cli/main.js +14 -3
- package/dist/cli/main.js.map +1 -1
- package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
- package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
- package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
- package/dist/cli-delegate/chunk-A3HJ6NTI.js +1084 -0
- package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
- package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
- package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
- package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
- package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
- package/dist/cli-delegate/chunk-HYWSRNUX.js +179 -0
- package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
- package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
- package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
- package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
- package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
- package/dist/cli-delegate/chunk-QFKQLQ7X.js +10835 -0
- package/dist/cli-delegate/chunk-TEGJU2JX.js +6908 -0
- package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
- package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
- package/dist/cli-delegate/cmd_analyze_session.js +294 -0
- package/dist/cli-delegate/cmd_conformance.js +643 -0
- package/dist/cli-delegate/cmd_converge.js +326 -0
- package/dist/cli-delegate/cmd_doctor.js +142 -0
- package/dist/cli-delegate/cmd_explain.js +2576 -0
- package/dist/cli-delegate/cmd_export.js +298 -0
- package/dist/cli-delegate/cmd_fleet.js +333 -0
- package/dist/cli-delegate/cmd_memory_get.js +3878 -0
- package/dist/cli-delegate/cmd_migrate.js +11 -0
- package/dist/cli-delegate/cmd_preflight.js +153 -0
- package/dist/cli-delegate/cmd_prune.js +446 -0
- package/dist/cli-delegate/cmd_refresh.js +28 -0
- package/dist/cli-delegate/cmd_settings_check.js +574 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +225 -0
- package/dist/cli-delegate/cmd_sync.js +266 -0
- package/dist/cli-delegate/cmd_uninstall.js +657 -0
- package/dist/cli-delegate/cmd_update.js +425 -0
- package/dist/cli-delegate/cmd_upgrade.js +444 -0
- package/dist/cli-delegate/cmd_validate.js +307 -0
- package/dist/cli-delegate/cmd_versions.js +370 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +45 -16
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -7
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -4
- package/dist/install/emit_host_rules_cli.js +2 -1
- package/dist/install/emit_host_rules_cli.js.map +1 -1
- package/dist/install/install.mjs +634 -409
- package/dist/install/ruleInScope.js +123 -0
- package/dist/install/ruleInScope.js.map +1 -0
- package/dist/install/rule_scope.js +7 -3
- package/dist/install/rule_scope.js.map +1 -1
- package/dist/install/toolDetection.js +40 -12
- package/dist/install/toolDetection.js.map +1 -1
- package/dist/install/wizard-plan.js +12 -3
- package/dist/install/wizard-plan.js.map +1 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1553 -2728
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +6 -22
- package/dist/scripts/condense.js.map +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +14 -0
- package/package.json +4 -2
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
- package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
- package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
- package/src/config/discovery/packs.yml +7 -0
- package/src/config/evaluator-budgets.json +1 -1
- package/src/config/pack-size-budget.json +27 -0
- package/src/install/emit_host_rules_cli.ts +2 -1
- package/src/install/ruleInScope.ts +133 -0
- package/src/install/rule_scope.ts +7 -3
- package/src/install/toolDetection.ts +39 -8
- package/src/install/wizard-plan.ts +13 -3
- package/src/scripts/_cli/cmd_conformance.ts +2 -1
- package/src/scripts/_cli/cmd_converge.ts +2 -1
- package/src/scripts/_cli/cmd_doctor.ts +214 -13
- package/src/scripts/_cli/cmd_export.ts +2 -1
- package/src/scripts/_cli/cmd_fleet.ts +2 -1
- package/src/scripts/_cli/cmd_migrate.ts +2 -1
- package/src/scripts/_cli/cmd_preflight.ts +2 -1
- package/src/scripts/_cli/cmd_refresh.ts +2 -1
- package/src/scripts/_cli/cmd_update.ts +2 -1
- package/src/scripts/_cli/cmd_upgrade.ts +2 -1
- package/src/scripts/_cli/cmd_versions.ts +2 -1
- package/src/scripts/_cli/detection_report.ts +480 -0
- package/src/scripts/_dispatch.bash +52 -14
- package/src/scripts/_lib/changelog_eras.ts +103 -9
- package/src/scripts/_lib/environment_detector.ts +441 -0
- package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
- package/src/scripts/_lib/package_root.ts +72 -0
- package/src/scripts/ai_council/config.ts +15 -7
- package/src/scripts/ai_council/modes.ts +68 -2
- package/src/scripts/ai_council/transport_resolver.ts +331 -0
- package/src/scripts/check_bundle_path_leakage.ts +10 -4
- package/src/scripts/check_dependency_floors.ts +153 -0
- package/src/scripts/check_installer_import_purity.ts +295 -0
- package/src/scripts/check_pack_size.ts +188 -0
- package/src/scripts/condense.ts +7 -28
- package/src/scripts/council_cli.ts +6 -2
- package/src/scripts/evaluator_umbrella.sh +34 -1
- package/src/scripts/install-hooks.sh +36 -4
- package/src/scripts/install.ts +210 -5
- package/src/scripts/lint_eval_fixture_citations.ts +113 -0
- 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
|
|
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
|
-
|
|
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/
|
|
66
|
-
// change tracks them too — `git ls-files` on an untracked
|
|
67
|
-
// nothing, so listing an as-yet-untracked root here is
|
|
68
|
-
|
|
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;
|