@event4u/agent-config 17.0.0 → 17.2.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 (164) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +157 -468
  5. package/README.md +4 -3
  6. package/dist/agent-src/commands/project/analyze.md +9 -6
  7. package/dist/agent-src/commands/project/map.md +58 -0
  8. package/dist/agent-src/commands/project.md +5 -3
  9. package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
  10. package/dist/agent-src/skills/code-intelligence/SKILL.md +6 -0
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/agent-src/templates/roadmaps.md +5 -1
  13. package/dist/cli/registry.js +1 -1
  14. package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
  15. package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
  16. package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
  17. package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
  18. package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
  19. package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
  20. package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
  21. package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
  22. package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +32 -47
  24. package/dist/cli-delegate/cmd_doctor.js +4 -4
  25. package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
  26. package/dist/cli-delegate/cmd_explain.js +1 -1
  27. package/dist/cli-delegate/cmd_export.js +6 -6
  28. package/dist/cli-delegate/cmd_fleet.js +3 -3
  29. package/dist/cli-delegate/cmd_preflight.js +3 -3
  30. package/dist/cli-delegate/cmd_prune.js +6 -6
  31. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  32. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  33. package/dist/cli-delegate/cmd_sync.js +6 -6
  34. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  35. package/dist/cli-delegate/cmd_update.js +9 -13
  36. package/dist/cli-delegate/cmd_validate.js +6 -6
  37. package/dist/discovery/deprecation-report.md +1 -1
  38. package/dist/discovery/discovery-manifest.json +41 -15
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +3 -3
  41. package/dist/discovery/dormancy-report.md +1 -1
  42. package/dist/discovery/orphan-report.md +1 -1
  43. package/dist/discovery/packs.json +6 -5
  44. package/dist/discovery/trust-report.md +2 -2
  45. package/dist/discovery/workspaces.json +5 -4
  46. package/dist/hooks/dispatch.js +163 -148
  47. package/dist/hooks/dispatch.sha256 +1 -1
  48. package/dist/hooks/instructions_observer.chunk.cjs +301 -0
  49. package/dist/hooks/instructions_observer.chunk.cjs.sha256 +1 -0
  50. package/dist/install/claudeRuleRewrite.js +10 -3
  51. package/dist/install/conflict.js +24 -2
  52. package/dist/install/conflictTracker.js +14 -3
  53. package/dist/install/install.mjs +222 -117
  54. package/dist/install/installThinLayer.js +1 -0
  55. package/dist/install/loggedWrites.js +142 -0
  56. package/dist/install/paths.js +12 -2
  57. package/dist/install/preserve.js +25 -4
  58. package/dist/install/txlog.js +10 -6
  59. package/dist/mcp/registry-manifest.json +3 -3
  60. package/dist/mcp/server.json +2 -2
  61. package/dist/mcp/server.mjs +33595 -22916
  62. package/dist/scripts/_lib/egress_rules.js +4 -1
  63. package/dist/scripts/_lib/identifier_redaction.js +35 -2
  64. package/dist/scripts/ai_council/seat_policy.js +7 -1
  65. package/dist/server/schemas/settings.js +1 -1
  66. package/package.json +4 -1
  67. package/src/config/ci-local-parity.yml +1 -1
  68. package/src/config/continuity-surface.json +7 -0
  69. package/src/config/evaluator-budgets.json +4 -4
  70. package/src/config/gate-coverage.yml +27 -7
  71. package/src/config/gate-violation-baselines.json +9 -3
  72. package/src/config/gitignore-block.txt +6 -0
  73. package/src/config/hook-bundle-budget.json +19 -4
  74. package/src/config/hook-token-budget.json +1 -1
  75. package/src/config/packed-binary-manifest.json +27 -0
  76. package/src/config/production-entries.json +18 -0
  77. package/src/config/release-gate-locality.yml +0 -7
  78. package/src/config/skill-body-budget.json +7 -1
  79. package/src/install/claudeRuleRewrite.ts +10 -1
  80. package/src/install/conflict.ts +24 -1
  81. package/src/install/conflictTracker.ts +18 -2
  82. package/src/install/installThinLayer.ts +6 -0
  83. package/src/install/loggedWrites.ts +148 -0
  84. package/src/install/paths.ts +11 -2
  85. package/src/install/preserve.ts +40 -4
  86. package/src/install/txlog.ts +10 -6
  87. package/src/scripts/_cli/cmd_conformance.ts +3 -2
  88. package/src/scripts/_cli/cmd_update.ts +15 -17
  89. package/src/scripts/_lib/bench_ab_complexity.ts +14 -2
  90. package/src/scripts/_lib/collector_record.ts +1 -0
  91. package/src/scripts/_lib/council_settings_block.ts +9 -2
  92. package/src/scripts/_lib/egress_rules.ts +4 -1
  93. package/src/scripts/_lib/host_capability.ts +10 -5
  94. package/src/scripts/_lib/identifier_redaction.ts +38 -2
  95. package/src/scripts/_lib/price_freshness.ts +112 -0
  96. package/src/scripts/_lib/production_reach.ts +194 -0
  97. package/src/scripts/_lib/quota_parked.ts +17 -28
  98. package/src/scripts/_lib/release_findings_ingest.ts +13 -335
  99. package/src/scripts/_lib/runtime_journal.ts +7 -7
  100. package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
  101. package/src/scripts/ai_council/chairman.ts +5 -2
  102. package/src/scripts/ai_council/clients.ts +4 -3
  103. package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
  104. package/src/scripts/ai_council/free_route_catalog.ts +142 -5
  105. package/src/scripts/ai_council/free_route_client.ts +4 -0
  106. package/src/scripts/ai_council/hand_payload.ts +115 -7
  107. package/src/scripts/ai_council/orchestrator.ts +7 -10
  108. package/src/scripts/ai_council/route_chooser.ts +1 -1
  109. package/src/scripts/ai_council/route_member.ts +66 -4
  110. package/src/scripts/ai_council/seat_policy.ts +8 -1
  111. package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
  112. package/src/scripts/ai_council/status_surface.ts +2 -0
  113. package/src/scripts/ask_transport.ts +137 -15
  114. package/src/scripts/check_finding_dispositions.ts +25 -71
  115. package/src/scripts/check_release_pr_shape.ts +3 -3
  116. package/src/scripts/check_step_production_reach.ts +257 -0
  117. package/src/scripts/code_graph/build.ts +210 -10
  118. package/src/scripts/code_graph/cli.ts +168 -46
  119. package/src/scripts/code_graph/detect.ts +5 -4
  120. package/src/scripts/code_graph/docs_layer.ts +57 -0
  121. package/src/scripts/code_graph/extract.ts +58 -13
  122. package/src/scripts/code_graph/extract_more.ts +314 -0
  123. package/src/scripts/code_graph/laravel_overlay.ts +454 -0
  124. package/src/scripts/code_graph/map.ts +846 -0
  125. package/src/scripts/code_graph/map_model.ts +977 -0
  126. package/src/scripts/code_graph/map_verify.ts +140 -0
  127. package/src/scripts/code_graph/map_viewer/viewer.css +278 -0
  128. package/src/scripts/code_graph/map_viewer/viewer.js +756 -0
  129. package/src/scripts/code_graph/scip_reader.ts +201 -0
  130. package/src/scripts/code_graph/sqlite_store.ts +8 -1
  131. package/src/scripts/code_graph/types.ts +81 -15
  132. package/src/scripts/code_graph/validate.ts +16 -26
  133. package/src/scripts/code_graph/verbs.ts +7 -0
  134. package/src/scripts/council_cli.ts +10 -8
  135. package/src/scripts/hook_chunks.mjs +7 -0
  136. package/src/scripts/hook_manifest.json +1 -1
  137. package/src/scripts/hook_manifest.yaml +23 -0
  138. package/src/scripts/hooks/dispatch_hook.ts +4 -4
  139. package/src/scripts/hooks/instructions_observer_hook.ts +25 -6
  140. package/src/scripts/hooks/lazy_concern.ts +4 -0
  141. package/src/scripts/hooks/payload_stub.ts +27 -0
  142. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
  143. package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
  144. package/src/scripts/install-hooks.sh +5 -0
  145. package/src/scripts/install.ts +2 -2
  146. package/src/scripts/lint_agents_layout.ts +2 -0
  147. package/src/scripts/lint_decision_classes.ts +34 -0
  148. package/src/scripts/lint_hook_manifest.ts +25 -0
  149. package/src/scripts/lint_roadmap_blockers.ts +52 -3
  150. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -6
  151. package/src/scripts/mcp_server/graph_tools.ts +73 -1
  152. package/src/scripts/mcp_server/tools.ts +19 -36
  153. package/src/scripts/release.ts +5 -9
  154. package/src/scripts/release_drill.ts +11 -165
  155. package/src/scripts/release_publication.ts +0 -9
  156. package/src/scripts/report_production_reach.ts +35 -0
  157. package/src/scripts/worker_reuse_shadow_report.ts +399 -1
  158. package/src/server/schemas/settings.ts +1 -1
  159. package/src/vendor/grammars/README.md +10 -5
  160. package/src/vendor/grammars/tree-sitter-go.wasm +0 -0
  161. package/src/vendor/grammars/tree-sitter-java.wasm +0 -0
  162. package/src/vendor/grammars/tree-sitter-python.wasm +0 -0
  163. package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
  164. /package/dist/cli-delegate/{chunk-E6XNBVE4.js → chunk-KR5ZIFPO.js} +0 -0
@@ -542,7 +542,7 @@ export async function main(argv: string[] | null = null, options: MainOptions =
542
542
  cache_warmer(latest);
543
543
  }
544
544
  _refresh_state(latest, latest, state_path);
545
- _refresh_global_lockfile(latest, out);
545
+ _report_global_install(latest, out);
546
546
  return 0;
547
547
  }
548
548
 
@@ -556,30 +556,28 @@ function _relativeToOr(target: string, base: string): string {
556
556
  }
557
557
 
558
558
  /**
559
- * Update the global `installed.lock` if it exists.
559
+ * Name `agent-config upgrade` when the global install is older than the pin.
560
560
  *
561
- * Resolution prefers `~/.event4u/agent-config/installed.lock` and falls back to
562
- * the legacy `~/.config/agent-config/installed.lock`.
563
- *
564
- * Phase 1.6 — the lockfile is only present when the user has run a global
565
- * install; we never create one here, but we keep it in lockstep when `update`
566
- * flips the pin. Atomic write goes through `installed_lock.write_lockfile`.
561
+ * Read-only by design: `installed.lock` records what the global install
562
+ * deployed, and `upgrade` (plus its `--check` and unattended paths) reads it as
563
+ * the installed version. This command used to rewrite it to the pin, so after
564
+ * `update` the lock claimed a version no `npm install -g` had installed and
565
+ * `upgrade --check` reported "up to date" over the old binary.
567
566
  */
568
- function _refresh_global_lockfile(version: string, out: OutSink): void {
569
- const read_path = installed_lock.lockfile_path();
570
- const write_path = installed_lock.lockfile_write_path();
571
- const existing = installed_lock.read_lockfile(read_path);
567
+ function _report_global_install(version: string, out: OutSink): void {
568
+ const existing = installed_lock.read_lockfile(installed_lock.lockfile_path());
572
569
  if (existing === null) {
573
570
  return;
574
571
  }
575
572
  const recorded = (existing as unknown as Record<string, unknown>)['agent_config_version'];
576
- const tools = [...(existing.tools ?? [])];
577
- if (recorded === version && read_path === write_path) {
578
- _print(out, `ℹ️ ${write_path} already records ${version}.`);
573
+ if (typeof recorded !== 'string' || !update_check._is_newer(version, _normalize(recorded))) {
579
574
  return;
580
575
  }
581
- installed_lock.write_lockfile(version, tools, { path: write_path });
582
- _print(out, `✅ Refreshed global lockfile at ${write_path}.`);
576
+ _print(
577
+ out,
578
+ `ℹ️ The global install is ${_normalize(recorded)}; this pin does not change it. ` +
579
+ `Run \`agent-config upgrade\` to install ${version} globally.`,
580
+ );
583
581
  }
584
582
 
585
583
  /** Read `version` from the package's own `package.json`. */
@@ -62,7 +62,15 @@ import * as fs from 'node:fs';
62
62
  import * as path from 'node:path';
63
63
 
64
64
  import { getParser, type TsNode } from '../code_graph/loader.js';
65
- import { EXT_LANG, type Lang } from '../code_graph/types.js';
65
+ import { EXT_LANG } from '../code_graph/types.js';
66
+
67
+ /**
68
+ * The languages whose node vocabulary below was read off a real parse. The
69
+ * engine indexes more; a language without a probed vocabulary would score 0
70
+ * silently, so it is not scored at all.
71
+ */
72
+ type Lang = 'javascript' | 'typescript' | 'php';
73
+ const SCORED: ReadonlySet<string> = new Set<Lang>(['javascript', 'typescript', 'php']);
66
74
 
67
75
  export interface FunctionComplexity {
68
76
  /** Declared name, or `<anon>` for an unnamed closure/arrow. */
@@ -349,7 +357,11 @@ function logicalOperands(root: TsNode, lang: Lang): TsNode[] {
349
357
 
350
358
  export function langForPath(relPath: string): Lang | null {
351
359
  const ext = path.extname(relPath).toLowerCase();
352
- return (EXT_LANG as Record<string, Lang | undefined>)[ext] ?? null;
360
+ // `.vue` maps to typescript for the engine only after its template is
361
+ // blanked; scored raw here it would read markup as code.
362
+ if (ext === '.vue') return null;
363
+ const lang = (EXT_LANG as Record<string, string | undefined>)[ext];
364
+ return lang !== undefined && SCORED.has(lang) ? (lang as Lang) : null;
353
365
  }
354
366
 
355
367
  /** Cognitive complexity of every function unit in one source string. */
@@ -49,6 +49,7 @@ export const COLLECTOR_EVENTS = [
49
49
  'agent_error',
50
50
  'subagent_start',
51
51
  'subagent_stop',
52
+ 'instructions_loaded',
52
53
  ] as const;
53
54
  export type CollectorEvent = (typeof COLLECTOR_EVENTS)[number];
54
55
 
@@ -16,8 +16,8 @@
16
16
  * `load_settings` does not hand `build_members` the config FILE — it hands it
17
17
  * this block. So a key the loader validates and this projection drops is
18
18
  * enforced at load and invisible at runtime. That has now happened to
19
- * `quorum`, to `quorum_min_present`, and to `fallback.api_on_quota` in turn;
20
- * each carries its own comment below. A new key belongs in BOTH places or in
19
+ * `quorum`, to `quorum_min_present`, to `fallback.api_on_quota`, and to a
20
+ * member's `base_url` route fields in turn; each carries its own comment below. A new key belongs in BOTH places or in
21
21
  * neither.
22
22
  */
23
23
 
@@ -48,6 +48,13 @@ export function synthesizeAiCouncilBlock(cfg: CouncilConfig): Dict {
48
48
  if (m.prompt_cache_ttl !== '5m') {
49
49
  entry['prompt_cache_ttl'] = m.prompt_cache_ttl;
50
50
  }
51
+ // Dropping these turned every configured `base_url` route into an
52
+ // unknown provider at `build_members`, so no free route was ever built.
53
+ if (m.route) {
54
+ entry['base_url'] = m.route.base_url;
55
+ entry['content_ceiling'] = m.route.declared_ceiling;
56
+ if (m.route.proof !== null) entry['billing_proof'] = { ...m.route.proof };
57
+ }
51
58
  members[name] = entry;
52
59
  }
53
60
  const advisors: Dict = {};
@@ -198,7 +198,10 @@ export function pemMaskEnd(text: string, span: SpanBounds): number | null {
198
198
 
199
199
  // IBAN length per country (ISO 13616 registry), packed as `CCnn` pairs. A country
200
200
  // missing here leaves its IBANs unmasked, so the table errs towards inclusion.
201
- const IBAN_LENGTHS =
201
+ // Checked 2026-10-22 against the registry's Release 103: all 89 countries and
202
+ // lengths agree. The test pins this release to the table's digest.
203
+ export const IBAN_REGISTRY_RELEASE = 103;
204
+ export const IBAN_LENGTHS =
202
205
  'AD24AE23AL28AT20AZ28BA20BE16BG22BH22BI27BR29BY28CH21CR22CY28CZ24DE22DJ27DK18DO28EE20EG29ES24FI18FK18FO18FR27GB22GE22GI23GL18GR27GT28HN28HR21HU28IE22IL23IQ23IS26IT27JO30KW30KZ20LB28LC32LI21LT20LU20LV21LY25MC27MD24ME22MK19MN20MR27MT31MU30NI28NL18NO15OM23PK24PL28PS29PT25QA29RO24RS22RU33SA24SC31SD18SE24SI19SK24SM27SO23ST25SV28TL23TN24TR26UA29VA22VG24XK20YE30';
203
206
 
204
207
  export function isValidIban(value: string): boolean {
@@ -365,19 +365,24 @@ export function resolveHostCapabilities(
365
365
  * settings decision, so there is no override parameter here (that stays on
366
366
  * {@link resolveHostCapabilities} for back-compat callers only).
367
367
  *
368
- * The one probed fact today: `agent_teams` resolves `true` when
368
+ * Two probed facts today. `agent_teams` resolves `true` when
369
369
  * `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is set (any non-empty value) in the
370
370
  * process environment — the one host fact this repo can observe about Claude
371
371
  * Code's documented-but-unshipped-by-default multi-instance Agent Teams
372
372
  * primitive. A registry row can also carry `agent_teams: true` directly for a
373
373
  * host where it is unconditionally available; the two sources OR together —
374
- * a probe never turns a registry-granted capability back off.
374
+ * a probe never turns a registry-granted capability back off. `worker_resume`
375
+ * follows the same flag on `claude` only, because the by-name re-task was
376
+ * observed there under it and nowhere else.
375
377
  */
376
378
  export function probeHostCapabilities(hostId: string | null | undefined): HostCapabilityManifest {
377
379
  const base = resolveHostCapabilities(hostId);
378
380
  const flag = process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
379
381
  const probedAgentTeams = typeof flag === 'string' && flag !== '';
380
- return { ...base, agent_teams: base.agent_teams || probedAgentTeams };
382
+ // By-name resume was observed on claude only under this flag (cold-start
383
+ // 1.4, 2.1.306); council 2026-10-22 decision 1 made it a live probe.
384
+ const probedResume = probedAgentTeams && hostId === 'claude';
385
+ return { ...base, agent_teams: base.agent_teams || probedAgentTeams, worker_resume: base.worker_resume || probedResume };
381
386
  }
382
387
 
383
388
  /** Every manifest field that carries a capability answer (i.e. not `schema_version`). */
@@ -448,9 +453,9 @@ export function describeHostCapabilities(
448
453
  // A row entry claims the field whatever its value: an explicit
449
454
  // `false` in the table is an observation ("checked, absent"), which is
450
455
  // a different fact from the default's "nobody answered".
451
- if (row !== undefined && typeof row[field] === 'boolean') {
456
+ if (row !== undefined && typeof row[field] === 'boolean' && !(row[field] === false && manifest[field] === true)) {
452
457
  sources[field] = 'registry';
453
- } else if (field === 'agent_teams' && probedAgentTeams) {
458
+ } else if ((field === 'agent_teams' || (field === 'worker_resume' && hostId === 'claude')) && probedAgentTeams) {
454
459
  sources[field] = 'live-probe';
455
460
  } else {
456
461
  sources[field] = 'default';
@@ -7,11 +7,47 @@
7
7
  * error and a context word is not required here. The rows' checksums still
8
8
  * hold — a number that fails Luhn is not a card, a string that fails mod-97 is
9
9
  * not an IBAN. The tag spelling and the counter keys are this consumer's.
10
+ *
11
+ * Without the context word a digit row must stand on its own shape, so the store
12
+ * tightens four rows where tree text measured false positives (PREREG (b), store
13
+ * reading): phone and card digits may not touch a letter on either side (a
14
+ * digit-led hex SHA is not a card) nor follow a decimal point, and a national
15
+ * number may not continue a `-` or `/` list (`ADR-046/047`); a leading `+` at a line start — a diff's added
16
+ * line — needs a grouped number (`+49 30 …`), a dotted quad is never a phone, and
17
+ * a North-American number keeps its context word unless it is written formatted,
18
+ * because a bare ten-digit integer (`2147483647`) has the same digits.
10
19
  */
11
20
  import { detect, type EgressRule, IDENTIFIER_RULES, replaceSpans } from './egress_rules.js';
12
21
 
13
- /** The identifier rows with every context requirement dropped. */
14
- export const STORAGE_IDENTIFIER_RULES: readonly EgressRule[] = IDENTIFIER_RULES.map(({ context: _context, ...rest }) => rest);
22
+ /** Store patterns for the rows the shape alone cannot carry; the rest are the table's own. */
23
+ const STORE_PATTERN: Readonly<Record<string, RegExp>> = {
24
+ 'payment-card': /(?<![0-9A-Za-z.])[0-9](?:[ -]?[0-9]){12,18}(?![0-9A-Za-z])/g,
25
+ 'phone-international':
26
+ /(?<![A-Za-z0-9+])(?:(?<!^|\n)|(?=\+[1-9][0-9]{0,2}[ (]))\+[1-9][0-9]{0,2}(?:[ ./-]?(?:\([0-9]{1,6}\)|(?=([0-9]{1,6}))\1)){1,5}(?![0-9A-Za-z])/g,
27
+ 'phone-national': /(?<![A-Za-z0-9+(./-])(?:\(0[1-9][0-9]{1,4}\)|0[1-9][0-9]{1,4})(?:[ ./-]?(?=([0-9]{2,8}))\1){1,3}(?![0-9A-Za-z])/g,
28
+ };
29
+
30
+ const DOTTED_QUAD = /^\+?[0-9]{1,3}(?:\.[0-9]{1,3}){3}$/;
31
+
32
+ /** A North-American number that is written as one: two separators, or a bracketed area code. */
33
+ function isFormattedNanp(value: string): boolean {
34
+ return value.includes('(') || (value.match(/[ .-]/g) ?? []).length >= 2;
35
+ }
36
+
37
+ function storeRow(row: EgressRule): EgressRule[] {
38
+ const { context: _context, ...rest } = row;
39
+ if (row.rule === 'phone-nanp') {
40
+ // Two rows: the table's own with its context word, and a formatted shape without one.
41
+ return [row, { ...rest, rule: 'phone-nanp-formatted', validator: isFormattedNanp }];
42
+ }
43
+ const pattern = STORE_PATTERN[row.rule];
44
+ if (!pattern) return [rest];
45
+ if (row.rule === 'phone-international') return [{ ...rest, pattern, exclusions: [...(rest.exclusions ?? []), DOTTED_QUAD] }];
46
+ return [{ ...rest, pattern }];
47
+ }
48
+
49
+ /** The identifier rows as the stores read them: context dropped, except where shape alone is not evidence. */
50
+ export const STORAGE_IDENTIFIER_RULES: readonly EgressRule[] = IDENTIFIER_RULES.flatMap(storeRow);
15
51
 
16
52
  /** Counter key and tag per placeholder type; `CC` predates the table's `CARD`. */
17
53
  const TAG: Readonly<Record<string, string>> = { CARD: 'CC' };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A price row is evidence with a date, and it expires.
3
+ *
4
+ * `internal/bench/pricing.yaml` priced a `claude-haiku-5-5` leg with a row sourced
5
+ * 2026-05-14 for a former Haiku generation at 2.5x the served model's rate, and the
6
+ * cost verdict read it without complaint. A row now refuses to price when it is
7
+ * older than PRICE_MAX_AGE_DAYS, when it names no source model (nothing vouches for
8
+ * its generation), when its source model is an older generation than the served
9
+ * one, or when it belongs to another model family. A refusal is a reason string,
10
+ * never a zero price.
11
+ */
12
+ import { load as yamlLoad } from 'js-yaml';
13
+
14
+ export const PRICE_MAX_AGE_DAYS = 120;
15
+
16
+ const FAMILIES = ['haiku', 'sonnet', 'opus', 'fable', 'mythos'] as const;
17
+
18
+ /** USD per million tokens. */
19
+ export interface Rates {
20
+ input: number;
21
+ output: number;
22
+ cache_write: number;
23
+ cache_read: number;
24
+ }
25
+
26
+ export interface PriceRow extends Rates {
27
+ /** Lookup key: a family (`haiku`) or a full model id (`claude-haiku-5-5`). */
28
+ tier: string;
29
+ /** ISO date the rates were read from `source`. */
30
+ sourced_on: string;
31
+ source?: string;
32
+ /** The model whose published rates these are. */
33
+ model?: string;
34
+ }
35
+
36
+ function familyOf(id: string): string | null {
37
+ const m = id.toLowerCase();
38
+ return FAMILIES.find((f) => m.includes(f)) ?? null;
39
+ }
40
+
41
+ /** `claude-haiku-5-5` → 5.5, `claude-fable-5` → 5; a trailing 8-digit date is not a minor. */
42
+ export function modelGeneration(id: string): number | null {
43
+ const m = id.toLowerCase().match(/(?:haiku|sonnet|opus|fable|mythos)-(\d{1,2})(?:-(\d{1,2}))?(?![0-9])/);
44
+ if (!m) return null;
45
+ return Number(m[1]) + (m[2] === undefined ? 0 : Number(m[2]) / 10);
46
+ }
47
+
48
+ const DAY_MS = 86_400_000;
49
+
50
+ export function priceRefusal(row: PriceRow, servedModel: string, asOf: Date): string | null {
51
+ const sourced = /^\d{4}-\d{2}-\d{2}$/.test(row.sourced_on) ? Date.parse(`${row.sourced_on}T00:00:00Z`) : Number.NaN;
52
+ if (Number.isNaN(sourced)) return `price row ${row.tier} carries no readable sourced_on date`;
53
+ const ageDays = Math.floor((asOf.getTime() - sourced) / DAY_MS);
54
+ if (ageDays > PRICE_MAX_AGE_DAYS) {
55
+ return `price row ${row.tier} is ${ageDays} days old (sourced ${row.sourced_on}), beyond the ${PRICE_MAX_AGE_DAYS}-day bound`;
56
+ }
57
+ const servedFamily = familyOf(servedModel);
58
+ if (servedFamily === null || familyOf(row.model ?? row.tier) !== servedFamily) {
59
+ return `price row ${row.tier} is not of the family of served model ${servedModel}`;
60
+ }
61
+ if (!row.model) return `price row ${row.tier} names no source model, so nothing vouches for its generation`;
62
+ const rowGen = modelGeneration(row.model);
63
+ const servedGen = modelGeneration(servedModel);
64
+ if (rowGen === null || servedGen === null) return `cannot read the generation of ${row.model} or ${servedModel}`;
65
+ if (servedGen > rowGen) {
66
+ return `price row ${row.tier} is sourced for generation ${rowGen} (${row.model}); served model ${servedModel} is generation ${servedGen}`;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** The served model's own row first, then its family's; the first refusal is reported. */
72
+ export function selectPrice(rows: readonly PriceRow[], servedModel: string, asOf: Date): { price: Rates; row: PriceRow } | { refusal: string } {
73
+ const id = servedModel.toLowerCase();
74
+ const family = familyOf(id);
75
+ const candidates = [...rows.filter((r) => r.tier.toLowerCase() === id), ...rows.filter((r) => family !== null && r.tier.toLowerCase() === family)];
76
+ if (candidates.length === 0) return { refusal: `no price row for served model ${servedModel}` };
77
+ let first: string | null = null;
78
+ for (const row of candidates) {
79
+ const why = priceRefusal(row, servedModel, asOf);
80
+ if (why === null) {
81
+ return { price: { input: row.input, output: row.output, cache_write: row.cache_write, cache_read: row.cache_read }, row };
82
+ }
83
+ first ??= why;
84
+ }
85
+ return { refusal: first! };
86
+ }
87
+
88
+ function isoDate(v: unknown): string {
89
+ if (v instanceof Date) return v.toISOString().slice(0, 10);
90
+ return typeof v === 'string' ? v : '';
91
+ }
92
+
93
+ /** Rows of a `pricing.yaml` document; a row without a tier is skipped. */
94
+ export function parsePriceRows(yamlText: string): PriceRow[] {
95
+ const doc = yamlLoad(yamlText) as { models?: unknown } | null;
96
+ const models = Array.isArray(doc?.models) ? (doc.models as Record<string, unknown>[]) : [];
97
+ const rows: PriceRow[] = [];
98
+ for (const m of models) {
99
+ if (!m || typeof m['tier'] !== 'string') continue;
100
+ rows.push({
101
+ tier: m['tier'],
102
+ ...(typeof m['model'] === 'string' ? { model: m['model'] } : {}),
103
+ sourced_on: isoDate(m['sourced_on']),
104
+ ...(typeof m['source'] === 'string' ? { source: m['source'] } : {}),
105
+ input: Number(m['input']),
106
+ output: Number(m['output']),
107
+ cache_write: Number(m['cache_write']),
108
+ cache_read: Number(m['cache_read']),
109
+ });
110
+ }
111
+ return rows;
112
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Which `src/` symbols a production entry reaches (road-to-a-tick-the-shipped-path-earns 3.1).
3
+ *
4
+ * Static and name-level, the same reading as `dispatch_import_closure`: relative specifiers only,
5
+ * dynamic `import()` not followed. A symbol counts as reached when some module
6
+ * in the closure imports it BY NAME from its defining module (or imports that
7
+ * module whole), or when the defining module is itself an entry. Reaching the
8
+ * module is not enough — `egress_normalize.ts` is in the closure for one regex
9
+ * while `detectNormalized` has no production importer, and that is the case
10
+ * this exists to see.
11
+ */
12
+ import { spawnSync } from 'node:child_process';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+
16
+ export const ENTRIES_CONFIG_REL = 'src/config/production-entries.json';
17
+
18
+ export interface ReachTree {
19
+ list(): readonly string[];
20
+ read(rel: string): string | null;
21
+ }
22
+
23
+ /** The checkout under `root`: tracked plus untracked-not-ignored files. */
24
+ export function worktreeOf(root: string): ReachTree {
25
+ let files: string[] | null = null;
26
+ return {
27
+ list() {
28
+ if (files === null) {
29
+ const res = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
30
+ cwd: root,
31
+ encoding: 'utf-8',
32
+ maxBuffer: 64 * 1024 * 1024,
33
+ });
34
+ files = res.status === 0 ? res.stdout.split('\n').filter((l) => l !== '') : [];
35
+ }
36
+ return files;
37
+ },
38
+ read(rel) {
39
+ const abs = path.join(root, rel);
40
+ return fs.existsSync(abs) && fs.statSync(abs).isFile() ? fs.readFileSync(abs, 'utf-8') : null;
41
+ },
42
+ };
43
+ }
44
+
45
+ /** A commit's tree, read from objects — for replaying an old range. */
46
+ export function commitTreeOf(root: string, ref: string): ReachTree {
47
+ const ls = spawnSync('git', ['ls-tree', '-r', '--name-only', ref], { cwd: root, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
48
+ const files = ls.status === 0 ? ls.stdout.split('\n').filter((l) => l !== '') : [];
49
+ const present = new Set(files);
50
+ return {
51
+ list: () => files,
52
+ read(rel) {
53
+ if (!present.has(rel)) return null;
54
+ const res = spawnSync('git', ['show', `${ref}:${rel}`], { cwd: root, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
55
+ return res.status === 0 ? res.stdout : null;
56
+ },
57
+ };
58
+ }
59
+
60
+ export function globToRegExp(glob: string): RegExp {
61
+ let re = '';
62
+ for (let i = 0; i < glob.length; i += 1) {
63
+ const c = glob[i] as string;
64
+ if (c === '*' && glob[i + 1] === '*') {
65
+ re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*';
66
+ i += glob[i + 2] === '/' ? 2 : 1;
67
+ } else if (c === '*') re += '[^/]*';
68
+ else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
69
+ }
70
+ return new RegExp(`^${re}$`);
71
+ }
72
+
73
+ /** Resolve a `.js` specifier from `fromRel` to the source it names in `tree`. */
74
+ function resolve(tree: ReachTree, fromRel: string, spec: string): string | null {
75
+ const base = path.posix.join(path.posix.dirname(fromRel), spec);
76
+ for (const c of [base.replace(/\.js$/, '.ts'), base, `${base}.ts`, `${base}/index.ts`]) {
77
+ const norm = path.posix.normalize(c);
78
+ if (tree.read(norm) !== null) return norm;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** `{ a, b as c }` → `['a', 'b']`; `* as ns` and a default import → `['*']`. */
84
+ export function importedNames(clause: string): string[] {
85
+ const names: string[] = [];
86
+ const braces = /\{([^}]*)\}/.exec(clause);
87
+ if (braces) {
88
+ for (const part of (braces[1] as string).split(',')) {
89
+ const name = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0]?.trim();
90
+ if (name) names.push(name);
91
+ }
92
+ }
93
+ const outside = clause.replace(/\{[^}]*\}/, '');
94
+ if (/\*\s+as\s+\w+/.test(outside) || /^\s*(?:type\s+)?[A-Za-z_$][\w$]*\s*(?:,|$)/.test(outside)) names.push('*');
95
+ return names;
96
+ }
97
+
98
+ export interface ModuleImport {
99
+ readonly target: string;
100
+ readonly names: readonly string[];
101
+ }
102
+
103
+ /** Every relative import of `rel`, resolved, with the names it takes. */
104
+ export function moduleImports(tree: ReachTree, rel: string): ModuleImport[] {
105
+ const source = (tree.read(rel) ?? '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
106
+ const out: ModuleImport[] = [];
107
+ const re = /\b(import|export)\s+(?:type\s+)?((?:(?!\b(?:import|export)\b)[^;'"])*?)\bfrom\s*['"](\.{1,2}\/[^'"]+)['"]|\bimport\s*['"](\.{1,2}\/[^'"]+)['"]/g;
108
+ let m: RegExpExecArray | null;
109
+ while ((m = re.exec(source)) !== null) {
110
+ const spec = (m[3] ?? m[4]) as string;
111
+ const target = resolve(tree, rel, spec);
112
+ if (target === null) continue;
113
+ out.push({ target, names: m[4] !== undefined ? [] : importedNames(m[2] ?? '') });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** Entries from the committed config: the fixed list plus every script an entry source names. */
119
+ export function productionEntries(tree: ReachTree): string[] {
120
+ const raw = tree.read(ENTRIES_CONFIG_REL);
121
+ if (raw === null) return [];
122
+ const cfg = JSON.parse(raw) as { entries?: string[]; entry_sources?: string[] };
123
+ const entries = new Set((cfg.entries ?? []).filter((e) => tree.read(e) !== null));
124
+ const sources = (cfg.entry_sources ?? []).map(globToRegExp);
125
+ for (const file of tree.list()) {
126
+ if (!sources.some((re) => re.test(file))) continue;
127
+ for (const m of (tree.read(file) ?? '').matchAll(/(?:src\/)?scripts\/([A-Za-z0-9_/.-]+?)(?:\.ts)?(?![A-Za-z0-9_/.-])/g)) {
128
+ const candidate = `src/scripts/${m[1] as string}.ts`;
129
+ if (tree.read(candidate) !== null) entries.add(candidate);
130
+ }
131
+ }
132
+ return [...entries].sort();
133
+ }
134
+
135
+ /**
136
+ * Exports the module's own code also uses — named again after their declaration.
137
+ * A reached module's `main` runs its helpers without importing them, so a helper
138
+ * a concern calls is reached; one only its test calls is not.
139
+ */
140
+ export function usedExports(source: string): string[] {
141
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
142
+ const out: string[] = [];
143
+ for (const m of code.matchAll(/\bexport\s+(?:declare\s+)?(?:async\s+)?(?:function\*?|const|let|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g)) {
144
+ const name = m[1] as string;
145
+ const uses = code.match(new RegExp(`(?<![\\w$.])${name.replace(/\$/g, '\\$')}(?![\\w$])`, 'g'))?.length ?? 0;
146
+ if (uses >= 2) out.push(name);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ export interface Reach {
152
+ readonly entries: ReadonlySet<string>;
153
+ readonly modules: ReadonlySet<string>;
154
+ /** `<module>#<name>`; `<module>#*` when a closure module takes it whole. */
155
+ readonly symbols: ReadonlySet<string>;
156
+ }
157
+
158
+ export function productionReach(tree: ReachTree, extra: readonly string[] = []): Reach {
159
+ const entries = new Set([...productionEntries(tree), ...extra.filter((e) => tree.read(e) !== null)]);
160
+ const modules = new Set<string>();
161
+ const symbols = new Set<string>();
162
+ const queue = [...entries];
163
+ while (queue.length > 0) {
164
+ const rel = queue.shift() as string;
165
+ if (modules.has(rel)) continue;
166
+ modules.add(rel);
167
+ for (const name of usedExports(tree.read(rel) ?? '')) symbols.add(`${rel}#${name}`);
168
+ for (const imp of moduleImports(tree, rel)) {
169
+ for (const n of imp.names) symbols.add(`${imp.target}#${n}`);
170
+ if (!modules.has(imp.target)) queue.push(imp.target);
171
+ }
172
+ }
173
+ return { entries, modules, symbols };
174
+ }
175
+
176
+ /** The `src/` symbols a test takes, and whether production reaches at least one. */
177
+ export function testReaches(tree: ReachTree, reach: Reach, testRel: string): { taken: string[]; reached: string[] } {
178
+ const taken: string[] = [];
179
+ const reached: string[] = [];
180
+ for (const imp of moduleImports(tree, testRel)) {
181
+ if (!imp.target.startsWith('src/') || /\.test\.ts$/.test(imp.target)) continue;
182
+ for (const n of imp.names.length ? imp.names : ['*']) {
183
+ const key = `${imp.target}#${n}`;
184
+ taken.push(key);
185
+ const hit =
186
+ reach.entries.has(imp.target) ||
187
+ reach.symbols.has(key) ||
188
+ reach.symbols.has(`${imp.target}#*`) ||
189
+ (n === '*' && reach.modules.has(imp.target));
190
+ if (hit) reached.push(key);
191
+ }
192
+ }
193
+ return { taken, reached };
194
+ }
@@ -60,7 +60,9 @@ export function markerFile(repoRoot: string, runId: string): string {
60
60
  *
61
61
  * Idempotent on the timestamp: a second park inside one run keeps the original
62
62
  * `parked_at` and merges the provider set, so "when did this run first hit the
63
- * cliff" survives a round that parks twice.
63
+ * cliff" survives a round that parks twice. A host park is the exception: it
64
+ * sets its own `source` and refreshes `parked_at`, since the host's rate window
65
+ * starts at its own signal, not at an earlier council park.
64
66
  */
65
67
  export function writeQuotaParked(
66
68
  repoRoot: string,
@@ -71,12 +73,13 @@ export function writeQuotaParked(
71
73
  ): QuotaParkedMarker {
72
74
  const file = markerFile(repoRoot, runId);
73
75
  const existing = readMarker(file);
76
+ const host = source === 'host';
74
77
  const merged = [...new Set([...(existing?.providers ?? []), ...providers])].sort();
75
78
  const marker: QuotaParkedMarker = {
76
79
  run_id: runId,
77
80
  providers: merged,
78
- parked_at: existing?.parked_at ?? now().toISOString(),
79
- source: existing?.source ?? source,
81
+ parked_at: (host ? null : existing?.parked_at) ?? now().toISOString(),
82
+ source: host ? source : (existing?.source ?? source),
80
83
  };
81
84
  fs.mkdirSync(path.dirname(file), { recursive: true });
82
85
  fs.writeFileSync(file, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
@@ -106,20 +109,7 @@ export function clearQuotaParked(repoRoot: string, runId: string): void {
106
109
  * per parked run, so the scan is cheap by construction.
107
110
  */
108
111
  export function findQuotaParked(repoRoot: string, sessionId: string): QuotaParkedMarker | null {
109
- const direct = readMarker(markerFile(repoRoot, sessionId));
110
- if (direct !== null) return direct;
111
- let names: string[];
112
- try {
113
- names = fs.readdirSync(path.join(repoRoot, QUOTA_PARKED_DIR_REL));
114
- } catch {
115
- return null;
116
- }
117
- for (const name of names) {
118
- if (!name.endsWith('.json')) continue;
119
- const m = readMarker(path.join(repoRoot, QUOTA_PARKED_DIR_REL, name));
120
- if (m !== null && m.run_id === sessionId) return m;
121
- }
122
- return null;
112
+ return readMarker(markerFile(repoRoot, sessionId)) ?? allMarkers(repoRoot).find((m) => m.run_id === sessionId) ?? null;
123
113
  }
124
114
 
125
115
  /** The plan's rolling rate window; a host marker older than this no longer describes it. */
@@ -127,20 +117,19 @@ export const HOST_RATE_WINDOW_MS = 5 * 3_600_000;
127
117
 
128
118
  /** True when a host-sourced marker was parked inside the rate window ending at `nowMs`. */
129
119
  export function recentHostMarker(repoRoot: string, nowMs: number = Date.now(), windowMs: number = HOST_RATE_WINDOW_MS): boolean {
130
- let names: string[];
120
+ return allMarkers(repoRoot).some((m) => {
121
+ const age = nowMs - Date.parse(m.parked_at);
122
+ return m.source === 'host' && age >= 0 && age < windowMs;
123
+ });
124
+ }
125
+
126
+ function allMarkers(repoRoot: string): QuotaParkedMarker[] {
127
+ const dir = path.join(repoRoot, QUOTA_PARKED_DIR_REL);
131
128
  try {
132
- names = fs.readdirSync(path.join(repoRoot, QUOTA_PARKED_DIR_REL));
129
+ return fs.readdirSync(dir).flatMap((n) => (n.endsWith('.json') ? (readMarker(path.join(dir, n)) ?? []) : []));
133
130
  } catch {
134
- return false;
135
- }
136
- for (const name of names) {
137
- if (!name.endsWith('.json')) continue;
138
- const m = readMarker(path.join(repoRoot, QUOTA_PARKED_DIR_REL, name));
139
- if (m === null || m.source !== 'host') continue;
140
- const at = Date.parse(m.parked_at);
141
- if (Number.isFinite(at) && nowMs - at >= 0 && nowMs - at < windowMs) return true;
131
+ return [];
142
132
  }
143
- return false;
144
133
  }
145
134
 
146
135
  function readMarker(file: string): QuotaParkedMarker | null {