@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +157 -468
- package/README.md +4 -3
- package/dist/agent-src/commands/project/analyze.md +9 -6
- package/dist/agent-src/commands/project/map.md +58 -0
- package/dist/agent-src/commands/project.md +5 -3
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
- package/dist/agent-src/skills/code-intelligence/SKILL.md +6 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +5 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
- package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
- package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
- package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
- package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
- package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
- package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
- package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
- package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +32 -47
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_preflight.js +3 -3
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +9 -13
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +41 -15
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +163 -148
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/instructions_observer.chunk.cjs +301 -0
- package/dist/hooks/instructions_observer.chunk.cjs.sha256 +1 -0
- package/dist/install/claudeRuleRewrite.js +10 -3
- package/dist/install/conflict.js +24 -2
- package/dist/install/conflictTracker.js +14 -3
- package/dist/install/install.mjs +222 -117
- package/dist/install/installThinLayer.js +1 -0
- package/dist/install/loggedWrites.js +142 -0
- package/dist/install/paths.js +12 -2
- package/dist/install/preserve.js +25 -4
- package/dist/install/txlog.js +10 -6
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +33595 -22916
- package/dist/scripts/_lib/egress_rules.js +4 -1
- package/dist/scripts/_lib/identifier_redaction.js +35 -2
- package/dist/scripts/ai_council/seat_policy.js +7 -1
- package/dist/server/schemas/settings.js +1 -1
- package/package.json +4 -1
- package/src/config/ci-local-parity.yml +1 -1
- package/src/config/continuity-surface.json +7 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +27 -7
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/gitignore-block.txt +6 -0
- package/src/config/hook-bundle-budget.json +19 -4
- package/src/config/hook-token-budget.json +1 -1
- package/src/config/packed-binary-manifest.json +27 -0
- package/src/config/production-entries.json +18 -0
- package/src/config/release-gate-locality.yml +0 -7
- package/src/config/skill-body-budget.json +7 -1
- package/src/install/claudeRuleRewrite.ts +10 -1
- package/src/install/conflict.ts +24 -1
- package/src/install/conflictTracker.ts +18 -2
- package/src/install/installThinLayer.ts +6 -0
- package/src/install/loggedWrites.ts +148 -0
- package/src/install/paths.ts +11 -2
- package/src/install/preserve.ts +40 -4
- package/src/install/txlog.ts +10 -6
- package/src/scripts/_cli/cmd_conformance.ts +3 -2
- package/src/scripts/_cli/cmd_update.ts +15 -17
- package/src/scripts/_lib/bench_ab_complexity.ts +14 -2
- package/src/scripts/_lib/collector_record.ts +1 -0
- package/src/scripts/_lib/council_settings_block.ts +9 -2
- package/src/scripts/_lib/egress_rules.ts +4 -1
- package/src/scripts/_lib/host_capability.ts +10 -5
- package/src/scripts/_lib/identifier_redaction.ts +38 -2
- package/src/scripts/_lib/price_freshness.ts +112 -0
- package/src/scripts/_lib/production_reach.ts +194 -0
- package/src/scripts/_lib/quota_parked.ts +17 -28
- package/src/scripts/_lib/release_findings_ingest.ts +13 -335
- package/src/scripts/_lib/runtime_journal.ts +7 -7
- package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
- package/src/scripts/ai_council/chairman.ts +5 -2
- package/src/scripts/ai_council/clients.ts +4 -3
- package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
- package/src/scripts/ai_council/free_route_catalog.ts +142 -5
- package/src/scripts/ai_council/free_route_client.ts +4 -0
- package/src/scripts/ai_council/hand_payload.ts +115 -7
- package/src/scripts/ai_council/orchestrator.ts +7 -10
- package/src/scripts/ai_council/route_chooser.ts +1 -1
- package/src/scripts/ai_council/route_member.ts +66 -4
- package/src/scripts/ai_council/seat_policy.ts +8 -1
- package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
- package/src/scripts/ai_council/status_surface.ts +2 -0
- package/src/scripts/ask_transport.ts +137 -15
- package/src/scripts/check_finding_dispositions.ts +25 -71
- package/src/scripts/check_release_pr_shape.ts +3 -3
- package/src/scripts/check_step_production_reach.ts +257 -0
- package/src/scripts/code_graph/build.ts +210 -10
- package/src/scripts/code_graph/cli.ts +168 -46
- package/src/scripts/code_graph/detect.ts +5 -4
- package/src/scripts/code_graph/docs_layer.ts +57 -0
- package/src/scripts/code_graph/extract.ts +58 -13
- package/src/scripts/code_graph/extract_more.ts +314 -0
- package/src/scripts/code_graph/laravel_overlay.ts +454 -0
- package/src/scripts/code_graph/map.ts +846 -0
- package/src/scripts/code_graph/map_model.ts +977 -0
- package/src/scripts/code_graph/map_verify.ts +140 -0
- package/src/scripts/code_graph/map_viewer/viewer.css +278 -0
- package/src/scripts/code_graph/map_viewer/viewer.js +756 -0
- package/src/scripts/code_graph/scip_reader.ts +201 -0
- package/src/scripts/code_graph/sqlite_store.ts +8 -1
- package/src/scripts/code_graph/types.ts +81 -15
- package/src/scripts/code_graph/validate.ts +16 -26
- package/src/scripts/code_graph/verbs.ts +7 -0
- package/src/scripts/council_cli.ts +10 -8
- package/src/scripts/hook_chunks.mjs +7 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +4 -4
- package/src/scripts/hooks/instructions_observer_hook.ts +25 -6
- package/src/scripts/hooks/lazy_concern.ts +4 -0
- package/src/scripts/hooks/payload_stub.ts +27 -0
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
- package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
- package/src/scripts/install-hooks.sh +5 -0
- package/src/scripts/install.ts +2 -2
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_decision_classes.ts +34 -0
- package/src/scripts/lint_hook_manifest.ts +25 -0
- package/src/scripts/lint_roadmap_blockers.ts +52 -3
- package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -6
- package/src/scripts/mcp_server/graph_tools.ts +73 -1
- package/src/scripts/mcp_server/tools.ts +19 -36
- package/src/scripts/release.ts +5 -9
- package/src/scripts/release_drill.ts +11 -165
- package/src/scripts/release_publication.ts +0 -9
- package/src/scripts/report_production_reach.ts +35 -0
- package/src/scripts/worker_reuse_shadow_report.ts +399 -1
- package/src/server/schemas/settings.ts +1 -1
- package/src/vendor/grammars/README.md +10 -5
- package/src/vendor/grammars/tree-sitter-go.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-java.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-python.wasm +0 -0
- package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
- /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
|
-
|
|
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
|
-
*
|
|
559
|
+
* Name `agent-config upgrade` when the global install is older than the pin.
|
|
560
560
|
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
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
|
|
569
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
|
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
|
-
|
|
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. */
|
|
@@ -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`,
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
return fs.readdirSync(dir).flatMap((n) => (n.endsWith('.json') ? (readMarker(path.join(dir, n)) ?? []) : []));
|
|
133
130
|
} catch {
|
|
134
|
-
return
|
|
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 {
|