@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,186 @@
1
+ /**
2
+ * Per-target branch-convergence policy — does the DEFAULT branch belong in the
3
+ * integration set when a PR targets something else?
4
+ *
5
+ * Decided by AI council on 2026-09-03 (`anthropic/claude-sonnet-4-5` +
6
+ * `openai/codex-default`) for the `cascade-default-inclusion-policy` blocker of
7
+ * `road-to-cascading-base-integration`. Both seats independently rejected the
8
+ * two offered answers — always-include, and include-past-a-commit-distance —
9
+ * and named the same third one: an explicit per-target policy read from
10
+ * repository intent. A commit-distance threshold was refused by both as an
11
+ * unmeasured constant.
12
+ *
13
+ * The load-bearing property is WHERE the policy is read from, and both seats
14
+ * called it non-negotiable: **the resolved PR target commit SHA, and nothing
15
+ * else.** Reading it from the PR head, the checkout, or an unpinned branch name
16
+ * lets a reviewed change edit its own acceptance criteria — the confused-deputy
17
+ * bypass. One seat: *"I would block any implementation reading policy from PR
18
+ * head, checkout state, or unpinned branch name."* So this module takes a
19
+ * `targetSha` it never resolves itself and a reader that can only answer at a
20
+ * SHA; there is deliberately no filesystem access anywhere in it.
21
+ *
22
+ * ── Where the policy file lives, and why it is NOT `.agent-settings.yml` ──
23
+ *
24
+ * The council's specification named `.agent-settings.yml`, section
25
+ * `branchConvergence`, on the premise that it is a committed file. In this
26
+ * repository, and in every consumer this package installs into, it is not:
27
+ * `.gitignore:317` ignores it here, and `src/config/gitignore-block.txt:56`
28
+ * writes that same ignore into every consumer's managed block. `git show
29
+ * <sha>:.agent-settings.yml` therefore returns nothing at every SHA in every
30
+ * repository this ships to, which would make the policy permanently unreadable
31
+ * and the feature dead on arrival.
32
+ *
33
+ * A SHA-pinned read requires a TRACKED file. The architecture the council
34
+ * specified is kept byte for byte — same section name, same shape, same
35
+ * exclusive read from the target SHA — and only the path moves, to a tracked
36
+ * repo-root file. If `.agent-settings.yml` ever becomes tracked, the
37
+ * `branchConvergence:` section moves across unchanged.
38
+ *
39
+ * Deferred by explicit council instruction and not to be added without
40
+ * reopening that decision: globs, pattern precedence, inheritance, configurable
41
+ * reason strings, and any repository-wide fallback policy.
42
+ */
43
+ import { parse as parseYaml } from 'yaml';
44
+
45
+ /** The tracked, SHA-readable file the policy is read from. */
46
+ export const POLICY_PATH = '.branch-convergence.yml';
47
+
48
+ /** The only two answers a target may give. Closed by council instruction. */
49
+ export type DefaultBranchPolicy = 'include' | 'exclude';
50
+
51
+ export interface BranchConvergenceTarget {
52
+ readonly defaultBranch: DefaultBranchPolicy;
53
+ }
54
+
55
+ export interface BranchConvergencePolicy {
56
+ readonly enabled: boolean;
57
+ /** Keyed by EXACT target branch name (`release/1.x`), never a pattern. */
58
+ readonly targets: Readonly<Record<string, BranchConvergenceTarget>>;
59
+ }
60
+
61
+ /**
62
+ * Read one path at one commit. Returns `null` when the path does not exist at
63
+ * that commit — which is a real answer, not an error.
64
+ *
65
+ * The signature takes the SHA FIRST and offers no way to ask for the working
66
+ * tree. That is the trust boundary expressed in a type: a caller cannot
67
+ * accidentally read the PR head, because there is no argument for it.
68
+ */
69
+ export type ShaFileReader = (sha: string, repoPath: string) => string | null;
70
+
71
+ /** The target is not the default branch and no exact entry names it. */
72
+ export class MissingBranchConvergencePolicy extends Error {
73
+ readonly target: string;
74
+ constructor(target: string) {
75
+ super(`MissingBranchConvergencePolicy(target="${target}")`);
76
+ this.name = 'MissingBranchConvergencePolicy';
77
+ this.target = target;
78
+ }
79
+ }
80
+
81
+ /** The policy exists at the target SHA but does not validate. */
82
+ export class InvalidBranchConvergencePolicy extends Error {
83
+ constructor(detail: string) {
84
+ super(`InvalidBranchConvergencePolicy: ${detail}`);
85
+ this.name = 'InvalidBranchConvergencePolicy';
86
+ }
87
+ }
88
+
89
+ /** The PR target names no commit the server will report. Fails closed. */
90
+ export class UnresolvableTargetSha extends Error {
91
+ readonly target: string;
92
+ constructor(target: string) {
93
+ super(`UnresolvableTargetSha(target="${target}")`);
94
+ this.name = 'UnresolvableTargetSha';
95
+ this.target = target;
96
+ }
97
+ }
98
+
99
+ const TARGET_KEYS = new Set(['defaultBranch']);
100
+ const ROOT_KEYS = new Set(['enabled', 'targets']);
101
+
102
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
103
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
104
+ }
105
+
106
+ /**
107
+ * Parse and validate the `branchConvergence` section of a policy document.
108
+ *
109
+ * Exported separately from the SHA read so the VALIDATION is testable without a
110
+ * git object, for the same reason `classifyConflicts` is pure in
111
+ * `sync_pr_branch.ts`: the rejection of a malformed policy is the useful part,
112
+ * and a validator only reachable through a git call is a validator nobody
113
+ * covers.
114
+ *
115
+ * Duplicate keys are rejected by the YAML parser itself (`uniqueKeys` is on by
116
+ * default in `yaml` v2), so a target named twice is a parse failure rather than
117
+ * a silent last-wins.
118
+ */
119
+ export function parseBranchConvergencePolicy(text: string): BranchConvergencePolicy | null {
120
+ let doc: unknown;
121
+ try {
122
+ doc = parseYaml(text);
123
+ } catch (exc) {
124
+ throw new InvalidBranchConvergencePolicy(exc instanceof Error ? exc.message : String(exc));
125
+ }
126
+ if (!isPlainObject(doc)) {
127
+ return null;
128
+ }
129
+ const section = doc['branchConvergence'];
130
+ if (section === undefined) {
131
+ return null;
132
+ }
133
+ if (!isPlainObject(section)) {
134
+ throw new InvalidBranchConvergencePolicy('`branchConvergence` is not a mapping');
135
+ }
136
+ for (const k of Object.keys(section)) {
137
+ if (!ROOT_KEYS.has(k)) {
138
+ throw new InvalidBranchConvergencePolicy(`unknown field \`branchConvergence.${k}\``);
139
+ }
140
+ }
141
+ const enabled = section['enabled'];
142
+ if (typeof enabled !== 'boolean') {
143
+ throw new InvalidBranchConvergencePolicy('`enabled` must be true or false');
144
+ }
145
+ const rawTargets = section['targets'] ?? {};
146
+ if (!isPlainObject(rawTargets)) {
147
+ throw new InvalidBranchConvergencePolicy('`targets` must be a mapping');
148
+ }
149
+ const targets: Record<string, BranchConvergenceTarget> = {};
150
+ for (const [name, entry] of Object.entries(rawTargets)) {
151
+ if (!isPlainObject(entry)) {
152
+ throw new InvalidBranchConvergencePolicy(`\`targets.${name}\` is not a mapping`);
153
+ }
154
+ for (const k of Object.keys(entry)) {
155
+ if (!TARGET_KEYS.has(k)) {
156
+ throw new InvalidBranchConvergencePolicy(`unknown field \`targets.${name}.${k}\``);
157
+ }
158
+ }
159
+ const v = entry['defaultBranch'];
160
+ if (v !== 'include' && v !== 'exclude') {
161
+ throw new InvalidBranchConvergencePolicy(
162
+ `\`targets.${name}.defaultBranch\` must be include or exclude, got ${JSON.stringify(v)}`,
163
+ );
164
+ }
165
+ targets[name] = { defaultBranch: v };
166
+ }
167
+ return { enabled, targets };
168
+ }
169
+
170
+ /**
171
+ * The policy as the PR TARGET carries it, at the target's own commit.
172
+ *
173
+ * `null` means the file does not exist at that commit. That is not an error
174
+ * here — it becomes `MissingBranchConvergencePolicy` one layer up, and only for
175
+ * a target that actually needs an entry.
176
+ */
177
+ export function loadPolicyAtSha(targetSha: string, read: ShaFileReader): BranchConvergencePolicy | null {
178
+ if (!/^[0-9a-f]{7,40}$/.test(targetSha)) {
179
+ throw new InvalidBranchConvergencePolicy(`\`targetSha\` is not a commit id: ${JSON.stringify(targetSha)}`);
180
+ }
181
+ const text = read(targetSha, POLICY_PATH);
182
+ if (text === null) {
183
+ return null;
184
+ }
185
+ return parseBranchConvergencePolicy(text);
186
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The situational-awareness fingerprint a run last OBSERVED, recorded where a
3
+ * stop-path concern can read it without spawning anything.
4
+ *
5
+ * `road-to-wired-instruments` Phase 2.1. The fingerprint itself is built by
6
+ * `roadmap_context.ts` from `origin/main` plus every open PR head — which costs
7
+ * a `gh` call, so it can only be taken by the probe and never by a hook on the
8
+ * Stop path, where a subprocess is a cost paid on every reply.
9
+ *
10
+ * Why a file, and why this comparison and not another:
11
+ * `run_checkpoint.RunCheckpoint.context_fingerprint` already had the right idea
12
+ * and no producer: `session_eol_hook` built its checkpoints without ever passing
13
+ * one, so the field was `null` in every checkpoint this package has ever
14
+ * written. A drift detector whose input is structurally always-null reports no
15
+ * drift, forever — the defect class this roadmap exists over, in the mechanism
16
+ * meant to catch it. This module is the missing producer, and it makes the
17
+ * value reachable from BOTH consumers: the checkpoint and the continuation
18
+ * ladder.
19
+ *
20
+ * The comparison the ladder makes is deliberately narrow: the fingerprint this
21
+ * run ENGAGED under, against the newest one the run itself has OBSERVED. It is
22
+ * not "has `origin/main` moved", which changes constantly and would halt every
23
+ * long run within a day (Risk 1 of the roadmap). Nothing is observed unless the
24
+ * run re-probes, so the rung cannot fire on repository traffic the run never
25
+ * looked at — it fires when the run looked, and what it saw had changed.
26
+ *
27
+ * Best-effort throughout. A failed write or an unreadable file is `null`, which
28
+ * reads as "not known" and never as "unchanged": an absent observation must
29
+ * never manufacture a halt, and must never assert the world stood still either.
30
+ */
31
+
32
+ import * as fs from 'node:fs';
33
+ import * as path from 'node:path';
34
+
35
+ import { atomicWriteJson } from './loop_guards.js';
36
+
37
+ export const CONTEXT_OBSERVATION_REL = path.join(
38
+ 'agents',
39
+ 'runtime',
40
+ 'state',
41
+ 'context-observation.json',
42
+ );
43
+
44
+ /**
45
+ * One probe's reading.
46
+ *
47
+ * NOT keyed on a roadmap, because the fingerprint is not scoped to one:
48
+ * `contextFingerprint(base_sha, open_prs)` takes the base ref and every open PR
49
+ * head and no roadmap at all. A per-roadmap file would key a repository-wide
50
+ * fact on something it does not depend on, and would then miss every observation
51
+ * taken by an unscoped `roadmap:context` run. `roadmap` is recorded as
52
+ * provenance — which invocation took the reading — never as a lookup key.
53
+ */
54
+ export interface ContextObservation {
55
+ readonly schema_version: 1;
56
+ /** The roadmap the probe was scoped to, or `''` when it was unscoped. */
57
+ readonly roadmap: string;
58
+ /** `roadmap_context.contextFingerprint()` — compared, never parsed. */
59
+ readonly fingerprint: string;
60
+ /** ISO-8601 instant of the observation. */
61
+ readonly at: string;
62
+ }
63
+
64
+ /** The single observation file. */
65
+ export function observationFile(repoRoot: string): string {
66
+ return path.join(repoRoot, CONTEXT_OBSERVATION_REL);
67
+ }
68
+
69
+ /**
70
+ * Record what the probe just saw. Returns whether the write landed.
71
+ *
72
+ * Never throws: this is called from a reporting command, and a command that
73
+ * fails because it could not write a diagnostic breadcrumb is worse than one
74
+ * whose breadcrumb is missing.
75
+ */
76
+ export function recordContextObservation(
77
+ repoRoot: string,
78
+ scope: string,
79
+ fingerprint: string,
80
+ now: () => Date = () => new Date(),
81
+ ): boolean {
82
+ if (fingerprint.trim() === '') return false;
83
+ const obs: ContextObservation = {
84
+ schema_version: 1,
85
+ roadmap: scope,
86
+ fingerprint,
87
+ at: now().toISOString(),
88
+ };
89
+ try {
90
+ fs.mkdirSync(path.dirname(observationFile(repoRoot)), { recursive: true });
91
+ } catch {
92
+ return false;
93
+ }
94
+ return atomicWriteJson(observationFile(repoRoot), obs);
95
+ }
96
+
97
+ /** The newest recorded observation, or `null` when none reads. */
98
+ export function readContextObservation(repoRoot: string): ContextObservation | null {
99
+ try {
100
+ const parsed: unknown = JSON.parse(fs.readFileSync(observationFile(repoRoot), 'utf-8'));
101
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
102
+ const o = parsed as Record<string, unknown>;
103
+ if (typeof o['fingerprint'] !== 'string' || o['fingerprint'] === '') return null;
104
+ if (typeof o['at'] !== 'string') return null;
105
+ return {
106
+ schema_version: 1,
107
+ roadmap: typeof o['roadmap'] === 'string' ? o['roadmap'] : '',
108
+ fingerprint: o['fingerprint'],
109
+ at: o['at'],
110
+ };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Has the plan premise moved?
118
+ *
119
+ * TRUE only when both sides are known AND differ. An unknown on either side is
120
+ * "not known", exactly as `run_checkpoint.verifyCheckpoint` treats `head` and
121
+ * `context_fingerprint`: a detector that reads "I could not tell" as "it moved"
122
+ * halts healthy runs, and one false alarm on a rung that ends a run costs more
123
+ * than a missed one, because it trains the operator to switch the rung off.
124
+ */
125
+ export function premiseMoved(
126
+ claimed: string | null | undefined,
127
+ observed: string | null | undefined,
128
+ ): boolean {
129
+ if (claimed === null || claimed === undefined || claimed === '') return false;
130
+ if (observed === null || observed === undefined || observed === '') return false;
131
+ return claimed !== observed;
132
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The run-continuation termination ladder — the pure decision, on its own.
3
+ *
4
+ * Extracted from `hooks/run_continuation_hook.ts` by `road-to-wired-instruments`
5
+ * Phase 2, for two reasons and one constraint. The reasons: the ladder is a pure
6
+ * function over a state record and three numbers, and it is the surface Phase 2
7
+ * changes, so the rung under change gets a home where it can be read without
8
+ * 1,500 lines of I/O around it. The constraint: `check_source_size_budget` is a
9
+ * ratchet and the hook sits PAST the 1,500-line cap, where every added line is
10
+ * an added violation — so the phase pays for its additions by moving the code it
11
+ * is editing to a file under the cap, rather than by raising a baseline.
12
+ *
13
+ * `LadderState` is structural on purpose. The hook's `RunState` carries a dozen
14
+ * fields this decision has no business reading, and taking the whole record here
15
+ * would invert the dependency: a `_lib` module importing from a hook.
16
+ */
17
+
18
+ import type { UnavailableDependency } from './loop_guards.js';
19
+ import type { RunTerminalState } from './outcome_vocabularies.js';
20
+
21
+ /** Iterations one run may spend before the cap ends it. */
22
+ export const MAX_ITERATIONS = 25;
23
+ /** Wall-clock a run may span before the clock ends it. */
24
+ export const WALL_CLOCK_CAP_MS = 4 * 60 * 60 * 1000;
25
+ /** Identical consecutive open-step readings that read as a stall. */
26
+ export const STALL_WINDOW = 3;
27
+
28
+ export type LadderAction =
29
+ | 'engage'
30
+ | 'complete'
31
+ | 'blocked'
32
+ | 'halt-max-iterations'
33
+ | 'halt-wall-clock'
34
+ | 'halt-stall'
35
+ | 'halt-dependency-unavailable'
36
+ | 'halt-premise-invalidated';
37
+
38
+ /** The terminal rungs — the set `RunState.halted` may hold. */
39
+ /**
40
+ * `halt-roadmap-absent` is deliberately NOT in this union and not in
41
+ * `LadderAction`. It is an event name the caller emits directly when the claimed
42
+ * roadmap is unreadable from the authoritative tree, and it CLEARS the state
43
+ * rather than stamping `halted` — widening the union would let `parseRecord`
44
+ * accept it as a `halted` value, which would mean a run whose budget was cleared
45
+ * also carried a halt stamp. The two are mutually exclusive by construction.
46
+ */
47
+ export const HALT_ACTIONS: readonly LadderAction[] = [
48
+ 'halt-max-iterations',
49
+ 'halt-wall-clock',
50
+ 'halt-stall',
51
+ // Phase 5.4. Checked BEFORE the counter rungs: a dependency the run cannot
52
+ // obtain is not something more iterations close, so consuming the budget
53
+ // against it is the waste the rung exists to stop.
54
+ 'halt-dependency-unavailable',
55
+ // `road-to-wired-instruments` 2.2, and BEFORE the counter rungs for the same
56
+ // reason: a run whose plan premise moved does not become correct by running
57
+ // more iterations against the stale plan, and letting it cap out reports
58
+ // `exhausted` — a budget problem, whose remedy is a bigger budget — for what
59
+ // is actually a staleness problem, whose remedy is a re-probe.
60
+ 'halt-premise-invalidated',
61
+ ];
62
+
63
+ /**
64
+ * The subset of a run's state this decision reads. The hook's `RunState`
65
+ * satisfies it structurally; nothing here may widen beyond what the rungs use.
66
+ */
67
+ export interface LadderState {
68
+ readonly halted?: LadderAction;
69
+ readonly iterations: number;
70
+ readonly started_at: string;
71
+ readonly history: readonly number[];
72
+ }
73
+
74
+ /**
75
+ * The run terminal state each ladder action reports, or `null` for the one
76
+ * action that is not terminal.
77
+ *
78
+ * This is a REAL crossing between two vocabularies, and the reason it exists at
79
+ * all is that a terminal state nothing produces is the defect this roadmap is
80
+ * named after. The ladder decides in its own words — the rung a reader of the
81
+ * ledger needs — and the run vocabulary is what a closing report must say
82
+ * (`contexts/execution/terminal-states.md`). Registered as a row in
83
+ * `outcome_vocabularies.CROSS_DOMAIN_MAPPINGS`, which is the one place a reader
84
+ * looks to learn a crossing exists.
85
+ *
86
+ * `halt-wall-clock` and `halt-max-iterations` both report `exhausted`: they are
87
+ * two budgets, and the contract's word covers "a declared budget ran out"
88
+ * without distinguishing which. `halt-dependency-unavailable` reports `blocked`
89
+ * — a thing that is absent, which is that state's own definition.
90
+ */
91
+ export const TERMINAL_STATE_BY_ACTION: Readonly<Record<LadderAction, RunTerminalState | null>> = {
92
+ engage: null,
93
+ complete: 'success',
94
+ blocked: 'blocked',
95
+ 'halt-max-iterations': 'exhausted',
96
+ 'halt-wall-clock': 'exhausted',
97
+ 'halt-stall': 'stagnated',
98
+ 'halt-dependency-unavailable': 'blocked',
99
+ 'halt-premise-invalidated': 'premise-invalidated',
100
+ };
101
+
102
+ /** The run terminal state for a ladder action; `null` while the run continues. */
103
+ export function terminalStateFor(action: LadderAction): RunTerminalState | null {
104
+ return TERMINAL_STATE_BY_ACTION[action] ?? null;
105
+ }
106
+
107
+ /**
108
+ * The termination ladder, pure. `state` is the record BEFORE this stop;
109
+ * `openCount` is the scan of the claimed roadmap as it stands now.
110
+ */
111
+ export function ladder(
112
+ state: LadderState,
113
+ openCount: number,
114
+ nowMs: number,
115
+ // No default: `= 0` would silently restore the complete-instead-of-blocked
116
+ // behaviour for any caller that omitted it.
117
+ blockedCount: number,
118
+ caps: { maxIterations: number; wallClockMs: number; stallWindow: number } = {
119
+ maxIterations: MAX_ITERATIONS,
120
+ wallClockMs: WALL_CLOCK_CAP_MS,
121
+ stallWindow: STALL_WINDOW,
122
+ },
123
+ // Phase 5.4. `null` when nothing unobtainable was detected. Optional so every
124
+ // existing caller and test keeps its meaning unchanged.
125
+ unavailable: UnavailableDependency | null = null,
126
+ // `road-to-wired-instruments` 2.1. TRUE only when the run's engaged-under
127
+ // fingerprint and its newest observation are BOTH known and differ — see
128
+ // `_lib/context_observation.premiseMoved`. Optional and defaulted false, so
129
+ // a caller that cannot observe the world decides exactly as it did before.
130
+ premiseInvalidated: boolean = false,
131
+ ): LadderAction {
132
+ // A halt is terminal for this run id. Checked BEFORE `complete` so a
133
+ // halted run whose roadmap later reads zero-open does not report a
134
+ // completion it never reached.
135
+ if (state.halted) return state.halted;
136
+ // `scanOpenSteps` EXCLUDES `blocked-by:` steps from `openCount`, so zero-open
137
+ // with blocked steps left is exhaustion of runnable work, not completion —
138
+ // ADR-235's own terminal outcome, and never a sixth halt.
139
+ if (openCount === 0) return blockedCount > 0 ? 'blocked' : 'complete';
140
+ // BEFORE the counter rungs, deliberately: a missing credential, an absent
141
+ // binary or an exhausted quota is not closed by iterating, so spending the
142
+ // budget on it converts a nameable blocker into an anonymous cap-out.
143
+ if (unavailable !== null) return 'halt-dependency-unavailable';
144
+ // 2.2 — also before the counter rungs, and AFTER the zero-open rungs: a run
145
+ // that has actually finished its scope reports `complete`, because a stale
146
+ // premise cannot un-finish work already done and verified. Everything below
147
+ // this line is a budget, and a budget is the wrong word for staleness.
148
+ if (premiseInvalidated) return 'halt-premise-invalidated';
149
+ if (state.iterations >= caps.maxIterations) return 'halt-max-iterations';
150
+ const started = Date.parse(state.started_at);
151
+ if (Number.isFinite(started) && nowMs - started >= caps.wallClockMs) {
152
+ return 'halt-wall-clock';
153
+ }
154
+ const tail = state.history.slice(-caps.stallWindow);
155
+ if (tail.length >= caps.stallWindow && tail.every((n) => n === openCount)) {
156
+ return 'halt-stall';
157
+ }
158
+ return 'engage';
159
+ }
160
+
161
+ /**
162
+ * Read a persisted `halted` stamp tolerantly.
163
+ *
164
+ * `road-to-wired-instruments` 2.3. `LadderAction` is the SECOND value domain
165
+ * this phase widened — the run-state file records the rung that ended a run —
166
+ * so it owes the same forward-compatibility answer the run vocabulary got. The
167
+ * previous reader accepted only members of `HALT_ACTIONS` and dropped anything
168
+ * else, which does not crash but downgrades a halt stamped by a newer build to
169
+ * NO HALT: the older binary then re-engages a run that was deliberately ended.
170
+ * Fail-open in the one direction a budget must not fail open.
171
+ *
172
+ * So an unrecognised `halt-`prefixed value is PRESERVED. That is safe by
173
+ * inspection of every consumer: `ladder` returns a stamp verbatim, and the
174
+ * caller's branches are equality tests against known names whose default is
175
+ * "allow this stop and write nothing" — the correct behaviour for a halt whose
176
+ * name this build cannot interpret. The old reader's docblock claimed such a
177
+ * value would "become an action no branch below handles"; that was never true
178
+ * for a halt-prefixed one, and the claim is corrected here rather than carried.
179
+ *
180
+ * Anything not `halt-`prefixed is still dropped: `engage`, `complete` and
181
+ * `blocked` are not stamps, and accepting one here would immortalise a state
182
+ * file that the terminal branches delete.
183
+ */
184
+ export function parseHaltStamp(v: unknown): LadderAction | null {
185
+ if (typeof v !== 'string') return null;
186
+ if ((HALT_ACTIONS as readonly string[]).includes(v)) return v as LadderAction;
187
+ return v.startsWith('halt-') ? (v as LadderAction) : null;
188
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The binding that joins an `evolution_lab run` to a frozen `ExperimentSpec`.
3
+ *
4
+ * `experiment_freeze.ts` sits between two mechanisms that both stop looking
5
+ * before the interesting window opens. `evolution_lab` fails closed on a
6
+ * holdout leak and on a budget breach, and both guards fire BEFORE the run
7
+ * starts. `corpus_manifest` pins the corpus as a capture taken at a moment and
8
+ * compared by a human running a verb. Neither watches the run while it happens,
9
+ * and `verbRun` has a real mid-run window: it walks the record set once to
10
+ * clone, then RE-READS every record from disk to evaluate it. A record
11
+ * rewritten between those two reads yields a run that cloned one thing and
12
+ * scored another, and every individual reading looks fine.
13
+ *
14
+ * This is the join, not a second pinning scheme: the five frozen elements are
15
+ * built from identities the run already carries — the cascade stage order, a
16
+ * digest of the record set, the promotion-evidence field set, the unmutated
17
+ * tree shape every clone is made from, and the protected surfaces each clone
18
+ * inherits. A freeze over invented identities freezes nothing, because nothing
19
+ * else in the process reads them.
20
+ *
21
+ * The digest is deliberately not persisted. A freeze whose scope is one process
22
+ * needs no store, and a stored digest immediately raises the question of who
23
+ * invalidates it.
24
+ */
25
+
26
+ import * as crypto from 'node:crypto';
27
+
28
+ import { WITH_SURFACES, target_shape_hash } from '../bench_ab_clone.js';
29
+ import { CASCADE_STAGES } from './evaluation_cascade.js';
30
+ import { PROMOTION_EVIDENCE_FIELDS } from './promotion_evidence.js';
31
+ import type { ExperimentSpec } from './experiment_freeze.js';
32
+
33
+ /**
34
+ * Identity of the evaluator: which stages, in which order.
35
+ *
36
+ * Order is part of the identity and is NOT sorted away. The cascade is a
37
+ * cheapest-first sequence whose whole economic claim is that an early stage
38
+ * aborts before an expensive one runs; two runs that scored the same candidates
39
+ * in a different stage order did not use the same evaluator.
40
+ */
41
+ export function evaluatorIdentity(): string {
42
+ return `evaluation-cascade:${CASCADE_STAGES.join('>')}`;
43
+ }
44
+
45
+ /**
46
+ * Identity of the task the arms are scored against.
47
+ *
48
+ * Sorted, unlike the evaluator: the evidence fields are a SET a candidate must
49
+ * satisfy, and the order they happen to be declared in is not a property of the
50
+ * task.
51
+ */
52
+ export function taskIdentity(): string {
53
+ return `promotion-evidence:${[...PROMOTION_EVIDENCE_FIELDS].sort().join('+')}`;
54
+ }
55
+
56
+ /**
57
+ * A digest over the record set actually being run.
58
+ *
59
+ * Takes `(path, bytes)` pairs rather than reading the files itself, so the
60
+ * caller decides what "the record set" is and this function stays pure. Sorted
61
+ * by path, because the order a directory happened to enumerate in is not a
62
+ * property of the corpus — the same argument `experiment_freeze` makes for
63
+ * fixtures.
64
+ */
65
+ export function recordSetDigest(entries: ReadonlyArray<readonly [path: string, bytes: string]>): string {
66
+ const h = crypto.createHash('sha256');
67
+ for (const [p, bytes] of [...entries].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
68
+ h.update(`${p}\n`, 'utf-8');
69
+ h.update(crypto.createHash('sha256').update(bytes, 'utf-8').digest('hex'), 'utf-8');
70
+ h.update('\n', 'utf-8');
71
+ }
72
+ return `records:${h.digest('hex').slice(0, 32)}`;
73
+ }
74
+
75
+ /**
76
+ * Build the spec for one `evolution_lab run`.
77
+ *
78
+ * `readFile` is injected so the same construction is exercised in a test
79
+ * without a repository tree, and `baseline` is injected for the same reason —
80
+ * `target_shape_hash()` walks a fixture directory, which a unit test has no
81
+ * business depending on.
82
+ */
83
+ export function buildRunSpec(
84
+ recordFiles: readonly string[],
85
+ readFile: (p: string) => string,
86
+ baseline: string = target_shape_hash(),
87
+ ): ExperimentSpec {
88
+ return {
89
+ evaluator: evaluatorIdentity(),
90
+ corpus: recordSetDigest(recordFiles.map((f) => [f, readFile(f)] as const)),
91
+ task: taskIdentity(),
92
+ baseline,
93
+ fixtures: [...WITH_SURFACES],
94
+ };
95
+ }
@@ -105,11 +105,18 @@ export interface JournalConsumptionRow {
105
105
  * cannot drift: if this string and {@link rowIsIgnoredBlocker} ever disagree,
106
106
  * the disagreement is visible in one file. It is a documented query shape, not
107
107
  * a live query — nothing in this module opens a database.
108
+ *
109
+ * The `IN` list is DERIVED from {@link NON_SUCCESS_STATES}, not written out.
110
+ * It used to be four hand-typed literals, and "kept beside the predicate so the
111
+ * two cannot drift" was true of the FILE and false of the LIST: when the run
112
+ * vocabulary gained a seventh state (`road-to-wired-instruments` 2.3) the
113
+ * predicate picked it up from the set and this string did not, so the same
114
+ * module answered its own question two ways. Co-location is not a binding.
108
115
  */
109
116
  export const IGNORED_BLOCKER_QUERY = `
110
117
  SELECT episode_id, state, suggestion
111
118
  FROM journal_events
112
- WHERE state IN ('blocked', 'approval-required', 'exhausted', 'stagnated')
119
+ WHERE state IN (${[...NON_SUCCESS_STATES].map((s) => `'${s}'`).join(', ')})
113
120
  AND consumption IS NULL
114
121
  ORDER BY episode_id
115
122
  `.trim();
@@ -23,7 +23,7 @@
23
23
  import type { RunTerminalState } from './outcome_vocabularies.js';
24
24
 
25
25
  /**
26
- * The six terminal states. Contract: `contexts/execution/terminal-states.md`.
26
+ * The run terminal states. Contract: `contexts/execution/terminal-states.md`.
27
27
  *
28
28
  * Alias of the registry's `RunTerminalState`. This module stays the public
29
29
  * surface — `runtime_journal.test.ts:212` pins the import path and `:215`
@@ -39,6 +39,11 @@ export const NON_SUCCESS_STATES: ReadonlySet<TerminalState> = new Set([
39
39
  'approval-required',
40
40
  'exhausted',
41
41
  'stagnated',
42
+ // A run that stopped because its plan premise moved owes the reader the
43
+ // same thing every other non-success state owes: what to do next. Here that
44
+ // is always "re-probe and re-plan", and leaving it out of this set would let
45
+ // an envelope report a halted run with no next action at all.
46
+ 'premise-invalidated',
42
47
  ]);
43
48
 
44
49
  /**