@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
|
@@ -11,12 +11,23 @@
|
|
|
11
11
|
* the 36.2 %-against-48 % run at `docs/CLAIMS.md:188-189`. Whatever that
|
|
12
12
|
* instrument's flaws — and ADR-202 closed it — it points one way.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* SHIPPED ON FOR CLAUDE CODE SINCE ADR-265 — corrected 2026-09-08 (R2 finding
|
|
15
|
+
* 4), because this paragraph read "DEFAULT OFF, AND OFF MEANS ZERO BYTES …
|
|
16
|
+
* under every shipped default it emits nothing … there is no measured emission
|
|
17
|
+
* to register yet" and all four clauses became false in the same change that
|
|
18
|
+
* edited the paragraph below it. The shipped template carries
|
|
19
|
+
* `lean_projection.mode: delivery` with `hosts: [claude-code]`
|
|
20
|
+
* (`src/config/agent-settings.template.yml`), `gateOpen` returns true on that
|
|
21
|
+
* pair, and the emission IS measured: p50 6,674 B, p90 16,188 B, max 20,406 B
|
|
22
|
+
* over 318 gate-open fires on the frozen corpus, which is the distribution the
|
|
23
|
+
* `user_prompt_submit` slot raise in `src/config/hook-token-budget.json` is
|
|
24
|
+
* derived from.
|
|
25
|
+
*
|
|
26
|
+
* OFF STILL MEANS ZERO BYTES, and that half is unchanged: the concern returns
|
|
27
|
+
* before reading the router unless the resolved (mode, hosts) pair actually
|
|
28
|
+
* thins THIS host — see `gateOpen`, which checks both, because the projector
|
|
29
|
+
* does. On any other host, and on a `lean_projection.mode: eager-all` rollback,
|
|
30
|
+
* it emits nothing.
|
|
20
31
|
*
|
|
21
32
|
* ONE MATCHER, SHARED WITH THE OFFLINE MODEL. Everything about selection,
|
|
22
33
|
* ordering, capping and body loading comes from `_lib/rule_injection.ts`, which
|
|
@@ -31,6 +42,29 @@
|
|
|
31
42
|
* uses. State lives under `agents/runtime/state/`, the class
|
|
32
43
|
* `context-hygiene.json` already occupies; no new state convention is created.
|
|
33
44
|
*
|
|
45
|
+
* WHAT IS RE-DELIVERED AFTER A COMPACTION, EXACTLY
|
|
46
|
+
* (road-to-delivery-for-every-host 2.4 — stated because a rule lost at a
|
|
47
|
+
* compaction boundary is lost for the rest of the session, and "re-armed" alone
|
|
48
|
+
* does not say what a reader may rely on):
|
|
49
|
+
*
|
|
50
|
+
* · `pre_compact` clears the WHOLE seen-set for that session, not the rules
|
|
51
|
+
* matched on the compacted turn. There is no per-rule bookkeeping to be
|
|
52
|
+
* partially wrong about.
|
|
53
|
+
* · Nothing is delivered BY the compaction itself. The slot emits zero bytes
|
|
54
|
+
* and exits allow; re-arming is silent.
|
|
55
|
+
* · A rule's body returns on the NEXT turn whose trigger matches it — which
|
|
56
|
+
* means a rule whose trigger does not fire again is NOT restored. Delivery
|
|
57
|
+
* is trigger-driven on both sides of the boundary; compaction resets the
|
|
58
|
+
* de-duplication, it does not replay a transcript.
|
|
59
|
+
* · The seen-set is per session, so a compaction in one session re-arms only
|
|
60
|
+
* that session.
|
|
61
|
+
*
|
|
62
|
+
* Held by three fixtures in `tests/scripts/rule_inject_hook.test.ts` under
|
|
63
|
+
* "once per session per rule, re-armed on compaction": the dedup case, the
|
|
64
|
+
* matched-rule → compaction → matching-turn → body-present case, and the
|
|
65
|
+
* per-session case. All three predate this roadmap; 2.4 adds the contract
|
|
66
|
+
* above, not the coverage, and says so rather than claiming new tests.
|
|
67
|
+
*
|
|
34
68
|
* NEVER BLOCKS. Every failure path returns 0: unreadable stdin, malformed JSON,
|
|
35
69
|
* missing router, unreadable body, unwritable state. The one non-zero exit is
|
|
36
70
|
* the host's advisory context channel (exit 2 + `decision: "warn"`), the same
|
|
@@ -54,8 +88,12 @@ import * as path from 'node:path';
|
|
|
54
88
|
import process from 'node:process';
|
|
55
89
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
56
90
|
|
|
57
|
-
import { hookSectionEnabled, leanProjectionModeRaw } from '../_lib/hook_settings.js';
|
|
58
|
-
import {
|
|
91
|
+
import { hookSectionEnabled, leanProjectionHostsRaw, leanProjectionModeRaw } from '../_lib/hook_settings.js';
|
|
92
|
+
import {
|
|
93
|
+
deliversBodies,
|
|
94
|
+
normalizeLeanProjectionMode,
|
|
95
|
+
resolveLeanProjectionHosts,
|
|
96
|
+
} from '../_lib/lean_projection_mode.js';
|
|
59
97
|
import {
|
|
60
98
|
loadRuleBody,
|
|
61
99
|
loadRouter,
|
|
@@ -81,15 +119,38 @@ const EXIT_WARN = 2;
|
|
|
81
119
|
* slot, for a concern that is default-OFF and emits nothing. Measured: 202 ms
|
|
82
120
|
* -> 196 ms on the `pre_tool_use` p95 when the concern is unbound entirely, and
|
|
83
121
|
* the CI latency gate went red on the branch that introduced it while passing
|
|
84
|
-
* on main. So the runtime cap is
|
|
122
|
+
* on main. So the runtime cap is stated in the unit
|
|
85
123
|
* `hook-token-budget.json` already enforces — 5,000 tok at the ~4 bytes/token
|
|
86
|
-
* this corpus measures
|
|
87
|
-
* row.
|
|
124
|
+
* this corpus measures was 20,480 B, and that WAS this concern's registered
|
|
125
|
+
* row until 2026-09-08. It matched the concern row and not the slot row; see
|
|
126
|
+
* the correction below.
|
|
127
|
+
*
|
|
128
|
+
* LOWERED 20480 -> 16384 on 2026-09-08 (R2 finding 3), and the paragraphs above
|
|
129
|
+
* are kept because they record how the retired number was derived. The defect
|
|
130
|
+
* was that the derivation above and the one behind the
|
|
131
|
+
* `user_prompt_submit` slot row were TWO STATISTICS IN TWO UNITS: this cap was
|
|
132
|
+
* the p90 of the matched-body TOKEN distribution, converted at ~4 B/tok; the
|
|
133
|
+
* slot row is the p90 gate-open FIRE SIZE in bytes, which is the activation
|
|
134
|
+
* charge owner ruling E2 specifies. They disagreed by 25 %, in the direction
|
|
135
|
+
* that licensed ONE concern to emit more than the whole slot — carrying 12
|
|
136
|
+
* other concerns — is registered for. Reconciled downward onto the slot row,
|
|
137
|
+
* because that is the owner-specified charge; the cost is measured and recorded
|
|
138
|
+
* in `hook-token-budget.json`'s own `rule-inject_reason` (33 -> 45 fires
|
|
139
|
+
* truncated, 63 -> 88 bodies withheld over 330 corpus fires).
|
|
140
|
+
*
|
|
141
|
+
* WHAT THIS CAP DOES, precisely: `selectForInjection` drops whole bodies to
|
|
142
|
+
* stay under it, so a fire is TRUNCATED and never over-emitted. It is the only
|
|
143
|
+
* number that acts on a single fire — the per-slot sums are an authoring-time
|
|
144
|
+
* control read by `bench_hook_injection`, and the runtime dispatcher enforces
|
|
145
|
+
* only `per_turn_aggregate_bytes.ceiling_bytes`.
|
|
88
146
|
*
|
|
89
147
|
* Re-run that command if the corpus or the bodies move; a cap copied from a
|
|
90
|
-
* stale measurement is worse than no cap, because it looks derived.
|
|
148
|
+
* stale measurement is worse than no cap, because it looks derived. The
|
|
149
|
+
* tripwire in `tests/scripts/rule_inject_hook.test.ts` holds this equal to the
|
|
150
|
+
* registered concern row and at or below the slot sum, so the two units cannot
|
|
151
|
+
* drift apart again unnoticed.
|
|
91
152
|
*/
|
|
92
|
-
export const CAP_BYTES =
|
|
153
|
+
export const CAP_BYTES = 16384;
|
|
93
154
|
|
|
94
155
|
/** Tools whose input names a file this concern can match path triggers against. */
|
|
95
156
|
export const FILE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'Read', 'MultiEdit']);
|
|
@@ -216,18 +277,44 @@ export function buildInjection(
|
|
|
216
277
|
|
|
217
278
|
// ── main ─────────────────────────────────────────────────────────────────
|
|
218
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The one host this concern is bound on.
|
|
282
|
+
*
|
|
283
|
+
* The manifest binds `rule-inject` under `claude` alone, so the host axis
|
|
284
|
+
* `gateOpen` has to consult is a constant here rather than something read off
|
|
285
|
+
* the envelope — the envelope carries no host id, and inventing one from the
|
|
286
|
+
* process environment would be a guess where the manifest is a fact.
|
|
287
|
+
*/
|
|
288
|
+
export const DELIVERY_HOST = 'claude-code';
|
|
289
|
+
|
|
219
290
|
/**
|
|
220
291
|
* Whether the settings gate applies.
|
|
221
292
|
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
293
|
+
* BOTH AXES, because the projector reads both. Until 2026-09-08 this keyed on
|
|
294
|
+
* `lean_projection.mode` alone while `condense` gates stub-writing on
|
|
295
|
+
* `thinsHost(mode, hosts, host)` (R2 finding 5). The two disagreed in exactly
|
|
296
|
+
* the states the host axis exists for: `mode: delivery` with a `hosts:` list
|
|
297
|
+
* that does not resolve to `claude-code` — the documented "thin no host"
|
|
298
|
+
* state, which a fully typo'd list also produces — left this host with a
|
|
299
|
+
* FULL-BODIED tree while the hook kept injecting on a match, so every matched
|
|
300
|
+
* rule was delivered twice. `hosts: [cursor]` was the same defect with a
|
|
301
|
+
* second half: cursor received stubs with no bound slot to deliver them back.
|
|
302
|
+
*
|
|
303
|
+
* `deliversBodies` is checked separately from the host list rather than via
|
|
304
|
+
* `thinsHost`, which is true for `thin` as well: `thin` writes the same stubs
|
|
305
|
+
* and binds NO delivery concern, so it must never open this gate.
|
|
306
|
+
*
|
|
307
|
+
* An explicit `hooks.rule_inject` opt-in still opens it — that is an operator
|
|
308
|
+
* asking for the concern by name, independent of the projection mode. A DIRECT
|
|
309
|
+
* CLI invocation is a probe by definition — an operator piping an envelope into
|
|
310
|
+
* this file is asking to see what it would deliver — so there the gate defaults
|
|
311
|
+
* to open. `AGENT_CONFIG_REPLAY` re-imposes it, which is what keeps
|
|
312
|
+
* `bench_hook_injection` measuring the configured tree rather than the probe.
|
|
228
313
|
*/
|
|
229
314
|
export function gateOpen(root: string, cliEntry: boolean): boolean {
|
|
230
|
-
|
|
315
|
+
const mode = normalizeLeanProjectionMode(leanProjectionModeRaw(root));
|
|
316
|
+
const hosts = resolveLeanProjectionHosts(leanProjectionHostsRaw(root)).hosts;
|
|
317
|
+
if (deliversBodies(mode) && hosts.includes(DELIVERY_HOST)) return true;
|
|
231
318
|
if (hookSectionEnabled(root, 'rule_inject')) return true;
|
|
232
319
|
return cliEntry && process.env['AGENT_CONFIG_REPLAY'] !== '1';
|
|
233
320
|
}
|
|
@@ -45,7 +45,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
45
45
|
|
|
46
46
|
import recycleThresholdConfig from '../../config/recycle-threshold-budget.json';
|
|
47
47
|
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
listContinuityRecords,
|
|
50
|
+
RECYCLE_MAX_AGE_HOURS,
|
|
51
|
+
recycle_envelope_rel,
|
|
52
|
+
} from '../_lib/recycle_envelope_paths.js';
|
|
49
53
|
import {
|
|
50
54
|
emptyCounters,
|
|
51
55
|
eolSessionKey,
|
|
@@ -56,6 +60,12 @@ import {
|
|
|
56
60
|
} from '../_lib/session_eol.js';
|
|
57
61
|
import { readContextObservation } from '../_lib/context_observation.js';
|
|
58
62
|
import { buildCheckpoint, writeCheckpoint } from '../_lib/run_checkpoint.js';
|
|
63
|
+
import {
|
|
64
|
+
auto_record_enabled,
|
|
65
|
+
buildContinuityRecord,
|
|
66
|
+
run_checkpoints_enabled,
|
|
67
|
+
} from '../_lib/continuity_writer.js';
|
|
68
|
+
import { publishContinuityRecord } from '../_lib/continuity_slot.js';
|
|
59
69
|
import { read_claimed_slug } from '../session_register_hook.js';
|
|
60
70
|
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
61
71
|
import { readHookStdin } from './hook_stdin.js';
|
|
@@ -210,6 +220,62 @@ export function envelopeExists(
|
|
|
210
220
|
}
|
|
211
221
|
}
|
|
212
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Records that were written and never read — the detection layer this defect
|
|
225
|
+
* did not have.
|
|
226
|
+
*
|
|
227
|
+
* `road-to-a-recycle-envelope-that-is-consumed` step 1.2. Twelve unconsumed
|
|
228
|
+
* envelopes accumulated across the estate over roughly a month, and the only
|
|
229
|
+
* reason anyone found them was a stray-copy check during an unrelated task. The
|
|
230
|
+
* producer reported success, the consumer reported `absent`, and nothing
|
|
231
|
+
* compared the two — so the mechanism was inert for weeks with no signal.
|
|
232
|
+
*
|
|
233
|
+
* Advisory ONLY. A hook that blocks a session end over a stale runtime file is
|
|
234
|
+
* worse than the file, and that is Kill-register K3 of the roadmap rather than
|
|
235
|
+
* a preference here.
|
|
236
|
+
*
|
|
237
|
+
* Silent in the normal case, which is what keeps it worth reading: a record
|
|
238
|
+
* younger than {@link RECYCLE_MAX_AGE_HOURS} is a pending resume, not a defect,
|
|
239
|
+
* and past that age the consumer discards it loudly on its own. What this
|
|
240
|
+
* reports is the third state — old enough to be dead, still sitting there,
|
|
241
|
+
* which means no successor ever came for it.
|
|
242
|
+
*/
|
|
243
|
+
export function unconsumedRecordLines(
|
|
244
|
+
workspaceRoot: string,
|
|
245
|
+
now: Date = new Date(),
|
|
246
|
+
maxAgeHours: number = RECYCLE_MAX_AGE_HOURS,
|
|
247
|
+
): string[] {
|
|
248
|
+
const out: string[] = [];
|
|
249
|
+
for (const file of listContinuityRecords(workspaceRoot)) {
|
|
250
|
+
let raw: string;
|
|
251
|
+
try {
|
|
252
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
253
|
+
} catch {
|
|
254
|
+
continue; // an unreadable record proves nothing in either direction
|
|
255
|
+
}
|
|
256
|
+
let written: unknown;
|
|
257
|
+
let nextTask: unknown;
|
|
258
|
+
try {
|
|
259
|
+
const parsed = JSON.parse(raw) as { written_at?: unknown; next_task?: unknown };
|
|
260
|
+
written = parsed.written_at;
|
|
261
|
+
nextTask = parsed.next_task;
|
|
262
|
+
} catch {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (typeof written !== 'string') continue;
|
|
266
|
+
const stamp = Date.parse(written);
|
|
267
|
+
if (Number.isNaN(stamp)) continue;
|
|
268
|
+
const ageHours = (now.getTime() - stamp) / 3_600_000;
|
|
269
|
+
if (ageHours <= maxAgeHours) continue;
|
|
270
|
+
const task = typeof nextTask === 'string' && nextTask.trim() !== '' ? nextTask.trim() : '(no next_task)';
|
|
271
|
+
out.push(
|
|
272
|
+
`unconsumed continuity record: ${path.relative(workspaceRoot, file)} is ` +
|
|
273
|
+
`${ageHours.toFixed(0)}h old and was never read — next_task was "${task.slice(0, 120)}"`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
213
279
|
/**
|
|
214
280
|
* The follow-up line: the advisory already fired, and no envelope arrived.
|
|
215
281
|
*
|
|
@@ -389,7 +455,7 @@ export function main(): number {
|
|
|
389
455
|
// Best-effort throughout. A checkpoint is a recovery aid, and a
|
|
390
456
|
// recovery aid that can fail a Stop is a liability.
|
|
391
457
|
const checkpointRunId = payloadSessionId(payload, envelope);
|
|
392
|
-
if (shouldAdvise && checkpointRunId !== '') {
|
|
458
|
+
if (shouldAdvise && checkpointRunId !== '' && run_checkpoints_enabled(workspaceRoot)) {
|
|
393
459
|
try {
|
|
394
460
|
const slug = read_claimed_slug(workspaceRoot, checkpointRunId);
|
|
395
461
|
if (slug !== null) {
|
|
@@ -411,6 +477,8 @@ export function main(): number {
|
|
|
411
477
|
}
|
|
412
478
|
}
|
|
413
479
|
|
|
480
|
+
writeContinuityRecord(workspaceRoot, rawSessionId, counters, threshold, tokens);
|
|
481
|
+
|
|
414
482
|
if (shouldAdvise && threshold !== null && tokens !== null) {
|
|
415
483
|
process.stdout.write(
|
|
416
484
|
`${JSON.stringify({
|
|
@@ -434,6 +502,56 @@ export function main(): number {
|
|
|
434
502
|
return 0;
|
|
435
503
|
}
|
|
436
504
|
|
|
505
|
+
/**
|
|
506
|
+
* The continuity-record handler — one of the three lifecycle handlers this
|
|
507
|
+
* concern carries, and independently switchable from the other two.
|
|
508
|
+
*
|
|
509
|
+
* `road-to-continuity-writer-activation` step 1.2, and it lands INSIDE this
|
|
510
|
+
* concern rather than as a fourth concern id on purpose: `check_estate_count`
|
|
511
|
+
* ratchets `concern_count` with allowance 0, and the 2026-09-08 AI council
|
|
512
|
+
* refused a temporary allowance 2/2. The substance the 2026-09-07 council's D2
|
|
513
|
+
* asked for is independent switching and failure isolation, and a handler
|
|
514
|
+
* behind its own settings key inside a `try` gives both without a manifest
|
|
515
|
+
* split. The split itself is step 2.1 and stays blocked until a concern is
|
|
516
|
+
* retired to pay for it.
|
|
517
|
+
*
|
|
518
|
+
* Fires from the recycle THRESHOLD, not from the once-per-session advisory
|
|
519
|
+
* stamp: the stamp fires once, so a record written from it would be frozen at
|
|
520
|
+
* the moment the session crossed the threshold and would go stale as the
|
|
521
|
+
* session continued. Driving it from the raw threshold condition instead means
|
|
522
|
+
* every later Stop supersedes the record under the slot's own policy, so what a
|
|
523
|
+
* successor reads is always the newest state, and the write rate is still
|
|
524
|
+
* bounded to the end phase of a session rather than every turn of it.
|
|
525
|
+
*
|
|
526
|
+
* Best-effort throughout, like the checkpoint above it: a continuity record is
|
|
527
|
+
* a recovery aid, and a recovery aid that can fail a Stop is a liability.
|
|
528
|
+
*/
|
|
529
|
+
function writeContinuityRecord(
|
|
530
|
+
workspaceRoot: string,
|
|
531
|
+
rawSessionId: string | null,
|
|
532
|
+
counters: EolCounters,
|
|
533
|
+
threshold: number | null,
|
|
534
|
+
tokens: number | null,
|
|
535
|
+
): void {
|
|
536
|
+
if (threshold === null || tokens === null || tokens < threshold) return;
|
|
537
|
+
const sessionId = String(rawSessionId ?? '').trim();
|
|
538
|
+
if (sessionId === '') return;
|
|
539
|
+
try {
|
|
540
|
+
if (!auto_record_enabled(workspaceRoot)) return;
|
|
541
|
+
const slug = read_claimed_slug(workspaceRoot, sessionId);
|
|
542
|
+
const decision = buildContinuityRecord({
|
|
543
|
+
root: workspaceRoot,
|
|
544
|
+
sessionId,
|
|
545
|
+
slug,
|
|
546
|
+
counters,
|
|
547
|
+
});
|
|
548
|
+
if (decision.record === null) return;
|
|
549
|
+
publishContinuityRecord(workspaceRoot, sessionId, decision.record);
|
|
550
|
+
} catch {
|
|
551
|
+
// never block the Stop path, and never suppress a sibling handler
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
437
555
|
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
438
556
|
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
439
557
|
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
@@ -397,27 +397,74 @@ export function restore_hot_context(
|
|
|
397
397
|
|
|
398
398
|
/**
|
|
399
399
|
* Build the compact memory index when `memory.session_index: on`; `null`
|
|
400
|
-
* on the default-off path, empty corpus,
|
|
401
|
-
*
|
|
402
|
-
*
|
|
400
|
+
* on the default-off path, empty corpus, a refused root, an already-served
|
|
401
|
+
* session, or any failure (never blocks).
|
|
402
|
+
*
|
|
403
|
+
* Memory roots are cwd-relative (same contract as the MCP tool handlers), so
|
|
404
|
+
* the build runs chdir-wrapped to the workspace root — and that relativity is
|
|
405
|
+
* exactly why the trust contract exists: whatever cwd this lands on is where
|
|
406
|
+
* `agents/memory` is read from. `_lib/session_index_trust.ts` carries the six
|
|
407
|
+
* properties the 2026-09-07 D3 ruling required (identity, canonicalization,
|
|
408
|
+
* freshness, ordering, duplicate invocation, size limits); this function is
|
|
409
|
+
* where two of them bind, because they need runtime facts the module cannot
|
|
410
|
+
* see — the workspace root and the session id.
|
|
411
|
+
*
|
|
412
|
+
* Refusals are logged with their code rather than swallowed. `null` alone
|
|
413
|
+
* cannot distinguish "no curated memory here" from "this root pointed at
|
|
414
|
+
* another tree", and that distinction is the whole point of the contract.
|
|
403
415
|
*/
|
|
404
|
-
function _session_index_block_or_null(root: string): string | null {
|
|
416
|
+
function _session_index_block_or_null(root: string, sessionId: string): string | null {
|
|
405
417
|
try {
|
|
406
418
|
// Lazy require (ESM-safe via createRequire) keeps the default-off
|
|
407
419
|
// fast path free of the memory_lookup + settings-cascade import cost.
|
|
408
420
|
const req = createRequire(import.meta.url);
|
|
409
421
|
const mod = req('./session_memory_index.js') as {
|
|
410
422
|
session_index_enabled: (root: string) => boolean;
|
|
411
|
-
|
|
423
|
+
serve_session_index_block: (grant: unknown, cap?: number) => string | null;
|
|
424
|
+
session_index_trust: (workspaceRoot: string) => { ok: boolean; code?: string; detail?: string };
|
|
412
425
|
};
|
|
413
426
|
if (!mod.session_index_enabled(root)) return null;
|
|
427
|
+
|
|
428
|
+
// P1-P3, computed ONCE here and handed to the server as a grant. The
|
|
429
|
+
// builder used to re-check it, which R2 finding 7 named as a duplicate
|
|
430
|
+
// for a diagnostic nothing forwards; `serve_session_index_block` now
|
|
431
|
+
// takes the grant, so the check cannot be skipped and is not repeated.
|
|
432
|
+
const verdict = mod.session_index_trust(root);
|
|
433
|
+
if (!verdict.ok) {
|
|
434
|
+
process.stderr.write(`hot-context-hook: session index refused [${String(verdict.code)}]: ${String(verdict.detail)}\n`);
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
414
438
|
const prev = process.cwd();
|
|
439
|
+
let block: string | null;
|
|
415
440
|
try {
|
|
416
441
|
process.chdir(root);
|
|
417
|
-
|
|
442
|
+
block = mod.serve_session_index_block(verdict);
|
|
418
443
|
} finally {
|
|
419
444
|
process.chdir(prev);
|
|
420
445
|
}
|
|
446
|
+
|
|
447
|
+
// P5 — duplicate invocation, claimed LAST. `session_start` can fire
|
|
448
|
+
// more than once for one session (resume, host reconnect, compaction
|
|
449
|
+
// boundary on some hosts), and two injections double a fixed cost while
|
|
450
|
+
// presenting two corpora as one session's memory.
|
|
451
|
+
//
|
|
452
|
+
// The ordering is the whole property and it took two corrections to get
|
|
453
|
+
// right. Claiming before the verdict let a refused root burn the
|
|
454
|
+
// session's one claim; claiming before the RENDER let an empty corpus
|
|
455
|
+
// burn it too (R2 finding 5) — so a session that started before its
|
|
456
|
+
// memory was curated would never receive an index afterwards. Nothing
|
|
457
|
+
// is claimed until there is something to serve.
|
|
458
|
+
if (block === null) return null;
|
|
459
|
+
const trust = req('./_lib/session_index_trust.js') as {
|
|
460
|
+
claimSessionOnce: (workspaceRoot: string, sessionId: string) => { ok: boolean; code?: string };
|
|
461
|
+
};
|
|
462
|
+
const claim = trust.claimSessionOnce(root, sessionId);
|
|
463
|
+
if (!claim.ok) {
|
|
464
|
+
process.stderr.write(`hot-context-hook: session index already served [${String(claim.code)}]\n`);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
return block;
|
|
421
468
|
} catch (exc) {
|
|
422
469
|
process.stderr.write(`hot-context-hook: session index skipped: ${String(exc)}\n`);
|
|
423
470
|
return null;
|
|
@@ -486,7 +533,15 @@ export function main(): number {
|
|
|
486
533
|
// Opt-in compact memory index (road-to-memory-retrieval-economy
|
|
487
534
|
// P5) — default OFF; rides the same injection surface. Memory
|
|
488
535
|
// roots are cwd-relative, so resolve from the workspace root.
|
|
489
|
-
|
|
536
|
+
// R2 finding 1: reading the payload alone left P5 INERT whenever
|
|
537
|
+
// the host supplied no payload id. `dispatch_hook.ts` resolves
|
|
538
|
+
// `envelope.session_id` from the payload OR `AGENT_SESSION_ID`, and
|
|
539
|
+
// every sibling concern reads the envelope first — proven by two
|
|
540
|
+
// dispatcher runs that emitted the block twice and never latched.
|
|
541
|
+
const sessionId = String(
|
|
542
|
+
envelope.session_id ?? payload.session_id ?? payload.sessionId ?? '',
|
|
543
|
+
);
|
|
544
|
+
const indexBlock = _session_index_block_or_null(root, sessionId);
|
|
490
545
|
if (indexBlock !== null) {
|
|
491
546
|
blocks.push(indexBlock);
|
|
492
547
|
}
|
|
@@ -360,6 +360,130 @@ function _countRoadmapTree(dir: string): number {
|
|
|
360
360
|
return n;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Blocker ids declared `Status: open` in one file, by id.
|
|
365
|
+
*
|
|
366
|
+
* Reads exactly one fact per blocker — id and open-ness. It is deliberately
|
|
367
|
+
* NOT `_scanBoth`: the five-field contract, the class taxonomy and the
|
|
368
|
+
* decidability ratchet are not applied here, because the corpus this runs over
|
|
369
|
+
* includes `archive/` and applying the contract there lands 23 findings on day
|
|
370
|
+
* one against a change that caused none of them. That shape has been reverted
|
|
371
|
+
* twice in this repository — `lint_roadmap_later_disposition` at 68 files and
|
|
372
|
+
* `check_no_new_legacy_path` at 46 — and the exclusion also protects a true
|
|
373
|
+
* thing an archived roadmap is allowed to say: a blocker genuinely unresolved
|
|
374
|
+
* when its roadmap closed is history, not a defect.
|
|
375
|
+
*/
|
|
376
|
+
function _openBlockerIds(text: string): Set<string> {
|
|
377
|
+
const stripped = _stripFencedCode(text);
|
|
378
|
+
const out = new Set<string>();
|
|
379
|
+
const sectionMatch = BLOCKERS_SECTION_RE.exec(stripped);
|
|
380
|
+
if (!sectionMatch) {
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
const sectionStart = sectionMatch.index + sectionMatch[0].length;
|
|
384
|
+
const rest = stripped.slice(sectionStart);
|
|
385
|
+
const h2 = NEXT_H2_RE.exec(rest);
|
|
386
|
+
const section = stripped.slice(sectionStart, h2 ? sectionStart + h2.index : stripped.length);
|
|
387
|
+
|
|
388
|
+
BLOCKER_HEADING_RE.lastIndex = 0;
|
|
389
|
+
const heads: Array<{ end: number; start: number; id: string }> = [];
|
|
390
|
+
let hm: RegExpExecArray | null;
|
|
391
|
+
while ((hm = BLOCKER_HEADING_RE.exec(section)) !== null) {
|
|
392
|
+
heads.push({ start: hm.index, end: hm.index + hm[0].length, id: (hm[1] as string).trim() });
|
|
393
|
+
if (hm.index === BLOCKER_HEADING_RE.lastIndex) {
|
|
394
|
+
BLOCKER_HEADING_RE.lastIndex++;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (let i = 0; i < heads.length; i++) {
|
|
398
|
+
const cur = heads[i] as { end: number; start: number; id: string };
|
|
399
|
+
const bodyEnd =
|
|
400
|
+
i + 1 < heads.length ? (heads[i + 1] as { start: number }).start : section.length;
|
|
401
|
+
const body = section.slice(cur.end, bodyEnd);
|
|
402
|
+
// Resolved is a prefix test for the same reason `blocker_is_resolved`
|
|
403
|
+
// makes it one: `resolved 2026-09-08 by …` is resolved.
|
|
404
|
+
if (!/^-[ \t]*\*\*Status:\*\*[ \t]*resolved\b/im.test(body)) {
|
|
405
|
+
out.add(cur.id);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Sorted `*.md` directly under the archive directory. */
|
|
412
|
+
function _globArchivedRoadmaps(): string[] {
|
|
413
|
+
const dir = path.join(REPO_ROOT, 'agents', 'roadmaps', 'archive');
|
|
414
|
+
let entries: fs.Dirent[];
|
|
415
|
+
try {
|
|
416
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
417
|
+
} catch {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
return entries
|
|
421
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
422
|
+
.map((e) => path.join(dir, e.name))
|
|
423
|
+
.sort();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
interface Overlap {
|
|
427
|
+
id: string;
|
|
428
|
+
active: string[];
|
|
429
|
+
archived: string[];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** id → the files (repo-relative, sorted by input order) declaring it open. */
|
|
433
|
+
function _openIdIndex(files: string[]): Map<string, string[]> {
|
|
434
|
+
const index = new Map<string, string[]>();
|
|
435
|
+
for (const f of files) {
|
|
436
|
+
const rel = _relPosix(f, REPO_ROOT);
|
|
437
|
+
for (const id of _openBlockerIds(fs.readFileSync(f, 'utf-8'))) {
|
|
438
|
+
const hits = index.get(id);
|
|
439
|
+
if (hits === undefined) {
|
|
440
|
+
index.set(id, [rel]);
|
|
441
|
+
} else {
|
|
442
|
+
hits.push(rel);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return index;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Blocker ids declared open in BOTH an active and an archived roadmap.
|
|
451
|
+
*
|
|
452
|
+
* The assertion is the one an archived record cannot legitimately make: a
|
|
453
|
+
* blocker still being decided in the active tree, simultaneously declared open
|
|
454
|
+
* in a file that says the work closed. The transition check in
|
|
455
|
+
* `archive_completed_roadmaps` catches the archival that produces this state;
|
|
456
|
+
* this catches it however else it arrives — a hand edit, a bad merge, or a
|
|
457
|
+
* commit that shipped stale index content.
|
|
458
|
+
*
|
|
459
|
+
* `open_blockers` is a shrink-only ratchet over the ACTIVE tree only, so
|
|
460
|
+
* without this the count is satisfiable by archiving rather than by resolving.
|
|
461
|
+
*
|
|
462
|
+
* Deliberately NOT "no id open in more than one file". Two ACTIVE roadmaps may
|
|
463
|
+
* legitimately share one cross-cutting blocker, and forbidding that would break
|
|
464
|
+
* a real pattern to catch a different defect (AI council 2026-09-08, 2/2
|
|
465
|
+
* present: the unscoped form "forbids legitimate cross-cutting blockers in
|
|
466
|
+
* multiple active roadmaps"). Two ARCHIVED files sharing an open id is out of
|
|
467
|
+
* scope for the same day-one reason as the contract itself: measured at 2 on
|
|
468
|
+
* this tree, so a hard assertion there would red the build on records this
|
|
469
|
+
* change did not create.
|
|
470
|
+
*/
|
|
471
|
+
function _archiveOverlap(
|
|
472
|
+
activeFiles: string[] = _globRoadmaps(),
|
|
473
|
+
archivedFiles: string[] = _globArchivedRoadmaps(),
|
|
474
|
+
): Overlap[] {
|
|
475
|
+
const active = _openIdIndex(activeFiles);
|
|
476
|
+
const archived = _openIdIndex(archivedFiles);
|
|
477
|
+
const out: Overlap[] = [];
|
|
478
|
+
for (const [id, activeHits] of [...active.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
479
|
+
const archivedHits = archived.get(id);
|
|
480
|
+
if (archivedHits !== undefined) {
|
|
481
|
+
out.push({ id, active: activeHits, archived: archivedHits });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
363
487
|
function main(): number {
|
|
364
488
|
try {
|
|
365
489
|
assertScanned({
|
|
@@ -411,6 +535,38 @@ function main(): number {
|
|
|
411
535
|
return 1;
|
|
412
536
|
}
|
|
413
537
|
|
|
538
|
+
// An archived roadmap says the work closed. An open blocker declared in one
|
|
539
|
+
// AND in an active roadmap therefore contradicts itself, and the shrink-only
|
|
540
|
+
// `open_blockers` ratchet reads the active tree only — so without this the
|
|
541
|
+
// count is satisfiable by archiving rather than by resolving.
|
|
542
|
+
//
|
|
543
|
+
// HARD rather than ratcheted, and measured before it shipped: 0 overlapping
|
|
544
|
+
// ids on this tree at 2026-09-08 (12 active open ids, 33 archived), so on
|
|
545
|
+
// the day it lands it fires on nothing and there is no backlog to
|
|
546
|
+
// grandfather — the same argument the `Class:` contract above makes.
|
|
547
|
+
const overlap = _archiveOverlap();
|
|
548
|
+
if (overlap.length > 0) {
|
|
549
|
+
process.stderr.write(
|
|
550
|
+
`\n❌ ${overlap.length} blocker id(s) declared open in both an active ` +
|
|
551
|
+
'and an archived roadmap\n',
|
|
552
|
+
);
|
|
553
|
+
for (const o of overlap) {
|
|
554
|
+
process.stderr.write(` ${o.id}\n`);
|
|
555
|
+
for (const rel of o.active) {
|
|
556
|
+
process.stderr.write(` active: ${rel}\n`);
|
|
557
|
+
}
|
|
558
|
+
for (const rel of o.archived) {
|
|
559
|
+
process.stderr.write(` archived: ${rel}\n`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
process.stderr.write(
|
|
563
|
+
'\n Resolve it in one place. An archived record may keep a blocker that\n' +
|
|
564
|
+
' was genuinely unresolved when its roadmap closed; it may not keep one\n' +
|
|
565
|
+
' the active tree is still deciding.\n',
|
|
566
|
+
);
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
|
|
414
570
|
// The decidability ratchet. A blocker that names no option, no command and
|
|
415
571
|
// no recommendation is a research task handed to the person with the least
|
|
416
572
|
// context — the defect this half of the gate exists to stop growing.
|
|
@@ -434,6 +590,10 @@ function main(): number {
|
|
|
434
590
|
}
|
|
435
591
|
if (!QUIET) {
|
|
436
592
|
process.stdout.write(`\n✅ ${roadmaps.length} roadmap(s) blocker-contract-clean\n`);
|
|
593
|
+
process.stdout.write(
|
|
594
|
+
`✅ 0 blocker id(s) open in both an active and an archived roadmap ` +
|
|
595
|
+
`(${_globArchivedRoadmaps().length} archived file(s) read)\n`,
|
|
596
|
+
);
|
|
437
597
|
process.stdout.write(`✅ ${verdict.message}\n`);
|
|
438
598
|
}
|
|
439
599
|
return 0;
|
|
@@ -477,6 +637,9 @@ export {
|
|
|
477
637
|
_scan,
|
|
478
638
|
_scanBoth,
|
|
479
639
|
_globRoadmaps,
|
|
640
|
+
_globArchivedRoadmaps,
|
|
641
|
+
_openBlockerIds,
|
|
642
|
+
_archiveOverlap,
|
|
480
643
|
main,
|
|
481
644
|
};
|
|
482
|
-
export type { Violation, ScanResult };
|
|
645
|
+
export type { Violation, ScanResult, Overlap };
|