@event4u/agent-config 14.6.0 → 14.8.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 +255 -145
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +264 -10
- package/dist/agent-src/commands/analyze.md +1 -1
- package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
- package/dist/agent-src/commands/evals/roadmap.json +17 -1
- package/dist/agent-src/commands/pr/create.md +14 -2
- package/dist/agent-src/commands/pr/merge.md +379 -0
- package/dist/agent-src/commands/roadmap/process-full.md +272 -32
- package/dist/agent-src/commands/roadmap.md +1 -1
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +95 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
- package/dist/agent-src/rules/fix-what-you-see.md +108 -0
- package/dist/agent-src/rules/no-roadmap-references.md +0 -5
- package/dist/agent-src/rules/recurring-criticism.md +110 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
- package/dist/agent-src/rules/rule-type-governance.md +0 -2
- package/dist/agent-src/rules/self-repair-loop.md +17 -0
- package/dist/agent-src/rules/skill-quality.md +0 -2
- package/dist/agent-src/rules/source-confidentiality.md +37 -7
- package/dist/agent-src/scripts/archival_sweep.ts +117 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/gate_budget.ts +282 -0
- package/dist/agent-src/scripts/gate_execute.ts +72 -31
- package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
- package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
- package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
- package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
- package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
- package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
- package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
- package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
- package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
- package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
- package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
- package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
- package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
- package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
- package/dist/agent-src/skills/security/SKILL.md +8 -0
- package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
- package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
- package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
- package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +65 -4
- package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/dist/cli/registry.js +2 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
- package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
- package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
- package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
- package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
- package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
- package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
- package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
- package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
- package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
- package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +466 -716
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +10 -10
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +10 -9
- package/dist/cli-delegate/cmd_handoff.js +10 -9
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +1 -1
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +11 -10
- package/dist/cli-delegate/cmd_route_audit.js +6 -5
- package/dist/cli-delegate/cmd_route_explain.js +3 -3
- package/dist/cli-delegate/cmd_session_recycle.js +840 -30
- package/dist/cli-delegate/cmd_settings_get.js +3 -3
- package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +11 -10
- package/dist/cli-delegate/cmd_uninstall.js +13 -11
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +11 -10
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +111 -37
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +15 -12
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +11 -8
- package/dist/hooks/dispatch.js +3165 -2192
- package/dist/install/claudePathsPlan.js +187 -0
- package/dist/install/claudePathsPlan.js.map +1 -0
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/hostLayerFingerprint.js +136 -0
- package/dist/install/hostLayerFingerprint.js.map +1 -0
- package/dist/install/install.mjs +553 -425
- package/dist/install/partitionEligibility.js +432 -0
- package/dist/install/partitionEligibility.js.map +1 -0
- package/dist/install/ruleLayerPartition.js +157 -0
- package/dist/install/ruleLayerPartition.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1863 -1234
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_src.js +41 -0
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/scripts/_lib/install_layout.js +83 -0
- package/dist/scripts/_lib/install_layout.js.map +1 -0
- package/dist/scripts/_lib/installed_lock.js +444 -0
- package/dist/scripts/_lib/installed_lock.js.map +1 -0
- package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
- package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
- package/dist/scripts/condense.js +63 -251
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +12 -6
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
- package/docs/guidelines/agent-infra/false-green.md +47 -1
- package/docs/guidelines/agent-infra/memory-access.md +17 -0
- package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archival_sweep.ts +117 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/gate_budget.ts +282 -0
- package/src/agent-src/scripts/gate_execute.ts +72 -31
- package/src/agent-src/scripts/roadmap_gates.ts +74 -3
- package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
- package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/src/config/agent-settings.template.yml +27 -2
- package/src/config/ci-local-parity.yml +39 -0
- package/src/config/estate-count-budget.json +612 -3
- package/src/config/evaluator-budgets.json +3 -3
- package/src/config/gate-coverage.yml +220 -12
- package/src/config/gate-violation-baselines.json +73 -63
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/rule-activation-census.json +14 -6
- package/src/config/rule-stub-ceilings.json +34 -7
- package/src/install/claudePathsPlan.ts +196 -0
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/hostLayerFingerprint.ts +145 -0
- package/src/install/partitionEligibility.ts +502 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
- package/src/scripts/_dispatch.bash +17 -1
- package/src/scripts/_lib/ac_heading.ts +30 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/agent_src.ts +42 -0
- package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
- package/src/scripts/_lib/envelope_grounding.ts +49 -9
- package/src/scripts/_lib/installed_lock.ts +50 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/md_section.ts +129 -0
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/_lib/scope_guard.sh +26 -3
- package/src/scripts/_lib/self_repair.ts +19 -0
- package/src/scripts/_lib/self_repair_class_b.ts +171 -0
- package/src/scripts/adr/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +12 -32
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/ai_council/cli_help.ts +55 -0
- package/src/scripts/audit_adr_coverage.ts +103 -17
- package/src/scripts/before_complete_hook.ts +153 -28
- package/src/scripts/bench_ab_v2_complexity.ts +27 -7
- package/src/scripts/bench_hook_latency.ts +204 -0
- package/src/scripts/build_archive_index.ts +34 -0
- package/src/scripts/check_adr_frontmatter.ts +574 -39
- package/src/scripts/check_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_references.ts +78 -0
- package/src/scripts/check_release_published.ts +32 -11
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_single_delivery.ts +194 -17
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/ci_settle.ts +170 -0
- package/src/scripts/condense.ts +79 -259
- package/src/scripts/council_cli.ts +3 -3
- package/src/scripts/dispatch_r2_reviewer.ts +6 -14
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +77 -14
- package/src/scripts/hooks/block_no_verify.ts +61 -10
- package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +154 -187
- package/src/scripts/hooks/dispatch_issues.ts +38 -19
- package/src/scripts/hooks/fallback_yaml.ts +66 -0
- package/src/scripts/hooks/host_semantics.ts +21 -0
- package/src/scripts/hooks/injection_budget.ts +13 -3
- package/src/scripts/hooks/py_json_dumps.ts +103 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
- package/src/scripts/hooks/run_continuation_hook.ts +8 -5
- package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
- package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
- package/src/scripts/hooks/state_io.ts +870 -21
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
- package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
- package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
- package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
- package/src/scripts/injection_scan_hook.ts +137 -5
- package/src/scripts/install.ts +16 -11
- package/src/scripts/language_mirror_hook.ts +561 -25
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_plan_risk_register.ts +4 -3
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/lint_skill_router_head.ts +4 -4
- package/src/scripts/mcp_server/tools.ts +2 -2
- package/src/scripts/new_roadmap.ts +136 -0
- package/src/scripts/pack_mcp_content.ts +20 -5
- package/src/scripts/release.ts +37 -38
- package/src/scripts/report_layer_overlap.ts +6 -0
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/self_repair_hook.ts +37 -5
- package/src/scripts/session_register_hook.ts +11 -0
- package/src/scripts/skill_usage_collect.ts +11 -1
- package/src/scripts/skill_usage_report.ts +278 -8
- package/src/scripts/sync_pr_branch.ts +170 -20
- package/src/server/schemas/settings.ts +8 -0
- package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
- package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
- package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archival-sweep spawner for the roadmap dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `update_roadmap_progress.ts` rather than authored there: that
|
|
5
|
+
* file sits at the 1500-line source ceiling `check_source_size_budget`
|
|
6
|
+
* ratchets, and the sweep plus its contract is the newest and most separable
|
|
7
|
+
* thing in it. The contract below is that text, moved intact.
|
|
8
|
+
*
|
|
9
|
+
* `--archive` (opt-in; wired into `task roadmap-progress` and
|
|
10
|
+
* `agent-config roadmap:progress`) runs the archival sweep before rendering
|
|
11
|
+
* instead of printing "Completed roadmaps not yet archived" and leaving the
|
|
12
|
+
* work to a human who has been ignoring the line for weeks. It is a flag rather
|
|
13
|
+
* than the default because the PostToolUse hook re-runs the WRITE path once per
|
|
14
|
+
* turn on every roadmap edit, and a hook that silently `git mv`s files mid-work
|
|
15
|
+
* is a bigger problem than the warning it would remove. `--check` never
|
|
16
|
+
* archives — see `run_archival_sweep`.
|
|
17
|
+
*
|
|
18
|
+
* WHY the warning existed at all, since this is the one place a reader of the
|
|
19
|
+
* flag will look: the sweep's own default is `--changed-only`, so a PR archives
|
|
20
|
+
* exactly the roadmaps it completed. A roadmap completed by a PR whose sweep did
|
|
21
|
+
* not run is then complete, on the trunk, and OUTSIDE every later branch's
|
|
22
|
+
* history — no `--changed-only` sweep finds it again. Measured 2026-08-20: six
|
|
23
|
+
* such roadmaps, with every regen reprinting the warning at whoever ran it.
|
|
24
|
+
* `/create-pr` § 1c keeps `--changed-only`; repo-wide reconciliation is this
|
|
25
|
+
* flag's job and nothing else's.
|
|
26
|
+
*
|
|
27
|
+
* This layout is deliberate rather than convenient: the prose homes for it are
|
|
28
|
+
* both at their ceilings (`roadmap-progress-mechanics.md` sits 122 chars under
|
|
29
|
+
* the 16,000-char depth ceiling, and `roadmap-management/SKILL.md` tips
|
|
30
|
+
* `skill_too_large` on any prose block), so the contract lives with the code it
|
|
31
|
+
* governs. Moving it out means making room first.
|
|
32
|
+
*
|
|
33
|
+
* AI council 2026-08-20 (anthropic + openai, blind peer review): both seats
|
|
34
|
+
* converged on explicit opt-in + `--all` scope + hook archival-free, and both
|
|
35
|
+
* independently required the `--check` exclusion, the archive-before-render
|
|
36
|
+
* order, and that a failed sweep must not render. RECORDED DISSENT: one seat
|
|
37
|
+
* argued for flipping the sweep's own default to `--all` instead. Rejected —
|
|
38
|
+
* that makes every PR a potential estate-wide cleanup, which is exactly what
|
|
39
|
+
* `--changed-only` exists to prevent.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { spawnSync } from 'node:child_process';
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
import * as path from 'node:path';
|
|
45
|
+
import { fileURLToPath } from 'node:url';
|
|
46
|
+
|
|
47
|
+
// Same directory as `update_roadmap_progress.ts`, so the `path.dirname` below
|
|
48
|
+
// resolves the sweep script exactly as it did before the split.
|
|
49
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
50
|
+
|
|
51
|
+
function _isFile(p: string): boolean {
|
|
52
|
+
try {
|
|
53
|
+
return fs.statSync(p).isFile();
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run the archival sweep for every complete roadmap, in-place.
|
|
61
|
+
*
|
|
62
|
+
* Spawned rather than imported: `archive_completed_roadmaps.ts` imports
|
|
63
|
+
* `collect` from `update_roadmap_progress.ts`, so a static import back would close a cycle, and
|
|
64
|
+
* `main()` is synchronous so a dynamic `await import()` is not available either.
|
|
65
|
+
* The sweep is the same twin `_regen_dashboard` already spawns in the other
|
|
66
|
+
* direction, so the spawn shape is the established one in this pair.
|
|
67
|
+
*
|
|
68
|
+
* No recursion: the sweep re-runs this script with NO flags, so its inner
|
|
69
|
+
* dashboard pass has archival off and terminates at depth 2.
|
|
70
|
+
*
|
|
71
|
+
* `--all`, not the sweep's `--changed-only` default. The default exists so a PR
|
|
72
|
+
* archives exactly the roadmaps it completed; here the caller asked the
|
|
73
|
+
* repo-wide dashboard to reconcile the estate, and the roadmaps that leak are
|
|
74
|
+
* by construction the ones NOT in this branch's history — six had accumulated
|
|
75
|
+
* on the trunk while the warning printed every regen. A roadmap the sweep
|
|
76
|
+
* refuses (open blockers) stays put and is still reported below.
|
|
77
|
+
*/
|
|
78
|
+
export function run_archival_sweep(root: string): SweepResult {
|
|
79
|
+
const script = path.join(path.dirname(_HERE), 'archive_completed_roadmaps.ts');
|
|
80
|
+
if (!_isFile(script)) {
|
|
81
|
+
// A consumer install without the sweep script: nothing to run, and that
|
|
82
|
+
// is a normal state — the warning below still reports the roadmaps.
|
|
83
|
+
return { ran: false, ok: true, stdout: '', stderr: '' };
|
|
84
|
+
}
|
|
85
|
+
const argv = [script, '--all', '--repo-root', root];
|
|
86
|
+
const binName = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
|
87
|
+
let dir = path.dirname(_HERE);
|
|
88
|
+
for (;;) {
|
|
89
|
+
const candidate = path.join(dir, 'node_modules', '.bin', binName);
|
|
90
|
+
if (fs.existsSync(candidate)) {
|
|
91
|
+
return _sweepResult(spawnSync(candidate, argv, { cwd: root, encoding: 'utf-8' }));
|
|
92
|
+
}
|
|
93
|
+
const parent = path.dirname(dir);
|
|
94
|
+
if (parent === dir) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
dir = parent;
|
|
98
|
+
}
|
|
99
|
+
return _sweepResult(spawnSync('npx', ['tsx', ...argv], { cwd: root, encoding: 'utf-8' }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SweepResult {
|
|
103
|
+
ran: boolean;
|
|
104
|
+
ok: boolean;
|
|
105
|
+
stdout: string;
|
|
106
|
+
stderr: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _sweepResult(r: ReturnType<typeof spawnSync>): SweepResult {
|
|
110
|
+
return {
|
|
111
|
+
ran: true,
|
|
112
|
+
ok: r.status === 0,
|
|
113
|
+
stdout: String(r.stdout ?? ''),
|
|
114
|
+
stderr: String(r.stderr ?? ''),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* ./scripts-run src/scripts/archive_completed_roadmaps # --changed-only (default)
|
|
32
32
|
* ./scripts-run src/scripts/archive_completed_roadmaps --all
|
|
33
33
|
* ./scripts-run src/scripts/archive_completed_roadmaps --base origin/main --dry-run
|
|
34
|
+
* ./scripts-run src/scripts/archive_completed_roadmaps --repo-root /path/to/proj
|
|
34
35
|
*
|
|
35
36
|
* --- Parity notes (ADR-200) ---
|
|
36
37
|
*
|
|
@@ -397,10 +398,13 @@ function _regen_dashboard(root: string, dry_run: boolean): void {
|
|
|
397
398
|
// Python: `_run([sys.executable, str(script)], root)`; the twin spawns the
|
|
398
399
|
// dashboard generator twin through the same `tsx` runtime, cwd=root.
|
|
399
400
|
_runTwin(root, script);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
401
|
+
// The dashboard is NOT staged. It is gitignored in this repository, and
|
|
402
|
+
// `git add -- <ignored-path>` exits 1 with "The following paths are ignored"
|
|
403
|
+
// -- a code `_run` returns and this call site discarded, so the previous
|
|
404
|
+
// `git add` here was a silent no-op from the moment the file was untracked
|
|
405
|
+
// (2026-08-21) and nothing objected. Regenerating it is still correct; the
|
|
406
|
+
// developer reads it on disk, and `task roadmap-progress-check` compares a
|
|
407
|
+
// present copy against a fresh render.
|
|
404
408
|
}
|
|
405
409
|
|
|
406
410
|
/**
|
|
@@ -462,19 +466,21 @@ function _runTwin(root: string, script: string): void {
|
|
|
462
466
|
const _PROG = 'archive_completed_roadmaps.py';
|
|
463
467
|
|
|
464
468
|
function _usage(): string {
|
|
465
|
-
return `usage: ${_PROG} [-h] [--all] [--base BASE] [--dry-run]\n`;
|
|
469
|
+
return `usage: ${_PROG} [-h] [--all] [--base BASE] [--dry-run] [--repo-root REPO_ROOT]\n`;
|
|
466
470
|
}
|
|
467
471
|
|
|
468
472
|
interface Args {
|
|
469
473
|
all: boolean;
|
|
470
474
|
base: string;
|
|
471
475
|
dry_run: boolean;
|
|
476
|
+
repo_root: string | null;
|
|
472
477
|
}
|
|
473
478
|
|
|
474
479
|
function _parseArgs(argv: readonly string[]): Args {
|
|
475
480
|
let all = false;
|
|
476
481
|
let base = 'origin/main';
|
|
477
482
|
let dry_run = false;
|
|
483
|
+
let repo_root: string | null = null;
|
|
478
484
|
const emitError = (msg: string): never => {
|
|
479
485
|
process.stderr.write(_usage());
|
|
480
486
|
process.stderr.write(`${_PROG}: error: ${msg}\n`);
|
|
@@ -502,17 +508,30 @@ function _parseArgs(argv: readonly string[]): Args {
|
|
|
502
508
|
} else if (tok.startsWith('--base=')) {
|
|
503
509
|
base = tok.slice('--base='.length);
|
|
504
510
|
i += 1;
|
|
511
|
+
} else if (tok === '--repo-root') {
|
|
512
|
+
const val = argv[i + 1];
|
|
513
|
+
if (val === undefined) {
|
|
514
|
+
emitError('argument --repo-root: expected one argument');
|
|
515
|
+
}
|
|
516
|
+
repo_root = val as string;
|
|
517
|
+
i += 2;
|
|
518
|
+
} else if (tok.startsWith('--repo-root=')) {
|
|
519
|
+
repo_root = tok.slice('--repo-root='.length);
|
|
520
|
+
i += 1;
|
|
505
521
|
} else {
|
|
506
522
|
emitError(`unrecognized arguments: ${tok}`);
|
|
507
523
|
}
|
|
508
524
|
}
|
|
509
|
-
return { all, base, dry_run };
|
|
525
|
+
return { all, base, dry_run, repo_root };
|
|
510
526
|
}
|
|
511
527
|
|
|
512
528
|
function main(argv?: readonly string[]): number {
|
|
513
529
|
const ns = _parseArgs(argv ?? process.argv.slice(2));
|
|
514
530
|
|
|
515
|
-
|
|
531
|
+
// An explicit `--repo-root` wins over the `git rev-parse` resolution. The
|
|
532
|
+
// dashboard generator passes it so a monorepo sub-project sweep cannot walk
|
|
533
|
+
// up to the enclosing repo and archive the PARENT's roadmaps.
|
|
534
|
+
const root = ns.repo_root ?? _repo_root();
|
|
516
535
|
const archived = archive_completed(root, {
|
|
517
536
|
changed_only: !ns.all,
|
|
518
537
|
base: ns.base,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The `--check` tracked/untracked mode table for the roadmap dashboard.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from `update_roadmap_progress.ts` so the verdict is a pure function
|
|
4
|
+
// over observable state — the generator does the I/O, this decides. It also
|
|
5
|
+
// keeps that script under its per-file size cap.
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Is the dashboard expected to be a committed artefact? Explicit, never
|
|
12
|
+
* inferred from git state — an unconditional "absent means pass" cannot tell a
|
|
13
|
+
* correctly-untracked repo from a generator that silently stopped producing
|
|
14
|
+
* output. `tracked` is the default and the historical behaviour.
|
|
15
|
+
*/
|
|
16
|
+
export type DashboardMode = 'tracked' | 'untracked';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Is `abs` in the git index? Used only by `--check --untracked-mode`, to tell
|
|
20
|
+
* "correctly absent" from "still committed". No git at all answers `false`,
|
|
21
|
+
* which is correct: nothing is tracked there.
|
|
22
|
+
*/
|
|
23
|
+
export function isTrackedInGit(abs: string, repo_root: string): boolean {
|
|
24
|
+
const r = spawnSync('git', ['ls-files', '--error-unmatch', '--', abs], {
|
|
25
|
+
cwd: repo_root,
|
|
26
|
+
encoding: 'utf-8',
|
|
27
|
+
timeout: 10_000,
|
|
28
|
+
});
|
|
29
|
+
return r.status === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DashboardStateInput {
|
|
33
|
+
mode: DashboardMode;
|
|
34
|
+
/** Is the file on disk? */
|
|
35
|
+
present: boolean;
|
|
36
|
+
/** Is the file in the git index? Only consulted in `untracked` mode. */
|
|
37
|
+
trackedInGit: boolean;
|
|
38
|
+
/** Does the on-disk content equal a fresh render? Only meaningful if present. */
|
|
39
|
+
current: boolean;
|
|
40
|
+
/** Repo-relative path, for the messages. */
|
|
41
|
+
rel: string;
|
|
42
|
+
/**
|
|
43
|
+
* How to regenerate, for the stale/missing messages. Defaults to the
|
|
44
|
+
* dashboard's own command. A second caller — `build_archive_index --check`
|
|
45
|
+
* — reuses this table for a different generated artefact, and a message
|
|
46
|
+
* telling that reader to run the dashboard generator would be wrong.
|
|
47
|
+
*/
|
|
48
|
+
regen?: string;
|
|
49
|
+
/**
|
|
50
|
+
* What the artefact is called in the "deliberately does not commit the X"
|
|
51
|
+
* hint. Defaults to `dashboard` so every existing message is byte-identical.
|
|
52
|
+
*/
|
|
53
|
+
noun?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DashboardStateVerdict {
|
|
57
|
+
/** True when the dashboard half of `--check` fails. */
|
|
58
|
+
stale: boolean;
|
|
59
|
+
/** Diagnostic for stderr, or null. */
|
|
60
|
+
error: string | null;
|
|
61
|
+
/** Success line for stdout, or null when `stale`. */
|
|
62
|
+
ok: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The SOURCE path, not `.augment/scripts/…`: that tree is a gitignored
|
|
66
|
+
// projection built by `task sync`, so the advice failed outright in a fresh
|
|
67
|
+
// worktree and in any CI step ordered before the sync.
|
|
68
|
+
const REGEN =
|
|
69
|
+
'Run `./scripts-run src/agent-src/scripts/update_roadmap_progress` ' +
|
|
70
|
+
'to regenerate (or `task roadmap-progress` in Taskfile projects).';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The mode table:
|
|
74
|
+
*
|
|
75
|
+
* tracked: absent -> fail | present+stale -> fail | present+current -> pass
|
|
76
|
+
* untracked: in-index -> fail | absent -> pass | present+stale -> fail
|
|
77
|
+
* | present+current -> pass
|
|
78
|
+
*
|
|
79
|
+
* Absence is distinguished ONLY in untracked mode; a present file is
|
|
80
|
+
* freshness-checked in both, so untracked never means unchecked.
|
|
81
|
+
*/
|
|
82
|
+
export function evaluateDashboardState(inp: DashboardStateInput): DashboardStateVerdict {
|
|
83
|
+
const { mode, present, trackedInGit, current, rel } = inp;
|
|
84
|
+
const regen = inp.regen ?? REGEN;
|
|
85
|
+
const noun = inp.noun ?? 'dashboard';
|
|
86
|
+
|
|
87
|
+
if (mode === 'untracked' && trackedInGit) {
|
|
88
|
+
// Migration incomplete: declared untracked, still carried by git.
|
|
89
|
+
// Print the fix; never run it — git-index ops are the user's.
|
|
90
|
+
return {
|
|
91
|
+
stale: true,
|
|
92
|
+
error:
|
|
93
|
+
`❌ ${rel} is still tracked by git, but this repository runs ` +
|
|
94
|
+
'`--untracked-mode`. Untrack it, keeping the working-tree file:\n' +
|
|
95
|
+
` git rm --cached ${rel}\n`,
|
|
96
|
+
ok: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!present) {
|
|
101
|
+
if (mode === 'tracked') {
|
|
102
|
+
return {
|
|
103
|
+
stale: true,
|
|
104
|
+
error:
|
|
105
|
+
`❌ ${rel} is missing. ` +
|
|
106
|
+
regen.replace('to regenerate', 'to generate it') +
|
|
107
|
+
' If this repository deliberately does not commit the ' +
|
|
108
|
+
`${noun}, pass \`--untracked-mode\`.\n`,
|
|
109
|
+
ok: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// untracked + absent: the declared state. "up to date" would mislead.
|
|
113
|
+
return {
|
|
114
|
+
stale: false,
|
|
115
|
+
error: null,
|
|
116
|
+
ok: `✅ ${rel} is not committed here (--untracked-mode) and no stale copy is on disk.\n`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!current) {
|
|
121
|
+
return { stale: true, error: `❌ ${rel} is stale. ${regen}\n`, ok: null };
|
|
122
|
+
}
|
|
123
|
+
return { stale: false, error: null, ok: `✅ ${rel} is up to date.\n` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `evaluateDashboardState` against the real filesystem and git index. The
|
|
128
|
+
* generator hands over the paths; every read the verdict needs happens here, so
|
|
129
|
+
* the caller's check branch stays three lines.
|
|
130
|
+
*/
|
|
131
|
+
export function evaluateDashboardOnDisk(opts: {
|
|
132
|
+
mode: DashboardMode;
|
|
133
|
+
target: string;
|
|
134
|
+
repo_root: string;
|
|
135
|
+
rendered: string;
|
|
136
|
+
rel: string;
|
|
137
|
+
regen?: string;
|
|
138
|
+
noun?: string;
|
|
139
|
+
}): DashboardStateVerdict {
|
|
140
|
+
const present = fs.existsSync(opts.target);
|
|
141
|
+
return evaluateDashboardState({
|
|
142
|
+
mode: opts.mode,
|
|
143
|
+
present,
|
|
144
|
+
trackedInGit: opts.mode === 'untracked' && isTrackedInGit(opts.target, opts.repo_root),
|
|
145
|
+
current: present && fs.readFileSync(opts.target, { encoding: 'utf-8' }) === opts.rendered,
|
|
146
|
+
rel: opts.rel,
|
|
147
|
+
...(opts.regen === undefined ? {} : { regen: opts.regen }),
|
|
148
|
+
...(opts.noun === undefined ? {} : { noun: opts.noun }),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** One roadmap row, as far as the estate report below needs it. */
|
|
153
|
+
export interface EstateRow {
|
|
154
|
+
rel: string;
|
|
155
|
+
done: number;
|
|
156
|
+
total_active: number;
|
|
157
|
+
deferred: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The `--check` report: the artefact's own verdict, then the estate-wide
|
|
162
|
+
* conditions — completed-but-unarchived, and Iron Law 3 deferred items.
|
|
163
|
+
*
|
|
164
|
+
* `dashboardOnly` drops the estate half. Two obligations with two blast radii:
|
|
165
|
+
* the artefact verdict is about ONE file and is safe behind a required status
|
|
166
|
+
* check, while the estate conditions are about roadmaps an unrelated change
|
|
167
|
+
* never touched, so requiring them turns any pre-existing estate defect into a
|
|
168
|
+
* merge block on every PR. It did, on 2026-08-22.
|
|
169
|
+
*
|
|
170
|
+
* Extracted from the generator so the report is a pure function of the verdict
|
|
171
|
+
* and two lists — and because the generator sits exactly on its 1500-line
|
|
172
|
+
* source ceiling, where the honest way to add a feature is to take lines out.
|
|
173
|
+
*/
|
|
174
|
+
export function reportCheckVerdict(inp: {
|
|
175
|
+
verdict: DashboardStateVerdict;
|
|
176
|
+
complete: readonly EstateRow[];
|
|
177
|
+
pending: readonly EstateRow[];
|
|
178
|
+
dashboardOnly: boolean;
|
|
179
|
+
warnMergeGated: () => void;
|
|
180
|
+
gatedCount: number;
|
|
181
|
+
}): { rc: number; stdout: string; stderr: string } {
|
|
182
|
+
const { verdict, dashboardOnly } = inp;
|
|
183
|
+
const complete = dashboardOnly ? [] : inp.complete;
|
|
184
|
+
const pending = dashboardOnly ? [] : inp.pending;
|
|
185
|
+
let err = verdict.error ?? '';
|
|
186
|
+
if (complete.length) {
|
|
187
|
+
err +=
|
|
188
|
+
'❌ Completed roadmaps are still in `agents/roadmaps/` — ' +
|
|
189
|
+
'move them to `agents/roadmaps/archive/` (per the ' +
|
|
190
|
+
'`roadmap-progress-sync` rule):\n';
|
|
191
|
+
for (const r of complete) err += ` - ${r.rel} (${r.done}/${r.total_active} done)\n`;
|
|
192
|
+
}
|
|
193
|
+
if (pending.length) {
|
|
194
|
+
err +=
|
|
195
|
+
'❌ Iron Law 3 — roadmaps with unresolved `[~]` deferred ' +
|
|
196
|
+
'items must NOT auto-archive. Resolve via `roadmap-management § 4b` ' +
|
|
197
|
+
'(spawn follow-up, restore, or cancel):\n';
|
|
198
|
+
for (const r of pending) {
|
|
199
|
+
err += ` - ${r.rel} (${r.done}/${r.total_active} done · ${r.deferred} deferred)\n`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!dashboardOnly && inp.gatedCount > 0) inp.warnMergeGated();
|
|
203
|
+
const rc = verdict.stale || complete.length > 0 || pending.length > 0 ? 1 : 0;
|
|
204
|
+
return { rc, stdout: rc === 0 ? (verdict.ok ?? '') : '', stderr: err };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `--tracked-mode` / `--untracked-mode` → the mode, or null for any other token. */
|
|
208
|
+
export function parseModeToken(tok: string): DashboardMode | null {
|
|
209
|
+
if (tok === '--tracked-mode') return 'tracked';
|
|
210
|
+
if (tok === '--untracked-mode') return 'untracked';
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate_budget — the class-1 spend bound and its append-only receipt ledger
|
|
3
|
+
* (`road-to-gate-autonomy` Phase 2, blocker `b-gate-budget-preauth`).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Class 1 of the gate taxonomy is "billable or long-running
|
|
6
|
+
* but reversible; the human ingredient is *spend consent*, not judgment".
|
|
7
|
+
* `gate_execute.ts` shipped without it on purpose — "no invented ledger",
|
|
8
|
+
* because the budget SHAPE was an open maintainer decision. That decision is
|
|
9
|
+
* now taken (option (a): a per-run cap AND a rolling-window cap, with an
|
|
10
|
+
* append-only receipt ledger as the audit surface), so this module supplies
|
|
11
|
+
* exactly the shape that was decided and nothing more.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THIS IS NOT. It is **not** a consent bypass. `gates --execute` still
|
|
14
|
+
* requires `--confirm` on every class-1 run, so the this-turn,
|
|
15
|
+
* names-the-exact-object confirmation `non-destructive-by-default` asks for is
|
|
16
|
+
* unchanged. The caps bound the SIZE of an authorised spend; they never
|
|
17
|
+
* manufacture the authorisation. A ledger under `agents/runtime/state/` is
|
|
18
|
+
* agent-writable, so a mechanism that read an "authorisation" out of it would
|
|
19
|
+
* be an agent consenting on the user's behalf — which is precisely the threat
|
|
20
|
+
* model the live-trigger-eval terminal abort exists for. That is why the
|
|
21
|
+
* receipt records CONSUMPTION only, and why step 2.3's bypass flag is not
|
|
22
|
+
* built on top of this file.
|
|
23
|
+
*
|
|
24
|
+
* A per-run cap alone is not a spend bound — it bounds one mistake, not a week
|
|
25
|
+
* of them — which is the whole reason the decision carries two numbers.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ledger location, relative to the workspace root.
|
|
32
|
+
*
|
|
33
|
+
* The name is the one already present in `gate_execute.ts` as the
|
|
34
|
+
* absent-ledger placeholder, kept verbatim so the path a class-1 entry has
|
|
35
|
+
* been REPORTING since Phase 2 is the path it now reads. `agents/runtime/state/`
|
|
36
|
+
* is this tree's state convention (`run_checkpoint`, `unattended_guard`,
|
|
37
|
+
* `recycle_envelope_paths` all live there); a bare `agents/state/` is not.
|
|
38
|
+
*/
|
|
39
|
+
export const GATE_BUDGET_LEDGER_REL = path.join(
|
|
40
|
+
'agents',
|
|
41
|
+
'runtime',
|
|
42
|
+
'state',
|
|
43
|
+
'gate-budget-ledger.jsonl',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the absolute ledger path.
|
|
48
|
+
*
|
|
49
|
+
* Callers import THIS rather than joining {@link GATE_BUDGET_LEDGER_REL}
|
|
50
|
+
* themselves: a bare relative constant is the shape that goes silently dead
|
|
51
|
+
* when the location moves, because nothing joins it any more and nothing
|
|
52
|
+
* complains.
|
|
53
|
+
*/
|
|
54
|
+
export function gateBudgetLedgerPath(workspaceRoot: string): string {
|
|
55
|
+
return path.join(workspaceRoot, GATE_BUDGET_LEDGER_REL);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The two caps decided by option (a). Both are USD. */
|
|
59
|
+
export interface GateBudgetCaps {
|
|
60
|
+
maxCostPerRunUsd: number;
|
|
61
|
+
maxCostPerRolling7dUsd: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One append-only ledger line.
|
|
66
|
+
*
|
|
67
|
+
* The five fields the decision names — blocker, authorization, estimated and
|
|
68
|
+
* actual cost, timestamp — plus the single-use marker. `authorization` records
|
|
69
|
+
* HOW the spend was consented to; today the only value a run can honestly
|
|
70
|
+
* write is `confirm-flag`, because `--confirm` is the consent. A future
|
|
71
|
+
* human-only authorisation surface would add its own value here rather than
|
|
72
|
+
* reinterpreting this one.
|
|
73
|
+
*/
|
|
74
|
+
export interface GateBudgetReceipt {
|
|
75
|
+
kind: 'consumption';
|
|
76
|
+
blocker: string;
|
|
77
|
+
authorization: 'confirm-flag';
|
|
78
|
+
estimated_usd: number;
|
|
79
|
+
actual_usd: number | null;
|
|
80
|
+
at: string;
|
|
81
|
+
single_use: true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Rolling window, in days, that `maxCostPerRolling7dUsd` bounds. */
|
|
85
|
+
export const ROLLING_WINDOW_DAYS = 7;
|
|
86
|
+
|
|
87
|
+
function _num(value: unknown): number | null {
|
|
88
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _dig(settings: unknown, keys: readonly string[]): unknown {
|
|
95
|
+
let cursor: unknown = settings;
|
|
96
|
+
for (const k of keys) {
|
|
97
|
+
if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
cursor = (cursor as Record<string, unknown>)[k];
|
|
101
|
+
}
|
|
102
|
+
return cursor;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the caps out of an already-merged settings dict.
|
|
107
|
+
*
|
|
108
|
+
* Returns `null` when either cap is absent or non-numeric — deliberately NOT a
|
|
109
|
+
* fallback to the template numbers. An install whose settings do not carry the
|
|
110
|
+
* caps has not authorised a standing budget, and inventing one from the
|
|
111
|
+
* template default would authorise spend nobody configured. The caller renders
|
|
112
|
+
* the consent line instead.
|
|
113
|
+
*/
|
|
114
|
+
export function readGateBudgetCaps(settings: unknown): GateBudgetCaps | null {
|
|
115
|
+
const perRun = _num(_dig(settings, ['roadmap', 'gate_budget', 'max_cost_per_run_usd']));
|
|
116
|
+
const rolling = _num(
|
|
117
|
+
_dig(settings, ['roadmap', 'gate_budget', 'max_cost_per_rolling_7d_usd']),
|
|
118
|
+
);
|
|
119
|
+
if (perRun === null || rolling === null) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return { maxCostPerRunUsd: perRun, maxCostPerRolling7dUsd: rolling };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse a USD figure out of a blocker's `Budget:` field.
|
|
127
|
+
*
|
|
128
|
+
* An explicit currency marker (`$` or `USD`) is REQUIRED. Without it a field
|
|
129
|
+
* like "~50 queries per run" would be read as fifty dollars, and a spend bound
|
|
130
|
+
* derived from a misread is worse than no bound: it would let a large run
|
|
131
|
+
* through under a small number. Returns `null` when no USD figure is stated.
|
|
132
|
+
*/
|
|
133
|
+
export function parseBudgetUsd(field: string): number | null {
|
|
134
|
+
const text = field.trim();
|
|
135
|
+
if (text === '') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const dollar = /\$\s*([0-9]+(?:[.,][0-9]+)?)/.exec(text);
|
|
139
|
+
if (dollar) {
|
|
140
|
+
return Number.parseFloat(dollar[1]!.replace(',', '.'));
|
|
141
|
+
}
|
|
142
|
+
const usd = /([0-9]+(?:[.,][0-9]+)?)\s*(?:usd|dollars?)\b/i.exec(text);
|
|
143
|
+
if (usd) {
|
|
144
|
+
return Number.parseFloat(usd[1]!.replace(',', '.'));
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Read every well-formed receipt. A malformed line is skipped, never fatal. */
|
|
150
|
+
export function readGateBudgetLedger(workspaceRoot: string): GateBudgetReceipt[] {
|
|
151
|
+
const file = gateBudgetLedgerPath(workspaceRoot);
|
|
152
|
+
if (!fs.existsSync(file)) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const out: GateBudgetReceipt[] = [];
|
|
156
|
+
for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (trimmed === '') {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let parsed: unknown;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(trimmed);
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const rec = parsed as Record<string, unknown>;
|
|
171
|
+
if (rec['kind'] !== 'consumption' || typeof rec['blocker'] !== 'string') {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
out.push({
|
|
175
|
+
kind: 'consumption',
|
|
176
|
+
blocker: rec['blocker'],
|
|
177
|
+
authorization: 'confirm-flag',
|
|
178
|
+
estimated_usd: _num(rec['estimated_usd']) ?? 0,
|
|
179
|
+
actual_usd: _num(rec['actual_usd']),
|
|
180
|
+
at: typeof rec['at'] === 'string' ? rec['at'] : '',
|
|
181
|
+
single_use: true,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Append one receipt. Append-only by construction — nothing here rewrites. */
|
|
188
|
+
export function appendGateBudgetReceipt(
|
|
189
|
+
workspaceRoot: string,
|
|
190
|
+
receipt: GateBudgetReceipt,
|
|
191
|
+
): void {
|
|
192
|
+
const file = gateBudgetLedgerPath(workspaceRoot);
|
|
193
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
194
|
+
fs.appendFileSync(file, `${JSON.stringify(receipt)}\n`, 'utf-8');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Spend inside the rolling window.
|
|
199
|
+
*
|
|
200
|
+
* `actual_usd` when the run reported one, the estimate otherwise — an
|
|
201
|
+
* unreported actual must not read as zero spend. A receipt with an unparseable
|
|
202
|
+
* timestamp counts as IN the window, because dropping it would understate the
|
|
203
|
+
* total and the cap is meant to fail closed.
|
|
204
|
+
*/
|
|
205
|
+
export function rollingSpendUsd(records: readonly GateBudgetReceipt[], now: Date): number {
|
|
206
|
+
const cutoff = now.getTime() - ROLLING_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
207
|
+
let total = 0;
|
|
208
|
+
for (const r of records) {
|
|
209
|
+
const stamp = Date.parse(r.at);
|
|
210
|
+
if (Number.isFinite(stamp) && stamp < cutoff) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
total += r.actual_usd ?? r.estimated_usd;
|
|
214
|
+
}
|
|
215
|
+
return total;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type GateBudgetRefusal =
|
|
219
|
+
| 'no_caps'
|
|
220
|
+
| 'no_estimate'
|
|
221
|
+
| 'over_per_run'
|
|
222
|
+
| 'over_rolling_7d';
|
|
223
|
+
|
|
224
|
+
export type GateBudgetVerdict =
|
|
225
|
+
| { ok: true; estimateUsd: number; rollingUsd: number }
|
|
226
|
+
| { ok: false; reason: GateBudgetRefusal; detail: string };
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Decide whether a class-1 run fits inside the standing budget.
|
|
230
|
+
*
|
|
231
|
+
* Every refusal path renders rather than runs — the blocker's own prescription
|
|
232
|
+
* for a missing ledger, generalised to every way the budget can say no.
|
|
233
|
+
*/
|
|
234
|
+
export function evaluateGateBudget(opts: {
|
|
235
|
+
caps: GateBudgetCaps | null;
|
|
236
|
+
records: readonly GateBudgetReceipt[];
|
|
237
|
+
estimateUsd: number | null;
|
|
238
|
+
now: Date;
|
|
239
|
+
}): GateBudgetVerdict {
|
|
240
|
+
const { caps, records, estimateUsd, now } = opts;
|
|
241
|
+
if (caps === null) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
reason: 'no_caps',
|
|
245
|
+
detail:
|
|
246
|
+
'no standing class-1 budget is configured ' +
|
|
247
|
+
'(`roadmap.gate_budget.max_cost_per_run_usd` and ' +
|
|
248
|
+
'`roadmap.gate_budget.max_cost_per_rolling_7d_usd`)',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (estimateUsd === null) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
reason: 'no_estimate',
|
|
255
|
+
detail:
|
|
256
|
+
'the entry states no USD estimate in its **Budget:** field, so no cap ' +
|
|
257
|
+
'can be compared against it',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (estimateUsd > caps.maxCostPerRunUsd) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
reason: 'over_per_run',
|
|
264
|
+
detail:
|
|
265
|
+
`the estimate ($${estimateUsd.toFixed(2)}) exceeds the per-run cap ` +
|
|
266
|
+
`($${caps.maxCostPerRunUsd.toFixed(2)})`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const spent = rollingSpendUsd(records, now);
|
|
270
|
+
if (spent + estimateUsd > caps.maxCostPerRolling7dUsd) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
reason: 'over_rolling_7d',
|
|
274
|
+
detail:
|
|
275
|
+
`$${spent.toFixed(2)} is already receipted in the last ` +
|
|
276
|
+
`${String(ROLLING_WINDOW_DAYS)} days and this run would add ` +
|
|
277
|
+
`$${estimateUsd.toFixed(2)}, over the rolling cap ` +
|
|
278
|
+
`($${caps.maxCostPerRolling7dUsd.toFixed(2)})`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, estimateUsd, rollingUsd: spent };
|
|
282
|
+
}
|