@event4u/agent-config 9.10.0 → 9.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +111 -0
  3. package/README.md +20 -16
  4. package/agents/templates/.ai-council.yml.example +481 -0
  5. package/agents/templates/.ai-video.xml.example +247 -0
  6. package/dist/agent-src/commands/fix/ci.md +29 -0
  7. package/dist/agent-src/commands/pr/create.md +60 -0
  8. package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
  9. package/dist/agent-src/rules/design-fidelity.md +2 -2
  10. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
  11. package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
  12. package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
  13. package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
  14. package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
  15. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
  16. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  17. package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
  18. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
  21. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  22. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
  23. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
  24. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
  25. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
  26. package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
  27. package/dist/cli/initRouting.js +74 -11
  28. package/dist/cli/initRouting.js.map +1 -1
  29. package/dist/cli/main.js +14 -3
  30. package/dist/cli/main.js.map +1 -1
  31. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  32. package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
  33. package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
  34. package/dist/cli-delegate/chunk-A3HJ6NTI.js +1084 -0
  35. package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
  36. package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
  37. package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
  38. package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
  39. package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
  40. package/dist/cli-delegate/chunk-HYWSRNUX.js +179 -0
  41. package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
  42. package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
  43. package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
  44. package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
  45. package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
  46. package/dist/cli-delegate/chunk-QFKQLQ7X.js +10835 -0
  47. package/dist/cli-delegate/chunk-TEGJU2JX.js +6908 -0
  48. package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
  49. package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
  50. package/dist/cli-delegate/cmd_analyze_session.js +294 -0
  51. package/dist/cli-delegate/cmd_conformance.js +643 -0
  52. package/dist/cli-delegate/cmd_converge.js +326 -0
  53. package/dist/cli-delegate/cmd_doctor.js +142 -0
  54. package/dist/cli-delegate/cmd_explain.js +2576 -0
  55. package/dist/cli-delegate/cmd_export.js +298 -0
  56. package/dist/cli-delegate/cmd_fleet.js +333 -0
  57. package/dist/cli-delegate/cmd_memory_get.js +3878 -0
  58. package/dist/cli-delegate/cmd_migrate.js +11 -0
  59. package/dist/cli-delegate/cmd_preflight.js +153 -0
  60. package/dist/cli-delegate/cmd_prune.js +446 -0
  61. package/dist/cli-delegate/cmd_refresh.js +28 -0
  62. package/dist/cli-delegate/cmd_settings_check.js +574 -0
  63. package/dist/cli-delegate/cmd_settings_migrate.js +225 -0
  64. package/dist/cli-delegate/cmd_sync.js +266 -0
  65. package/dist/cli-delegate/cmd_uninstall.js +657 -0
  66. package/dist/cli-delegate/cmd_update.js +425 -0
  67. package/dist/cli-delegate/cmd_upgrade.js +444 -0
  68. package/dist/cli-delegate/cmd_validate.js +307 -0
  69. package/dist/cli-delegate/cmd_versions.js +370 -0
  70. package/dist/discovery/deprecation-report.md +1 -1
  71. package/dist/discovery/discovery-manifest.json +45 -16
  72. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  73. package/dist/discovery/discovery-manifest.summary.md +3 -3
  74. package/dist/discovery/orphan-report.md +1 -1
  75. package/dist/discovery/packs.json +13 -7
  76. package/dist/discovery/trust-report.md +2 -2
  77. package/dist/discovery/workspaces.json +6 -4
  78. package/dist/install/emit_host_rules_cli.js +2 -1
  79. package/dist/install/emit_host_rules_cli.js.map +1 -1
  80. package/dist/install/install.mjs +634 -409
  81. package/dist/install/ruleInScope.js +123 -0
  82. package/dist/install/ruleInScope.js.map +1 -0
  83. package/dist/install/rule_scope.js +7 -3
  84. package/dist/install/rule_scope.js.map +1 -1
  85. package/dist/install/toolDetection.js +40 -12
  86. package/dist/install/toolDetection.js.map +1 -1
  87. package/dist/install/wizard-plan.js +12 -3
  88. package/dist/install/wizard-plan.js.map +1 -1
  89. package/dist/mcp/registry-manifest.json +3 -3
  90. package/dist/mcp/server.json +2 -2
  91. package/dist/mcp/server.mjs +1553 -2728
  92. package/dist/router.json +1 -1
  93. package/dist/scripts/condense.js +6 -22
  94. package/dist/scripts/condense.js.map +1 -1
  95. package/docs/guidelines/design-fidelity-mechanics.md +14 -0
  96. package/package.json +4 -2
  97. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
  98. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  99. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
  100. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
  101. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
  102. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
  103. package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
  104. package/src/config/discovery/packs.yml +7 -0
  105. package/src/config/evaluator-budgets.json +1 -1
  106. package/src/config/pack-size-budget.json +27 -0
  107. package/src/install/emit_host_rules_cli.ts +2 -1
  108. package/src/install/ruleInScope.ts +133 -0
  109. package/src/install/rule_scope.ts +7 -3
  110. package/src/install/toolDetection.ts +39 -8
  111. package/src/install/wizard-plan.ts +13 -3
  112. package/src/scripts/_cli/cmd_conformance.ts +2 -1
  113. package/src/scripts/_cli/cmd_converge.ts +2 -1
  114. package/src/scripts/_cli/cmd_doctor.ts +214 -13
  115. package/src/scripts/_cli/cmd_export.ts +2 -1
  116. package/src/scripts/_cli/cmd_fleet.ts +2 -1
  117. package/src/scripts/_cli/cmd_migrate.ts +2 -1
  118. package/src/scripts/_cli/cmd_preflight.ts +2 -1
  119. package/src/scripts/_cli/cmd_refresh.ts +2 -1
  120. package/src/scripts/_cli/cmd_update.ts +2 -1
  121. package/src/scripts/_cli/cmd_upgrade.ts +2 -1
  122. package/src/scripts/_cli/cmd_versions.ts +2 -1
  123. package/src/scripts/_cli/detection_report.ts +480 -0
  124. package/src/scripts/_dispatch.bash +52 -14
  125. package/src/scripts/_lib/changelog_eras.ts +103 -9
  126. package/src/scripts/_lib/environment_detector.ts +441 -0
  127. package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
  128. package/src/scripts/_lib/package_root.ts +72 -0
  129. package/src/scripts/ai_council/config.ts +15 -7
  130. package/src/scripts/ai_council/modes.ts +68 -2
  131. package/src/scripts/ai_council/transport_resolver.ts +331 -0
  132. package/src/scripts/check_bundle_path_leakage.ts +10 -4
  133. package/src/scripts/check_dependency_floors.ts +153 -0
  134. package/src/scripts/check_installer_import_purity.ts +295 -0
  135. package/src/scripts/check_pack_size.ts +188 -0
  136. package/src/scripts/condense.ts +7 -28
  137. package/src/scripts/council_cli.ts +6 -2
  138. package/src/scripts/evaluator_umbrella.sh +34 -1
  139. package/src/scripts/install-hooks.sh +36 -4
  140. package/src/scripts/install.ts +210 -5
  141. package/src/scripts/lint_eval_fixture_citations.ts +113 -0
  142. package/src/scripts/lint_ui_stack_bundles.ts +201 -0
@@ -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
+ }
@@ -254,6 +254,13 @@ function json_string_ascii(s: string): string {
254
254
  return out;
255
255
  }
256
256
 
257
+ /**
258
+ * Predicate over a SOURCE file path — `true` = the file arrives in the install.
259
+ * Identical shape to `PlanSource.fileFilter` (`src/install/plan.ts`), so the
260
+ * plan path and the deploy path can share one predicate instance.
261
+ */
262
+ export type FileFilter = (srcFile: string) => boolean;
263
+
257
264
  /**
258
265
  * Anchor-relative paths the deploy of `src` → `<anchor>/<dest_rel>` maintains
259
266
  * — written OR skipped-as-identical.
@@ -262,8 +269,20 @@ function json_string_ascii(s: string): string {
262
269
  * symlinked files resolve to file entries, symlinked subdirectories are walked
263
270
  * through their resolved targets. `dest_rel` may be empty (`""`) for plan
264
271
  * entries that deploy into the anchor root.
272
+ *
273
+ * `file_filter` MUST be the same predicate the copy was given. This set is what
274
+ * the reaper treats as "should exist"; a file the copy skipped but this set
275
+ * still lists is never reaped, and a file this set omits while the copy wrote it
276
+ * is deleted on the next deploy. Passing the filter to one and not the other is
277
+ * the failure mode the rule-scoping work exists to avoid — see
278
+ * `install._deploy_global_content`, which derives one filter and hands it to
279
+ * both.
265
280
  */
266
- export function expected_deploy_files(src: string, dest_rel: string): Set<string> {
281
+ export function expected_deploy_files(
282
+ src: string,
283
+ dest_rel: string,
284
+ file_filter: FileFilter | null = null,
285
+ ): Set<string> {
267
286
  const out = new Set<string>();
268
287
  let src_stat: fs.Stats;
269
288
  try {
@@ -273,6 +292,9 @@ export function expected_deploy_files(src: string, dest_rel: string): Set<string
273
292
  return out;
274
293
  }
275
294
  if (!src_stat.isDirectory()) {
295
+ if (file_filter !== null && !file_filter(src)) {
296
+ return out;
297
+ }
276
298
  out.add(as_posix(dest_rel));
277
299
  return out;
278
300
  }
@@ -298,6 +320,12 @@ export function expected_deploy_files(src: string, dest_rel: string): Set<string
298
320
  _walk(fs.realpathSync(entry), rel);
299
321
  continue;
300
322
  }
323
+ // The predicate sees the SOURCE path, never `rel`: the rule-scope
324
+ // predicate reads the file's own frontmatter tags, so a dest-relative
325
+ // path would not resolve. Same contract as the copy twin.
326
+ if (file_filter !== null && !file_filter(entry)) {
327
+ continue;
328
+ }
301
329
  out.add(as_posix(rel));
302
330
  }
303
331
  };
@@ -554,7 +582,15 @@ function prune_empty_dirs(prune_candidates: Set<string>, anchor_resolved: string
554
582
  }
555
583
 
556
584
  /** `anchor in node.parents` — is `anchor` a strict ancestor of `node`? */
557
- function is_ancestor(anchor: string, node: string): boolean {
585
+ /**
586
+ * Is `node` strictly inside `anchor`? Pure path arithmetic, no I/O — callers
587
+ * pass already-resolved (realpath'd) paths when symlink escape matters.
588
+ *
589
+ * Exported for `install._copy_dir_dereferencing_symlinks`, which must reject a
590
+ * symlink whose target leaves the package root. One definition rather than a
591
+ * second copy of the `path.relative` idiom.
592
+ */
593
+ export function is_ancestor(anchor: string, node: string): boolean {
558
594
  const rel = path.relative(anchor, node);
559
595
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
560
596
  }