@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.
Files changed (111) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +159 -380
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/merge.md +47 -38
  7. package/dist/agent-src/commands/roadmap/next.md +8 -0
  8. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
  9. package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
  10. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
  11. package/dist/agent-src/skills/code-intelligence/SKILL.md +57 -1
  12. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
  13. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +1 -1
  16. package/dist/cli-delegate/archive_completed_roadmaps.js +52 -4
  17. package/dist/cli-delegate/{chunk-TGIPZBCW.js → chunk-2KKXJW25.js} +22 -8
  18. package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
  19. package/dist/cli-delegate/{chunk-BIXEDQPZ.js → chunk-KCNTUSPI.js} +1 -1
  20. package/dist/cli-delegate/cmd_export.js +2 -2
  21. package/dist/cli-delegate/cmd_handoff.js +20 -12
  22. package/dist/cli-delegate/cmd_prune.js +2 -2
  23. package/dist/cli-delegate/cmd_session_recycle.js +3 -13
  24. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  25. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  26. package/dist/cli-delegate/cmd_sync.js +2 -2
  27. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  28. package/dist/cli-delegate/cmd_validate.js +2 -2
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +8 -8
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +1 -1
  33. package/dist/discovery/dormancy-report.md +1 -1
  34. package/dist/discovery/orphan-report.md +1 -1
  35. package/dist/discovery/packs.json +2 -2
  36. package/dist/discovery/trust-report.md +1 -1
  37. package/dist/discovery/workspaces.json +2 -2
  38. package/dist/hooks/dispatch.js +880 -289
  39. package/dist/install/install.mjs +15 -3
  40. package/dist/mcp/registry-manifest.json +2 -2
  41. package/dist/mcp/server.json +2 -2
  42. package/dist/mcp/server.mjs +5458 -2453
  43. package/dist/scripts/_lib/lean_projection_mode.js +108 -0
  44. package/dist/scripts/_lib/router_match.js +243 -0
  45. package/dist/scripts/_lib/rule_injection.js +216 -0
  46. package/dist/scripts/condense.js +20 -20
  47. package/dist/scripts/project_thin_rules.js +139 -19
  48. package/dist/server/schemas/settings.js +16 -3
  49. package/docs/MIGRATION.md +56 -0
  50. package/docs/contracts/settings-classes.md +39 -7
  51. package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
  52. package/llms.txt +1 -1
  53. package/package.json +1 -1
  54. package/src/agent-src/scripts/archive_completed_roadmaps.ts +106 -4
  55. package/src/config/agent-settings.template.yml +47 -1
  56. package/src/config/continuity-surface.json +7 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-violation-baselines.json +2 -2
  59. package/src/config/hook-token-budget.json +7 -6
  60. package/src/config/packed-binary-manifest.json +1 -1
  61. package/src/config/routing-coverage-seed.json +9 -2
  62. package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
  63. package/src/scripts/_cli/handoff_sessions.ts +14 -44
  64. package/src/scripts/_lib/continuity_slot.ts +295 -0
  65. package/src/scripts/_lib/continuity_writer.ts +253 -0
  66. package/src/scripts/_lib/hook_settings.ts +85 -0
  67. package/src/scripts/_lib/lean_projection_mode.ts +127 -0
  68. package/src/scripts/_lib/packed_binary_predicate.ts +37 -0
  69. package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
  70. package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
  71. package/src/scripts/_lib/session_eol.ts +44 -0
  72. package/src/scripts/_lib/session_index_trust.ts +346 -0
  73. package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
  74. package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
  75. package/src/scripts/audit_mcp_tools.ts +69 -25
  76. package/src/scripts/build_mcp_catalog.ts +24 -1
  77. package/src/scripts/check_host_tree_parity.ts +291 -0
  78. package/src/scripts/check_preamble_payload_budget.ts +136 -7
  79. package/src/scripts/check_rule_projection_integrity.ts +102 -2
  80. package/src/scripts/ci_settle.ts +205 -5
  81. package/src/scripts/code_graph/build.ts +79 -0
  82. package/src/scripts/code_graph/cli.ts +258 -7
  83. package/src/scripts/code_graph/detect.ts +38 -0
  84. package/src/scripts/code_graph/query.ts +25 -2
  85. package/src/scripts/code_graph/sqlite_store.ts +16 -1
  86. package/src/scripts/code_graph/types.ts +52 -3
  87. package/src/scripts/code_graph/validate.ts +3 -1
  88. package/src/scripts/code_graph/verbs.ts +590 -0
  89. package/src/scripts/condense.ts +20 -20
  90. package/src/scripts/handoff_context_hook.ts +39 -1
  91. package/src/scripts/hook_manifest.json +1 -1
  92. package/src/scripts/hook_manifest.yaml +24 -1
  93. package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
  94. package/src/scripts/hooks/code_graph_context_hook.ts +7 -18
  95. package/src/scripts/hooks/push_settle_hook.ts +3 -2
  96. package/src/scripts/hooks/rule_inject_hook.ts +107 -20
  97. package/src/scripts/hooks/session_eol_hook.ts +120 -2
  98. package/src/scripts/hot_context_hook.ts +62 -7
  99. package/src/scripts/lint_roadmap_blockers.ts +164 -1
  100. package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
  101. package/src/scripts/mcp_server/graph_tools.ts +294 -0
  102. package/src/scripts/mcp_server/path_util.ts +31 -0
  103. package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
  104. package/src/scripts/mcp_server/tools.ts +3 -13
  105. package/src/scripts/model_rule_injection.ts +61 -4
  106. package/src/scripts/project_thin_rules.ts +149 -16
  107. package/src/scripts/report_standing_payload_by_host.ts +650 -0
  108. package/src/scripts/schemas/agent-settings.schema.json +7 -1
  109. package/src/scripts/session_memory_index.ts +81 -8
  110. package/src/server/schemas/settings.ts +23 -4
  111. 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
- * DEFAULT OFF, AND OFF MEANS ZERO BYTES. The concern registers on two slots but
15
- * returns before reading the router unless `lean_projection.mode: delivery` is
16
- * set. Under every shipped default it emits nothing, costs no injection budget,
17
- * and leaves the standing corpus exactly as it is today. That is why its
18
- * `hook-token-budget.json` row is registered against the per-prompt cap rather
19
- * than a measured emission: there is no measured emission to register yet.
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 { deliversBodies, normalizeLeanProjectionMode } from '../_lib/lean_projection_mode.js';
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 the same bound in the unit
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 is 20,480 B, which is exactly this concern's registered
87
- * row. Cap and budget row are now one number instead of two units.
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 = 20480;
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
- * Through the dispatcher the gate is absolute: no `lean_projection.mode:
223
- * delivery`, no bytes. A DIRECT CLI invocation is a probe by definition — an
224
- * operator piping an envelope into this file is asking to see what it would
225
- * deliver — so there the mode defaults to on. `AGENT_CONFIG_REPLAY` re-imposes
226
- * the gate, which is what keeps `bench_hook_injection` measuring the shipped
227
- * default (zero bytes) rather than the probe.
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
- if (deliversBodies(normalizeLeanProjectionMode(leanProjectionModeRaw(root)))) return true;
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 { recycle_envelope_rel } from '../_lib/recycle_envelope_paths.js';
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, or any failure (never blocks).
401
- * Memory roots are cwd-relative (same contract as the MCP tool handlers),
402
- * so the build runs chdir-wrapped to the workspace root.
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
- build_session_index_block: () => string | null;
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
- return mod.build_session_index_block();
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
- const indexBlock = _session_index_block_or_null(root);
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 };