@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +63 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +513 -17
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- 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 +1043 -390
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +703 -115
- package/package.json +1 -1
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +62 -2
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +4 -3
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/release_highlights.ts +75 -10
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_release_highlights.ts +17 -1
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/evolution_lab.ts +53 -1
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/git_authorization_hook.ts +476 -6
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
- package/src/scripts/hooks/merge_impact.ts +207 -0
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/lint_code_comments.ts +106 -1
- package/src/scripts/release.ts +5 -17
- package/src/scripts/release_drill.ts +76 -1
- package/src/scripts/release_env.ts +31 -0
- package/src/scripts/release_publication.ts +125 -1
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- 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
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
687
|
+
const all = scanText(rel, text);
|
|
688
|
+
findings.push(...(added ? findingsOnAddedLines(all, added.get(rel)) : all));
|
|
584
689
|
}
|
|
585
690
|
|
|
586
691
|
try {
|
package/src/scripts/release.ts
CHANGED
|
@@ -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');
|