@event4u/agent-config 15.0.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +142 -348
- package/dist/agent-src/commands/roadmap/create.md +3 -2
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +50 -0
- package/dist/agent-src/rules/user-interaction.md +31 -30
- package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-H32DGUJH.js} +14 -89
- package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
- package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
- package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +831 -272
- package/dist/cli-delegate/cmd_explain.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +13 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1274 -571
- package/dist/install/install.mjs +1 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1218 -632
- package/dist/scripts/_lib/installed_lock.js +1 -1
- package/dist/server/io/yamlIO.js +50 -0
- package/package.json +1 -1
- package/src/config/continuity-surface.json +7 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/host-injection-effect.json +1 -1
- package/src/config/preamble-payload-budget.json +12 -7
- package/src/config/preamble-payload-exceptions.json +28 -0
- package/src/scripts/_dispatch.bash +3 -1
- package/src/scripts/_lib/base_ref_payload.ts +315 -0
- package/src/scripts/_lib/dropped_decision.ts +142 -0
- package/src/scripts/_lib/installed_lock.ts +1 -1
- package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
- package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
- package/src/scripts/_lib/release_findings_ingest.ts +324 -0
- package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
- package/src/scripts/_lib/review_baseline.ts +254 -0
- package/src/scripts/_lib/review_skipped_record.ts +61 -1
- package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
- package/src/scripts/_lib/turn_end_refusals.ts +51 -4
- package/src/scripts/check_beta_review_markers.ts +35 -4
- package/src/scripts/check_finding_dispositions.ts +167 -16
- package/src/scripts/check_held_object_arrivals.ts +381 -0
- package/src/scripts/check_preamble_payload_budget.ts +399 -122
- package/src/scripts/check_release_published.ts +1 -1
- package/src/scripts/check_reply_consistency.ts +86 -17
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +39 -3
- package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
- package/src/scripts/hooks/review_baseline_hook.ts +130 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
- package/src/scripts/hooks_doctor.ts +4 -1
- package/src/scripts/install.ts +1 -1
- package/src/scripts/interruption_report.ts +8 -2
- package/src/scripts/janitor.ts +8 -1
- package/src/scripts/measure_turn_end_gate.ts +302 -5
- package/src/scripts/release.ts +52 -64
- package/src/scripts/release_drill.ts +193 -14
- package/src/scripts/release_env.ts +1 -1
- package/src/scripts/release_publication.ts +1 -1
- package/src/scripts/report_held_object_arrivals.ts +456 -0
- package/src/scripts/sync_agent_settings.ts +140 -18
- package/src/server/io/yamlIO.ts +48 -0
|
@@ -3,38 +3,63 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WHY THIS EXISTS
|
|
5
5
|
* ---------------
|
|
6
|
-
* `src/config/preamble-payload-budget.json`
|
|
7
|
-
* never move UP."* `grace_ceiling_history` in the same file
|
|
8
|
-
* twice — 138,212 → 138,273 on 2026-09-02 and 138,273 → 138,490 on
|
|
9
|
-
* each with a careful prose justification. ADR-264 resolved that
|
|
10
|
-
* against the practice: the sentence stands, the two raises are
|
|
11
|
-
* by contradictory wording, and standing-rule growth needs a
|
|
12
|
-
* reduction elsewhere.
|
|
6
|
+
* `src/config/preamble-payload-budget.json` said of its grace ceiling: *"It may
|
|
7
|
+
* never move UP."* `grace_ceiling_history` in the same file recorded it moving
|
|
8
|
+
* up twice — 138,212 → 138,273 on 2026-09-02 and 138,273 → 138,490 on
|
|
9
|
+
* 2026-09-08 — each with a careful prose justification. ADR-264 resolved that
|
|
10
|
+
* contradiction against the practice: the sentence stands, the two raises are
|
|
11
|
+
* violations caused by contradictory wording, and standing-rule growth needs a
|
|
12
|
+
* compensating reduction elsewhere.
|
|
13
13
|
*
|
|
14
14
|
* That decision then had no enforcement. A sentence in a JSON string is not a
|
|
15
|
-
* gate, and the file's own history
|
|
16
|
-
* This module is the enforcement
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* gate, and the file's own history was the evidence: nothing objected either
|
|
16
|
+
* time. This module is the enforcement.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT BOUNDS NOW — AND WHY THE SUBJECT CHANGED
|
|
19
|
+
* -------------------------------------------------
|
|
20
|
+
* It used to bound one number, `ci_delivery.grace_ceiling`. That key is GONE
|
|
21
|
+
* (`road-to-delivery-for-every-host` 4.4): the ceiling is no longer stored, it
|
|
22
|
+
* is MEASURED at the base ref as `max(design_ceiling, base + active grants)`.
|
|
23
|
+
* A measured ceiling captures every merged reduction automatically, which a
|
|
24
|
+
* stored one never did.
|
|
25
|
+
*
|
|
26
|
+
* Moving the ceiling out of the config did not remove the need for a
|
|
27
|
+
* shrink-only bound — it moved the attack surface. Three numbers in the tree
|
|
28
|
+
* can still widen the effective ceiling without adding a single token of
|
|
29
|
+
* payload, and all three are now bounded:
|
|
30
|
+
*
|
|
31
|
+
* · `design_ceiling`, derived from `baseline_tokens` × `headroom_pct`. It is
|
|
32
|
+
* the FLOOR of the measured ceiling, so raising it raises the ceiling
|
|
33
|
+
* directly. ADR-264's sentence applies to it verbatim.
|
|
34
|
+
* · every existing exception's `granted_tokens` — enlarging a live grant is
|
|
35
|
+
* a raise wearing a break-glass label.
|
|
36
|
+
* · every existing exception's `watermark` — the watermark is what pins the
|
|
37
|
+
* base while a grant is live, so raising it launders the grant into the
|
|
38
|
+
* next base, which is precisely the failure the watermark exists to stop.
|
|
39
|
+
*
|
|
40
|
+
* A NEW exception id is deliberately NOT a violation: recording a fresh,
|
|
41
|
+
* approved, dated grant is the break-glass path working. Editing one that
|
|
42
|
+
* already existed is not.
|
|
20
43
|
*
|
|
21
44
|
* WHAT IT CANNOT DO, STATED PLAINLY
|
|
22
45
|
* ---------------------------------
|
|
23
46
|
* The change under review can edit this file. Both council seats named that as
|
|
24
|
-
* the defect fatal to
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
47
|
+
* the defect fatal to a mechanism that GRANTS budget, and it is not fatal to
|
|
48
|
+
* one that only REFUSES: disabling this check requires visibly editing it, and
|
|
49
|
+
* every gate in this repository already rests on that same review. A ratchet
|
|
50
|
+
* whose only failure mode is "someone deleted the ratchet" is strictly better
|
|
51
|
+
* than the sentence it replaces, which had no failure mode because it did
|
|
52
|
+
* nothing. The 2026-09-10 verdict added one mitigation on top and named its
|
|
53
|
+
* limit in the same breath: CI runs this code AS IT EXISTS AT THE BASE REF, so
|
|
54
|
+
* a same-PR weakening cannot measure its own change — *"the mechanism does not
|
|
55
|
+
* prevent the exploit; it makes the exploit auditable."*
|
|
33
56
|
*/
|
|
34
57
|
import { spawnSync } from 'node:child_process';
|
|
35
58
|
|
|
36
|
-
/** Repo-relative path of the budget config the
|
|
59
|
+
/** Repo-relative path of the budget config the bounds live in. */
|
|
37
60
|
export const BUDGET_CONFIG_PATH = 'src/config/preamble-payload-budget.json';
|
|
61
|
+
/** Repo-relative path of the break-glass ledger the grant bounds live in. */
|
|
62
|
+
export const EXCEPTIONS_CONFIG_PATH = 'src/config/preamble-payload-exceptions.json';
|
|
38
63
|
|
|
39
64
|
/** Injection seam: run git, capture output, never throw on a non-zero exit. */
|
|
40
65
|
export type GitRunner = (args: readonly string[], cwd: string) => {
|
|
@@ -48,28 +73,68 @@ export const realGit: GitRunner = (args, cwd) => {
|
|
|
48
73
|
return { ok: r.status === 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
49
74
|
};
|
|
50
75
|
|
|
76
|
+
/** Named shrink-only numbers, keyed by a stable bound id. */
|
|
77
|
+
export type BoundSet = Record<string, number>;
|
|
78
|
+
|
|
51
79
|
export interface BoundsRatchetVerdict {
|
|
52
80
|
ok: boolean;
|
|
53
81
|
violations: string[];
|
|
54
|
-
/** The
|
|
55
|
-
|
|
82
|
+
/** The bounds read at the base ref, or `null` when they could not be read. */
|
|
83
|
+
baseBounds: BoundSet | null;
|
|
56
84
|
/** The ref compared against, or `null` when none resolved. */
|
|
57
85
|
baseRef: string | null;
|
|
58
86
|
/** Why the comparison was skipped, when it was. Printed, never silent. */
|
|
59
87
|
note: string | null;
|
|
60
88
|
/**
|
|
61
|
-
* `false` means the
|
|
89
|
+
* `false` means the bounds could not be READ, not that one rose.
|
|
62
90
|
*
|
|
63
91
|
* Two refusals reach a caller with `ok: false` and they need opposite
|
|
64
|
-
* actions: a risen
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* `ok: false` that predates this field is a genuine rise.
|
|
92
|
+
* actions: a risen bound is fixed by lowering the addition, an unverifiable
|
|
93
|
+
* one by repairing the checkout. A completion review caught both being
|
|
94
|
+
* rendered as "ROSE", which would send an operator to shrink a rule over
|
|
95
|
+
* what was a fetch problem.
|
|
69
96
|
*/
|
|
70
97
|
verified: boolean;
|
|
71
98
|
}
|
|
72
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The bound map, derived identically from head and base configs.
|
|
102
|
+
*
|
|
103
|
+
* ONE function for both sides, on purpose: two derivations of one boundary is
|
|
104
|
+
* the drift shape that let the grace ceiling rise twice under a sentence
|
|
105
|
+
* forbidding it. `budget` and `exceptions` are parsed JSON, or `null` when the
|
|
106
|
+
* file is absent — an absent exceptions ledger contributes no bounds, which is
|
|
107
|
+
* correct: there are no grants to protect.
|
|
108
|
+
*/
|
|
109
|
+
export function boundsFrom(budget: unknown, exceptions: unknown): BoundSet | null {
|
|
110
|
+
const b = (budget ?? null) as Record<string, unknown> | null;
|
|
111
|
+
if (b === null) return null;
|
|
112
|
+
const baseline = Number(b['baseline_tokens']);
|
|
113
|
+
const headroom = Number(b['headroom_pct']);
|
|
114
|
+
if (!Number.isFinite(baseline) || !Number.isFinite(headroom)) return null;
|
|
115
|
+
// `design_ceiling` is the whole config-side bound now. The retained stored
|
|
116
|
+
// allowance stage 1 kept beside it was deleted in stage 2 (ADR-276), so a
|
|
117
|
+
// tree at or below design is bounded by this number and a tree above it is
|
|
118
|
+
// bounded by its own base measurement — neither of which lives in a field
|
|
119
|
+
// anyone can edit upward.
|
|
120
|
+
const out: BoundSet = { design_ceiling: Math.round(baseline * (1 + headroom / 100)) };
|
|
121
|
+
|
|
122
|
+
const ex = (exceptions ?? null) as Record<string, unknown> | null;
|
|
123
|
+
const list = ex === null ? null : ex['exceptions'];
|
|
124
|
+
if (Array.isArray(list)) {
|
|
125
|
+
for (const item of list) {
|
|
126
|
+
const e = (item ?? {}) as Record<string, unknown>;
|
|
127
|
+
const id = e['id'];
|
|
128
|
+
if (typeof id !== 'string' || id.trim() === '') continue;
|
|
129
|
+
const granted = Number(e['granted_tokens']);
|
|
130
|
+
const watermark = Number(e['watermark']);
|
|
131
|
+
if (Number.isFinite(granted)) out[`exception:${id}:granted_tokens`] = granted;
|
|
132
|
+
if (Number.isFinite(watermark)) out[`exception:${id}:watermark`] = watermark;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
73
138
|
function showAtRef(git: GitRunner, repoRoot: string, ref: string, relPath: string): string | null {
|
|
74
139
|
const r = git(['show', `${ref}:${relPath}`], repoRoot);
|
|
75
140
|
return r.ok ? r.stdout : null;
|
|
@@ -78,29 +143,24 @@ function showAtRef(git: GitRunner, repoRoot: string, ref: string, relPath: strin
|
|
|
78
143
|
/**
|
|
79
144
|
* A condition that stops the comparison: skip when advisory, refuse when not.
|
|
80
145
|
*
|
|
81
|
-
* One helper for all
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
146
|
+
* One helper for all causes rather than an inline branch each, so the posture
|
|
147
|
+
* cannot drift between them — the failure this closes was four independent
|
|
148
|
+
* `ok: true` returns that all had to be found before any of them could be
|
|
149
|
+
* trusted.
|
|
85
150
|
*/
|
|
86
|
-
function unverified(
|
|
87
|
-
reason: string,
|
|
88
|
-
requireBase: boolean,
|
|
89
|
-
baseGraceCeiling: number | null,
|
|
90
|
-
baseRef: string | null,
|
|
91
|
-
): BoundsRatchetVerdict {
|
|
151
|
+
function unverified(reason: string, requireBase: boolean, baseRef: string | null): BoundsRatchetVerdict {
|
|
92
152
|
if (requireBase) {
|
|
93
153
|
return {
|
|
94
154
|
ok: false,
|
|
95
155
|
verified: false,
|
|
96
|
-
|
|
156
|
+
baseBounds: null,
|
|
97
157
|
baseRef,
|
|
98
|
-
note: `${reason}, so the shrink-only
|
|
158
|
+
note: `${reason}, so the shrink-only bounds were NOT verified in this run`,
|
|
99
159
|
violations: [
|
|
100
|
-
`the shrink-only
|
|
160
|
+
`the shrink-only bounds could not be verified: ${reason}. This run is `
|
|
101
161
|
+ 'ENFORCING, so an unverifiable bound refuses rather than skips — an '
|
|
102
162
|
+ 'unreadable base would otherwise grant budget on an infrastructure '
|
|
103
|
-
+ 'failure. This is NOT a
|
|
163
|
+
+ 'failure. This is NOT a bound rise: fix the checkout (a full fetch '
|
|
104
164
|
+ 'of the base ref) rather than lowering the addition.',
|
|
105
165
|
],
|
|
106
166
|
};
|
|
@@ -109,33 +169,29 @@ function unverified(
|
|
|
109
169
|
ok: true,
|
|
110
170
|
verified: false,
|
|
111
171
|
violations: [],
|
|
112
|
-
|
|
172
|
+
baseBounds: null,
|
|
113
173
|
baseRef,
|
|
114
|
-
note: `${reason}, so the shrink-only
|
|
174
|
+
note: `${reason}, so the shrink-only bounds were NOT verified in this run`,
|
|
115
175
|
};
|
|
116
176
|
}
|
|
117
177
|
|
|
118
178
|
/**
|
|
119
|
-
* Compare
|
|
179
|
+
* Compare every shrink-only bound in this tree against the one at the base ref.
|
|
120
180
|
*
|
|
121
181
|
* TWO POSTURES, AND WHICH ONE APPLIES IS THE CALLER'S TO DECLARE.
|
|
122
182
|
*
|
|
123
|
-
* Advisory (the default
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* built.
|
|
183
|
+
* Advisory (the default): a base ref or base config that cannot be read SKIPS,
|
|
184
|
+
* reported in both the human and JSON output so it cannot pass for a check.
|
|
185
|
+
* That default is not laziness and the original reasoning still holds — a
|
|
186
|
+
* shallow clone, a first commit and a detached build all legitimately have no
|
|
187
|
+
* base, and a gate that reds on them gets switched off, which is how a
|
|
188
|
+
* repository loses the enforcement it just built.
|
|
130
189
|
*
|
|
131
190
|
* Enforcing (`requireBase: true`): the same conditions REFUSE. An AI council
|
|
132
191
|
* (2/2, 2026-09-10) made this blocking for the move to a base-measured ceiling,
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* infrastructure failure — the exact fail-open the reserve decision refused.
|
|
137
|
-
* anthropic's wording was "replaced or mode-gated"; this is the mode gate, so
|
|
138
|
-
* that no existing caller silently changes behaviour.
|
|
192
|
+
* because the asymmetry inverts once the base ref stops being a cross-check and
|
|
193
|
+
* becomes the ceiling itself. Today an unreadable base costs a comparison;
|
|
194
|
+
* there it would grant an unbounded budget on an infrastructure failure.
|
|
139
195
|
*
|
|
140
196
|
* The flag is the caller's rather than derived from `GITHUB_ACTIONS` here: this
|
|
141
197
|
* module cannot tell an enforcing CI run from a diagnostic one on the same
|
|
@@ -154,67 +210,124 @@ export function assertBoundsDidNotRise(opts: {
|
|
|
154
210
|
*/
|
|
155
211
|
requireBase?: boolean;
|
|
156
212
|
/**
|
|
157
|
-
* The
|
|
213
|
+
* The shrink-only numbers as they stand in THIS tree.
|
|
158
214
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
215
|
+
* Passed in rather than re-read here so head and base go through
|
|
216
|
+
* `boundsFrom` from the caller's own configuration objects — including an
|
|
217
|
+
* effective value the caller may have taken from a flag rather than the
|
|
218
|
+
* file, which is the second path a bound could otherwise be widened by.
|
|
163
219
|
*/
|
|
164
|
-
|
|
220
|
+
headBounds: BoundSet;
|
|
165
221
|
}): BoundsRatchetVerdict {
|
|
166
222
|
const git = opts.git ?? realGit;
|
|
167
223
|
const requireBase = opts.requireBase === true;
|
|
168
224
|
if (opts.baseRef === null || opts.baseRef.trim() === '') {
|
|
169
|
-
return unverified('no base ref resolved', requireBase, null
|
|
225
|
+
return unverified('no base ref resolved', requireBase, null);
|
|
170
226
|
}
|
|
171
227
|
const cfg = showAtRef(git, opts.repoRoot, opts.baseRef, BUDGET_CONFIG_PATH);
|
|
172
228
|
if (cfg === null) {
|
|
173
|
-
return unverified(
|
|
174
|
-
`${BUDGET_CONFIG_PATH} could not be read at ${opts.baseRef}`,
|
|
175
|
-
requireBase,
|
|
176
|
-
null,
|
|
177
|
-
opts.baseRef,
|
|
178
|
-
);
|
|
229
|
+
return unverified(`${BUDGET_CONFIG_PATH} could not be read at ${opts.baseRef}`, requireBase, opts.baseRef);
|
|
179
230
|
}
|
|
180
|
-
let
|
|
231
|
+
let baseBudget: unknown;
|
|
181
232
|
try {
|
|
182
|
-
|
|
183
|
-
const delivery = (parsed['ci_delivery'] ?? {}) as Record<string, unknown>;
|
|
184
|
-
const n = Number(delivery['grace_ceiling']);
|
|
185
|
-
baseGrace = Number.isFinite(n) ? n : null;
|
|
233
|
+
baseBudget = JSON.parse(cfg);
|
|
186
234
|
} catch {
|
|
235
|
+
return unverified(`${BUDGET_CONFIG_PATH} at ${opts.baseRef} is not parseable`, requireBase, opts.baseRef);
|
|
236
|
+
}
|
|
237
|
+
// An absent exceptions ledger at the base ref is normal — it did not exist
|
|
238
|
+
// before this mechanism landed, and a ref predating it has no grants to
|
|
239
|
+
// protect. An unparseable one is not, and refuses like a bad budget.
|
|
240
|
+
const exRaw = showAtRef(git, opts.repoRoot, opts.baseRef, EXCEPTIONS_CONFIG_PATH);
|
|
241
|
+
let baseExceptions: unknown = null;
|
|
242
|
+
if (exRaw !== null) {
|
|
243
|
+
try {
|
|
244
|
+
baseExceptions = JSON.parse(exRaw);
|
|
245
|
+
} catch {
|
|
246
|
+
return unverified(
|
|
247
|
+
`${EXCEPTIONS_CONFIG_PATH} at ${opts.baseRef} is not parseable`,
|
|
248
|
+
requireBase,
|
|
249
|
+
opts.baseRef,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const baseBounds = boundsFrom(baseBudget, baseExceptions);
|
|
255
|
+
if (baseBounds === null) {
|
|
187
256
|
return unverified(
|
|
188
|
-
|
|
257
|
+
`there are no shrink-only bounds at ${opts.baseRef} to ratchet against`,
|
|
189
258
|
requireBase,
|
|
190
|
-
null,
|
|
191
259
|
opts.baseRef,
|
|
192
260
|
);
|
|
193
261
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
262
|
+
|
|
263
|
+
const violations: string[] = [];
|
|
264
|
+
for (const [name, head] of Object.entries(opts.headBounds)) {
|
|
265
|
+
const base = baseBounds[name];
|
|
266
|
+
// A bound absent at the base is a NEW one — a freshly recorded grant,
|
|
267
|
+
// which is the break-glass path working rather than a rise.
|
|
268
|
+
if (base === undefined) continue;
|
|
269
|
+
if (head > base) violations.push(explain(name, base, head, opts.baseRef));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// DELETION CANNOT DISCHARGE DEBT. A grant that existed at the base and is
|
|
273
|
+
// gone at head was not repaid, it was erased — the 2026-09-11 council's
|
|
274
|
+
// blocking finding #3, and the one failure a shrink-only comparison misses
|
|
275
|
+
// by construction, because a removed number is not a smaller number.
|
|
276
|
+
// Only `exception:` bounds are checked for deletion: `design_ceiling` is
|
|
277
|
+
// always present, and the stored allowance that used to need an exemption
|
|
278
|
+
// here no longer exists.
|
|
279
|
+
const reportedDeletions = new Set<string>();
|
|
280
|
+
for (const name of Object.keys(baseBounds)) {
|
|
281
|
+
if (!name.startsWith('exception:')) continue;
|
|
282
|
+
if (opts.headBounds[name] !== undefined) continue;
|
|
283
|
+
const m = /^exception:(.+):(?:granted_tokens|watermark)$/.exec(name);
|
|
284
|
+
const id = m === null ? name : String(m[1]);
|
|
285
|
+
// One record carries two bounds; report the record once.
|
|
286
|
+
if (reportedDeletions.has(id)) continue;
|
|
287
|
+
reportedDeletions.add(id);
|
|
288
|
+
violations.push(
|
|
289
|
+
`standing-payload exception '${id}' existed at ${opts.baseRef} and is gone at head. A deleted ` +
|
|
290
|
+
'record is not a repaid one: repayment is established by the payload returning to the ' +
|
|
291
|
+
'watermark, and erasing the entry erases the debt along with the evidence of it. Mark it ' +
|
|
292
|
+
'repaid once the measurement supports that, or leave it in place.',
|
|
200
293
|
);
|
|
201
294
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
295
|
+
|
|
296
|
+
if (violations.length > 0) {
|
|
297
|
+
return { ok: false, verified: true, baseBounds, baseRef: opts.baseRef, note: null, violations };
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, verified: true, violations: [], baseBounds, baseRef: opts.baseRef, note: null };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** One refusal message per bound kind — a generic "a bound rose" sends the
|
|
303
|
+
* reader nowhere, and the three kinds have three different fixes. */
|
|
304
|
+
function explain(name: string, base: number, head: number, baseRef: string): string {
|
|
305
|
+
const rose = `rose from ${String(base)} to ${String(head)} against ${baseRef}`;
|
|
306
|
+
if (name === 'design_ceiling') {
|
|
307
|
+
return (
|
|
308
|
+
`the standing-payload design ceiling ${rose}. It derives from baseline_tokens × ` +
|
|
309
|
+
`headroom_pct in ${BUDGET_CONFIG_PATH}, and it is the FLOOR of the measured ceiling, so ` +
|
|
310
|
+
'raising it widens every future budget without adding a token of payload. ADR-264 decided ' +
|
|
311
|
+
'that standing-rule growth needs a compensating reduction elsewhere, not a bigger ceiling. ' +
|
|
312
|
+
'Lower the addition, migrate the prose out of the standing rule, or reduce another standing ' +
|
|
313
|
+
'asset by the same amount.'
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const m = /^exception:(.+):(granted_tokens|watermark)$/.exec(name);
|
|
317
|
+
if (m !== null && m[2] === 'granted_tokens') {
|
|
318
|
+
return (
|
|
319
|
+
`standing-payload exception '${String(m[1])}' had its grant ${rose}. Enlarging a grant that ` +
|
|
320
|
+
'already exists is a ceiling raise wearing a break-glass label. Record a NEW approved, dated ' +
|
|
321
|
+
'exception instead, so the enlargement carries its own approver, reason and expiry.'
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (m !== null) {
|
|
325
|
+
return (
|
|
326
|
+
`standing-payload exception '${String(m[1])}' had its watermark ${rose}. The watermark pins the ` +
|
|
327
|
+
'base while the grant is live, and raising it launders the granted tokens into the next base — ' +
|
|
328
|
+
'which is exactly the failure the watermark exists to prevent. It is the payload measured ' +
|
|
329
|
+
'BEFORE the grant and does not change afterwards.'
|
|
330
|
+
);
|
|
218
331
|
}
|
|
219
|
-
return
|
|
332
|
+
return `the shrink-only bound '${name}' ${rose}, and ${BUDGET_CONFIG_PATH} says it may not move upward.`;
|
|
220
333
|
}
|
|
@@ -41,15 +41,21 @@ import * as path from 'node:path';
|
|
|
41
41
|
import { read_lockfile } from './installed_lock.js';
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* The
|
|
44
|
+
* The five detectors, in the order the gate runs them.
|
|
45
45
|
*
|
|
46
46
|
* The roadmap's § 0 names three (A promissory, B language, C verification).
|
|
47
47
|
* That was true of the draft and is not true of the tree: detector D
|
|
48
|
-
* (`completion`) landed under round 7 § Phase 1
|
|
49
|
-
*
|
|
48
|
+
* (`completion`) landed under round 7 § Phase 1, detector E
|
|
49
|
+
* (`pending-decision`) under road-to-a-question-that-survives-the-turn, and
|
|
50
|
+
* detector F (`untested`) on 2026-09-11 — all of which run in the same detector
|
|
51
|
+
* list as the first three. Counting three would silently drop a detector's
|
|
50
52
|
* refusals, so the set is read off `DetectorId` in the gate rather than off the
|
|
51
53
|
* prose.
|
|
52
54
|
*
|
|
55
|
+
* F is the one that asks whether a change is TESTED rather than whether
|
|
56
|
+
* something ran: C is satisfied by `eslint`, which is how a feature with zero
|
|
57
|
+
* test lines passed every guard in that file.
|
|
58
|
+
*
|
|
53
59
|
* This said "the same UNCONDITIONAL list" until 2026-08-18, which was wrong and
|
|
54
60
|
* is worth naming rather than quietly rewording: `main()` runs A and D only when
|
|
55
61
|
* no subagent dispatch is open. It does not change this set — every detector
|
|
@@ -63,6 +69,8 @@ export const DETECTOR_IDS = [
|
|
|
63
69
|
'language',
|
|
64
70
|
'verification',
|
|
65
71
|
'completion',
|
|
72
|
+
'pending-decision',
|
|
73
|
+
'untested',
|
|
66
74
|
] as const;
|
|
67
75
|
|
|
68
76
|
export type RefusalDetectorId = (typeof DETECTOR_IDS)[number];
|
|
@@ -138,10 +146,39 @@ export interface RefusalRecord {
|
|
|
138
146
|
counts?: Partial<DetectorCounts>;
|
|
139
147
|
/** Package version recorded at the most recent refusal, when readable. */
|
|
140
148
|
agent_config_version?: string;
|
|
149
|
+
/**
|
|
150
|
+
* The host's own id for the prompt being processed at the most recent
|
|
151
|
+
* refusal, when the payload carries one.
|
|
152
|
+
*
|
|
153
|
+
* Recorded BESIDE `refused_turn`, never instead of it. The ordinal stays the
|
|
154
|
+
* re-entrancy key — its drift history is why both guard layers exist, and
|
|
155
|
+
* swapping the key of a wedge-critical guard on an axis nothing measures is
|
|
156
|
+
* a behaviour change dressed as a correction. What the pair buys is the
|
|
157
|
+
* measurement: two refusals carrying the same `refused_prompt_id` and
|
|
158
|
+
* different `refused_turn` are one prompt whose ordinal drifted, which is
|
|
159
|
+
* the failure this file's own header describes and which the ordinal alone
|
|
160
|
+
* cannot distinguish from two genuine turns.
|
|
161
|
+
*/
|
|
162
|
+
refused_prompt_id?: string;
|
|
141
163
|
}
|
|
142
164
|
|
|
165
|
+
/**
|
|
166
|
+
* A zeroed counter per detector, DERIVED from `DETECTOR_IDS` rather than
|
|
167
|
+
* written out.
|
|
168
|
+
*
|
|
169
|
+
* It was an object literal of four keys until detector E landed, and the
|
|
170
|
+
* literal is what made adding a detector a silent arithmetic defect rather than
|
|
171
|
+
* a type error: `DetectorCounts` is `Record<RefusalDetectorId, number>`, the
|
|
172
|
+
* literal satisfied it for the union of the day, and a fifth id then read back
|
|
173
|
+
* as `undefined` — so every `+=` over it produced `NaN` and the whole rollup
|
|
174
|
+
* went quiet. The sibling test asserts that the id list and the gate's union
|
|
175
|
+
* agree; nothing asserted that the COUNTER covered the list, which is the half
|
|
176
|
+
* this construction removes the need to assert.
|
|
177
|
+
*/
|
|
143
178
|
export function emptyCounts(): DetectorCounts {
|
|
144
|
-
|
|
179
|
+
const out = {} as DetectorCounts;
|
|
180
|
+
for (const id of DETECTOR_IDS) out[id] = 0;
|
|
181
|
+
return out;
|
|
145
182
|
}
|
|
146
183
|
|
|
147
184
|
function isDetector(v: unknown): v is RefusalDetectorId {
|
|
@@ -174,6 +211,9 @@ export function parseRecord(raw: string): RefusalRecord | null {
|
|
|
174
211
|
if (typeof o['agent_config_version'] === 'string') {
|
|
175
212
|
rec.agent_config_version = o['agent_config_version'];
|
|
176
213
|
}
|
|
214
|
+
if (typeof o['refused_prompt_id'] === 'string') {
|
|
215
|
+
rec.refused_prompt_id = o['refused_prompt_id'];
|
|
216
|
+
}
|
|
177
217
|
const counts = o['counts'];
|
|
178
218
|
if (typeof counts === 'object' && counts !== null && !Array.isArray(counts)) {
|
|
179
219
|
const parsed: Partial<DetectorCounts> = {};
|
|
@@ -231,6 +271,7 @@ export function foldRefusal(
|
|
|
231
271
|
turnOrdinal: number;
|
|
232
272
|
at: string;
|
|
233
273
|
version?: string | undefined;
|
|
274
|
+
promptId?: string | undefined;
|
|
234
275
|
},
|
|
235
276
|
): RefusalRecord {
|
|
236
277
|
const counts = prev === null ? emptyCounts() : countsOf(prev);
|
|
@@ -247,6 +288,12 @@ export function foldRefusal(
|
|
|
247
288
|
else if (prev?.agent_config_version !== undefined) {
|
|
248
289
|
rec.agent_config_version = prev.agent_config_version;
|
|
249
290
|
}
|
|
291
|
+
// Not inherited from `prev` when this refusal carries none: the field
|
|
292
|
+
// describes THIS refusal's prompt, and carrying a previous one forward would
|
|
293
|
+
// read as an identity the payload never supplied.
|
|
294
|
+
if (input.promptId !== undefined && input.promptId !== '') {
|
|
295
|
+
rec.refused_prompt_id = input.promptId;
|
|
296
|
+
}
|
|
250
297
|
return rec;
|
|
251
298
|
}
|
|
252
299
|
|
|
@@ -42,6 +42,12 @@ const KEEP_RE = /^keep-beta-until:\s*(\d{4}-\d{2}-\d{2})\s*$/m;
|
|
|
42
42
|
* - a lapsed contract **in** the baseline -> warning (inherited debt)
|
|
43
43
|
* - a lapsed contract **not** in it -> ERROR (a fresh lapse, today)
|
|
44
44
|
*
|
|
45
|
+
* SUPERSEDED 2026-09-11 on the second line: a fresh lapse is a warning too, and
|
|
46
|
+
* the reason is at {@link LAPSED_SEVERITY_FRESH}. The council's choice of a
|
|
47
|
+
* ratchet over a flat error stands as the record of what was decided then; what
|
|
48
|
+
* changed is the owner's ruling that no date may red CI by itself. The baseline
|
|
49
|
+
* therefore labels rather than gates.
|
|
50
|
+
*
|
|
45
51
|
* So new work is enforced immediately while the 86-contract cohort does not red
|
|
46
52
|
* an arbitrary future PR whose author caused none of it. The cohort is real:
|
|
47
53
|
* 44 of the 86 lapsed on the same day and 64 within four days, which is one past
|
|
@@ -116,7 +122,31 @@ export function _resetLapsedBaseline(): void {
|
|
|
116
122
|
* whether this becomes an error is that roadmap's step 0.2 and is not taken here.
|
|
117
123
|
*/
|
|
118
124
|
const LAPSED_SEVERITY_IN_BASELINE: 'error' | 'warning' = 'warning';
|
|
119
|
-
|
|
125
|
+
/**
|
|
126
|
+
* A fresh lapse is a WARNING since 2026-09-11, by owner decision.
|
|
127
|
+
*
|
|
128
|
+
* It was an error, and the error said: this contract must still be beta,
|
|
129
|
+
* because a date passed. That is the one thing a date cannot establish.
|
|
130
|
+
* Stability is decided on observable criteria — active enforcement, consumer
|
|
131
|
+
* reliance, no known pending incompatible change — and none of them contains an
|
|
132
|
+
* elapsed-time term. `keep-beta-until` SCHEDULES A REVIEW. It does not prohibit
|
|
133
|
+
* promotion before that date, does not require continued beta after it, and
|
|
134
|
+
* must not turn CI red on its own.
|
|
135
|
+
*
|
|
136
|
+
* What this gate still errors on is unchanged and is the part that was never
|
|
137
|
+
* about time: a beta contract declaring NO disposition at all, one declaring
|
|
138
|
+
* two, and a window set further out than the maximum — a contract that says
|
|
139
|
+
* nothing about its own maturity, or parks the question indefinitely. Those are
|
|
140
|
+
* statements about missing or contradictory evidence, which CI may absolutely
|
|
141
|
+
* refuse. "The window expired" is not one of them.
|
|
142
|
+
*
|
|
143
|
+
* The inherited/fresh distinction stays in the REPORT because it still tells a
|
|
144
|
+
* reader which lapses are cohort debt and which arrived since. It no longer
|
|
145
|
+
* gates anything, so the frozen baseline is now a label rather than a ratchet;
|
|
146
|
+
* retiring it is the remaining step, recorded in
|
|
147
|
+
* `stubs/road-to-evidence-driven-stability.md`.
|
|
148
|
+
*/
|
|
149
|
+
const LAPSED_SEVERITY_FRESH: 'error' | 'warning' = 'warning';
|
|
120
150
|
const SUPERSEDED_RE = /^superseded-by:\s*\S+\s*$/m;
|
|
121
151
|
|
|
122
152
|
const MAX_REVIEW_WINDOW_DAYS = 90;
|
|
@@ -569,9 +599,10 @@ function main(): number {
|
|
|
569
599
|
}
|
|
570
600
|
if (upcoming.length > 0) {
|
|
571
601
|
process.stdout.write(
|
|
572
|
-
`\
|
|
573
|
-
'exit code unchanged
|
|
574
|
-
'
|
|
602
|
+
`\nReviews falling due within ${String(args.horizon)} day(s) — advisory, ` +
|
|
603
|
+
'exit code unchanged, and it stays unchanged when the date passes:\n' +
|
|
604
|
+
'the window schedules a maturity DECISION, it does not require continued\n' +
|
|
605
|
+
'beta and it never reds CI on its own. Evidence decides; the date only asks:\n',
|
|
575
606
|
);
|
|
576
607
|
for (const u of upcoming) {
|
|
577
608
|
process.stdout.write(
|