@event4u/agent-config 14.22.0 → 14.23.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 +159 -380
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/merge.md +47 -38
- package/dist/agent-src/commands/roadmap/next.md +8 -0
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
- package/dist/agent-src/skills/code-intelligence/SKILL.md +57 -1
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/archive_completed_roadmaps.js +52 -4
- package/dist/cli-delegate/{chunk-TGIPZBCW.js → chunk-2KKXJW25.js} +22 -8
- package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
- package/dist/cli-delegate/{chunk-BIXEDQPZ.js → chunk-KCNTUSPI.js} +1 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +20 -12
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +3 -13
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- 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 +880 -289
- package/dist/install/install.mjs +15 -3
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +5458 -2453
- package/dist/scripts/_lib/lean_projection_mode.js +108 -0
- package/dist/scripts/_lib/router_match.js +243 -0
- package/dist/scripts/_lib/rule_injection.js +216 -0
- package/dist/scripts/condense.js +20 -20
- package/dist/scripts/project_thin_rules.js +139 -19
- package/dist/server/schemas/settings.js +16 -3
- package/docs/MIGRATION.md +56 -0
- package/docs/contracts/settings-classes.md +39 -7
- package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
- package/src/config/agent-settings.template.yml +47 -1
- package/src/config/continuity-surface.json +7 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/hook-token-budget.json +7 -6
- package/src/config/packed-binary-manifest.json +1 -1
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
- package/src/scripts/_cli/handoff_sessions.ts +14 -44
- package/src/scripts/_lib/continuity_slot.ts +295 -0
- package/src/scripts/_lib/continuity_writer.ts +253 -0
- package/src/scripts/_lib/hook_settings.ts +85 -0
- package/src/scripts/_lib/lean_projection_mode.ts +127 -0
- package/src/scripts/_lib/packed_binary_predicate.ts +37 -0
- package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
- package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
- package/src/scripts/_lib/session_eol.ts +44 -0
- package/src/scripts/_lib/session_index_trust.ts +346 -0
- package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
- package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
- package/src/scripts/audit_mcp_tools.ts +69 -25
- package/src/scripts/build_mcp_catalog.ts +24 -1
- package/src/scripts/check_host_tree_parity.ts +291 -0
- package/src/scripts/check_preamble_payload_budget.ts +136 -7
- package/src/scripts/check_rule_projection_integrity.ts +102 -2
- package/src/scripts/ci_settle.ts +205 -5
- package/src/scripts/code_graph/build.ts +79 -0
- package/src/scripts/code_graph/cli.ts +258 -7
- package/src/scripts/code_graph/detect.ts +38 -0
- package/src/scripts/code_graph/query.ts +25 -2
- package/src/scripts/code_graph/sqlite_store.ts +16 -1
- package/src/scripts/code_graph/types.ts +52 -3
- package/src/scripts/code_graph/validate.ts +3 -1
- package/src/scripts/code_graph/verbs.ts +590 -0
- package/src/scripts/condense.ts +20 -20
- package/src/scripts/handoff_context_hook.ts +39 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +24 -1
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
- package/src/scripts/hooks/code_graph_context_hook.ts +7 -18
- package/src/scripts/hooks/push_settle_hook.ts +3 -2
- package/src/scripts/hooks/rule_inject_hook.ts +107 -20
- package/src/scripts/hooks/session_eol_hook.ts +120 -2
- package/src/scripts/hot_context_hook.ts +62 -7
- package/src/scripts/lint_roadmap_blockers.ts +164 -1
- package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
- package/src/scripts/mcp_server/graph_tools.ts +294 -0
- package/src/scripts/mcp_server/path_util.ts +31 -0
- package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
- package/src/scripts/mcp_server/tools.ts +3 -13
- package/src/scripts/model_rule_injection.ts +61 -4
- package/src/scripts/project_thin_rules.ts +149 -16
- package/src/scripts/report_standing_payload_by_host.ts +650 -0
- package/src/scripts/schemas/agent-settings.schema.json +7 -1
- package/src/scripts/session_memory_index.ts +81 -8
- package/src/server/schemas/settings.ts +23 -4
- package/src/vendor/grammars/README.md +27 -2
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trust contract for the session-start memory index.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, AND WHOSE RULING IT IMPLEMENTS.
|
|
5
|
+
*
|
|
6
|
+
* The `memory.session_index` block injects curated memory at `session_start`.
|
|
7
|
+
* Restored memory is UNTRUSTED CONTEXT arriving on an injection surface, and an
|
|
8
|
+
* AI council (2026-09-07, ruling D3, recorded in
|
|
9
|
+
* `road-to-continuity-writer-activation` step 3.1) required six properties —
|
|
10
|
+
* repository and worktree identity, path canonicalization, freshness, ordering,
|
|
11
|
+
* duplicate invocation and size limits — each with a test, BEFORE the restore
|
|
12
|
+
* moves off the `hot-context` concern. Five of the six did not exist: measured
|
|
13
|
+
* at HEAD, `session_memory_index.ts` implemented size limits only. This module
|
|
14
|
+
* is those six in one place, so the relocation has something to move.
|
|
15
|
+
*
|
|
16
|
+
* THE THREAT, STATED CONCRETELY RATHER THAN AS A CATEGORY.
|
|
17
|
+
*
|
|
18
|
+
* `MEMORY_ROOT` in `memory_lookup.ts` is the RELATIVE path `agents/memory`, so
|
|
19
|
+
* it resolves against the process cwd. The hook therefore chdirs to a workspace
|
|
20
|
+
* root it received from a host payload and reads memory from wherever that
|
|
21
|
+
* landed. Nothing checked that the root was the workspace the session belongs
|
|
22
|
+
* to, nothing resolved symlinks before trusting it, and nothing stopped a second
|
|
23
|
+
* `session_start` in one session from injecting the block twice. So the
|
|
24
|
+
* attacker this module answers is not an intruder: it is a wrong or stale root —
|
|
25
|
+
* a sibling checkout, a worktree pointed at another tree, a leftover directory,
|
|
26
|
+
* a symlink out of the workspace — serving another repository's curated memory
|
|
27
|
+
* into this session as though it were this repository's.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IT DOES NOT CLAIM.
|
|
30
|
+
*
|
|
31
|
+
* It does not authenticate the memory's CONTENT. A curated entry that is wrong,
|
|
32
|
+
* or hostile, and sits at the correct path inside the correct workspace passes
|
|
33
|
+
* every property here — that is the review boundary of `agents/memory/`, not a
|
|
34
|
+
* check's. Nor does it make the index trustworthy in the sense that a model may
|
|
35
|
+
* act on it: the block stays spotlighted as DATA, per `untrusted-input-defense`,
|
|
36
|
+
* and this module bounds only WHICH bytes reach that block.
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from 'node:fs';
|
|
39
|
+
import * as path from 'node:path';
|
|
40
|
+
|
|
41
|
+
/** P6 — the row cap. Owned here so all six properties read from one place. */
|
|
42
|
+
export const SESSION_INDEX_ROW_CAP = 30;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* P3 — the abandonment bound, in days.
|
|
46
|
+
*
|
|
47
|
+
* A STATED DEFAULT, NOT A MEASURED OPTIMUM, said plainly because a number that
|
|
48
|
+
* looks derived and is not is worse than an admitted guess. Curated memory is
|
|
49
|
+
* deliberately long-lived — a stable `product-rules.yml` is not stale at 90
|
|
50
|
+
* days — so the bound is set well beyond any legitimate quiet period and exists
|
|
51
|
+
* to catch an ABANDONED root, not an inactive one. The two conditions that
|
|
52
|
+
* would falsify it are recorded in `road-to-continuity-writer-activation`
|
|
53
|
+
* step 3.1 rather than here.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_ROOT_AGE_DAYS = 400;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Curated sources, matching the layouts `memory_lookup._iter_curated_entries`
|
|
59
|
+
* actually reads: `<root>/<type>.yml` AND `<root>/<type>/**\/*.yml`, plus the
|
|
60
|
+
* agent-written `intake/*.jsonl`. Reading only the first of those is what made
|
|
61
|
+
* P3 silently no-op on two of the three layouts — R2 finding 4.
|
|
62
|
+
*/
|
|
63
|
+
const CURATED_SUFFIXES = ['.yml', '.yaml', '.jsonl'] as const;
|
|
64
|
+
|
|
65
|
+
/** Depth bound on the walk. A memory root is shallow; a cycle is not. */
|
|
66
|
+
const MAX_WALK_DEPTH = 8;
|
|
67
|
+
|
|
68
|
+
export type TrustRefusalCode =
|
|
69
|
+
| 'workspace-root-unresolvable'
|
|
70
|
+
| 'memory-root-absent'
|
|
71
|
+
| 'memory-root-escapes-workspace'
|
|
72
|
+
| 'curated-source-escapes-workspace'
|
|
73
|
+
| 'source-mtime-in-future'
|
|
74
|
+
| 'memory-root-abandoned'
|
|
75
|
+
| 'already-injected-this-session';
|
|
76
|
+
|
|
77
|
+
export interface TrustRefusal {
|
|
78
|
+
readonly ok: false;
|
|
79
|
+
readonly code: TrustRefusalCode;
|
|
80
|
+
readonly detail: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TrustGrant {
|
|
84
|
+
readonly ok: true;
|
|
85
|
+
/** Canonical, containment-checked absolute path to the memory root. */
|
|
86
|
+
readonly memoryRoot: string;
|
|
87
|
+
/** Canonical absolute path to the workspace the root was checked against. */
|
|
88
|
+
readonly workspaceRoot: string;
|
|
89
|
+
/** P3 — mtime of the newest curated source read, epoch ms; 0 when none. */
|
|
90
|
+
readonly newestSourceMs: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type TrustVerdict = TrustGrant | TrustRefusal;
|
|
94
|
+
|
|
95
|
+
function refuse(code: TrustRefusalCode, detail: string): TrustRefusal {
|
|
96
|
+
return { ok: false, code, detail };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* P2 — canonicalize, then compare by SEGMENTS.
|
|
101
|
+
*
|
|
102
|
+
* A string prefix test says `/a/bc` is inside `/a/b`, which is how a
|
|
103
|
+
* containment check passes something it should refuse. Splitting on the
|
|
104
|
+
* separator makes the comparison mean what it says.
|
|
105
|
+
*/
|
|
106
|
+
export function isContained(child: string, parent: string): boolean {
|
|
107
|
+
const rel = path.relative(parent, child);
|
|
108
|
+
if (rel === '') return true;
|
|
109
|
+
if (path.isAbsolute(rel)) return false;
|
|
110
|
+
return !rel.split(path.sep).includes('..');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Canonicalize a path, following symlinks. `null` when it does not resolve. */
|
|
114
|
+
export function canonical(p: string): string | null {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync(p);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SourceWalk {
|
|
123
|
+
/** Newest mtime across every curated source read, epoch ms; 0 when none. */
|
|
124
|
+
readonly newestMs: number;
|
|
125
|
+
/** Sources whose canonical path lies outside the workspace. P1's other half. */
|
|
126
|
+
readonly escaped: readonly string[];
|
|
127
|
+
/** How many sources the walk actually read — 0 means "nothing to judge". */
|
|
128
|
+
readonly count: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One recursive walk serving P1's file half and P3's reading, because R2 found
|
|
133
|
+
* both defects in the same omission: the previous version read only `*.yml`
|
|
134
|
+
* directly at the root, which is one of the three layouts
|
|
135
|
+
* `memory_lookup._iter_curated_entries` supports. So freshness silently
|
|
136
|
+
* no-opped on a type-directory corpus (a 3-year-old one was served past the
|
|
137
|
+
* 400-day bound), and containment was never checked below the root at all.
|
|
138
|
+
*
|
|
139
|
+
* The containment half is the sharper of the two. Canonicalizing the ROOT does
|
|
140
|
+
* not stop a symlinked FILE inside it from resolving into another tree, and
|
|
141
|
+
* that is the exact threat this module's docblock names — proven by the
|
|
142
|
+
* reviewer, who read a donor workspace's entry out of a victim session.
|
|
143
|
+
*
|
|
144
|
+
* Per-entry timestamps do not exist (a curated entry is `{id, key, body}`), so
|
|
145
|
+
* freshness stays a property of the source files rather than something invented
|
|
146
|
+
* per row.
|
|
147
|
+
*/
|
|
148
|
+
export function walkCuratedSources(memoryRoot: string, workspaceRoot: string): SourceWalk {
|
|
149
|
+
let newestMs = 0;
|
|
150
|
+
let count = 0;
|
|
151
|
+
const escaped: string[] = [];
|
|
152
|
+
|
|
153
|
+
const visit = (dir: string, depth: number): void => {
|
|
154
|
+
if (depth > MAX_WALK_DEPTH) return;
|
|
155
|
+
let names: fs.Dirent[];
|
|
156
|
+
try {
|
|
157
|
+
names = fs.readdirSync(dir, { withFileTypes: true });
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const ent of names) {
|
|
162
|
+
const full = path.join(dir, ent.name);
|
|
163
|
+
// `withFileTypes` reports a symlink as a symlink, so a directory
|
|
164
|
+
// reached through one is followed only after it canonicalizes
|
|
165
|
+
// inside the workspace — which is the containment check itself.
|
|
166
|
+
const resolved = canonical(full);
|
|
167
|
+
if (resolved === null) continue;
|
|
168
|
+
if (!isContained(resolved, workspaceRoot)) {
|
|
169
|
+
escaped.push(full);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
let st: fs.Stats;
|
|
173
|
+
try {
|
|
174
|
+
st = fs.statSync(resolved);
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (st.isDirectory()) {
|
|
179
|
+
visit(resolved, depth + 1);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!CURATED_SUFFIXES.some((sfx) => ent.name.endsWith(sfx))) continue;
|
|
183
|
+
count += 1;
|
|
184
|
+
if (st.mtimeMs > newestMs) newestMs = st.mtimeMs;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
visit(memoryRoot, 0);
|
|
189
|
+
return { newestMs, escaped, count };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface TrustInput {
|
|
193
|
+
/** The workspace root the session belongs to, as the host reported it. */
|
|
194
|
+
readonly workspaceRoot: string;
|
|
195
|
+
/** Relative memory root, matching `memory_lookup.MEMORY_ROOT`. */
|
|
196
|
+
readonly relativeMemoryRoot?: string;
|
|
197
|
+
/** Injected for tests; defaults to now. */
|
|
198
|
+
readonly now?: Date;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* P1 + P2 + P3 — decide whether a memory root may be read at all.
|
|
203
|
+
*
|
|
204
|
+
* Ordered cheapest-first, and each refusal names itself: a caller that logs the
|
|
205
|
+
* code can tell "this workspace has no memory" from "this root pointed
|
|
206
|
+
* somewhere else", which is the distinction the whole module exists for.
|
|
207
|
+
*/
|
|
208
|
+
export function verifyMemoryRoot(input: TrustInput): TrustVerdict {
|
|
209
|
+
const now = input.now ?? new Date();
|
|
210
|
+
const rel = input.relativeMemoryRoot ?? path.join('agents', 'memory');
|
|
211
|
+
|
|
212
|
+
// P2 — the workspace root is canonicalized FIRST. Everything downstream
|
|
213
|
+
// compares against the resolved form, never the reported one.
|
|
214
|
+
const workspaceRoot = canonical(input.workspaceRoot);
|
|
215
|
+
if (workspaceRoot === null) {
|
|
216
|
+
return refuse('workspace-root-unresolvable', `workspace root does not resolve: ${input.workspaceRoot}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// P1 — identity, checked LEXICALLY first and on the canonical path second.
|
|
220
|
+
// The order is load-bearing and a test found it: a `..` root whose target
|
|
221
|
+
// does not exist would otherwise be reported as merely absent, which reads
|
|
222
|
+
// as "this workspace has no memory" when the truth is "this root pointed
|
|
223
|
+
// outside the tree". The escape is the more important fact, so it is the
|
|
224
|
+
// one reported, whether or not the target happens to exist.
|
|
225
|
+
const lexical = path.resolve(workspaceRoot, rel);
|
|
226
|
+
if (!isContained(lexical, workspaceRoot)) {
|
|
227
|
+
return refuse(
|
|
228
|
+
'memory-root-escapes-workspace',
|
|
229
|
+
`${lexical} is not inside ${workspaceRoot} — refusing to serve another tree's memory`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const memoryRoot = canonical(lexical);
|
|
234
|
+
if (memoryRoot === null) {
|
|
235
|
+
return refuse('memory-root-absent', `no memory root at ${path.join(input.workspaceRoot, rel)}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The second half of P1, and the half a lexical check cannot see: the path
|
|
239
|
+
// is inside the tree and the SYMLINK it resolves through is not.
|
|
240
|
+
if (!isContained(memoryRoot, workspaceRoot)) {
|
|
241
|
+
return refuse(
|
|
242
|
+
'memory-root-escapes-workspace',
|
|
243
|
+
`${memoryRoot} is not inside ${workspaceRoot} — refusing to serve another tree's memory`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// P1's file half + P3, from one walk. R2 findings 3 and 4.
|
|
248
|
+
const walk = walkCuratedSources(memoryRoot, workspaceRoot);
|
|
249
|
+
if (walk.escaped.length > 0) {
|
|
250
|
+
return refuse(
|
|
251
|
+
'curated-source-escapes-workspace',
|
|
252
|
+
`${walk.escaped.length} curated source(s) resolve outside ${workspaceRoot}, first ${walk.escaped[0]} — refusing to serve another tree's memory`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// P3 — freshness, in the two directions it can fail.
|
|
257
|
+
const newestSourceMs = walk.newestMs;
|
|
258
|
+
if (newestSourceMs > now.getTime() + 60_000) {
|
|
259
|
+
return refuse(
|
|
260
|
+
'source-mtime-in-future',
|
|
261
|
+
`newest curated source is dated in the future (${new Date(newestSourceMs).toISOString()}) — clock skew or tampering`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (newestSourceMs > 0) {
|
|
265
|
+
const ageDays = (now.getTime() - newestSourceMs) / 86_400_000;
|
|
266
|
+
if (ageDays > MAX_ROOT_AGE_DAYS) {
|
|
267
|
+
return refuse(
|
|
268
|
+
'memory-root-abandoned',
|
|
269
|
+
`newest curated source is ${ageDays.toFixed(0)}d old, past the ${MAX_ROOT_AGE_DAYS}d abandonment bound`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { ok: true, memoryRoot, workspaceRoot, newestSourceMs };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface OrderableRow {
|
|
278
|
+
readonly id: string;
|
|
279
|
+
readonly title: string;
|
|
280
|
+
readonly tokens_estimate: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* P4 — the declared total order.
|
|
285
|
+
*
|
|
286
|
+
* Ordering is a trust property because the CAP truncates: without a declared
|
|
287
|
+
* order, which 30 of 200 entries reach the model is whatever the filesystem
|
|
288
|
+
* happened to yield, and two runs over the same tree can advertise different
|
|
289
|
+
* corpora. Cheapest row first (`tokens_estimate` ascending) so a fixed cap
|
|
290
|
+
* carries the most entries it can, with `id` ascending as the tie-break that
|
|
291
|
+
* makes the order TOTAL rather than merely defined — a comparator with ties is
|
|
292
|
+
* not deterministic under an unstable sort.
|
|
293
|
+
*/
|
|
294
|
+
export function orderRows<T extends OrderableRow>(rows: readonly T[]): T[] {
|
|
295
|
+
return [...rows].sort((a, b) => {
|
|
296
|
+
if (a.tokens_estimate !== b.tokens_estimate) return a.tokens_estimate - b.tokens_estimate;
|
|
297
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** P6 — apply the cap after ordering, never before. */
|
|
302
|
+
export function capRows<T>(rows: readonly T[], cap: number = SESSION_INDEX_ROW_CAP): T[] {
|
|
303
|
+
const limit = Number.isFinite(cap) && cap > 0 ? Math.min(cap, SESSION_INDEX_ROW_CAP) : SESSION_INDEX_ROW_CAP;
|
|
304
|
+
return rows.slice(0, limit);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Where the P5 latch lives. Gitignored runtime state, one file per session. */
|
|
308
|
+
export function latchPath(workspaceRoot: string, sessionId: string): string {
|
|
309
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/gu, '_').slice(0, 128);
|
|
310
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'session-index-latch', `${safe}.json`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* P5 — duplicate invocation.
|
|
315
|
+
*
|
|
316
|
+
* `session_start` can fire more than once for one session (a resume, a
|
|
317
|
+
* host-side reconnect, a compaction boundary on some hosts). Two injections
|
|
318
|
+
* double the fixed cost and can present two different corpora as the same
|
|
319
|
+
* session's memory. The latch is create-exclusive (`wx`), so the FIRST caller
|
|
320
|
+
* wins and every later one refuses — an ordering that matters, because
|
|
321
|
+
* check-then-write would let two concurrent starts both pass.
|
|
322
|
+
*
|
|
323
|
+
* Fails OPEN on an unwritable state directory: a hook that cannot latch must
|
|
324
|
+
* still be able to serve the index once, and refusing there would make a
|
|
325
|
+
* read-only workspace lose the feature entirely. That is a deliberate trade and
|
|
326
|
+
* it is the reason this property is a latch rather than a gate.
|
|
327
|
+
*/
|
|
328
|
+
export function claimSessionOnce(workspaceRoot: string, sessionId: string): TrustVerdict | { ok: true } {
|
|
329
|
+
if (sessionId.length === 0) {
|
|
330
|
+
// No session id to key on — cannot latch, so do not pretend to.
|
|
331
|
+
return { ok: true };
|
|
332
|
+
}
|
|
333
|
+
const target = latchPath(workspaceRoot, sessionId);
|
|
334
|
+
try {
|
|
335
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
336
|
+
fs.writeFileSync(target, JSON.stringify({ claimed_at: new Date().toISOString() }), { flag: 'wx' });
|
|
337
|
+
return { ok: true };
|
|
338
|
+
} catch (exc) {
|
|
339
|
+
const code = (exc as { code?: string }).code;
|
|
340
|
+
if (code === 'EEXIST') {
|
|
341
|
+
return refuse('already-injected-this-session', `session ${sessionId} already received the index`);
|
|
342
|
+
}
|
|
343
|
+
// fail-open — see the docblock
|
|
344
|
+
return { ok: true };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standing-payload bounds are shrink-only, checked against the base ref.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* `src/config/preamble-payload-budget.json` says of its grace ceiling: *"It may
|
|
7
|
+
* never move UP."* `grace_ceiling_history` in the same file records it moving up
|
|
8
|
+
* twice — 138,212 → 138,273 on 2026-09-02 and 138,273 → 138,490 on 2026-09-08 —
|
|
9
|
+
* each with a careful prose justification. ADR-264 resolved that contradiction
|
|
10
|
+
* against the practice: the sentence stands, the two raises are violations caused
|
|
11
|
+
* by contradictory wording, and standing-rule growth needs a compensating
|
|
12
|
+
* reduction elsewhere.
|
|
13
|
+
*
|
|
14
|
+
* That decision then had no enforcement. A sentence in a JSON string is not a
|
|
15
|
+
* gate, and the file's own history is the evidence: nothing objected either time.
|
|
16
|
+
* This module is the enforcement — and it is the ONLY thing
|
|
17
|
+
* `road-to-a-standing-budget-with-headroom` step 1.3 ships, because the 128-token
|
|
18
|
+
* Iron Law reserve that step designed was refused by an AI council 2/2 on
|
|
19
|
+
* 2026-09-08. See ADR-265.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT CANNOT DO, STATED PLAINLY
|
|
22
|
+
* ---------------------------------
|
|
23
|
+
* The change under review can edit this file. Both council seats named that as
|
|
24
|
+
* the defect fatal to the reserve — "the checker, its imports, token census,
|
|
25
|
+
* workflow, and status context are all part of the trusted computing base", so a
|
|
26
|
+
* protected record read by an unprotected verifier grants nothing. It is fatal to
|
|
27
|
+
* a mechanism that GRANTS budget and it is not fatal to one that only REFUSES,
|
|
28
|
+
* which is the whole reason the reserve stayed unshipped and this ratchet did
|
|
29
|
+
* not: disabling this check requires visibly editing it, and every gate in this
|
|
30
|
+
* repository already rests on that same review. A ratchet whose only failure mode
|
|
31
|
+
* is "someone deleted the ratchet" is strictly better than the sentence it
|
|
32
|
+
* replaces, which had no failure mode because it did nothing.
|
|
33
|
+
*/
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
|
|
36
|
+
/** Repo-relative path of the budget config the bound lives in. */
|
|
37
|
+
export const BUDGET_CONFIG_PATH = 'src/config/preamble-payload-budget.json';
|
|
38
|
+
|
|
39
|
+
/** Injection seam: run git, capture output, never throw on a non-zero exit. */
|
|
40
|
+
export type GitRunner = (args: readonly string[], cwd: string) => {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
stdout: string;
|
|
43
|
+
stderr: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const realGit: GitRunner = (args, cwd) => {
|
|
47
|
+
const r = spawnSync('git', [...args], { cwd, encoding: 'utf-8' });
|
|
48
|
+
return { ok: r.status === 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface BoundsRatchetVerdict {
|
|
52
|
+
ok: boolean;
|
|
53
|
+
violations: string[];
|
|
54
|
+
/** The bound read at the base ref, or `null` when it could not be read. */
|
|
55
|
+
baseGraceCeiling: number | null;
|
|
56
|
+
/** The ref compared against, or `null` when none resolved. */
|
|
57
|
+
baseRef: string | null;
|
|
58
|
+
/** Why the comparison was skipped, when it was. Printed, never silent. */
|
|
59
|
+
note: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function showAtRef(git: GitRunner, repoRoot: string, ref: string, relPath: string): string | null {
|
|
63
|
+
const r = git(['show', `${ref}:${relPath}`], repoRoot);
|
|
64
|
+
return r.ok ? r.stdout : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compare the grace ceiling in this tree against the one at the base ref.
|
|
69
|
+
*
|
|
70
|
+
* SKIPS rather than fails when the base ref or the base config cannot be read.
|
|
71
|
+
* That is the opposite of the fail-closed posture the same council demanded for
|
|
72
|
+
* the reserve, and the asymmetry is deliberate: an unreadable base there would
|
|
73
|
+
* have GRANTED budget on an infrastructure failure, whereas here it would REFUSE
|
|
74
|
+
* a change on one. A shallow clone, a first commit and a detached build all
|
|
75
|
+
* legitimately have no base, and a gate that reds on them gets switched off —
|
|
76
|
+
* which is how a repository loses the enforcement it just built. The skip is
|
|
77
|
+
* reported in both the human and JSON output so it cannot pass for a check.
|
|
78
|
+
*/
|
|
79
|
+
export function assertBoundsDidNotRise(opts: {
|
|
80
|
+
repoRoot: string;
|
|
81
|
+
baseRef: string | null;
|
|
82
|
+
git?: GitRunner;
|
|
83
|
+
/**
|
|
84
|
+
* The ceiling this run actually compares the payload against.
|
|
85
|
+
*
|
|
86
|
+
* The CI step passes `--ceiling <grace_ceiling>`, so the effective bound can
|
|
87
|
+
* be raised either by editing the config or by passing a bigger number. Both
|
|
88
|
+
* arrive here as one value, which closes the second path without a second
|
|
89
|
+
* check.
|
|
90
|
+
*/
|
|
91
|
+
headGraceCeiling: number;
|
|
92
|
+
}): BoundsRatchetVerdict {
|
|
93
|
+
const git = opts.git ?? realGit;
|
|
94
|
+
if (opts.baseRef === null || opts.baseRef.trim() === '') {
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
violations: [],
|
|
98
|
+
baseGraceCeiling: null,
|
|
99
|
+
baseRef: null,
|
|
100
|
+
note: 'no base ref resolved, so the shrink-only bound was NOT verified in this run',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const cfg = showAtRef(git, opts.repoRoot, opts.baseRef, BUDGET_CONFIG_PATH);
|
|
104
|
+
if (cfg === null) {
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
violations: [],
|
|
108
|
+
baseGraceCeiling: null,
|
|
109
|
+
baseRef: opts.baseRef,
|
|
110
|
+
note: `${BUDGET_CONFIG_PATH} could not be read at ${opts.baseRef}, so the shrink-only bound was NOT verified`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
let baseGrace: number | null = null;
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(cfg) as Record<string, unknown>;
|
|
116
|
+
const delivery = (parsed['ci_delivery'] ?? {}) as Record<string, unknown>;
|
|
117
|
+
const n = Number(delivery['grace_ceiling']);
|
|
118
|
+
baseGrace = Number.isFinite(n) ? n : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
violations: [],
|
|
123
|
+
baseGraceCeiling: null,
|
|
124
|
+
baseRef: opts.baseRef,
|
|
125
|
+
note: `${BUDGET_CONFIG_PATH} at ${opts.baseRef} is not parseable, so the shrink-only bound was NOT verified`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (baseGrace === null) {
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
violations: [],
|
|
132
|
+
baseGraceCeiling: null,
|
|
133
|
+
baseRef: opts.baseRef,
|
|
134
|
+
note: `no ci_delivery.grace_ceiling at ${opts.baseRef}, so there is no earlier bound to ratchet against`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (opts.headGraceCeiling > baseGrace) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
baseGraceCeiling: baseGrace,
|
|
141
|
+
baseRef: opts.baseRef,
|
|
142
|
+
note: null,
|
|
143
|
+
violations: [
|
|
144
|
+
`the standing-payload grace ceiling rose from ${String(baseGrace)} to ` +
|
|
145
|
+
`${String(opts.headGraceCeiling)} against ${opts.baseRef}. ` +
|
|
146
|
+
`${BUDGET_CONFIG_PATH} says it may not move upward and ADR-264 decided that ` +
|
|
147
|
+
'the sentence stands: standing-rule growth needs a compensating reduction ' +
|
|
148
|
+
'elsewhere, not a bigger ceiling. Lower the addition, migrate the prose out of ' +
|
|
149
|
+
'the standing rule, or reduce another standing asset by the same amount.',
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, violations: [], baseGraceCeiling: baseGrace, baseRef: opts.baseRef, note: null };
|
|
154
|
+
}
|