@event4u/agent-config 14.7.0 → 14.9.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 +175 -390
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +34 -4
- 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 +383 -0
- package/dist/agent-src/commands/review/changes.md +95 -14
- package/dist/agent-src/commands/roadmap/process-full.md +157 -3
- 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/host-capability-manifest.md +77 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
- package/dist/agent-src/rules/recurring-criticism.md +1 -1
- package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
- package/dist/agent-src/rules/source-confidentiality.md +1 -1
- package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
- 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/humanizer/SKILL.md +39 -0
- package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
- package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
- package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
- 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/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +28 -2
- package/dist/cli/registry.js +3 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
- package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
- package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
- package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
- package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +101 -25
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +7 -7
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_handoff.js +7 -6
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +6 -6
- 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 +3 -3
- package/dist/cli-delegate/cmd_settings_get.js +4 -4
- 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 +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +3 -3
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +83 -30
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -9
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +9 -7
- package/dist/hooks/dispatch.js +1295 -812
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/partitionEligibility.js +156 -0
- package/dist/install/partitionEligibility.js.map +1 -1
- 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 +1358 -805
- package/dist/scripts/_lib/claude_plugin.js +162 -0
- package/dist/scripts/_lib/claude_plugin.js.map +1 -0
- package/dist/scripts/_lib/environment_detector.js +397 -0
- package/dist/scripts/_lib/environment_detector.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/_lib/spawn_env.js +129 -0
- package/dist/scripts/_lib/spawn_env.js.map +1 -0
- package/dist/scripts/ai_council/modes.js +199 -0
- package/dist/scripts/ai_council/modes.js.map +1 -0
- package/dist/scripts/ai_council/transport_resolver.js +418 -0
- package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
- package/dist/scripts/condense.js +52 -89
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/wizard.js +3 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/ui/assets/index-Cxgy63kz.js +26 -0
- package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/false-green.md +47 -1
- package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
- package/package.json +7 -3
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/src/config/ci-local-parity.yml +21 -0
- package/src/config/estate-count-budget.json +13 -471
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +458 -12
- package/src/config/gate-violation-baselines.json +18 -8
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/publish-surface.json +60 -0
- package/src/config/requirements-trace-budget.json +32 -0
- package/src/config/rule-stub-ceilings.json +25 -4
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/partitionEligibility.ts +177 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_dispatch.bash +18 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/base_tree.ts +175 -0
- package/src/scripts/_lib/billing_grant.ts +165 -0
- package/src/scripts/_lib/billing_grant_cli.ts +123 -0
- package/src/scripts/_lib/council_fallback_posture.ts +53 -6
- package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
- package/src/scripts/_lib/envelope_grounding.ts +11 -1
- package/src/scripts/_lib/host_capability.ts +59 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/_lib/quota_parked.ts +133 -0
- package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
- package/src/scripts/_lib/review_telemetry.ts +196 -0
- package/src/scripts/_lib/subagent_response.ts +92 -0
- package/src/scripts/adr/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +25 -38
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/ai_council/clients.ts +12 -1
- package/src/scripts/ai_council/config.ts +72 -115
- package/src/scripts/ai_council/fallback_config.ts +19 -7
- package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
- package/src/scripts/ai_council/orchestrator.ts +2 -2
- package/src/scripts/ai_council/py_format.ts +124 -0
- package/src/scripts/ai_council/transport_resolver.ts +65 -2
- package/src/scripts/audit_adr_coverage.ts +134 -21
- 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_council_pin_staleness.ts +300 -0
- package/src/scripts/check_estate_count.ts +420 -293
- package/src/scripts/check_gate_coverage.ts +91 -0
- package/src/scripts/check_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_no_stub_inventory_table.ts +292 -0
- package/src/scripts/check_pack_size.ts +234 -1
- package/src/scripts/check_publish_surface.ts +297 -0
- package/src/scripts/check_references.ts +18 -0
- package/src/scripts/check_requirements_trace.ts +378 -0
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_secret_leak.ts +166 -12
- package/src/scripts/check_single_delivery.ts +137 -4
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/code_graph/cli.ts +44 -5
- package/src/scripts/code_graph/extract.ts +87 -0
- package/src/scripts/condense.ts +61 -91
- package/src/scripts/council_cli.ts +12 -16
- package/src/scripts/detect_ai_tells.ts +87 -0
- package/src/scripts/dispatch_r2_reviewer.ts +42 -0
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/generate_capabilities_index.ts +41 -0
- package/src/scripts/git_authorization_hook.ts +54 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
- 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/run_continuation_hook.ts +3 -1
- package/src/scripts/hooks/state_io.ts +88 -1
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
- package/src/scripts/injection_scan_hook.ts +257 -15
- package/src/scripts/lint_adr_sweep_routing.ts +365 -0
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/lint_workflow_security.ts +53 -10
- package/src/scripts/lint_workflow_security_allowlist.json +2 -18
- package/src/scripts/report_envelope_rate.ts +166 -0
- package/src/scripts/review_axis_report.ts +218 -0
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/run_supervise.ts +46 -1
- package/src/scripts/schemas/evals.schema.json +5 -0
- package/src/scripts/sync_pr_branch.ts +170 -20
- package/dist/cli/commands/commands.candidates.test.js +0 -257
- package/dist/cli/commands/commands.candidates.test.js.map +0 -1
- package/dist/cli/commands/profiles.test.js +0 -88
- package/dist/cli/commands/profiles.test.js.map +0 -1
- package/dist/cli/commands/recordTriggerEval.test.js +0 -113
- package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
- package/dist/cli/configRoot.test.js +0 -97
- package/dist/cli/configRoot.test.js.map +0 -1
- package/dist/cli/mcp/content.test.js +0 -54
- package/dist/cli/mcp/content.test.js.map +0 -1
- package/dist/cli/mcp/dispatch.test.js +0 -108
- package/dist/cli/mcp/dispatch.test.js.map +0 -1
- package/dist/server/writeRoot.test.js +0 -91
- package/dist/server/writeRoot.test.js.map +0 -1
- package/dist/shared/capabilities.test.js +0 -39
- package/dist/shared/capabilities.test.js.map +0 -1
- package/dist/ui/assets/index-C1pBRm1f.js +0 -26
- package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
- package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
- package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
|
@@ -20,21 +20,28 @@
|
|
|
20
20
|
*
|
|
21
21
|
* TWO CHECKS, ONE GATE
|
|
22
22
|
* --------------------
|
|
23
|
-
* **T2, the ratchet.** The
|
|
24
|
-
*
|
|
25
|
-
* count is a policy failure (exit 1)
|
|
26
|
-
* live number. A count BELOW the
|
|
27
|
-
*
|
|
28
|
-
* rather than becoming unused headroom, and a gate that only warned about it is
|
|
29
|
-
* how the headroom survived to be spent. The one exemption is a metric raised in
|
|
30
|
-
* the same change with a recorded reason AND a new baseline equal to the live
|
|
31
|
-
* count; an over-raise fails here rather than in the next change.
|
|
23
|
+
* **T2, the ratchet.** The floor is not stored — it is MEASURED on the base
|
|
24
|
+
* ref's own tree, with the same functions that measure HEAD. Growth in any gated
|
|
25
|
+
* count above that floor is a policy failure (exit 1) naming the metric, the
|
|
26
|
+
* floor and the live number. A count BELOW the floor is a drawdown and simply
|
|
27
|
+
* passes: there is no stored number left to leave stale.
|
|
32
28
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
29
|
+
* Changed 2026-08-22 (ADR-243). Until then the floor lived in
|
|
30
|
+
* `src/config/estate-count-budget.json` as a committed `baseline` object, and
|
|
31
|
+
* that made the file the most-conflicted non-generated path in the repository —
|
|
32
|
+
* 7 of 7 `CONFLICTING` open PRs on 2026-08-21, and 39 of 43 non-merge commits in
|
|
33
|
+
* a 60-day window moving it. Two whole failure classes came from the storage
|
|
34
|
+
* rather than from the policy, and both are now unrepresentable: a baseline left
|
|
35
|
+
* ABOVE the truth (headroom a later change could spend, which this file used to
|
|
36
|
+
* fail on as an "un-walked tightening"), and a baseline left BELOW it when main
|
|
37
|
+
* archived a roadmap without walking the number, which reddened every branch
|
|
38
|
+
* that merged main. The budget file's own final history entry recorded exactly
|
|
39
|
+
* that incident.
|
|
40
|
+
*
|
|
41
|
+
* What the storage did buy was an authorisation path for legitimate growth: a
|
|
42
|
+
* raise carrying a recorded reason. That is replaced by
|
|
43
|
+
* `estate_growth_exempt: <reason>`, a frontmatter line that must be ADDED in the
|
|
44
|
+
* change that needs it — see § THE THREE WAYS THE ESTATE MAY LEGITIMATELY GROW.
|
|
38
45
|
*
|
|
39
46
|
* **T3, one-in-one-out.** A change that adds an active roadmap archives, parks
|
|
40
47
|
* or merges one in the same change — or the added file carries an explicit
|
|
@@ -43,6 +50,62 @@
|
|
|
43
50
|
* reads `<base>...HEAD` over `agents/roadmaps/`, so on a branch that adds no
|
|
44
51
|
* roadmap it legitimately finds nothing to weigh.
|
|
45
52
|
*
|
|
53
|
+
* THE THREE WAYS THE ESTATE MAY LEGITIMATELY GROW
|
|
54
|
+
* -----------------------------------------------
|
|
55
|
+
* A floor measured off the base ref is exact, so unlike a lagging stored number
|
|
56
|
+
* it leaves no incidental headroom. Every legitimate increase therefore needs a
|
|
57
|
+
* named path, and there are three:
|
|
58
|
+
*
|
|
59
|
+
* 1. **An addition that cannot be offset** — `estate_offset_exempt: <reason>` in
|
|
60
|
+
* the added roadmap's frontmatter, unchanged from T3. It now also raises the
|
|
61
|
+
* `active_roadmaps` allowance by one, because under an exact floor the
|
|
62
|
+
* addition would otherwise fail the count half with no green path.
|
|
63
|
+
* 2. **Parking** — a roadmap moved from the active top level into `later/` in the
|
|
64
|
+
* same change raises the `later_roadmaps` allowance by one. Parking relocates
|
|
65
|
+
* estate rather than creating it, so it needs no authorisation; what the
|
|
66
|
+
* allowance does NOT cover is a `later/` file appearing from nowhere.
|
|
67
|
+
* 3. **Anything else** — `estate_growth_exempt: <reason>` added, in this change,
|
|
68
|
+
* to the frontmatter of any roadmap under `agents/roadmaps/`. The canonical
|
|
69
|
+
* case is a blocker discovered while doing the work: `open_blockers` rises,
|
|
70
|
+
* nothing was archived, and the reason belongs next to the blocker.
|
|
71
|
+
*
|
|
72
|
+
* Two properties of that third path are load-bearing. It is **diff-scoped**: the
|
|
73
|
+
* line must be ADDED between base and HEAD, so a claim cannot be left in a file
|
|
74
|
+
* to authorise every later change — the banking failure a stored baseline had.
|
|
75
|
+
* And it is deliberately **unbounded**, which the stored baseline could not
|
|
76
|
+
* afford: a raise there had to land exactly on the measurement or the surplus was
|
|
77
|
+
* inherited by the next change, where nobody could act on it. Here the next
|
|
78
|
+
* change's floor IS this change's measurement, so there is no surplus to inherit
|
|
79
|
+
* and a bound would buy nothing.
|
|
80
|
+
*
|
|
81
|
+
* WHY THE FLOOR IS MAIN'S TIP AND NOT THE MERGE BASE
|
|
82
|
+
* --------------------------------------------------
|
|
83
|
+
* The floor is measured at the base ref's TIP, so a branch that is behind main
|
|
84
|
+
* reads as growth until it syncs. That is deliberate, and the alternative is
|
|
85
|
+
* worse: against the merge base, two branches each adding one roadmap are both
|
|
86
|
+
* green and main ends up two higher than either of them ever measured. A ratchet
|
|
87
|
+
* whose floor is a common ancestor is not a ratchet on the trunk.
|
|
88
|
+
*
|
|
89
|
+
* On a GitHub PR build this costs nothing — the checkout IS the merge result and
|
|
90
|
+
* `resolveBaseRef` returns `HEAD^1`, the base tip, so the comparison is
|
|
91
|
+
* merged-tree against current main. Locally, a stale branch reads as growth it
|
|
92
|
+
* did not cause, and the remedy is the sync it already owed. Note what that
|
|
93
|
+
* replaced: the stored baseline produced the same obligation as a MERGE CONFLICT
|
|
94
|
+
* in a config file, resolved by hand, four times in one afternoon on
|
|
95
|
+
* 2026-08-21 — and resolved wrongly it silently returned another change's
|
|
96
|
+
* drawdown.
|
|
97
|
+
*
|
|
98
|
+
* WHY A MISSING BASE REF FAILS RATHER THAN SKIPS
|
|
99
|
+
* ---------------------------------------------
|
|
100
|
+
* `resolveBaseRef` has a ladder (origin/main, then the merge commit's first
|
|
101
|
+
* parent) precisely because the obvious answer is unavailable on a shallow PR
|
|
102
|
+
* build. When it still resolves nothing there is no floor at all, and a
|
|
103
|
+
* shrink-only gate with no floor passes everything — so this exits 1 and says
|
|
104
|
+
* so, rather than reporting a green it cannot back. `--base <ref>` is the
|
|
105
|
+
* explicit escape, and it is what the self-test fixtures use. Deliberately NOT
|
|
106
|
+
* gated on `CI`/`GITHUB_ACTIONS`: a gate that convicts on the runner and waves
|
|
107
|
+
* locally teaches contributors that its red is an environment artefact.
|
|
108
|
+
*
|
|
46
109
|
* WHY THE COUNTS ARE READ FROM THE DASHBOARD'S OWN PARSER
|
|
47
110
|
* -------------------------------------------------------
|
|
48
111
|
* `collect()` from `update_roadmap_progress` is the function the dashboard is
|
|
@@ -53,6 +116,11 @@
|
|
|
53
116
|
* them could disagree with the dashboard, and then the reader has to decide
|
|
54
117
|
* which one lied.
|
|
55
118
|
*
|
|
119
|
+
* The same function measures the BASE side, over a scratch copy of the base
|
|
120
|
+
* ref's `agents/roadmaps/` (`_lib/base_tree.ts`). Re-deriving the floor with a
|
|
121
|
+
* second implementation would let the two sides disagree about one tree, which
|
|
122
|
+
* is the failure this section already argues against, one level up.
|
|
123
|
+
*
|
|
56
124
|
* `later/` is counted from the filesystem because parked roadmaps are
|
|
57
125
|
* deliberately outside `collect()`'s corpus — they are estate all the same, and
|
|
58
126
|
* a drawdown that moved everything into `later/` and called the number down
|
|
@@ -83,11 +151,20 @@ import {
|
|
|
83
151
|
import { reportScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
84
152
|
import { GateLedger } from './_lib/gate_ledger.js';
|
|
85
153
|
import { resolveBaseRef } from './_lib/ratchet_base_ref.js';
|
|
154
|
+
import { materialiseSubtree } from './_lib/base_tree.js';
|
|
86
155
|
import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
|
|
87
156
|
|
|
88
157
|
const GATE = 'check_estate_count';
|
|
89
158
|
const BUDGET_REL = path.join('src', 'config', 'estate-count-budget.json');
|
|
90
159
|
const ROADMAPS_REL = path.join('agents', 'roadmaps');
|
|
160
|
+
/**
|
|
161
|
+
* The same path as a git pathspec.
|
|
162
|
+
*
|
|
163
|
+
* `ROADMAPS_REL` is `path.sep`-joined for filesystem reads; a pathspec must be
|
|
164
|
+
* POSIX or it matches nothing on Windows — silently, and a pathspec matching
|
|
165
|
+
* nothing is how the floor becomes zero.
|
|
166
|
+
*/
|
|
167
|
+
const ROADMAPS_POSIX = 'agents/roadmaps';
|
|
91
168
|
|
|
92
169
|
/** The three gated counts. Each is a metric the budget file defines in prose. */
|
|
93
170
|
export interface EstateCounts {
|
|
@@ -101,13 +178,6 @@ export type MetricName = keyof EstateCounts;
|
|
|
101
178
|
export const METRICS: readonly MetricName[] = ['active_roadmaps', 'later_roadmaps', 'open_blockers'];
|
|
102
179
|
|
|
103
180
|
export interface EstateBudget {
|
|
104
|
-
baseline: EstateCounts;
|
|
105
|
-
/**
|
|
106
|
-
* The audit trail a raise is read from. Each entry records the metrics it set
|
|
107
|
-
* and WHY in a real sentence; a raise whose newest entry carries no `why`, or
|
|
108
|
-
* whose `why` does not name the metric's new value, is refused.
|
|
109
|
-
*/
|
|
110
|
-
baseline_history?: Array<Partial<EstateCounts> & { at?: string; why?: string }>;
|
|
111
181
|
/**
|
|
112
182
|
* `applies_above_active: null` ⇒ one-in-one-out is unconditional.
|
|
113
183
|
*
|
|
@@ -120,11 +190,14 @@ export interface EstateBudget {
|
|
|
120
190
|
one_in_one_out: { applies_above_active: number | null };
|
|
121
191
|
}
|
|
122
192
|
|
|
123
|
-
/** One
|
|
193
|
+
/** One gated count that rose above the floor measured at the base ref. */
|
|
124
194
|
export interface GrowthFinding {
|
|
125
195
|
metric: MetricName;
|
|
126
|
-
|
|
196
|
+
/** The base ref's own measurement — never a number anyone typed. */
|
|
197
|
+
floor: number;
|
|
127
198
|
live: number;
|
|
199
|
+
/** Growth this change may make without an `estate_growth_exempt` claim. */
|
|
200
|
+
allowance: number;
|
|
128
201
|
}
|
|
129
202
|
|
|
130
203
|
/** How a change touched the active roadmap tree, as git reports it. */
|
|
@@ -135,44 +208,45 @@ export interface OffsetLedger {
|
|
|
135
208
|
offsets: string[];
|
|
136
209
|
/** Added files carrying an `estate_offset_exempt:` reason, with the reason. */
|
|
137
210
|
exempt: Array<{ file: string; reason: string }>;
|
|
211
|
+
/**
|
|
212
|
+
* The subset of `offsets` that went to `later/`.
|
|
213
|
+
*
|
|
214
|
+
* Tracked separately because parking is the one offset that RAISES another
|
|
215
|
+
* gated count: active falls by one and later rises by one, which under an
|
|
216
|
+
* exact floor is growth in `later_roadmaps` unless this allowance exists.
|
|
217
|
+
* Kept out of a general "any offset raises any allowance" rule on purpose —
|
|
218
|
+
* an archived roadmap must not buy a new `later/` file.
|
|
219
|
+
*/
|
|
220
|
+
parked: string[];
|
|
138
221
|
}
|
|
139
222
|
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
* `git show <baseRef>:<path>` — the one reading of the baseline this commit
|
|
146
|
-
* cannot rewrite. Same argument `_lib/ratchet_base_ref`'s header makes for entry
|
|
147
|
-
* sets, applied to a count.
|
|
148
|
-
*/
|
|
149
|
-
export interface RaiseFinding {
|
|
150
|
-
metric: MetricName;
|
|
151
|
-
from: number;
|
|
152
|
-
to: number;
|
|
153
|
-
/** A raise WITH a reason is legal and reported; without one it fails. */
|
|
154
|
-
reason: string | null;
|
|
223
|
+
/** An `estate_growth_exempt:` reason ADDED to a roadmap in this change. */
|
|
224
|
+
export interface GrowthClaim {
|
|
225
|
+
/** The roadmap the claim was added to, as git reports the path. */
|
|
226
|
+
file: string;
|
|
227
|
+
reason: string;
|
|
155
228
|
}
|
|
156
229
|
|
|
157
230
|
export interface EstateVerdict {
|
|
158
231
|
counts: EstateCounts;
|
|
159
|
-
|
|
232
|
+
/** The base ref's measurement, or `null` when it could not be taken. */
|
|
233
|
+
floor: EstateCounts | null;
|
|
234
|
+
/** The ref the floor was measured at, for the reader and for `--json`. */
|
|
235
|
+
floorRef: string | null;
|
|
236
|
+
/** `null` when the floor was measured; a stated reason otherwise. */
|
|
237
|
+
floorSkipReason: string | null;
|
|
238
|
+
/** Growth with no allowance and no claim covering it — this is what fails. */
|
|
160
239
|
growth: GrowthFinding[];
|
|
161
240
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* The subset of `tightened` that FAILS: below baseline with no bounded
|
|
169
|
-
* reasoned raise covering it. This is what `withinBudget` reads.
|
|
241
|
+
* Growth an `estate_growth_exempt` claim authorised.
|
|
242
|
+
*
|
|
243
|
+
* Reported rather than dropped: a `--json` consumer must be able to tell
|
|
244
|
+
* "nothing grew" from "something grew and a claim covered it", which is the
|
|
245
|
+
* same distinction the old verdict kept `tightened` whole for.
|
|
170
246
|
*/
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
/** `null` when the raise check could not run; the reason is printed either way. */
|
|
175
|
-
raiseSkipReason: string | null;
|
|
247
|
+
authorisedGrowth: GrowthFinding[];
|
|
248
|
+
/** The claims found in this change's diff, with their reasons. */
|
|
249
|
+
claims: GrowthClaim[];
|
|
176
250
|
/** `null` when the diff half could not run; the reason is printed either way. */
|
|
177
251
|
offsets: OffsetLedger | null;
|
|
178
252
|
offsetSkipReason: string | null;
|
|
@@ -299,6 +373,11 @@ function isDisposed(rel: string): boolean {
|
|
|
299
373
|
return /^agents\/roadmaps\/(archive|later|skipped|stubs)\//.test(norm);
|
|
300
374
|
}
|
|
301
375
|
|
|
376
|
+
/** A roadmap parked for later — the one disposition that grows another count. */
|
|
377
|
+
function isParked(rel: string): boolean {
|
|
378
|
+
return rel.split(path.sep).join('/').startsWith('agents/roadmaps/later/');
|
|
379
|
+
}
|
|
380
|
+
|
|
302
381
|
/**
|
|
303
382
|
* Read the exemption reason a newly added roadmap declares, if any.
|
|
304
383
|
*
|
|
@@ -329,6 +408,7 @@ export function classifyDiff(nameStatus: string, readFile: (rel: string) => stri
|
|
|
329
408
|
const added: string[] = [];
|
|
330
409
|
const offsets: string[] = [];
|
|
331
410
|
const exempt: Array<{ file: string; reason: string }> = [];
|
|
411
|
+
const parked: string[] = [];
|
|
332
412
|
for (const line of nameStatus.split('\n')) {
|
|
333
413
|
if (line.trim() === '') continue;
|
|
334
414
|
const cols = line.split('\t');
|
|
@@ -338,6 +418,7 @@ export function classifyDiff(nameStatus: string, readFile: (rel: string) => stri
|
|
|
338
418
|
const to = cols[2] ?? '';
|
|
339
419
|
if (isActiveTopLevel(from) && isDisposed(to)) {
|
|
340
420
|
offsets.push(from);
|
|
421
|
+
if (isParked(to)) parked.push(to);
|
|
341
422
|
} else if (isDisposed(from) && isActiveTopLevel(to)) {
|
|
342
423
|
added.push(to);
|
|
343
424
|
}
|
|
@@ -359,7 +440,42 @@ export function classifyDiff(nameStatus: string, readFile: (rel: string) => stri
|
|
|
359
440
|
exempt.push({ file, reason });
|
|
360
441
|
}
|
|
361
442
|
}
|
|
362
|
-
return { added, offsets, exempt };
|
|
443
|
+
return { added, offsets, exempt, parked };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The `estate_growth_exempt:` reasons this change ADDS, read from the patch.
|
|
448
|
+
*
|
|
449
|
+
* Read from the diff rather than from the file, and that is the point: a claim
|
|
450
|
+
* sitting in a roadmap authorises nothing on a later change, so an exemption
|
|
451
|
+
* cannot be banked the way surplus in a stored baseline could. It also means a
|
|
452
|
+
* newly added roadmap and an edited one need no separate handling — in a
|
|
453
|
+
* `base...HEAD` patch both arrive as `+` lines.
|
|
454
|
+
*
|
|
455
|
+
* `--unified=0` keeps context lines out, so a claim that merely sits NEAR an
|
|
456
|
+
* edited line is not read as added. Deliberately tolerant of leading whitespace
|
|
457
|
+
* and of quoted values, matching `exemptionReason`; deliberately NOT tolerant of
|
|
458
|
+
* an empty reason, because an exemption whose reason is blank is the silent
|
|
459
|
+
* exception the key exists to replace.
|
|
460
|
+
*/
|
|
461
|
+
export function growthClaims(patch: string): GrowthClaim[] {
|
|
462
|
+
const out: GrowthClaim[] = [];
|
|
463
|
+
let file = '';
|
|
464
|
+
for (const line of patch.split('\n')) {
|
|
465
|
+
// `+++ b/<path>` names the file the following `+` lines belong to. The
|
|
466
|
+
// `/dev/null` form is a deletion, which cannot carry a claim.
|
|
467
|
+
const head = /^\+\+\+ b\/(.+)$/.exec(line);
|
|
468
|
+
if (head !== null) {
|
|
469
|
+
file = (head[1] ?? '').trim();
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue;
|
|
473
|
+
const m = /^\+\s*estate_growth_exempt:\s*(.+?)\s*$/.exec(line);
|
|
474
|
+
if (m === null) continue;
|
|
475
|
+
const reason = (m[1] ?? '').trim().replace(/^["']|["']$/g, '').trim();
|
|
476
|
+
if (reason !== '') out.push({ file, reason });
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
363
479
|
}
|
|
364
480
|
|
|
365
481
|
export function evaluate(
|
|
@@ -373,67 +489,36 @@ export function evaluate(
|
|
|
373
489
|
} catch (err) {
|
|
374
490
|
throw new Error(`${BUDGET_REL} is unreadable: ${(err as Error).message}`);
|
|
375
491
|
}
|
|
376
|
-
|
|
377
|
-
|
|
492
|
+
// The file no longer carries a number, so it carries only policy — and a
|
|
493
|
+
// missing policy object is still a could-not-run rather than a pass. A gate
|
|
494
|
+
// that treats an unreadable config as "nothing to enforce" is the silent-pass
|
|
495
|
+
// shape this file argues against everywhere else.
|
|
496
|
+
if (budget.one_in_one_out === undefined) {
|
|
497
|
+
throw new Error(`${BUDGET_REL} carries no "one_in_one_out" object.`);
|
|
378
498
|
}
|
|
379
499
|
|
|
380
500
|
const counts = countEstate(repoRoot);
|
|
381
|
-
const growth: GrowthFinding[] = [];
|
|
382
|
-
const tightened: GrowthFinding[] = [];
|
|
383
|
-
for (const metric of METRICS) {
|
|
384
|
-
const baseline = budget.baseline[metric];
|
|
385
|
-
const live = counts[metric];
|
|
386
|
-
if (typeof baseline !== 'number') {
|
|
387
|
-
throw new Error(`${BUDGET_REL} baseline is missing the "${metric}" metric.`);
|
|
388
|
-
}
|
|
389
|
-
if (live > baseline) growth.push({ metric, baseline, live });
|
|
390
|
-
else if (live < baseline) tightened.push({ metric, baseline, live });
|
|
391
|
-
}
|
|
392
|
-
|
|
393
501
|
const baseRef = opts.baseRef ?? resolveBaseRef(repoRoot);
|
|
394
502
|
|
|
395
|
-
// The
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// only when the newest `baseline_history` entry carries a real reason AND
|
|
402
|
-
// records the metric at its new value.
|
|
403
|
-
const raises: RaiseFinding[] = [];
|
|
404
|
-
let raiseSkipReason: string | null = null;
|
|
503
|
+
// The FLOOR — measured, never read. `git show <ref>:<path>` gave the old
|
|
504
|
+
// stored number the one property that made it a ratchet: the change under
|
|
505
|
+
// review cannot rewrite the base side. The base ref's TREE has that same
|
|
506
|
+
// property, and unlike a number nobody has to remember to update it.
|
|
507
|
+
let floor: EstateCounts | null = null;
|
|
508
|
+
let floorSkipReason: string | null = null;
|
|
405
509
|
if (baseRef === null || baseRef === undefined) {
|
|
406
|
-
|
|
510
|
+
floorSkipReason =
|
|
511
|
+
'no base ref resolved (no origin/main, no merge-commit parent) — the floor cannot be measured';
|
|
407
512
|
} else {
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// budget this is the correct and expected reading.
|
|
413
|
-
raiseSkipReason = `${BUDGET_REL} does not exist at ${baseRef} — treated as the introducing change, so no raise is possible`;
|
|
414
|
-
} else {
|
|
415
|
-
let baseBudget: EstateBudget | null = null;
|
|
416
|
-
try {
|
|
417
|
-
baseBudget = JSON.parse(show.stdout) as EstateBudget;
|
|
418
|
-
} catch {
|
|
419
|
-
baseBudget = null;
|
|
420
|
-
}
|
|
421
|
-
if (baseBudget === null || baseBudget.baseline === undefined) {
|
|
422
|
-
raiseSkipReason = `${BUDGET_REL} at ${baseRef} is unparseable — raise undetectable`;
|
|
513
|
+
const base = materialiseSubtree(repoRoot, baseRef, ROADMAPS_POSIX);
|
|
514
|
+
try {
|
|
515
|
+
if (base.error !== null || base.files === 0) {
|
|
516
|
+
floorSkipReason = `could not read ${ROADMAPS_POSIX} at ${baseRef} — ${base.error ?? 'no files'}`;
|
|
423
517
|
} else {
|
|
424
|
-
|
|
425
|
-
for (const metric of METRICS) {
|
|
426
|
-
const before = baseBudget.baseline[metric];
|
|
427
|
-
const after = budget.baseline[metric];
|
|
428
|
-
if (typeof before !== 'number' || after <= before) continue;
|
|
429
|
-
const why = typeof newest.why === 'string' ? newest.why.trim() : '';
|
|
430
|
-
// The reason must belong to THIS raise: an entry that records a
|
|
431
|
-
// different number is an older reason being reused, which is the
|
|
432
|
-
// silent-reset shape RATCHET_RESET_KEY's header warns about.
|
|
433
|
-
const namesMetric = newest[metric] === after;
|
|
434
|
-
raises.push({ metric, from: before, to: after, reason: why !== '' && namesMetric ? why : null });
|
|
435
|
-
}
|
|
518
|
+
floor = countEstate(base.root);
|
|
436
519
|
}
|
|
520
|
+
} finally {
|
|
521
|
+
fs.rmSync(base.root, { recursive: true, force: true });
|
|
437
522
|
}
|
|
438
523
|
}
|
|
439
524
|
|
|
@@ -442,6 +527,7 @@ export function evaluate(
|
|
|
442
527
|
// argues against, and the count half above still ran.
|
|
443
528
|
let offsets: OffsetLedger | null = null;
|
|
444
529
|
let offsetSkipReason: string | null = null;
|
|
530
|
+
const claims: GrowthClaim[] = [];
|
|
445
531
|
if (baseRef === null || baseRef === undefined) {
|
|
446
532
|
offsetSkipReason = 'no base ref resolved (no origin/main, no merge-commit parent) — one-in-one-out not evaluated';
|
|
447
533
|
} else {
|
|
@@ -460,6 +546,11 @@ export function evaluate(
|
|
|
460
546
|
}
|
|
461
547
|
});
|
|
462
548
|
}
|
|
549
|
+
const patch = git(
|
|
550
|
+
['diff', '--unified=0', '--find-renames', `${baseRef}...HEAD`, '--', ROADMAPS_REL],
|
|
551
|
+
repoRoot,
|
|
552
|
+
);
|
|
553
|
+
if (patch.ok) claims.push(...growthClaims(patch.stdout));
|
|
463
554
|
}
|
|
464
555
|
|
|
465
556
|
const threshold = budget.one_in_one_out?.applies_above_active ?? null;
|
|
@@ -471,77 +562,55 @@ export function evaluate(
|
|
|
471
562
|
unpaid = Math.max(0, chargeable.length - offsets.offsets.length);
|
|
472
563
|
}
|
|
473
564
|
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
// rather than convicts — the same permissive reading the raise half itself
|
|
500
|
-
// takes on the same missing input (round 2 finding 4). Failing here while the
|
|
501
|
-
// raise half skips would refuse a legal reasoned raise with no green path,
|
|
502
|
-
// and would treat one missing fact two opposite ways inside one gate.
|
|
503
|
-
const tighteningProvable = raiseSkipReason === null;
|
|
504
|
-
const unwalked = tighteningProvable ? tightened.filter((t) => !boundedRaise.has(t.metric)) : [];
|
|
565
|
+
// The allowances, one metric at a time and never a blanket rule. An exempt
|
|
566
|
+
// addition and a parked roadmap are the two increases the existing policy
|
|
567
|
+
// already sanctions, and under an exact floor each needs its own headroom or
|
|
568
|
+
// the sanctioned act has no green path. `open_blockers` gets none: nothing in
|
|
569
|
+
// the policy sanctions a new blocker, so it takes the claim path or nothing.
|
|
570
|
+
const allowance: Record<MetricName, number> = {
|
|
571
|
+
active_roadmaps: offsets?.exempt.length ?? 0,
|
|
572
|
+
later_roadmaps: offsets?.parked.length ?? 0,
|
|
573
|
+
open_blockers: 0,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const over: GrowthFinding[] = [];
|
|
577
|
+
if (floor !== null) {
|
|
578
|
+
for (const metric of METRICS) {
|
|
579
|
+
const live = counts[metric];
|
|
580
|
+
const allowed = floor[metric] + allowance[metric];
|
|
581
|
+
if (live > allowed) {
|
|
582
|
+
over.push({ metric, floor: floor[metric], live, allowance: allowance[metric] });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// A claim authorises the growth in THIS change and nothing beyond it. It is
|
|
587
|
+
// read from the patch, so it cannot be inherited; see `growthClaims`.
|
|
588
|
+
const authorisedGrowth = claims.length > 0 ? over : [];
|
|
589
|
+
const growth = claims.length > 0 ? [] : over;
|
|
505
590
|
|
|
506
591
|
return {
|
|
507
592
|
counts,
|
|
508
|
-
|
|
593
|
+
floor,
|
|
594
|
+
floorRef: baseRef ?? null,
|
|
595
|
+
floorSkipReason,
|
|
509
596
|
growth,
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
raises,
|
|
513
|
-
raiseSkipReason,
|
|
597
|
+
authorisedGrowth,
|
|
598
|
+
claims,
|
|
514
599
|
offsets,
|
|
515
600
|
offsetSkipReason,
|
|
516
601
|
unpaid,
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
// exit 0) and reddened CI (that test, exit 1) every single time, and on
|
|
524
|
-
// 2026-08-18 it reddened `main` itself in run 32173675197, after which
|
|
525
|
-
// every subsequent PR failed on drift it had not caused. That is the
|
|
526
|
-
// permanently-red shape the budget file's own `67 -> 69` history entry
|
|
527
|
-
// describes from the other direction.
|
|
528
|
-
//
|
|
529
|
-
// Failing here is also the STRONGER reading of this gate's purpose: the
|
|
530
|
-
// file says in as many words that a lower measurement is the new ceiling
|
|
531
|
-
// and not headroom to spend later. A baseline left above the truth is
|
|
532
|
-
// exactly that headroom, and a gate that only warns about it is how the
|
|
533
|
-
// headroom survives to be spent.
|
|
534
|
-
withinBudget:
|
|
535
|
-
growth.length === 0 &&
|
|
536
|
-
unpaid === 0 &&
|
|
537
|
-
unwalked.length === 0 &&
|
|
538
|
-
raises.every((r) => r.reason !== null),
|
|
602
|
+
// No floor is a FAILURE, not a skip, and it is the one place this gate
|
|
603
|
+
// convicts on a missing input. The other halves can report "unproven" and
|
|
604
|
+
// still leave a meaningful verdict behind; the count half IS the verdict,
|
|
605
|
+
// so with no floor a green here would assert something nothing measured.
|
|
606
|
+
// A shrink-only gate whose floor is absent passes every possible tree.
|
|
607
|
+
withinBudget: floorSkipReason === null && growth.length === 0 && unpaid === 0,
|
|
539
608
|
};
|
|
540
609
|
}
|
|
541
610
|
|
|
542
611
|
/** Floors for `--self-test`, declared here so a truncation is a visible diff. */
|
|
543
|
-
const SELF_TEST_MIN_CASES =
|
|
544
|
-
const SELF_TEST_MIN_REJECT =
|
|
612
|
+
const SELF_TEST_MIN_CASES = 12;
|
|
613
|
+
const SELF_TEST_MIN_REJECT = 8;
|
|
545
614
|
|
|
546
615
|
/**
|
|
547
616
|
* Prove, on demand, that this gate's rejections still fire against its own CLI.
|
|
@@ -557,24 +626,35 @@ function selfTest(): number {
|
|
|
557
626
|
const script = path.join('src', 'scripts', 'check_estate_count.ts');
|
|
558
627
|
const roots: string[] = [];
|
|
559
628
|
|
|
560
|
-
/**
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
629
|
+
/**
|
|
630
|
+
* The budget body — POLICY ONLY since ADR-243.
|
|
631
|
+
*
|
|
632
|
+
* There is no number in it any more, which is why the raise cases the old
|
|
633
|
+
* self-test carried are gone rather than ported: with nothing stored, "raise
|
|
634
|
+
* the baseline with no reason" is not a state a change can reach.
|
|
635
|
+
*/
|
|
636
|
+
const budgetJson = (): string =>
|
|
637
|
+
`${JSON.stringify({ one_in_one_out: { applies_above_active: null } }, null, 4)}\n`;
|
|
638
|
+
|
|
639
|
+
const roadmap = (name: string, extra = ''): string =>
|
|
640
|
+
`# Roadmap: ${name}\n\n## Phase 1\n\n- [ ] **1.1** s\n${extra}`;
|
|
641
|
+
|
|
642
|
+
/** An open blocker — no `Status: resolved` prefix, so it counts as open. */
|
|
643
|
+
const BLOCKER = '\n## Blockers\n\n### Blocker: fixture\n\n- **Status:** open\n';
|
|
573
644
|
|
|
574
645
|
const fixture = (opts: {
|
|
575
646
|
roadmaps: number;
|
|
576
|
-
baseline: { active: number; later: number };
|
|
577
647
|
after: (dir: string) => void;
|
|
648
|
+
base?: string;
|
|
649
|
+
/**
|
|
650
|
+
* Build the repo with NO trunk branch, which is the only way to reach the
|
|
651
|
+
* no-floor case: `resolveBaseRef` falls back to a LOCAL `main` after
|
|
652
|
+
* origin/main, so merely omitting `--base` still resolves one. Measured —
|
|
653
|
+
* the first version of this case expected a reject and got exit 0.
|
|
654
|
+
*/
|
|
655
|
+
trunkless?: boolean;
|
|
656
|
+
/** Runs before the BASE commit, so a case can seed the base tree. */
|
|
657
|
+
before?: (dir: string) => void;
|
|
578
658
|
}): number => {
|
|
579
659
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ec-selftest-'));
|
|
580
660
|
roots.push(dir);
|
|
@@ -585,21 +665,23 @@ function selfTest(): number {
|
|
|
585
665
|
const g = (...args: string[]): void => {
|
|
586
666
|
spawnSync('git', args, { cwd: dir, encoding: 'utf-8' });
|
|
587
667
|
};
|
|
588
|
-
g('init', '-q', '-b', 'main');
|
|
668
|
+
g('init', '-q', '-b', opts.trunkless === true ? 'wip' : 'main');
|
|
589
669
|
g('config', 'user.email', 'selftest@local');
|
|
590
670
|
g('config', 'user.name', 'selftest');
|
|
591
671
|
g('config', 'commit.gpgsign', 'false');
|
|
592
672
|
for (let i = 0; i < opts.roadmaps; i++) {
|
|
593
|
-
put(`agents/roadmaps/road-to-${String(i)}.md`,
|
|
673
|
+
put(`agents/roadmaps/road-to-${String(i)}.md`, roadmap(`R${String(i)}`));
|
|
594
674
|
}
|
|
595
|
-
put('src/config/estate-count-budget.json', budgetJson(
|
|
675
|
+
put('src/config/estate-count-budget.json', budgetJson());
|
|
676
|
+
opts.before?.(dir);
|
|
596
677
|
g('add', '-A');
|
|
597
678
|
g('commit', '-qm', 'base');
|
|
598
679
|
g('checkout', '-qb', 'feat/x');
|
|
599
680
|
opts.after(dir);
|
|
600
681
|
g('add', '-A');
|
|
601
682
|
g('commit', '-qm', 'change');
|
|
602
|
-
|
|
683
|
+
const argv = opts.base === undefined ? [] : ['--base', opts.base];
|
|
684
|
+
return runGateCli(repo, script, argv, dir);
|
|
603
685
|
};
|
|
604
686
|
|
|
605
687
|
const write = (dir: string, rel: string, body: string): void => {
|
|
@@ -607,131 +689,184 @@ function selfTest(): number {
|
|
|
607
689
|
fs.writeFileSync(path.join(dir, rel), body, 'utf-8');
|
|
608
690
|
};
|
|
609
691
|
|
|
692
|
+
const mv = (dir: string, from: string, to: string): void => {
|
|
693
|
+
fs.mkdirSync(path.join(dir, path.dirname(to)), { recursive: true });
|
|
694
|
+
spawnSync('git', ['mv', from, to], { cwd: dir, encoding: 'utf-8' });
|
|
695
|
+
};
|
|
696
|
+
|
|
610
697
|
const cases: SelfTestCase[] = [
|
|
611
698
|
{
|
|
612
|
-
name: '
|
|
699
|
+
name: 'estate unchanged against the base tree → accept',
|
|
613
700
|
expect: 'accept',
|
|
614
|
-
run: () => fixture({ roadmaps: 3,
|
|
701
|
+
run: () => fixture({ roadmaps: 3, base: 'main', after: () => undefined }),
|
|
615
702
|
},
|
|
616
703
|
{
|
|
617
|
-
|
|
704
|
+
// The floor is now exact, so this case no longer depends on anyone
|
|
705
|
+
// having typed the right number at the base commit.
|
|
706
|
+
name: 'active count grows above the base-tree floor → reject',
|
|
618
707
|
expect: 'reject',
|
|
619
708
|
run: () =>
|
|
620
709
|
fixture({
|
|
621
710
|
roadmaps: 3,
|
|
622
|
-
|
|
623
|
-
after: (dir) => write(dir, 'agents/roadmaps/road-to-new.md', '
|
|
711
|
+
base: 'main',
|
|
712
|
+
after: (dir) => write(dir, 'agents/roadmaps/road-to-new.md', roadmap('N')),
|
|
624
713
|
}),
|
|
625
714
|
},
|
|
626
715
|
{
|
|
627
|
-
|
|
716
|
+
// Isolating the one-in-one-out half needs the COUNT half satisfied,
|
|
717
|
+
// and under an exact floor the only way to add a roadmap and stay at
|
|
718
|
+
// the floor is a claim. So the claim is what buys the isolation the
|
|
719
|
+
// old fixture bought with a pre-raised baseline.
|
|
720
|
+
name: 'addition with a growth claim but no offset → reject (one-in-one-out)',
|
|
628
721
|
expect: 'reject',
|
|
629
722
|
run: () =>
|
|
630
723
|
fixture({
|
|
631
|
-
// Baseline is 4 AT THE BASE COMMIT, not raised on the branch, so
|
|
632
|
-
// the ratchet and raise halves both pass and only the offset
|
|
633
|
-
// half can fail — otherwise this case would pass for another
|
|
634
|
-
// check's reason.
|
|
635
724
|
roadmaps: 3,
|
|
636
|
-
|
|
637
|
-
after: (dir) =>
|
|
725
|
+
base: 'main',
|
|
726
|
+
after: (dir) =>
|
|
727
|
+
write(
|
|
728
|
+
dir,
|
|
729
|
+
'agents/roadmaps/road-to-new.md',
|
|
730
|
+
'---\nestate_growth_exempt: fixture — isolates one-in-one-out from the count half\n---\n\n' +
|
|
731
|
+
roadmap('N'),
|
|
732
|
+
),
|
|
733
|
+
}),
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: 'addition carrying estate_offset_exempt → accept',
|
|
737
|
+
expect: 'accept',
|
|
738
|
+
run: () =>
|
|
739
|
+
fixture({
|
|
740
|
+
roadmaps: 3,
|
|
741
|
+
base: 'main',
|
|
742
|
+
after: (dir) =>
|
|
743
|
+
write(
|
|
744
|
+
dir,
|
|
745
|
+
'agents/roadmaps/road-to-new.md',
|
|
746
|
+
'---\nestate_offset_exempt: fixture — the addition that cannot be offset\n---\n\n' + roadmap('N'),
|
|
747
|
+
),
|
|
748
|
+
}),
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
name: 'addition paid for by an archive move → accept',
|
|
752
|
+
expect: 'accept',
|
|
753
|
+
run: () =>
|
|
754
|
+
fixture({
|
|
755
|
+
roadmaps: 3,
|
|
756
|
+
base: 'main',
|
|
757
|
+
after: (dir) => {
|
|
758
|
+
write(dir, 'agents/roadmaps/road-to-new.md', roadmap('N'));
|
|
759
|
+
mv(dir, 'agents/roadmaps/road-to-2.md', 'agents/roadmaps/archive/road-to-2.md');
|
|
760
|
+
},
|
|
761
|
+
}),
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
// Parking is the case an exact floor would otherwise forbid outright:
|
|
765
|
+
// active falls by one and later rises by one, and without the parking
|
|
766
|
+
// allowance the sanctioned drawdown action fails the gate.
|
|
767
|
+
name: 'parking a roadmap into later/ → accept',
|
|
768
|
+
expect: 'accept',
|
|
769
|
+
run: () =>
|
|
770
|
+
fixture({
|
|
771
|
+
roadmaps: 3,
|
|
772
|
+
base: 'main',
|
|
773
|
+
after: (dir) => mv(dir, 'agents/roadmaps/road-to-2.md', 'agents/roadmaps/later/road-to-2.md'),
|
|
638
774
|
}),
|
|
639
775
|
},
|
|
640
776
|
{
|
|
641
|
-
// The
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
name: 'baseline raised with no recorded reason → reject',
|
|
777
|
+
// The other side of that allowance, and the reason it is keyed on
|
|
778
|
+
// parking rather than on "any offset": a later/ file that nothing was
|
|
779
|
+
// parked into is estate arriving for free.
|
|
780
|
+
name: 'a later/ roadmap appearing from nowhere → reject',
|
|
646
781
|
expect: 'reject',
|
|
647
782
|
run: () =>
|
|
648
783
|
fixture({
|
|
649
784
|
roadmaps: 3,
|
|
650
|
-
|
|
651
|
-
after: (dir) => write(dir, '
|
|
785
|
+
base: 'main',
|
|
786
|
+
after: (dir) => write(dir, 'agents/roadmaps/later/road-to-parked.md', roadmap('P')),
|
|
652
787
|
}),
|
|
653
788
|
},
|
|
654
789
|
{
|
|
655
|
-
|
|
656
|
-
// over-raise of +6 as legal. A reason makes a raise reviewable; it does
|
|
657
|
-
// not make banked headroom safe, and the banked headroom is inherited by
|
|
658
|
-
// the next change, where nobody can act on it.
|
|
659
|
-
name: 'raise WITH a reason but overshooting the measurement → reject',
|
|
790
|
+
name: 'a new open blocker with no claim → reject',
|
|
660
791
|
expect: 'reject',
|
|
661
792
|
run: () =>
|
|
662
793
|
fixture({
|
|
663
794
|
roadmaps: 3,
|
|
664
|
-
|
|
665
|
-
after: (dir) =>
|
|
666
|
-
write(
|
|
667
|
-
dir,
|
|
668
|
-
'src/config/estate-count-budget.json',
|
|
669
|
-
budgetJson(9, 9, 'fixture: a deliberate re-baseline, recorded so the ratchet can see it'),
|
|
670
|
-
),
|
|
795
|
+
base: 'main',
|
|
796
|
+
after: (dir) => write(dir, 'agents/roadmaps/road-to-1.md', roadmap('R1', BLOCKER)),
|
|
671
797
|
}),
|
|
672
798
|
},
|
|
673
799
|
{
|
|
674
|
-
|
|
675
|
-
// unsatisfiable and a legal raise impossible. The addition carries
|
|
676
|
-
// `estate_offset_exempt` so the one-in-one-out half cannot be the reason
|
|
677
|
-
// this case passes or fails.
|
|
678
|
-
name: 'raise WITH a reason landing ON the measurement → accept',
|
|
800
|
+
name: 'a new open blocker WITH a growth claim → accept',
|
|
679
801
|
expect: 'accept',
|
|
680
802
|
run: () =>
|
|
681
803
|
fixture({
|
|
682
804
|
roadmaps: 3,
|
|
683
|
-
|
|
684
|
-
after: (dir) =>
|
|
685
|
-
write(
|
|
686
|
-
dir,
|
|
687
|
-
'agents/roadmaps/road-to-new.md',
|
|
688
|
-
'---\nestate_offset_exempt: fixture — isolates the raise half from one-in-one-out\n---\n\n# Roadmap: N\n\n## Phase 1\n\n- [ ] **1.1** s\n',
|
|
689
|
-
);
|
|
805
|
+
base: 'main',
|
|
806
|
+
after: (dir) =>
|
|
690
807
|
write(
|
|
691
808
|
dir,
|
|
692
|
-
'
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
809
|
+
'agents/roadmaps/road-to-1.md',
|
|
810
|
+
'---\nestate_growth_exempt: fixture — a blocker discovered while doing the work\n---\n\n' +
|
|
811
|
+
roadmap('R1', BLOCKER),
|
|
812
|
+
),
|
|
696
813
|
}),
|
|
697
814
|
},
|
|
698
815
|
{
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
name: 'count below baseline with no raise → reject (un-walked tightening)',
|
|
816
|
+
// An exemption whose reason is blank is the silent exception the key
|
|
817
|
+
// exists to replace, so a bare marker must not authorise anything.
|
|
818
|
+
name: 'a growth claim with an empty reason → reject',
|
|
703
819
|
expect: 'reject',
|
|
704
820
|
run: () =>
|
|
705
821
|
fixture({
|
|
706
822
|
roadmaps: 3,
|
|
707
|
-
|
|
708
|
-
after: (dir) =>
|
|
823
|
+
base: 'main',
|
|
824
|
+
after: (dir) =>
|
|
825
|
+
write(dir, 'agents/roadmaps/road-to-1.md', '---\nestate_growth_exempt:\n---\n\n' + roadmap('R1', BLOCKER)),
|
|
709
826
|
}),
|
|
710
827
|
},
|
|
711
828
|
{
|
|
712
|
-
|
|
713
|
-
|
|
829
|
+
// The anti-banking property, and the reason claims are read from the
|
|
830
|
+
// PATCH rather than from the file. A claim sitting in a roadmap at the
|
|
831
|
+
// base commit authorises nothing here: a stored baseline could be
|
|
832
|
+
// over-raised once and spent by every later change, and the whole
|
|
833
|
+
// point of reading the diff is that this shape has no equivalent.
|
|
834
|
+
name: 'a claim already present at base authorises nothing → reject',
|
|
835
|
+
expect: 'reject',
|
|
714
836
|
run: () =>
|
|
715
837
|
fixture({
|
|
716
838
|
roadmaps: 3,
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
write(
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
839
|
+
base: 'main',
|
|
840
|
+
before: (dir) =>
|
|
841
|
+
write(
|
|
842
|
+
dir,
|
|
843
|
+
'agents/roadmaps/road-to-1.md',
|
|
844
|
+
'---\nestate_growth_exempt: fixture — banked at base, must not carry over\n---\n\n' +
|
|
845
|
+
roadmap('R1'),
|
|
846
|
+
),
|
|
847
|
+
after: (dir) =>
|
|
848
|
+
write(
|
|
849
|
+
dir,
|
|
850
|
+
'agents/roadmaps/road-to-1.md',
|
|
851
|
+
'---\nestate_growth_exempt: fixture — banked at base, must not carry over\n---\n\n' +
|
|
852
|
+
roadmap('R1', BLOCKER),
|
|
853
|
+
),
|
|
726
854
|
}),
|
|
727
855
|
},
|
|
856
|
+
{
|
|
857
|
+
// The class that replaces the old "raise with no reason" case: with no
|
|
858
|
+
// stored number, the way to silence this gate is to deny it a floor.
|
|
859
|
+
name: 'no base ref resolvable → reject (no floor is not a pass)',
|
|
860
|
+
expect: 'reject',
|
|
861
|
+
run: () => fixture({ roadmaps: 3, trunkless: true, after: () => undefined }),
|
|
862
|
+
},
|
|
728
863
|
{
|
|
729
864
|
name: 'emptied roadmap root → reject (dead scope, not a pass)',
|
|
730
865
|
expect: 'reject',
|
|
731
866
|
run: () =>
|
|
732
867
|
fixture({
|
|
733
868
|
roadmaps: 1,
|
|
734
|
-
|
|
869
|
+
base: 'main',
|
|
735
870
|
after: (dir) => fs.rmSync(path.join(dir, 'agents/roadmaps/road-to-0.md')),
|
|
736
871
|
}),
|
|
737
872
|
},
|
|
@@ -804,25 +939,19 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
804
939
|
}
|
|
805
940
|
|
|
806
941
|
const ledger = new GateLedger(GATE);
|
|
807
|
-
ledger.plan([...METRICS, '
|
|
942
|
+
ledger.plan([...METRICS, 'floor', 'one_in_one_out']);
|
|
808
943
|
for (const metric of METRICS) {
|
|
944
|
+
// Round 2 finding 3, kept: a metric is recorded ONCE here, for whichever
|
|
945
|
+
// way it actually failed, so the ledger cannot show a green completeness
|
|
946
|
+
// record beside a red exit. With no floor there is no per-metric verdict
|
|
947
|
+
// to record at all, which is a skip rather than a completion.
|
|
809
948
|
const bad = verdict.growth.find((g) => g.metric === metric);
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
// surface, and the only one `--json` carries — showed a fully green
|
|
813
|
-
// completeness record beside a red exit. A metric is recorded once, here,
|
|
814
|
-
// for whichever way it actually failed.
|
|
815
|
-
const stale = verdict.unwalked.find((t) => t.metric === metric);
|
|
816
|
-
if (bad !== undefined) ledger.fail(metric, `${metric} grew ${bad.baseline} → ${bad.live}`);
|
|
817
|
-
else if (stale !== undefined)
|
|
818
|
-
ledger.fail(metric, `${metric} un-walked: ${stale.live} under a baseline of ${stale.baseline}`);
|
|
949
|
+
if (verdict.floor === null) ledger.skip(metric, 'precondition_unmet');
|
|
950
|
+
else if (bad !== undefined) ledger.fail(metric, `${metric} grew ${bad.floor} → ${bad.live}`);
|
|
819
951
|
else ledger.complete(metric);
|
|
820
952
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
else if (unreasoned.length > 0)
|
|
824
|
-
ledger.fail('baseline_raise', `${String(unreasoned.length)} raise(s) with no recorded reason`);
|
|
825
|
-
else ledger.complete('baseline_raise');
|
|
953
|
+
if (verdict.floorSkipReason !== null) ledger.fail('floor', verdict.floorSkipReason);
|
|
954
|
+
else ledger.complete('floor');
|
|
826
955
|
if (verdict.offsets === null) ledger.skip('one_in_one_out', 'precondition_unmet');
|
|
827
956
|
else if (verdict.unpaid > 0) ledger.fail('one_in_one_out', `${String(verdict.unpaid)} unpaid addition(s)`);
|
|
828
957
|
else ledger.complete('one_in_one_out');
|
|
@@ -838,63 +967,61 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
838
967
|
|
|
839
968
|
for (const metric of METRICS) {
|
|
840
969
|
const live = verdict.counts[metric];
|
|
841
|
-
const base = verdict.
|
|
970
|
+
const base = verdict.floor?.[metric];
|
|
971
|
+
if (base === undefined) {
|
|
972
|
+
process.stdout.write(` ${metric.padEnd(18)} ${String(live).padStart(5)} (floor unmeasured)\n`);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
842
975
|
const delta = live - base;
|
|
843
976
|
const sign = delta >= 0 ? '+' : '';
|
|
844
977
|
process.stdout.write(
|
|
845
|
-
` ${metric.padEnd(18)} ${String(live).padStart(5)} (
|
|
978
|
+
` ${metric.padEnd(18)} ${String(live).padStart(5)} (floor ${String(base)} at ${verdict.floorRef ?? '?'}, ${sign}${String(delta)})\n`,
|
|
846
979
|
);
|
|
847
980
|
}
|
|
848
981
|
if (verdict.offsets !== null) {
|
|
849
982
|
const o = verdict.offsets;
|
|
850
983
|
process.stdout.write(
|
|
851
984
|
` ${'this change'.padEnd(18)} +${String(o.added.length)} active / -${String(o.offsets.length)} disposed` +
|
|
985
|
+
`${o.parked.length > 0 ? `, ${String(o.parked.length)} parked` : ''}` +
|
|
852
986
|
`${o.exempt.length > 0 ? `, ${String(o.exempt.length)} exempt` : ''}\n`,
|
|
853
987
|
);
|
|
854
988
|
} else if (verdict.offsetSkipReason !== null) {
|
|
855
989
|
process.stdout.write(` ⚠️ ${verdict.offsetSkipReason}\n`);
|
|
856
990
|
}
|
|
857
|
-
|
|
858
|
-
process.stdout.write(`
|
|
991
|
+
for (const c of verdict.claims) {
|
|
992
|
+
process.stdout.write(` ↑ growth claimed in ${c.file}: ${c.reason.slice(0, 120)}\n`);
|
|
859
993
|
}
|
|
860
|
-
for (const
|
|
994
|
+
for (const g of verdict.authorisedGrowth) {
|
|
861
995
|
process.stdout.write(
|
|
862
|
-
` ↑
|
|
863
|
-
` ${(r.reason as string).slice(0, 120)}\n`,
|
|
996
|
+
` ↑ ${g.metric} grew ${String(g.floor)} → ${String(g.live)}, authorised by the claim above\n`,
|
|
864
997
|
);
|
|
865
998
|
}
|
|
866
999
|
|
|
867
|
-
|
|
1000
|
+
if (verdict.floorSkipReason !== null) {
|
|
868
1001
|
process.stderr.write(
|
|
869
|
-
`❌
|
|
870
|
-
|
|
871
|
-
'
|
|
872
|
-
'
|
|
873
|
-
'
|
|
874
|
-
|
|
875
|
-
' drawdown passed preflight and reddened CI — and once it reached the trunk,\n' +
|
|
876
|
-
' every later PR failed on drift it had not caused.\n',
|
|
1002
|
+
`❌ no floor: ${verdict.floorSkipReason}.\n` +
|
|
1003
|
+
" This gate compares the estate against the base ref's own tree, so with no base\n" +
|
|
1004
|
+
' ref there is nothing to compare against — and a shrink-only gate with no floor\n' +
|
|
1005
|
+
' passes every possible tree, which is why this is a failure and not a warning.\n' +
|
|
1006
|
+
' Fetch the base (`git fetch origin main`) or name it explicitly:\n' +
|
|
1007
|
+
' ./scripts-run src/scripts/check_estate_count --base origin/main\n',
|
|
877
1008
|
);
|
|
878
1009
|
}
|
|
879
1010
|
|
|
880
1011
|
for (const g of verdict.growth) {
|
|
1012
|
+
const allow = g.allowance > 0 ? ` (+${String(g.allowance)} allowed here)` : '';
|
|
881
1013
|
process.stderr.write(
|
|
882
|
-
`❌ the roadmap estate grew: ${g.metric} ${String(g.
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
'
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
process.stderr.write(
|
|
891
|
-
`❌ baseline raised with no recorded reason: ${r.metric} ${String(r.from)} → ${String(r.to)}.\n` +
|
|
892
|
-
` Read from the base ref, so editing the number in this commit cannot hide it —\n` +
|
|
893
|
-
' which is the whole difference between a ratchet and a number. A raise is legal,\n' +
|
|
894
|
-
` and it costs one appended \`baseline_history\` entry in ${BUDGET_REL} carrying\n` +
|
|
895
|
-
` \`"${r.metric}": ${String(r.to)}\` and a \`why\` written as a real sentence.\n`,
|
|
1014
|
+
`❌ the roadmap estate grew: ${g.metric} ${String(g.floor)} → ${String(g.live)}${allow}.\n` +
|
|
1015
|
+
` The floor is the measurement at ${verdict.floorRef ?? 'the base ref'}, not a number in a\n` +
|
|
1016
|
+
' config — so there is no number to edit and nothing to walk. The estate does not\n' +
|
|
1017
|
+
' walk down by itself, which is the defect this ratchet exists for. Either close,\n' +
|
|
1018
|
+
' park or archive enough to get back to the floor, or claim the growth where it\n' +
|
|
1019
|
+
' happened: add `estate_growth_exempt: <reason>` to the frontmatter of a roadmap\n' +
|
|
1020
|
+
' this change touches. The claim is read from the diff, so it authorises this\n' +
|
|
1021
|
+
' change and no later one, and the reason is a real sentence or it does not count.\n',
|
|
896
1022
|
);
|
|
897
1023
|
}
|
|
1024
|
+
|
|
898
1025
|
if (verdict.unpaid > 0 && verdict.offsets !== null) {
|
|
899
1026
|
process.stderr.write(
|
|
900
1027
|
`❌ ${String(verdict.unpaid)} new active roadmap(s) with no offset in the same change.\n` +
|