@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,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 (
|
|
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
|
|
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
|
/**
|