@event4u/agent-config 12.1.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +164 -186
- package/README.md +1 -1
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +36 -14
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +34 -12
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +70 -4
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +5 -5
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-7PZ4FSZB.js} +368 -191
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
- package/dist/cli-delegate/cmd_conformance.js +224 -104
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +73 -30
- 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 +7 -5
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +2653 -1365
- package/dist/install/install.mjs +12 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +2679 -887
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +70 -4
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +140 -12
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/hook-token-budget.json +8 -1
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +278 -5
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +101 -164
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +369 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_pr_ci_current.ts +409 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +99 -6
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/release.ts +43 -58
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/command.schema.json +1 -1
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/schemas/skill.schema.json +18 -1
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/server/schemas/settings.ts +2 -2
- package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
- package/src/config/budget-routing.json +0 -7
|
@@ -1,351 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tier vocabulary + cool-down state.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* ARCHIVED 2026-08-16 — the budget-routing DECISION LAYER and PERMIT LIFECYCLE
|
|
5
|
+
* that used to live here (`pickTier`, `acquireBudgetPermit`, `settlePermit`,
|
|
6
|
+
* `tripCooldown`, and the reserve/lock machinery behind them) were removed by a
|
|
7
|
+
* converged AI-council verdict (anthropic + openai, 2 of 2, 2026-08-16), which
|
|
8
|
+
* reversed the v1 contract locked on 2026-08-03 and retired its pre-registered
|
|
9
|
+
* AC1–AC5. `docs/contracts/budget-routing.md` is the migration record.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* tiers via the same relation.
|
|
11
|
+
* The short reason: `pickTier` required a `routing_switch` input whose sole
|
|
12
|
+
* source — the `subagents.budget_routing` settings key — was DELETED on purpose
|
|
13
|
+
* by always-on orchestration. Wiring it therefore meant inventing a replacement
|
|
14
|
+
* for a category that had been removed deliberately, not finishing an
|
|
15
|
+
* integration. It had zero production callers, and with `session_tier` non-null
|
|
16
|
+
* in 0 of 327 orchestration records there was no basis on which its saving could
|
|
17
|
+
* ever have been measured.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* What stayed, and why: `TIER_ORDER` and `readCooldowns` have a live consumer in
|
|
20
|
+
* `routing_doctor.ts`, which reports cool-down state as a diagnostic. That is
|
|
21
|
+
* monitoring, not routing, and it never depended on the decision layer.
|
|
22
|
+
*
|
|
23
|
+
* Reversing this needs a real change of conditions, not a preference. Both
|
|
24
|
+
* council members attached one: an authoritative per-request tier-selection
|
|
25
|
+
* signal appearing with a named production dispatch point, plus telemetry
|
|
26
|
+
* carrying BOTH the chosen and the realized tier so a saving can be computed at
|
|
27
|
+
* all. Until then the git history holds the implementation.
|
|
20
28
|
*/
|
|
21
29
|
import * as fs from 'node:fs';
|
|
22
30
|
import * as path from 'node:path';
|
|
23
|
-
import { fileURLToPath } from 'node:url';
|
|
24
31
|
|
|
25
32
|
export type BudgetTier = 'cheap' | 'medium' | 'strong';
|
|
26
33
|
|
|
27
|
-
/** Ordered cheapest-first
|
|
34
|
+
/** Ordered cheapest-first. */
|
|
28
35
|
export const TIER_ORDER: readonly BudgetTier[] = ['cheap', 'medium', 'strong'];
|
|
29
36
|
|
|
30
|
-
export interface TierBudgetState {
|
|
31
|
-
/** USD ceiling from cost.budgets.per_tier.<tier>; null = no tier cap. */
|
|
32
|
-
ceiling_usd: number | null;
|
|
33
|
-
/** Ledger-summed spend for this tier in the ceiling's window (USD). */
|
|
34
|
-
spent_usd: number;
|
|
35
|
-
/** Epoch ms until which this tier is cooling after a quota error; 0 = live. */
|
|
36
|
-
cooldown_until_ms: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type BudgetRoutingSwitch = 'ask' | 'auto' | 'off';
|
|
40
|
-
|
|
41
|
-
export interface PickTierInputs {
|
|
42
|
-
/** Cheapest tier the classifier marked adequate (quality floor). */
|
|
43
|
-
adequate_tier: BudgetTier;
|
|
44
|
-
/** Per-tier budget state, complete over TIER_ORDER. */
|
|
45
|
-
budgets: Record<BudgetTier, TierBudgetState>;
|
|
46
|
-
/**
|
|
47
|
-
* Caller-supplied switch. Formerly sourced from the `subagents.budget_routing`
|
|
48
|
-
* settings key, deleted by always-on orchestration (Phase 1) — this
|
|
49
|
-
* function has no production caller today, so the field stays as the
|
|
50
|
-
* DESIGNED input shape; a future wiring supplies its own source.
|
|
51
|
-
*/
|
|
52
|
-
routing_switch: BudgetRoutingSwitch;
|
|
53
|
-
/** Estimated cost of the dispatch (USD) for the availability check. */
|
|
54
|
-
estimated_cost_usd: number;
|
|
55
|
-
/** Clock injection — never Date.now() in the decision path. */
|
|
56
|
-
now_ms: number;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface PickTierDecision {
|
|
60
|
-
/** 'tier' → dispatch on `tier`; 'session' → run on the session model. */
|
|
61
|
-
route: 'tier' | 'session';
|
|
62
|
-
tier: BudgetTier | null;
|
|
63
|
-
/** 'proceed' | 'ask' (switch=ask and a budget-motivated downshift) */
|
|
64
|
-
action: 'proceed' | 'ask';
|
|
65
|
-
/** Non-empty when the relation degraded (exhausted / cooling / off). */
|
|
66
|
-
notice: string;
|
|
67
|
-
reason: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function tierAvailable(
|
|
71
|
-
s: TierBudgetState,
|
|
72
|
-
est: number,
|
|
73
|
-
now: number,
|
|
74
|
-
): { ok: boolean; why: string } {
|
|
75
|
-
if (s.cooldown_until_ms > now) {
|
|
76
|
-
return { ok: false, why: 'cooling after quota error' };
|
|
77
|
-
}
|
|
78
|
-
if (s.ceiling_usd === null) {
|
|
79
|
-
return { ok: true, why: 'no tier cap' };
|
|
80
|
-
}
|
|
81
|
-
if (s.spent_usd + est > s.ceiling_usd) {
|
|
82
|
-
return { ok: false, why: `ceiling ${s.ceiling_usd} reached (spent ${s.spent_usd})` };
|
|
83
|
-
}
|
|
84
|
-
return { ok: true, why: 'within ceiling' };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** The binding relation from docs/contracts/budget-routing.md. */
|
|
88
|
-
export function pickTier(inp: PickTierInputs): PickTierDecision {
|
|
89
|
-
if (inp.routing_switch === 'off') {
|
|
90
|
-
return {
|
|
91
|
-
route: 'session',
|
|
92
|
-
tier: null,
|
|
93
|
-
action: 'proceed',
|
|
94
|
-
notice: '',
|
|
95
|
-
reason: 'budget_routing off — session model, today\'s behavior',
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
const startIdx = TIER_ORDER.indexOf(inp.adequate_tier);
|
|
99
|
-
const skipped: string[] = [];
|
|
100
|
-
for (let i = startIdx; i < TIER_ORDER.length; i += 1) {
|
|
101
|
-
const tier = TIER_ORDER[i] as BudgetTier;
|
|
102
|
-
const avail = tierAvailable(inp.budgets[tier], inp.estimated_cost_usd, inp.now_ms);
|
|
103
|
-
if (avail.ok) {
|
|
104
|
-
const degraded = i > startIdx;
|
|
105
|
-
return {
|
|
106
|
-
route: 'tier',
|
|
107
|
-
tier,
|
|
108
|
-
action: inp.routing_switch === 'ask' ? 'ask' : 'proceed',
|
|
109
|
-
notice: degraded
|
|
110
|
-
? `cheapest adequate tier unavailable (${skipped.join('; ')}) — routed up to ${tier}`
|
|
111
|
-
: '',
|
|
112
|
-
reason: `${tier}: ${avail.why}${degraded ? '' : ' — cheapest adequate tier'}`,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
skipped.push(`${tier}: ${avail.why}`);
|
|
116
|
-
}
|
|
117
|
-
return {
|
|
118
|
-
route: 'session',
|
|
119
|
-
tier: null,
|
|
120
|
-
action: 'proceed',
|
|
121
|
-
notice: `all tier budgets unavailable (${skipped.join('; ')}) — session model, work not blocked`,
|
|
122
|
-
reason: 'fail-open: budget machinery degrades savings, never work',
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ── Atomic permit + cool-down (filesystem side) ────────────────────────────
|
|
127
|
-
|
|
128
|
-
export const RESERVE_FILE = 'tier-reserves.jsonl';
|
|
129
37
|
export const COOLDOWN_FILE = 'tier-cooldowns.json';
|
|
130
|
-
export const DEFAULT_COOLDOWN_MS = 60 * 60 * 1000;
|
|
131
|
-
|
|
132
|
-
// Reserve lifecycle constants — single source of truth shared with
|
|
133
|
-
// budget.mjs (the second reserve reader) via src/config/budget-routing.json.
|
|
134
|
-
// Duplicating the literal here was the exact two-truths defect the external
|
|
135
|
-
// review flagged; both readers now load the same file.
|
|
136
|
-
const _LIFECYCLE_CONFIG = path.join(
|
|
137
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
138
|
-
'..',
|
|
139
|
-
'..',
|
|
140
|
-
'config',
|
|
141
|
-
'budget-routing.json',
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
function _lifecycle(): { reserve_ttl_ms: number; lock_break_ms: number } {
|
|
145
|
-
try {
|
|
146
|
-
const raw = JSON.parse(fs.readFileSync(_LIFECYCLE_CONFIG, 'utf-8')) as Record<string, unknown>;
|
|
147
|
-
const ttl = typeof raw['reserve_ttl_ms'] === 'number' ? raw['reserve_ttl_ms'] : 600_000;
|
|
148
|
-
const brk = typeof raw['lock_break_ms'] === 'number' ? raw['lock_break_ms'] : 30_000;
|
|
149
|
-
return { reserve_ttl_ms: ttl, lock_break_ms: brk };
|
|
150
|
-
} catch {
|
|
151
|
-
return { reserve_ttl_ms: 600_000, lock_break_ms: 30_000 };
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function reserveTtlMs(): number {
|
|
156
|
-
return _lifecycle().reserve_ttl_ms;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
interface ReserveEntry {
|
|
160
|
-
ts_ms: number;
|
|
161
|
-
tier: BudgetTier;
|
|
162
|
-
est_usd: number;
|
|
163
|
-
status: 'pending' | 'settled';
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function _readReserves(reservePath: string): ReserveEntry[] {
|
|
167
|
-
if (!fs.existsSync(reservePath)) {
|
|
168
|
-
return [];
|
|
169
|
-
}
|
|
170
|
-
const out: ReserveEntry[] = [];
|
|
171
|
-
for (const line of fs.readFileSync(reservePath, 'utf-8').split('\n')) {
|
|
172
|
-
if (!line.trim()) continue;
|
|
173
|
-
try {
|
|
174
|
-
out.push(JSON.parse(line) as ReserveEntry);
|
|
175
|
-
} catch {
|
|
176
|
-
// tolerate a torn line — reserve file is advisory spend state
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return out;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Take the reserve lock; break a stale one (crash leftover) once. */
|
|
183
|
-
function _takeLock(lock: string, now_ms: number, lock_break_ms: number): number | null {
|
|
184
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
185
|
-
try {
|
|
186
|
-
return fs.openSync(lock, 'wx');
|
|
187
|
-
} catch {
|
|
188
|
-
try {
|
|
189
|
-
const age = now_ms - fs.statSync(lock).mtimeMs;
|
|
190
|
-
if (age > lock_break_ms) {
|
|
191
|
-
fs.rmSync(lock, { force: true });
|
|
192
|
-
continue; // one retry after breaking the stale lock
|
|
193
|
-
}
|
|
194
|
-
} catch {
|
|
195
|
-
continue; // lock vanished between open and stat — retry once
|
|
196
|
-
}
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Atomic budget reserve — full lifecycle (external review 2026-08-03):
|
|
205
|
-
*
|
|
206
|
-
* acquire → expire (TTL) / settle → compact-on-write
|
|
207
|
-
*
|
|
208
|
-
* Under the lock: live reserves = pending entries younger than the shared
|
|
209
|
-
* TTL (reserves are RACE PROTECTION, not spend accounting — real spend is
|
|
210
|
-
* the ledger's job); the file is COMPACTED to exactly those live entries
|
|
211
|
-
* on every acquire, so it stays bounded; a lock file older than
|
|
212
|
-
* `lock_break_ms` is a crash leftover and is broken with one retry.
|
|
213
|
-
* Returns granted=false (never throws) when the ceiling would be crossed
|
|
214
|
-
* or a fresh lock is genuinely held — the caller falls back per the
|
|
215
|
-
* relation (fail-open at the DECISION layer, conservative at the SPEND
|
|
216
|
-
* layer).
|
|
217
|
-
*/
|
|
218
|
-
export function acquireBudgetPermit(options: {
|
|
219
|
-
tracking_dir: string;
|
|
220
|
-
tier: BudgetTier;
|
|
221
|
-
estimated_cost_usd: number;
|
|
222
|
-
ceiling_usd: number | null;
|
|
223
|
-
spent_usd: number;
|
|
224
|
-
now_ms: number;
|
|
225
|
-
}): { granted: boolean; reason: string } {
|
|
226
|
-
const dir = options.tracking_dir;
|
|
227
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
228
|
-
const lock = path.join(dir, `${RESERVE_FILE}.lock`);
|
|
229
|
-
const { reserve_ttl_ms, lock_break_ms } = _lifecycle();
|
|
230
|
-
const fd = _takeLock(lock, options.now_ms, lock_break_ms);
|
|
231
|
-
if (fd === null) {
|
|
232
|
-
return { granted: false, reason: 'reserve lock busy — treat tier as unavailable this instant' };
|
|
233
|
-
}
|
|
234
|
-
try {
|
|
235
|
-
const reservePath = path.join(dir, RESERVE_FILE);
|
|
236
|
-
const live = _readReserves(reservePath).filter(
|
|
237
|
-
(e) => e.status === 'pending' && options.now_ms - e.ts_ms < reserve_ttl_ms,
|
|
238
|
-
);
|
|
239
|
-
const reserved = live
|
|
240
|
-
.filter((e) => e.tier === options.tier)
|
|
241
|
-
.reduce((acc, e) => acc + e.est_usd, 0);
|
|
242
|
-
if (
|
|
243
|
-
options.ceiling_usd !== null &&
|
|
244
|
-
options.spent_usd + reserved + options.estimated_cost_usd > options.ceiling_usd
|
|
245
|
-
) {
|
|
246
|
-
// Compact even on deny — expiry must not depend on a grant.
|
|
247
|
-
fs.writeFileSync(
|
|
248
|
-
reservePath,
|
|
249
|
-
live.map((e) => JSON.stringify(e)).join('\n') + (live.length ? '\n' : ''),
|
|
250
|
-
'utf-8',
|
|
251
|
-
);
|
|
252
|
-
return {
|
|
253
|
-
granted: false,
|
|
254
|
-
reason: `ceiling ${options.ceiling_usd} would be crossed (spent ${options.spent_usd} + reserved ${reserved})`,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
live.push({
|
|
258
|
-
ts_ms: options.now_ms,
|
|
259
|
-
tier: options.tier,
|
|
260
|
-
est_usd: options.estimated_cost_usd,
|
|
261
|
-
status: 'pending',
|
|
262
|
-
});
|
|
263
|
-
fs.writeFileSync(
|
|
264
|
-
reservePath,
|
|
265
|
-
live.map((e) => JSON.stringify(e)).join('\n') + '\n',
|
|
266
|
-
'utf-8',
|
|
267
|
-
);
|
|
268
|
-
return { granted: true, reason: 'reserved' };
|
|
269
|
-
} finally {
|
|
270
|
-
fs.closeSync(fd);
|
|
271
|
-
fs.rmSync(lock, { force: true });
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Settle the oldest live pending reserve of a tier (call when the real
|
|
277
|
-
* cost lands in the ledger, so the estimate stops counting immediately
|
|
278
|
-
* instead of waiting out the TTL). Settled entries are dropped by the
|
|
279
|
-
* next compaction. Best-effort: lock busy → no-op (the TTL is the
|
|
280
|
-
* backstop that makes settling optional, never load-bearing).
|
|
281
|
-
*/
|
|
282
|
-
export function settlePermit(options: {
|
|
283
|
-
tracking_dir: string;
|
|
284
|
-
tier: BudgetTier;
|
|
285
|
-
now_ms: number;
|
|
286
|
-
}): boolean {
|
|
287
|
-
const lock = path.join(options.tracking_dir, `${RESERVE_FILE}.lock`);
|
|
288
|
-
const { reserve_ttl_ms, lock_break_ms } = _lifecycle();
|
|
289
|
-
const fd = _takeLock(lock, options.now_ms, lock_break_ms);
|
|
290
|
-
if (fd === null) {
|
|
291
|
-
return false;
|
|
292
|
-
}
|
|
293
|
-
try {
|
|
294
|
-
const reservePath = path.join(options.tracking_dir, RESERVE_FILE);
|
|
295
|
-
const live = _readReserves(reservePath).filter(
|
|
296
|
-
(e) => e.status === 'pending' && options.now_ms - e.ts_ms < reserve_ttl_ms,
|
|
297
|
-
);
|
|
298
|
-
const idx = live.findIndex((e) => e.tier === options.tier);
|
|
299
|
-
if (idx === -1) {
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
live.splice(idx, 1);
|
|
303
|
-
fs.writeFileSync(
|
|
304
|
-
reservePath,
|
|
305
|
-
live.map((e) => JSON.stringify(e)).join('\n') + (live.length ? '\n' : ''),
|
|
306
|
-
'utf-8',
|
|
307
|
-
);
|
|
308
|
-
return true;
|
|
309
|
-
} finally {
|
|
310
|
-
fs.closeSync(fd);
|
|
311
|
-
fs.rmSync(lock, { force: true });
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
38
|
|
|
315
39
|
/**
|
|
316
|
-
*
|
|
40
|
+
* Read the cool-down map (missing/torn file → all tiers live).
|
|
317
41
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
42
|
+
* NOTE the asymmetry this now has, because it is load-bearing for the caller:
|
|
43
|
+
* nothing in the tree WRITES this file any more — `tripCooldown` was the only
|
|
44
|
+
* writer and it went with the permit lifecycle. `routing_doctor` already
|
|
45
|
+
* accounts for that and reports cool-down state as *unavailable, no producer*
|
|
46
|
+
* rather than reading an all-zero map as a measured "not cooling". Keep that
|
|
47
|
+
* distinction if you ever add a writer back.
|
|
322
48
|
*/
|
|
323
|
-
export function tripCooldown(options: {
|
|
324
|
-
tracking_dir: string;
|
|
325
|
-
tier: BudgetTier;
|
|
326
|
-
now_ms: number;
|
|
327
|
-
cooldown_ms?: number;
|
|
328
|
-
}): number {
|
|
329
|
-
const until = options.now_ms + (options.cooldown_ms ?? DEFAULT_COOLDOWN_MS);
|
|
330
|
-
const p = path.join(options.tracking_dir, COOLDOWN_FILE);
|
|
331
|
-
let state: Record<string, number> = {};
|
|
332
|
-
try {
|
|
333
|
-
state = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, number>;
|
|
334
|
-
} catch {
|
|
335
|
-
state = {};
|
|
336
|
-
}
|
|
337
|
-
for (const [k, v] of Object.entries(state)) {
|
|
338
|
-
if (typeof v !== 'number' || v <= options.now_ms) {
|
|
339
|
-
delete state[k];
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
state[options.tier] = until;
|
|
343
|
-
fs.mkdirSync(options.tracking_dir, { recursive: true });
|
|
344
|
-
fs.writeFileSync(p, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
|
|
345
|
-
return until;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Read the cool-down map (missing/torn file → all tiers live). */
|
|
349
49
|
export function readCooldowns(tracking_dir: string): Record<BudgetTier, number> {
|
|
350
50
|
const empty: Record<BudgetTier, number> = { cheap: 0, medium: 0, strong: 0 };
|
|
351
51
|
try {
|