@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
@@ -409,13 +409,97 @@ function _rstrip(text: string): string {
409
409
  return text.replace(/\s+$/u, '');
410
410
  }
411
411
 
412
+ /**
413
+ * Split a current-era body into (carry-over, archived) at `boundary`.
414
+ *
415
+ * The archive is named `pre-<boundary>`, so by its own contract it may only
416
+ * hold entries STRICTLY OLDER than the boundary. Sections at or above it —
417
+ * i.e. the incoming release's own entry, when it is already prepended — stay
418
+ * in the new era.
419
+ *
420
+ * On a fresh release run the body never contains the incoming entry (the split
421
+ * runs before the prepend), so the carry-over is empty and this is a no-op.
422
+ * On a `--resume` run the entry IS already committed and the bump step is
423
+ * correctly skipped, so without this partition the split archived the release
424
+ * it was named after and nothing ever put it back: the current era ended up
425
+ * empty and the `changelog-entry` CI gate went red. That is the 9.10.0
426
+ * failure. The partition makes the two orderings converge.
427
+ *
428
+ * Changelog bodies are newest-first, so the carry-over is a prefix — but the
429
+ * scan is per-heading rather than "stop at the first older one" so a
430
+ * hand-edited out-of-order body cannot silently archive a newer entry.
431
+ */
432
+ export function _partition_era_body(
433
+ body: readonly string[],
434
+ boundary: string,
435
+ ): { carry: string[]; archived: string[] } {
436
+ const b = _RELEASE_VERSION_RE.exec(boundary.trim());
437
+ if (!b) {
438
+ throw new Error(`not a bare semver (X.Y.Z): '${boundary}'`);
439
+ }
440
+ const bound: [number, number, number] = [
441
+ Number.parseInt(b[1]!, 10),
442
+ Number.parseInt(b[2]!, 10),
443
+ Number.parseInt(b[3]!, 10),
444
+ ];
445
+
446
+ // Section boundaries: every `## [X.Y.Z]` heading starts a new section.
447
+ // Content before the first heading (there should be none) rides along with
448
+ // the archive, matching the pre-fix behaviour for that shape.
449
+ const starts: number[] = [];
450
+ for (let i = 0; i < body.length; i++) {
451
+ if (VERSION_HEADING_RE.test(body[i]!)) {
452
+ starts.push(i);
453
+ }
454
+ }
455
+ if (starts.length === 0) {
456
+ return { carry: [], archived: [...body] };
457
+ }
458
+
459
+ const carry: string[] = [];
460
+ const archived: string[] = [...body.slice(0, starts[0]!)];
461
+ for (let s = 0; s < starts.length; s++) {
462
+ const from = starts[s]!;
463
+ const to = s + 1 < starts.length ? starts[s + 1]! : body.length;
464
+ const section = body.slice(from, to);
465
+ const m = VERSION_HEADING_RE.exec(body[from]!);
466
+ const v = m?.groups?.['version'];
467
+ const parsed = v ? _RELEASE_VERSION_RE.exec(v) : null;
468
+ if (parsed === null || parsed === undefined) {
469
+ archived.push(...section);
470
+ continue;
471
+ }
472
+ const cur: [number, number, number] = [
473
+ Number.parseInt(parsed[1]!, 10),
474
+ Number.parseInt(parsed[2]!, 10),
475
+ Number.parseInt(parsed[3]!, 10),
476
+ ];
477
+ const isOlder =
478
+ cur[0] < bound[0] ||
479
+ (cur[0] === bound[0] && cur[1] < bound[1]) ||
480
+ (cur[0] === bound[0] && cur[1] === bound[1] && cur[2] < bound[2]);
481
+ (isOlder ? archived : carry).push(...section);
482
+ }
483
+ return { carry, archived };
484
+ }
485
+
486
+ /** Drop trailing blank lines (in place semantics, returns the same array). */
487
+ function _trim_trailing_blanks(xs: string[]): string[] {
488
+ while (xs.length > 0 && xs[xs.length - 1]!.trim() === '') {
489
+ xs.pop();
490
+ }
491
+ return xs;
492
+ }
493
+
412
494
  /**
413
495
  * Execute `plan` against the on-disk CHANGELOG.md.
414
496
  *
415
497
  * - Refuses to overwrite an existing archive file.
416
- * - Moves every entry in the current era body into the new archive.
498
+ * - Moves every entry OLDER than the boundary into the new archive; an
499
+ * already-prepended entry for the incoming release stays in the new era
500
+ * (see `_partition_era_body`).
417
501
  * - Replaces the current era block with the collapsed pointer + the
418
- * freshly-labelled new current era header (empty body).
502
+ * freshly-labelled new current era header.
419
503
  */
420
504
  export function perform_split(plan: SplitPlan): void {
421
505
  if (fs.existsSync(plan.archive_path)) {
@@ -433,21 +517,31 @@ export function perform_split(plan: SplitPlan): void {
433
517
  }
434
518
 
435
519
  const [body_start, , next_era_line] = _era_body_bounds(lines, current_idx);
436
- const entries = lines.slice(body_start, next_era_line);
437
- // Trim trailing blank lines so the archive doesn't accumulate them.
438
- while (entries.length > 0 && entries[entries.length - 1]!.trim() === '') {
439
- entries.pop();
440
- }
520
+ const body = lines.slice(body_start, next_era_line);
521
+ // Entries at/above the boundary (a resume run's already-prepended release
522
+ // entry) stay in the new era; only older ones are archived.
523
+ const { carry, archived } = _partition_era_body(body, plan.boundary);
524
+ // Trim trailing blank lines so neither side accumulates them.
525
+ _trim_trailing_blanks(archived);
526
+ _trim_trailing_blanks(carry);
441
527
 
442
528
  const collapsed = _splitlines(_rstrip_newlines(collapsed_era_block(plan.boundary)));
443
529
  const new_era = _splitlines(_rstrip_newlines(new_era_intro_block(plan.new_era_label, plan.boundary)));
444
530
 
445
531
  const head = lines.slice(0, current_idx);
446
532
  const tail = lines.slice(next_era_line);
447
- const new_lines = [...head, ...collapsed, '', ...new_era, '', ...tail];
533
+ const new_lines = [
534
+ ...head,
535
+ ...collapsed,
536
+ '',
537
+ ...new_era,
538
+ '',
539
+ ...(carry.length > 0 ? [...carry, ''] : []),
540
+ ...tail,
541
+ ];
448
542
  const new_text = _rstrip(new_lines.join('\n')) + '\n';
449
543
 
450
- const archive_body = entries.length > 0 ? _rstrip(entries.join('\n')) + '\n' : '';
544
+ const archive_body = archived.length > 0 ? _rstrip(archived.join('\n')) + '\n' : '';
451
545
  const archive_text = archive_file_header(plan.boundary) + archive_body;
452
546
 
453
547
  fs.mkdirSync(path.dirname(plan.archive_path), { recursive: true });
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Read-only environment detection — one report, no spend (Phase 1 of
3
+ * `road-to-zero-ceremony-detection`).
4
+ *
5
+ * Answers "what does this machine already have?" in a single pass so the
6
+ * `doctor` surface and the council's transport resolution read the SAME
7
+ * facts instead of each probing on its own. Three record kinds:
8
+ *
9
+ * - `hosts` — which AI hosts are installed, their resolved binary, version.
10
+ * - `auth` — which provider credentials are PRESENT, and of which source.
11
+ * - `keys` — the `api_key_ref`-shaped reference for each present key.
12
+ *
13
+ * ## Guarantees
14
+ *
15
+ * - **Zero network.** Nothing here opens a socket. Enforced by the static
16
+ * import check in the test suite, not by convention.
17
+ * - **Zero spend.** The only subprocess is `<binary> --version`, which no
18
+ * provider bills. No completion, no token, ever — the roadmap's
19
+ * install-time-completion item was cut for exactly this reason.
20
+ * - **Presence, never validity.** An `auth` record says "a credential of this
21
+ * shape exists at this path". It does NOT say the credential works, is
22
+ * unexpired, or is accepted by the provider. Runtime auth failure stays
23
+ * authoritative (`CliClient._AUTH_FAILURE_PATTERNS`).
24
+ * - **Read-only.** Reads the filesystem and the injected env map; writes
25
+ * nothing, creates no directory.
26
+ *
27
+ * ## No third probe shape — what each field absorbs
28
+ *
29
+ * Auth probing already existed in two shapes before this module and a third
30
+ * must not appear. This module composes; it re-implements nothing:
31
+ *
32
+ * | Field | Absorbed from |
33
+ * |---|---|
34
+ * | `hosts[].installed` | `install/toolDetection.ts::detectInstalledTools` (the 23-host signal table) |
35
+ * | `hosts[].binary` | `install/toolDetection.ts::resolveToolBinary` (same table, path-returning) |
36
+ * | `hosts[].version` | `install/agentSwitchDetection.ts::parseVersionOutput` (the first-dotted-number parser) |
37
+ * | `auth` (`openai`, `cli-*`) | `_cli/cmd_doctor.ts`'s read-only `$CODEX_HOME/auth.json` probe — `codexHome()` moved HERE and imported back, so there is one definition |
38
+ * | `auth` (`anthropic`, `cli-subscription`) | `_lib/claude_plugin.ts::claude_config_dir` (`CLAUDE_CONFIG_DIR` override incl.) |
39
+ * | `auth` (`key-file`) | `_lib/user_global_paths.ts::resolve_with_fallback` (new namespace, legacy read-fallback) |
40
+ *
41
+ * The council's TTL'd `AuthCache` (`ai_council/solo_dispatch.ts`) is the third
42
+ * existing shape and is deliberately NOT absorbed: it caches a *liveness*
43
+ * verdict from an injected probe that may spend a call. This module only
44
+ * reports presence, so it needs no TTL — a per-process memo is enough.
45
+ *
46
+ * ## Known limitation — CLI logins the filesystem does not reveal
47
+ *
48
+ * A vendor CLI that stores its subscription credential OUTSIDE the filesystem
49
+ * is undetectable here. Measured case: on macOS the `claude` CLI keeps its
50
+ * OAuth credential in the system Keychain, so `~/.claude/.credentials.json`
51
+ * is absent even for a logged-in Claude Pro user. The detector reports
52
+ * `cli-subscription` only where a credential FILE exists (Linux, and the
53
+ * `CLAUDE_CONFIG_DIR` profile layouts).
54
+ *
55
+ * Consequence, stated because it costs money: with no detectable CLI login,
56
+ * `mode: auto` skips the cli rung and resolves to `api` — metered — for a user
57
+ * who does hold a subscription. This is a FALSE NEGATIVE, which is the
58
+ * direction this module is built to fail in (a false positive would claim a
59
+ * subscription that is not there and switch the USD gate off). The remedy is an
60
+ * explicit `mode: cli` on the member; `doctor` shows the detected source so the
61
+ * gap is visible rather than silent. Reading the Keychain would require a
62
+ * `security(1)` subprocess against a credential store — a spend-free but
63
+ * privacy-relevant probe, and out of scope here.
64
+ *
65
+ * @see docs/contracts/ai-council-config.md § Transport modes — the billing rules
66
+ * `classifyBilling` implements.
67
+ */
68
+
69
+ import { spawnSync } from 'node:child_process';
70
+ import * as fs from 'node:fs';
71
+ import * as os from 'node:os';
72
+ import * as path from 'node:path';
73
+
74
+ import {
75
+ detectInstalledTools,
76
+ knownToolIds,
77
+ resolveToolBinary,
78
+ } from '../../install/toolDetection.js';
79
+ import { parseVersionOutput } from '../../install/agentSwitchDetection.js';
80
+ import { claude_config_dir } from './claude_plugin.js';
81
+ import { hardenedSpawnEnv } from './spawn_env.js';
82
+ import { resolve_with_fallback, type EnvMap } from './user_global_paths.js';
83
+
84
+ /** Where a present credential was found. Never a claim that it works. */
85
+ export type AuthSource = 'cli-subscription' | 'cli-api-key' | 'key-file' | 'env-key';
86
+
87
+ /**
88
+ * How a call against this provider is paid for.
89
+ *
90
+ * `subscription` — covered by a flat-rate plan the user already pays; the USD
91
+ * cost gate does not apply (`cli_call_budget` guards the quota instead).
92
+ * `per-token` — metered spend; the full `cost_budget` applies.
93
+ */
94
+ export type BillingClass = 'subscription' | 'per-token';
95
+
96
+ /** Version string when parseable, the literal `'unknown'` when not, `null` when unprobed. */
97
+ export type HostVersion = string | 'unknown' | null;
98
+
99
+ export interface DetectedHost {
100
+ readonly id: string;
101
+ readonly installed: boolean;
102
+ /** Resolved executable path, or `null` when the host has no binary on `$PATH`. */
103
+ readonly binary: string | null;
104
+ readonly version: HostVersion;
105
+ }
106
+
107
+ export interface DetectedAuth {
108
+ readonly provider: string;
109
+ readonly source: AuthSource;
110
+ /** A filesystem path, or `env:<VAR>` — presence evidence only. */
111
+ readonly evidence: string;
112
+ }
113
+
114
+ export interface DetectedKey {
115
+ readonly provider: string;
116
+ /** `api_key_ref`-shaped: `file:<name>.key` or `env:<VAR>`. */
117
+ readonly ref: string;
118
+ }
119
+
120
+ export interface EnvironmentReport {
121
+ readonly hosts: readonly DetectedHost[];
122
+ readonly auth: readonly DetectedAuth[];
123
+ readonly keys: readonly DetectedKey[];
124
+ }
125
+
126
+ /**
127
+ * Provider → (default CLI binary, community-wrapper flag).
128
+ *
129
+ * Mirrors the `CliClient` subclass attributes without importing the client
130
+ * layer. The boolean is the load-bearing billing input: a VENDOR-OFFICIAL CLI
131
+ * (`false`) runs under the user's subscription; a COMMUNITY wrapper (`true`)
132
+ * shells out to the same paid API and stays metered. Canonical here so
133
+ * `cmd_doctor` and this module cannot drift.
134
+ */
135
+ export const PROVIDER_CLI_META: Readonly<Record<string, readonly [string, boolean]>> = {
136
+ anthropic: ['claude', false],
137
+ openai: ['codex', false],
138
+ gemini: ['gemini', false],
139
+ xai: ['grok', true],
140
+ perplexity: ['perplexity', true],
141
+ };
142
+
143
+ /** Provider → environment variable carrying a raw API key. */
144
+ const PROVIDER_ENV_KEY: Readonly<Record<string, string>> = {
145
+ anthropic: 'ANTHROPIC_API_KEY',
146
+ openai: 'OPENAI_API_KEY',
147
+ gemini: 'GEMINI_API_KEY',
148
+ xai: 'XAI_API_KEY',
149
+ perplexity: 'PERPLEXITY_API_KEY',
150
+ };
151
+
152
+ /** Provider → key-file name under the user-global root (`file:` ref target). */
153
+ const PROVIDER_KEY_FILE: Readonly<Record<string, string>> = {
154
+ anthropic: 'anthropic.key',
155
+ openai: 'openai.key',
156
+ gemini: 'gemini.key',
157
+ xai: 'xai.key',
158
+ perplexity: 'perplexity.key',
159
+ };
160
+
161
+ /** Providers this module knows how to classify, in a stable order. */
162
+ export function knownProviders(): readonly string[] {
163
+ return Object.keys(PROVIDER_CLI_META);
164
+ }
165
+
166
+ /** True when `provider`'s CLI is a community wrapper around the paid API. */
167
+ export function isCommunityCli(provider: string): boolean {
168
+ return PROVIDER_CLI_META[provider]?.[1] ?? false;
169
+ }
170
+
171
+ /**
172
+ * Billing class from (provider, detected auth source) — NEVER from transport.
173
+ *
174
+ * This signature is the invariant: there is no transport / mode parameter, so
175
+ * no caller can make billing depend on which transport was chosen. That is
176
+ * what keeps the per-provider rules intact when `auto` picks a transport at
177
+ * invocation time: a vendor-official CLI logged in under a subscription is
178
+ * unmetered; everything else — including a community CLI and including an
179
+ * unknown source — is metered, i.e. deliberately over-gated.
180
+ *
181
+ * `null` source means "nothing detected" and classifies as `per-token`: the
182
+ * safe direction is to keep the USD gate on.
183
+ */
184
+ export function classifyBilling(provider: string, source: AuthSource | null): BillingClass {
185
+ if (source !== 'cli-subscription') return 'per-token';
186
+ if (isCommunityCli(provider)) return 'per-token';
187
+ return 'subscription';
188
+ }
189
+
190
+ /**
191
+ * Codex CLI home — `CODEX_HOME` env override (the codex CLI's own
192
+ * convention), else `~/.codex`. Single definition; `cmd_doctor` imports it.
193
+ */
194
+ export function codexHome(env?: EnvMap | null): string {
195
+ const map = env ?? process.env;
196
+ const override = (map['CODEX_HOME'] ?? '').trim();
197
+ return override !== '' ? override : path.join(homeOf(env), '.codex');
198
+ }
199
+
200
+ function homeOf(env?: EnvMap | null): string {
201
+ const map = env ?? process.env;
202
+ const injected = (map['HOME'] ?? '').trim();
203
+ return injected !== '' ? injected : os.homedir();
204
+ }
205
+
206
+ const VERSION_PROBE_TIMEOUT_MS = 2000;
207
+
208
+ /** Injected `<binary> --version` runner. Returns raw combined output, or `null`. */
209
+ export type VersionProbe = (binary: string) => string | null;
210
+
211
+ export interface DetectEnvironmentOptions {
212
+ /** Home directory override (tests / fixture machines). */
213
+ readonly home?: string;
214
+ /** `$PATH` override (tests / fixture machines). */
215
+ readonly pathEnv?: string;
216
+ /** Environment map override; also carries `CODEX_HOME` / `EVENT4U_CONFIG_HOME`. */
217
+ readonly env?: EnvMap;
218
+ /**
219
+ * Version probe override. Omit to spawn `<binary> --version` with a
220
+ * hardened env and a 2s timeout; pass `() => null` to skip probing.
221
+ */
222
+ readonly probeVersion?: VersionProbe;
223
+ }
224
+
225
+ /**
226
+ * `<binary> --version` under a hardened env. Never throws — a spawn error,
227
+ * timeout, signal, or unparseable output all collapse to `null`, which the
228
+ * caller degrades to `'unknown'`.
229
+ *
230
+ * Hardened per `docs/spawn-site-policy.md` (this runs on the consumer runtime
231
+ * via `doctor`). Note the env scrub deliberately does NOT strip
232
+ * `CLAUDE_CONFIG_DIR` — see `docs/threat-model.md` row i.
233
+ */
234
+ function defaultProbeVersion(binary: string): string | null {
235
+ try {
236
+ const res = spawnSync(binary, ['--version'], {
237
+ timeout: VERSION_PROBE_TIMEOUT_MS,
238
+ encoding: 'utf-8',
239
+ windowsHide: true,
240
+ env: hardenedSpawnEnv(),
241
+ });
242
+ if (res.error !== undefined || res.signal !== null) return null;
243
+ return `${res.stdout ?? ''}\n${res.stderr ?? ''}`;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function isFile(p: string): boolean {
250
+ try {
251
+ return fs.statSync(p).isFile();
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ function exists(p: string): boolean {
258
+ try {
259
+ return fs.existsSync(p);
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Classify a codex `auth.json` by which credential shape it carries.
267
+ *
268
+ * OAuth tokens ⇒ the user logged in with a ChatGPT plan (`cli-subscription`);
269
+ * anything else ⇒ metered (`cli-api-key`). A present-but-unreadable or
270
+ * malformed file also reports `cli-api-key` — the over-gated direction, since
271
+ * assuming a plan would switch the USD gate off on no evidence.
272
+ */
273
+ function classifyCodexAuth(authPath: string): AuthSource {
274
+ let data: unknown;
275
+ try {
276
+ data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
277
+ } catch {
278
+ // Present but unreadable: over-gate (metered) rather than assume a plan.
279
+ return 'cli-api-key';
280
+ }
281
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
282
+ return 'cli-api-key';
283
+ }
284
+ const d = data as Record<string, unknown>;
285
+ const tokens = d['tokens'];
286
+ const hasOauth =
287
+ typeof tokens === 'object' &&
288
+ tokens !== null &&
289
+ !Array.isArray(tokens) &&
290
+ Object.keys(tokens as Record<string, unknown>).length > 0;
291
+ if (hasOauth) return 'cli-subscription';
292
+ return 'cli-api-key';
293
+ }
294
+
295
+ /** Provider-specific `cli-subscription` evidence path, or `null` when none applies. */
296
+ function cliSubscriptionEvidence(
297
+ provider: string,
298
+ home: string,
299
+ env: EnvMap,
300
+ claudeConfigDir: string,
301
+ ): { source: AuthSource; evidence: string } | null {
302
+ if (provider === 'anthropic') {
303
+ const configDir = claudeConfigDir;
304
+ for (const rel of ['.credentials.json', 'credentials.json']) {
305
+ const p = path.join(configDir, rel);
306
+ if (isFile(p)) return { source: 'cli-subscription', evidence: p };
307
+ }
308
+ return null;
309
+ }
310
+ if (provider === 'openai') {
311
+ const p = path.join(codexHome(env), 'auth.json');
312
+ if (isFile(p)) return { source: classifyCodexAuth(p), evidence: p };
313
+ return null;
314
+ }
315
+ if (provider === 'gemini') {
316
+ const p = path.join(home, '.gemini', 'oauth_creds.json');
317
+ if (isFile(p)) return { source: 'cli-subscription', evidence: p };
318
+ return null;
319
+ }
320
+ // xai / perplexity ship no subscription login — their CLIs read an API key.
321
+ return null;
322
+ }
323
+
324
+ function detectHosts(opts: DetectEnvironmentOptions): DetectedHost[] {
325
+ const installedMap = detectInstalledTools({
326
+ ...(opts.home !== undefined ? { home: opts.home } : {}),
327
+ pathEnv: opts.pathEnv,
328
+ });
329
+ const probe = opts.probeVersion ?? defaultProbeVersion;
330
+ const out: DetectedHost[] = [];
331
+ for (const id of knownToolIds()) {
332
+ const binary = resolveToolBinary(id, opts.pathEnv);
333
+ let version: HostVersion = null;
334
+ if (binary !== null) {
335
+ let raw: string | null;
336
+ try {
337
+ raw = probe(binary);
338
+ } catch {
339
+ raw = null;
340
+ }
341
+ // An unparseable version degrades to 'unknown' — never throws,
342
+ // never omits the host from the report.
343
+ version = raw === null ? 'unknown' : (parseVersionOutput(raw) ?? 'unknown');
344
+ }
345
+ out.push({ id, installed: installedMap[id] ?? false, binary, version });
346
+ }
347
+ return out;
348
+ }
349
+
350
+ function detectAuth(opts: DetectEnvironmentOptions): {
351
+ auth: DetectedAuth[];
352
+ keys: DetectedKey[];
353
+ } {
354
+ const env = opts.env ?? process.env;
355
+ const home = opts.home ?? homeOf(opts.env);
356
+ // Delegate to claude_plugin's resolver on the live path (it owns the
357
+ // CLAUDE_CONFIG_DIR override); derive from the injected home otherwise.
358
+ const claudeConfigDir =
359
+ opts.home === undefined && opts.env === undefined
360
+ ? claude_config_dir()
361
+ : ((env['CLAUDE_CONFIG_DIR'] ?? '').trim() || path.join(home, '.claude'));
362
+ const auth: DetectedAuth[] = [];
363
+ const keys: DetectedKey[] = [];
364
+
365
+ for (const provider of knownProviders()) {
366
+ const cli = cliSubscriptionEvidence(provider, home, env, claudeConfigDir);
367
+ if (cli !== null) {
368
+ auth.push({ provider, source: cli.source, evidence: cli.evidence });
369
+ }
370
+
371
+ const keyFile = PROVIDER_KEY_FILE[provider];
372
+ if (keyFile !== undefined) {
373
+ let resolved: string | null = null;
374
+ try {
375
+ resolved = resolve_with_fallback(keyFile, { env });
376
+ } catch {
377
+ resolved = null;
378
+ }
379
+ if (resolved !== null && exists(resolved)) {
380
+ auth.push({ provider, source: 'key-file', evidence: resolved });
381
+ keys.push({ provider, ref: `file:${keyFile}` });
382
+ }
383
+ }
384
+
385
+ const envVar = PROVIDER_ENV_KEY[provider];
386
+ if (envVar !== undefined && (env[envVar] ?? '').trim() !== '') {
387
+ auth.push({ provider, source: 'env-key', evidence: `env:${envVar}` });
388
+ keys.push({ provider, ref: `env:${envVar}` });
389
+ }
390
+ }
391
+ return { auth, keys };
392
+ }
393
+
394
+ /**
395
+ * Strongest auth source detected for `provider`, or `null`.
396
+ *
397
+ * "Strongest" is the transport-preference order `auto` walks: a vendor CLI
398
+ * login beats a stored key. Pure over an already-built report.
399
+ */
400
+ export function strongestAuth(
401
+ report: EnvironmentReport,
402
+ provider: string,
403
+ ): DetectedAuth | null {
404
+ const order: readonly AuthSource[] = [
405
+ 'cli-subscription',
406
+ 'cli-api-key',
407
+ 'key-file',
408
+ 'env-key',
409
+ ];
410
+ for (const source of order) {
411
+ const hit = report.auth.find((a) => a.provider === provider && a.source === source);
412
+ if (hit !== undefined) return hit;
413
+ }
414
+ return null;
415
+ }
416
+
417
+ let _cached: EnvironmentReport | null = null;
418
+
419
+ /**
420
+ * Build the environment report.
421
+ *
422
+ * Cached per process for the no-argument call (the production shape) so
423
+ * `doctor` and transport resolution observe one consistent snapshot; any call
424
+ * that injects options bypasses the cache and returns a fresh read, so tests
425
+ * and fixture machines never poison each other. Reset with
426
+ * `resetEnvironmentCache()`.
427
+ */
428
+ export function detectEnvironment(opts: DetectEnvironmentOptions = {}): EnvironmentReport {
429
+ const isDefaultCall = Object.keys(opts).length === 0;
430
+ if (isDefaultCall && _cached !== null) return _cached;
431
+ const hosts = detectHosts(opts);
432
+ const { auth, keys } = detectAuth(opts);
433
+ const report: EnvironmentReport = { hosts, auth, keys };
434
+ if (isDefaultCall) _cached = report;
435
+ return report;
436
+ }
437
+
438
+ /** Drop the per-process memo. Tests only. */
439
+ export function resetEnvironmentCache(): void {
440
+ _cached = null;
441
+ }
@@ -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
+ }