@event4u/agent-config 14.14.0 → 14.15.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.
Files changed (72) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +63 -0
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/create.md +16 -1
  7. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  8. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  9. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  13. package/dist/cli-delegate/cmd_conformance.js +513 -17
  14. package/dist/cli-delegate/cmd_doctor.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +5 -5
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +1043 -390
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/mcp/server.mjs +703 -115
  28. package/package.json +1 -1
  29. package/src/config/assurance-capability-registry.json +1 -1
  30. package/src/config/evaluator-budgets.json +4 -4
  31. package/src/config/gate-coverage.yml +62 -2
  32. package/src/config/gate-reachability-exemptions.json +1 -0
  33. package/src/config/gate-violation-baselines.json +4 -3
  34. package/src/config/rule-enforcement-baseline.json +0 -1
  35. package/src/config/web-launch-readiness.json +4 -2
  36. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  37. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  38. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  39. package/src/scripts/_dispatch.bash +11 -0
  40. package/src/scripts/_lib/branch_convergence.ts +186 -0
  41. package/src/scripts/_lib/context_observation.ts +132 -0
  42. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  43. package/src/scripts/_lib/experiment_binding.ts +95 -0
  44. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  45. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  46. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  47. package/src/scripts/_lib/release_highlights.ts +75 -10
  48. package/src/scripts/_lib/repeated_failure.ts +19 -2
  49. package/src/scripts/_lib/runtime_journal.ts +11 -4
  50. package/src/scripts/check_agent_artifact_location.ts +337 -0
  51. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  52. package/src/scripts/check_enforcement_coverage.ts +40 -1
  53. package/src/scripts/check_release_highlights.ts +17 -1
  54. package/src/scripts/check_web_launch_readiness.ts +310 -20
  55. package/src/scripts/evolution_lab.ts +53 -1
  56. package/src/scripts/generate_capability_matrix.ts +16 -9
  57. package/src/scripts/git_authorization_hook.ts +476 -6
  58. package/src/scripts/grade_target_readiness.ts +221 -2
  59. package/src/scripts/hook_effect_doctor.ts +47 -4
  60. package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
  61. package/src/scripts/hooks/merge_impact.ts +207 -0
  62. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  63. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  64. package/src/scripts/install-hooks.sh +18 -0
  65. package/src/scripts/lint_code_comments.ts +106 -1
  66. package/src/scripts/release.ts +5 -17
  67. package/src/scripts/release_drill.ts +76 -1
  68. package/src/scripts/release_env.ts +31 -0
  69. package/src/scripts/release_publication.ts +125 -1
  70. package/src/scripts/roadmap_context.ts +15 -0
  71. package/src/scripts/skill_scout.ts +633 -0
  72. package/src/scripts/sync_pr_branch.ts +521 -42
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Stage-2 impact analysis for a merge the guard is about to refuse.
3
+ *
4
+ * STAGE 1 asks "is this operation in the blocked set?" — a question about the
5
+ * command's shape, answered by `commandOp`. STAGE 2 asks "what would it
6
+ * actually do?" — a question about the diff, answered here.
7
+ *
8
+ * WHAT THIS DOES NOT DO, stated first because the opposite is the tempting
9
+ * reading: it never downgrades a refusal into an allow. Both council seats
10
+ * (2026-09-03, anthropic + codex-default, deep) rejected exactly that, and the
11
+ * argument is not about diff parsing being imperfect — it is that a purely
12
+ * additive diff can still fire a production deploy, close issues, hit outbound
13
+ * webhooks, and land against a base that moved since the review. Diff content
14
+ * is one axis; the destination is the other, and nothing readable from a patch
15
+ * tells you what merging into that branch triggers.
16
+ *
17
+ * So the verdict enriches the REFUSAL. A block that says "this PR adds two
18
+ * files and touches no migration, no exported symbol and no version — say
19
+ * `merge #1499` to authorize" is a question the user can answer in four words,
20
+ * including out loud. A block that says "refused" makes them go read the diff
21
+ * themselves. Same gate, and the difference is entirely in whether the human
22
+ * can act on it.
23
+ *
24
+ * THREE-VALUED ON PURPOSE. `undecidable` is a real answer and the most common
25
+ * one offline: no ref, no network, an unparsable patch. Collapsing it into
26
+ * `additive` would be the failure this module exists to avoid, so every unknown
27
+ * resolves toward "ask".
28
+ */
29
+ import { execFileSync } from "node:child_process";
30
+
31
+ export type ImpactVerdict = "additive" | "destructive" | "undecidable";
32
+
33
+ export interface MergeImpact {
34
+ verdict: ImpactVerdict;
35
+ /** Human-readable markers behind a `destructive` verdict, in diff order. */
36
+ markers: string[];
37
+ /** Why the analysis could not run, when the verdict is `undecidable`. */
38
+ reason?: string;
39
+ /** Files the diff touches, for the one-line summary in a refusal. */
40
+ filesChanged?: number;
41
+ }
42
+
43
+ /**
44
+ * Destructive markers, each decidable from the patch text alone.
45
+ *
46
+ * DELIBERATELY A PROXY SET, NOT A BREAKING-CHANGE ORACLE. "Does this break a
47
+ * consumer" is undecidable in general; these are the shapes that are cheap to
48
+ * see and expensive to miss. A marker firing means "a human should look", never
49
+ * "this is certainly breaking" — and the absence of every marker means "nothing
50
+ * cheap to see fired", never "this is certainly safe". That asymmetry is why an
51
+ * `additive` verdict still refuses.
52
+ */
53
+ const DESTRUCTIVE_MARKERS: ReadonlyArray<{ label: string; re: RegExp }> = [
54
+ // Schema removal. Matched on ADDED lines only — a migration that *removes* a
55
+ // dropColumn call is the opposite of destructive, and matching the raw patch
56
+ // would score it the same way.
57
+ { label: "migration drops a column or table", re: /^\+.*\b(dropColumn|dropTable|dropIfExists|removeColumn|drop_column|drop_table)\b/im },
58
+ { label: "raw DROP or TRUNCATE in a migration", re: /^\+.*\b(DROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT)|TRUNCATE\s+TABLE)\b/im },
59
+ // The author said so themselves. Conventional Commits marks a breaking change
60
+ // with a `!` before the colon or a body trailer.
61
+ { label: "author marked a BREAKING CHANGE", re: /^\+.*BREAKING[ -]CHANGE/m },
62
+ // A removed export is the shape most likely to break an importer.
63
+ { label: "an exported symbol was removed", re: /^-\s*export\s+(default\s+|const\s+|function\s+|class\s+|interface\s+|type\s+|async\s+)/m },
64
+ { label: "a public PHP or Python symbol was removed", re: /^-\s*(public\s+function|def\s+[a-z_]+\s*\()/m },
65
+ // Route removal breaks callers that never imported anything.
66
+ { label: "a route or endpoint was removed", re: /^-.*\b(Route::(get|post|put|patch|delete)|@(Get|Post|Put|Patch|Delete)Mapping|app\.(get|post|put|patch|delete)\s*\()/m },
67
+ { label: "a whole file was deleted", re: /^deleted file mode /m },
68
+ ];
69
+
70
+ /** A major-version bump in a manifest, which is the author declaring a break. */
71
+ const MAJOR_BUMP =
72
+ /^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
73
+
74
+ /**
75
+ * Classify a unified diff.
76
+ *
77
+ * Pure — the caller supplies the patch, so this is testable without a network,
78
+ * a repository, or a fixture PR.
79
+ */
80
+ export function classifyDiff(patch: string): MergeImpact {
81
+ if (patch.trim() === "") {
82
+ return { verdict: "undecidable", markers: [], reason: "empty diff" };
83
+ }
84
+
85
+ const markers: string[] = [];
86
+ for (const { label, re } of DESTRUCTIVE_MARKERS) {
87
+ if (re.test(patch)) {
88
+ markers.push(label);
89
+ }
90
+ }
91
+
92
+ const bump = MAJOR_BUMP.exec(patch);
93
+ if (bump && bump[1] !== bump[2]) {
94
+ markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
95
+ }
96
+
97
+ const filesChanged = (patch.match(/^diff --git /gm) ?? []).length;
98
+
99
+ if (markers.length > 0) {
100
+ return { verdict: "destructive", markers, filesChanged };
101
+ }
102
+ return { verdict: "additive", markers: [], filesChanged };
103
+ }
104
+
105
+ /** How the patch was obtained, so a refusal can say which source answered. */
106
+ export type PatchSource = "local" | "gh" | "none";
107
+
108
+ export interface PatchResult {
109
+ patch: string;
110
+ source: PatchSource;
111
+ reason?: string;
112
+ }
113
+
114
+ /**
115
+ * Fetch the diff for a pull request, local first.
116
+ *
117
+ * The ordering is not a preference, it is a cost decision. A local
118
+ * `git diff` against the merge base costs milliseconds and works offline; a
119
+ * `gh pr diff` costs a network round trip on a `pre_tool_use` hook, which is in
120
+ * the user's critical path. Local answers whenever the ref happens to be
121
+ * fetched, which in a drain run is almost always, because the run just synced
122
+ * the branch it is about to merge.
123
+ *
124
+ * Every failure returns `none` rather than throwing. The caller turns that into
125
+ * `undecidable`, which asks the user — a hook that crashed would be worse than
126
+ * a hook that admitted it did not know.
127
+ */
128
+ export function fetchPatch(
129
+ pr: number,
130
+ opts: { cwd?: string; allowNetwork?: boolean; exec?: typeof execFileSync } = {},
131
+ ): PatchResult {
132
+ const run = opts.exec ?? execFileSync;
133
+ const cwd = opts.cwd ?? process.cwd();
134
+ const capture = (file: string, args: string[]): string =>
135
+ run(file, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }) as unknown as string;
136
+
137
+ // A fetched PR ref is what a synced drain run leaves behind.
138
+ for (const ref of [`refs/pull/${pr}/head`, `pull/${pr}/head`]) {
139
+ try {
140
+ const base = capture("git", ["merge-base", "HEAD", ref]).trim();
141
+ if (base) {
142
+ const patch = capture("git", ["diff", `${base}...${ref}`]);
143
+ if (patch.trim() !== "") {
144
+ return { patch, source: "local" };
145
+ }
146
+ }
147
+ } catch {
148
+ /* try the next spelling, then the network */
149
+ }
150
+ }
151
+
152
+ if (opts.allowNetwork === false) {
153
+ return { patch: "", source: "none", reason: "no local ref and network reads disabled" };
154
+ }
155
+
156
+ try {
157
+ const patch = capture("gh", ["pr", "diff", String(pr), "--patch"]);
158
+ if (patch.trim() !== "") {
159
+ return { patch, source: "gh" };
160
+ }
161
+ return { patch: "", source: "none", reason: "gh returned an empty diff" };
162
+ } catch {
163
+ return { patch: "", source: "none", reason: "no local ref, and gh pr diff failed" };
164
+ }
165
+ }
166
+
167
+ /** Analyse a pull request end to end. `undecidable` whenever the patch is not readable. */
168
+ export function analyseMergeImpact(
169
+ pr: number,
170
+ opts: Parameters<typeof fetchPatch>[1] = {},
171
+ ): MergeImpact {
172
+ const got = fetchPatch(pr, opts);
173
+ if (got.source === "none") {
174
+ return { verdict: "undecidable", markers: [], ...(got.reason ? { reason: got.reason } : {}) };
175
+ }
176
+ return classifyDiff(got.patch);
177
+ }
178
+
179
+ /**
180
+ * One line for a refusal message.
181
+ *
182
+ * Phrased so the user can answer it out loud, which is the point of the whole
183
+ * stage: the shortest reply that unblocks the operation is quoted back to them.
184
+ */
185
+ export function describeImpact(pr: number, impact: MergeImpact): string {
186
+ const say = `Say "merge #${pr}" to authorize it.`;
187
+ switch (impact.verdict) {
188
+ case "additive":
189
+ return (
190
+ `Impact scan: PR #${pr} touches ${impact.filesChanged ?? 0} file(s) and trips no ` +
191
+ `destructive marker — no schema drop, no removed export or route, no major bump, ` +
192
+ `no BREAKING CHANGE. Additive as far as the diff can show, which is not the same ` +
193
+ `as safe to merge: the base has moved since review and merging may trigger a ` +
194
+ `deploy. ${say}`
195
+ );
196
+ case "destructive":
197
+ return (
198
+ `Impact scan: PR #${pr} is NOT additive — ${impact.markers.join("; ")}. ` +
199
+ `Read the diff before authorizing. ${say}`
200
+ );
201
+ default:
202
+ return (
203
+ `Impact scan: could not read the diff for PR #${pr} (${impact.reason ?? "unknown"}), ` +
204
+ `so nothing is known about what it changes. ${say}`
205
+ );
206
+ }
207
+ }
@@ -64,6 +64,13 @@
64
64
  * inert mechanism that says nothing is
65
65
  * indistinguishable from a healthy idle run,
66
66
  * which is the failure this file exists over)
67
+ * plan premise moved → allow (event: halt-premise-invalidated, ONCE per
68
+ * run. Engaged-under fingerprint and newest
69
+ * observation both known and differing. Before the
70
+ * counter rungs: iterating against a stale plan
71
+ * does not make it current, and capping out would
72
+ * report `exhausted`, a budget word, for staleness.
73
+ * Never fires on traffic the run did not observe)
67
74
  * quality-gate refusal → defer (event: deferred-quality-gate)
68
75
  * duplicate stop fire → repeat the BLOCK, no count (event: none —
69
76
  * an allow here would END the reply the block
@@ -173,8 +180,35 @@ import {
173
180
  readUnavailableDependency,
174
181
  stallSignal,
175
182
  type StallLevel,
176
- type UnavailableDependency,
177
183
  } from '../_lib/loop_guards.js';
184
+ import { premiseMoved, readContextObservation } from '../_lib/context_observation.js';
185
+ import { RUN_TERMINAL_VOCABULARY_VERSION } from '../_lib/outcome_vocabularies.js';
186
+ import {
187
+ HALT_ACTIONS,
188
+ ladder,
189
+ MAX_ITERATIONS,
190
+ parseHaltStamp,
191
+ STALL_WINDOW,
192
+ terminalStateFor,
193
+ WALL_CLOCK_CAP_MS,
194
+ type LadderAction,
195
+ } from '../_lib/continuation_ladder.js';
196
+
197
+ /**
198
+ * DECLARED in `_lib/continuation_ladder.ts`, re-exported here: one declaration,
199
+ * two import paths, no second copy to drift. That module's header says why the
200
+ * move happened (`road-to-wired-instruments` Phase 2).
201
+ */
202
+ export {
203
+ HALT_ACTIONS,
204
+ ladder,
205
+ MAX_ITERATIONS,
206
+ parseHaltStamp,
207
+ STALL_WINDOW,
208
+ terminalStateFor,
209
+ WALL_CLOCK_CAP_MS,
210
+ type LadderAction,
211
+ };
178
212
 
179
213
  /** Re-exported so a test can assert the IDENTITY, not two matching literals. */
180
214
  export { TRANSCRIPT_READ_MAX_BYTES };
@@ -198,11 +232,6 @@ export const EVENTS_RELPATH = path.join(
198
232
  );
199
233
  const STATE_DIR_REL = path.join('agents', 'runtime', 'state');
200
234
 
201
- /** Ladder defaults. Phase 1.1 pre-registers all three for revisit, not tuning. */
202
- export const MAX_ITERATIONS = 25;
203
- export const WALL_CLOCK_CAP_MS = 4 * 60 * 60 * 1000;
204
- export const STALL_WINDOW = 3;
205
-
206
235
  /**
207
236
  * How many consecutive absent-roadmap fires confirm the absence.
208
237
  *
@@ -285,6 +314,14 @@ export interface RunState {
285
314
  * cannot finish") named these rungs as its mitigation.
286
315
  */
287
316
  halted?: LadderAction;
317
+ /**
318
+ * The fingerprint this run ENGAGED under — its plan premise, recorded once on
319
+ * the first engagement from `_lib/context_observation` and then left alone.
320
+ * Refreshing it every fire would make both sides of the premise comparison
321
+ * equal by construction and the rung unreachable. Absent means the run never
322
+ * observed one, which is never a disagreement (`premiseMoved`).
323
+ */
324
+ context_fingerprint?: string;
288
325
  }
289
326
 
290
327
  /**
@@ -347,34 +384,6 @@ export interface ScanResult {
347
384
  next: NextStep | null;
348
385
  }
349
386
 
350
- export type LadderAction =
351
- | 'engage'
352
- | 'complete'
353
- | 'blocked'
354
- | 'halt-max-iterations'
355
- | 'halt-wall-clock'
356
- | 'halt-stall'
357
- | 'halt-dependency-unavailable';
358
-
359
- /** The terminal rungs — the set `RunState.halted` may hold. */
360
- /**
361
- * `halt-roadmap-absent` is deliberately NOT in this union and not in
362
- * `LadderAction`. It is an event name the caller emits directly when the claimed
363
- * roadmap is unreadable from the authoritative tree, and it CLEARS the state
364
- * rather than stamping `halted` — widening the union would let `parseRecord`
365
- * accept it as a `halted` value, which would mean a run whose budget was cleared
366
- * also carried a halt stamp. The two are mutually exclusive by construction.
367
- */
368
- export const HALT_ACTIONS: readonly LadderAction[] = [
369
- 'halt-max-iterations',
370
- 'halt-wall-clock',
371
- 'halt-stall',
372
- // Phase 5.4. Checked BEFORE the counter rungs: a dependency the run cannot
373
- // obtain is not something more iterations close, so consuming the budget
374
- // against it is the waste the rung exists to stop.
375
- 'halt-dependency-unavailable',
376
- ];
377
-
378
387
  /**
379
388
  * Frontmatter `execution.mode`, or null. Line-oriented on purpose: the
380
389
  * frontmatter this repo writes is two levels deep at most, and a YAML parser
@@ -480,50 +489,6 @@ export function extractVerify(stepText: string): string | null {
480
489
  return null;
481
490
  }
482
491
 
483
- /**
484
- * The termination ladder, pure. `state` is the record BEFORE this stop;
485
- * `openCount` is the scan of the claimed roadmap as it stands now.
486
- */
487
- export function ladder(
488
- state: RunState,
489
- openCount: number,
490
- nowMs: number,
491
- // No default: `= 0` would silently restore the complete-instead-of-blocked
492
- // behaviour for any caller that omitted it.
493
- blockedCount: number,
494
- caps: { maxIterations: number; wallClockMs: number; stallWindow: number } = {
495
- maxIterations: MAX_ITERATIONS,
496
- wallClockMs: WALL_CLOCK_CAP_MS,
497
- stallWindow: STALL_WINDOW,
498
- },
499
- // Phase 5.4. `null` when nothing unobtainable was detected. Optional so every
500
- // existing caller and test keeps its meaning unchanged.
501
- unavailable: UnavailableDependency | null = null,
502
- ): LadderAction {
503
- // A halt is terminal for this run id. Checked BEFORE `complete` so a
504
- // halted run whose roadmap later reads zero-open does not report a
505
- // completion it never reached.
506
- if (state.halted) return state.halted;
507
- // `scanOpenSteps` EXCLUDES `blocked-by:` steps from `openCount`, so zero-open
508
- // with blocked steps left is exhaustion of runnable work, not completion —
509
- // ADR-235's own terminal outcome, and never a sixth halt.
510
- if (openCount === 0) return blockedCount > 0 ? 'blocked' : 'complete';
511
- // BEFORE the counter rungs, deliberately: a missing credential, an absent
512
- // binary or an exhausted quota is not closed by iterating, so spending the
513
- // budget on it converts a nameable blocker into an anonymous cap-out.
514
- if (unavailable !== null) return 'halt-dependency-unavailable';
515
- if (state.iterations >= caps.maxIterations) return 'halt-max-iterations';
516
- const started = Date.parse(state.started_at);
517
- if (Number.isFinite(started) && nowMs - started >= caps.wallClockMs) {
518
- return 'halt-wall-clock';
519
- }
520
- const tail = state.history.slice(-caps.stallWindow);
521
- if (tail.length >= caps.stallWindow && tail.every((n) => n === openCount)) {
522
- return 'halt-stall';
523
- }
524
- return 'engage';
525
- }
526
-
527
492
  /**
528
493
  * Where a run's state lives: keyed on the session AND the roadmap.
529
494
  *
@@ -686,16 +651,17 @@ function readState(file: string): RunState | null {
686
651
  if (o['inert_reported'] === true) {
687
652
  rec.inert_reported = true;
688
653
  }
689
- // Validated against the halt set rather than accepted as any string:
690
- // an unrecognised value would be returned verbatim by `ladder` and
691
- // become an action no branch below handles.
692
- const halted = o['halted'];
693
- if (
694
- typeof halted === 'string' &&
695
- (HALT_ACTIONS as readonly string[]).includes(halted)
696
- ) {
697
- rec.halted = halted as LadderAction;
654
+ // Round-tripped for the same reason `history_source` is: dropped, the
655
+ // premise would be re-recorded every fire from the newest observation, so
656
+ // the two sides could never differ and the rung would be dead code.
657
+ if (typeof o['context_fingerprint'] === 'string' && o['context_fingerprint'] !== '') {
658
+ rec.context_fingerprint = o['context_fingerprint'];
698
659
  }
660
+ // Tolerant, not permissive — see `parseHaltStamp` for what it accepts
661
+ // beyond the halt set and why dropping a newer build's stamp was the
662
+ // wrong failure direction for a budget.
663
+ const halted = parseHaltStamp(o['halted']);
664
+ if (halted !== null) rec.halted = halted;
699
665
  return rec;
700
666
  } catch {
701
667
  return null;
@@ -1412,7 +1378,21 @@ export function main(): number {
1412
1378
  // differently. One producer, one definition.
1413
1379
  state.stall = stallSignal(state.history, STALL_WINDOW).level;
1414
1380
 
1415
- const action = ladder(state, scan.open, Date.now(), scan.blocked, undefined, unavailable);
1381
+ // 2.1 — repository drift, from the newest observation `roadmap:context`
1382
+ // recorded. No probe here: the fingerprint costs a `gh` call and this is the
1383
+ // Stop path. An unknown on either side is never a halt.
1384
+ const observed = readContextObservation(workspaceRoot)?.fingerprint ?? null;
1385
+ const premiseInvalidated = premiseMoved(state.context_fingerprint, observed);
1386
+
1387
+ const action = ladder(
1388
+ state,
1389
+ scan.open,
1390
+ Date.now(),
1391
+ scan.blocked,
1392
+ undefined,
1393
+ unavailable,
1394
+ premiseInvalidated,
1395
+ );
1416
1396
 
1417
1397
  if (action !== 'engage') {
1418
1398
  // A run that is ALREADY stamped emits nothing further — round 6 finding 6.
@@ -1436,6 +1416,12 @@ export function main(): number {
1436
1416
  iterations: state.iterations,
1437
1417
  open: scan.open,
1438
1418
  blocked: scan.blocked,
1419
+ // A ledger line is a persisted shape carrying a `RunTerminalState`,
1420
+ // so it names the vocabulary version it was written against: a
1421
+ // reader meeting an unknown value can then tell a newer writer
1422
+ // from a corrupt row.
1423
+ terminal_state: terminalStateFor(action),
1424
+ terminal_vocabulary_version: RUN_TERMINAL_VOCABULARY_VERSION,
1439
1425
  at: new Date().toISOString(),
1440
1426
  ...roots,
1441
1427
  });
@@ -1472,6 +1458,10 @@ export function main(): number {
1472
1458
 
1473
1459
  state.iterations += 1;
1474
1460
  state.last_turn = turnOrdinal;
1461
+ // Recorded ONCE — the premise this run engaged under. See the field's docblock.
1462
+ if (state.context_fingerprint === undefined && observed !== null) {
1463
+ state.context_fingerprint = observed;
1464
+ }
1475
1465
  state.history.push(scan.open);
1476
1466
  state.last_engaged_at = new Date().toISOString();
1477
1467
  if (!writeState(stateFile, state)) {
@@ -54,6 +54,7 @@ import {
54
54
  scanEolSlice,
55
55
  type EolCounters,
56
56
  } from '../_lib/session_eol.js';
57
+ import { readContextObservation } from '../_lib/context_observation.js';
57
58
  import { buildCheckpoint, writeCheckpoint } from '../_lib/run_checkpoint.js';
58
59
  import { read_claimed_slug } from '../session_register_hook.js';
59
60
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
@@ -381,7 +382,17 @@ export function main(): number {
381
382
  try {
382
383
  const slug = read_claimed_slug(workspaceRoot, checkpointRunId);
383
384
  if (slug !== null) {
384
- const cp = buildCheckpoint(workspaceRoot, eolSessionKey(checkpointRunId), slug);
385
+ // `road-to-wired-instruments` 2.1. The fingerprint was never
386
+ // passed here, so `context_fingerprint` was null in every
387
+ // checkpoint this package has ever written and the resume-time
388
+ // drift check it exists for could not fire. The value comes from
389
+ // the newest recorded probe rather than from a fresh one: this is
390
+ // the Stop path, and the probe costs a `gh` call. Absent stays
391
+ // absent, which reads as "not known" and never as "unchanged".
392
+ const observed = readContextObservation(workspaceRoot)?.fingerprint ?? null;
393
+ const cp = buildCheckpoint(workspaceRoot, eolSessionKey(checkpointRunId), slug, {
394
+ ...(observed !== null ? { contextFingerprint: observed } : {}),
395
+ });
385
396
  if (cp !== null) writeCheckpoint(workspaceRoot, cp);
386
397
  }
387
398
  } catch {
@@ -26,6 +26,24 @@ cat > "$HOOKS_DIR/pre-push" << 'EOF'
26
26
  # Pre-push hook: mirror the CI "Sync + Generate Tools Consistency" gate LOCALLY
27
27
  # so derived-output drift is caught BEFORE push, not in remote CI.
28
28
  #
29
+ # THIS HOOK REFUSES. IT NEVER MERGES, AND NO ONE MAY "IMPROVE" IT INTO ONE.
30
+ # A merge inside pre-push rewrites the working tree at the exact moment the
31
+ # contributor believes their work is finished: one rejected push silently
32
+ # becomes an unreviewed commit, and a conflict lands them mid-merge with no
33
+ # warning and no way back to the state they typed `git push` from. Detection
34
+ # belongs here; resolution belongs to a step run with the result in front of
35
+ # you. When this hook refuses for staleness, the thing that fixes it is
36
+ #
37
+ # task push-ready # fetch -> integrate the base SET -> regenerate
38
+ # # -> verify -> re-check freshness -> push
39
+ # task push-ready DRY=1 # the same six steps, read-only
40
+ #
41
+ # The base is a SET: a branch targeting a release line or a stacked parent may
42
+ # also have to integrate the default branch, per the branch-convergence policy
43
+ # read at the TARGET commit (src/scripts/_lib/branch_convergence.ts). The
44
+ # default is merged FIRST, so a conflict surfaces against the broader base
45
+ # where it is cheapest to abandon.
46
+ #
29
47
  # History: the remote "Consistency" check failed in a large share of PRs because
30
48
  # the old hook only verified dist sync + the COMMAND count — it missed
31
49
  # guideline/skill/rule count drift and the generate-tools / router / corpus
@@ -353,6 +353,107 @@ function changedFiles(root: string, base: string): string[] {
353
353
  return [...new Set([...tracked, ...untracked, ...dirty])].filter((f) => f.trim().length > 0);
354
354
  }
355
355
 
356
+ /**
357
+ * Line numbers this diff ADDED, per file. `'all'` for an untracked file, whose
358
+ * every line is new.
359
+ *
360
+ * The gate is described in `.github/workflows/rule-backstops.yml` as
361
+ * *"diff-scoped, so a pre-existing tree does not turn red"*, and selecting the
362
+ * FILE set from the diff while scanning each file WHOLE only delivers that for
363
+ * files nobody touches. Measured 2026-09-02 on PR #1813, which added no
364
+ * flagged comment at all and was refused on eleven legacy lines — four
365
+ * provenance paths and seven box rules — in four files it edited for unrelated
366
+ * reasons. A gate that reds on debt in any file you open makes every edit to an
367
+ * old file carry an unbounded comment sweep, and the escape it offers per line
368
+ * turns into exactly the allowlist growth `autonomous-execution` forbids.
369
+ *
370
+ * Teeth are unchanged where they belong: a comment the diff writes is scanned
371
+ * on the line it writes. The explicit-path mode (`--files`) stays whole-file,
372
+ * because a caller naming a path is asking for that file, not for a diff.
373
+ *
374
+ * Parsed from `--unified=0` hunk headers rather than from a patch body: the
375
+ * header carries the post-image start and count directly, so no line of added
376
+ * content has to be counted by hand.
377
+ */
378
+ export function addedLineMap(root: string, base: string): Map<string, Set<number> | 'all'> {
379
+ const out = new Map<string, Set<number> | 'all'>();
380
+ const u = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
381
+ cwd: root, encoding: 'utf-8',
382
+ });
383
+ if (u.status === 0) {
384
+ for (const f of u.stdout.split('\n')) {
385
+ if (f.trim()) out.set(f.trim(), 'all');
386
+ }
387
+ }
388
+ for (const argv of [
389
+ ['diff', '--unified=0', '--diff-filter=ACMR', `${base}...HEAD`],
390
+ ['diff', '--unified=0', '--diff-filter=ACMR'],
391
+ ]) {
392
+ const r = spawnSync('git', argv, { cwd: root, encoding: 'utf-8' });
393
+ if (r.status !== 0) continue;
394
+ mergeAddedLines(out, parseAddedLines(r.stdout));
395
+ }
396
+ return out;
397
+ }
398
+
399
+ /**
400
+ * Added line numbers per file, from a `git diff --unified=0` patch.
401
+ *
402
+ * Split out from the git call so the parse is testable without a repository
403
+ * whose diff the test would have to manufacture. A `@@` header with no count
404
+ * means one line — `@@ -4 +4 @@` — and a `+++ /dev/null` header is a deletion,
405
+ * which adds nothing.
406
+ */
407
+ export function parseAddedLines(diffText: string): Map<string, Set<number>> {
408
+ const out = new Map<string, Set<number>>();
409
+ let file: string | null = null;
410
+ for (const line of diffText.split('\n')) {
411
+ if (line.startsWith('+++ ')) {
412
+ const p = line.slice(4).trim();
413
+ file = p === '/dev/null' ? null : p.replace(/^b\//, '');
414
+ continue;
415
+ }
416
+ if (!file || !line.startsWith('@@')) continue;
417
+ const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
418
+ if (!m) continue;
419
+ const start = Number.parseInt(m[1] as string, 10);
420
+ const count = m[2] === undefined ? 1 : Number.parseInt(m[2], 10);
421
+ const set = out.get(file) ?? new Set<number>();
422
+ for (let n = start; n < start + count; n += 1) set.add(n);
423
+ out.set(file, set);
424
+ }
425
+ return out;
426
+ }
427
+
428
+ /** Fold one parsed patch into the accumulator; `'all'` always wins. */
429
+ function mergeAddedLines(
430
+ into: Map<string, Set<number> | 'all'>,
431
+ from: Map<string, Set<number>>,
432
+ ): void {
433
+ for (const [file, lines] of from) {
434
+ const prev = into.get(file);
435
+ if (prev === 'all') continue;
436
+ const set = prev ?? new Set<number>();
437
+ for (const n of lines) set.add(n);
438
+ into.set(file, set);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Findings the diff is answerable for: those sitting on a line it added.
444
+ *
445
+ * `'all'` (an untracked file) keeps everything, `undefined` (a file the diff
446
+ * did not touch) keeps nothing — the two ends of the same rule.
447
+ */
448
+ export function findingsOnAddedLines(
449
+ findings: readonly CommentFinding[],
450
+ lines: Set<number> | 'all' | undefined,
451
+ ): CommentFinding[] {
452
+ if (lines === undefined) return [];
453
+ if (lines === 'all') return [...findings];
454
+ return findings.filter((f) => lines.has(f.line));
455
+ }
456
+
356
457
  /** One fixture: a file body and the classes the gate must report on it. */
357
458
  export interface CommentCase {
358
459
  /** Printed by both consumers, so it reads as the assertion it makes. */
@@ -575,12 +676,16 @@ export function main(argv: string[]): number {
575
676
  if (!base && explicit.length === 0) { process.stderr.write('--base needs a ref\n'); return 1; }
576
677
 
577
678
  const candidates = (explicit.length > 0 ? explicit : changedFiles(root, base)).filter(isScannable);
679
+ // Whole file when a caller named the path; only the lines the diff added
680
+ // when the scope IS the diff — see `addedLineMap` for the measured reason.
681
+ const added = explicit.length > 0 ? null : addedLineMap(root, base);
578
682
  const findings: CommentFinding[] = [];
579
683
  for (const rel of candidates) {
580
684
  const abs = path.isAbsolute(rel) ? rel : path.join(root, rel);
581
685
  let text: string;
582
686
  try { text = fs.readFileSync(abs, 'utf-8'); } catch { continue; }
583
- findings.push(...scanText(rel, text));
687
+ const all = scanText(rel, text);
688
+ findings.push(...(added ? findingsOnAddedLines(all, added.get(rel)) : all));
584
689
  }
585
690
 
586
691
  try {
@@ -160,6 +160,8 @@ import {
160
160
  SystemExitError,
161
161
  set_augment_manifest_version,
162
162
  set_marketplace_version,
163
+ _rstrip,
164
+ _splitlines,
163
165
  } from './release_env.js';
164
166
  import type { RunResult } from './release_publication.js';
165
167
  // Re-export surface for the six names tests import from `release.js` and that
@@ -192,6 +194,7 @@ import {
192
194
  _target_from_branch,
193
195
  create_and_push_annotated_tag,
194
196
  guard_publication,
197
+ guard_release_curation,
195
198
  die,
196
199
  gh,
197
200
  git,
@@ -744,22 +747,6 @@ function prepend_changelog(p: string, entry: string): void {
744
747
  }
745
748
 
746
749
  /** Mirror of Python `str.splitlines()` (no trailing empty for a final newline). */
747
- function _splitlines(text: string): string[] {
748
- if (text === '') {
749
- return [];
750
- }
751
- const parts = text.split(/\r\n|\r|\n/);
752
- if (parts.length > 0 && parts[parts.length - 1] === '') {
753
- parts.pop();
754
- }
755
- return parts;
756
- }
757
-
758
- /** Mirror of Python `str.rstrip()` (trailing whitespace). */
759
- function _rstrip(text: string): string {
760
- return text.replace(/\s+$/u, '');
761
- }
762
-
763
750
  // ─── file mutations ───────────────────────────────────────────────────────────
764
751
 
765
752
  /** Update the top-level `version` field; preserve 4-space indentation. */
@@ -1167,7 +1154,6 @@ function _step(n: number, total: number, msg: string): void {
1167
1154
  process.stdout.write(`[${n}/${total}] ${msg}\n`);
1168
1155
  }
1169
1156
 
1170
-
1171
1157
  function execute(
1172
1158
  plan: Plan,
1173
1159
  opts: { wait_for_checks: boolean; dry_run: boolean; resume?: boolean; ci?: boolean },
@@ -1276,6 +1262,8 @@ function execute(
1276
1262
  run(['task', 'release-prepare']);
1277
1263
  }
1278
1264
 
1265
+ guard_release_curation(plan.target, pr_merged);
1266
+
1279
1267
  // ─── 3. commit ──────────────────────────────────────────────────────────
1280
1268
  if (pr_merged) {
1281
1269
  _step(3, total, 'PR already merged — skip commit');