@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
@@ -42,3 +42,130 @@ export function writesThinFiles(mode: LeanProjectionMode): boolean {
42
42
  export function deliversBodies(mode: LeanProjectionMode): boolean {
43
43
  return mode === 'delivery';
44
44
  }
45
+
46
+ /**
47
+ * The hosts a thinning mode may actually thin.
48
+ *
49
+ * Deliberately NOT "every host id the package knows". `condense`'s `TOOL_DIRS`
50
+ * writes a per-rule tree for exactly three hosts, so those are the only ids for
51
+ * which `lean_projection.hosts` can mean anything: naming `windsurf` there would
52
+ * be a request to thin a single concatenated file that has no per-rule stub
53
+ * shape, and naming `codex` a request to thin a tree the installer writes and
54
+ * `condense` never touches. Accepting such an id silently would leave the
55
+ * operator believing a host was scoped when nothing reads the entry.
56
+ */
57
+ export const THINNABLE_HOSTS: readonly string[] = ['claude-code', 'cursor', 'cline'];
58
+
59
+ /**
60
+ * The shipped default host set for a thinning mode.
61
+ *
62
+ * One host, and it is the one host that both binds `pre_tool_use` and honours a
63
+ * deny (`docs/enforcement-by-host.md:18-28`) — i.e. the only host where the
64
+ * delivery concern's verdict is acted on. Widening this is a decision, never a
65
+ * default: see `resolveLeanProjectionHosts`, which never widens implicitly.
66
+ */
67
+ export const DEFAULT_LEAN_PROJECTION_HOSTS: readonly string[] = ['claude-code'];
68
+
69
+ /** Why an id in a configured `hosts:` list was not kept. */
70
+ export type DroppedHostReason = 'unknown' | 'not-thinnable';
71
+
72
+ export interface LeanProjectionHosts {
73
+ /** The ids that survived, sorted and de-duplicated. */
74
+ readonly hosts: readonly string[];
75
+ /** Ids that were dropped, with the reason — reported, never silently discarded. */
76
+ readonly dropped: ReadonlyArray<{ readonly id: string; readonly reason: DroppedHostReason }>;
77
+ /** True when nothing was configured and the default was applied. */
78
+ readonly usedDefault: boolean;
79
+ }
80
+
81
+ /**
82
+ * Resolve a configured `lean_projection.hosts` list.
83
+ *
84
+ * Three properties, each of which is a failure this repository has seen in a
85
+ * neighbouring config surface:
86
+ *
87
+ * 1. **Absent means the default, never "all".** An empty or missing list
88
+ * resolves to `DEFAULT_LEAN_PROJECTION_HOSTS`. A key nobody set must not
89
+ * thin every host — that is D1, the defect this axis exists to repair.
90
+ * 2. **The set never widens implicitly.** Every id is checked against
91
+ * `THINNABLE_HOSTS`; anything else is dropped. A typo cannot enrol a host,
92
+ * and neither can a real host id that has no per-rule tree.
93
+ * 3. **A drop is reported, not swallowed.** The caller gets the ids and the
94
+ * reason so it can print them. Silently ignoring an entry an operator wrote
95
+ * is how a setting comes to mean nothing while looking configured.
96
+ *
97
+ * A configured list that resolves to NOTHING keeps `usedDefault: false` and an
98
+ * empty `hosts` — that is "thin no host", which is a legitimate and safe answer
99
+ * and must not fall back to the default. Falling back there would turn a
100
+ * fully-typo'd list into a Claude Code flip the operator never asked for.
101
+ *
102
+ * WHICH FILES REACH THIS AT ALL, stated because R2 finding 6 read the drop path
103
+ * as unreachable: `agent-settings.schema.json` and the wizard's Zod schema both
104
+ * constrain the items to an `enum`, so an out-of-vocabulary id written through
105
+ * either surface is a hard validation FAILURE and never arrives here. What
106
+ * arrives here is a HAND-EDITED `.agent-settings.yml` — the file
107
+ * `condense._lean_projection_settings` and `hooks/rule_inject_hook.gateOpen`
108
+ * both read directly, with no validation step in between. The two layers are
109
+ * defence in depth, not one contract stated twice: the schema is the authoring
110
+ * gate, this function is the read-time gate, and the drop wording below exists
111
+ * for the file the schema never saw.
112
+ */
113
+ export function resolveLeanProjectionHosts(raw: unknown): LeanProjectionHosts {
114
+ const known = new Set(THINNABLE_HOSTS);
115
+ if (!Array.isArray(raw) || raw.length === 0) {
116
+ return { hosts: [...DEFAULT_LEAN_PROJECTION_HOSTS], dropped: [], usedDefault: true };
117
+ }
118
+ const hosts: string[] = [];
119
+ const dropped: Array<{ id: string; reason: DroppedHostReason }> = [];
120
+ for (const entry of raw) {
121
+ const id = typeof entry === 'string' ? entry.trim().toLowerCase() : '';
122
+ if (id === '') {
123
+ dropped.push({ id: String(entry), reason: 'unknown' });
124
+ continue;
125
+ }
126
+ if (!known.has(id)) {
127
+ // `not-thinnable` is reserved for an id this package genuinely knows
128
+ // as a host but which owns no per-rule tree. Everything else is a
129
+ // typo or an invention, and the two deserve different wording
130
+ // because the operator's next action differs: fix the spelling, or
131
+ // learn that the host cannot be thinned at all.
132
+ dropped.push({ id, reason: KNOWN_NON_THINNABLE_HOSTS.has(id) ? 'not-thinnable' : 'unknown' });
133
+ continue;
134
+ }
135
+ if (!hosts.includes(id)) hosts.push(id);
136
+ }
137
+ hosts.sort();
138
+ return { hosts, dropped, usedDefault: false };
139
+ }
140
+
141
+ /**
142
+ * Host ids the package ships surfaces for that own no per-rule rule tree.
143
+ *
144
+ * Only used to word a drop precisely; it grants nothing. Sourced from
145
+ * `condense._ALL_TOOLS` plus the two hosts outside it that the host table
146
+ * carries (`codex`, `cowork`).
147
+ */
148
+ const KNOWN_NON_THINNABLE_HOSTS: ReadonlySet<string> = new Set([
149
+ 'claude-desktop', 'augment', 'copilot', 'windsurf', 'gemini', 'codex', 'cowork',
150
+ ]);
151
+
152
+ /** One line per dropped id, for a caller that prints warnings. Empty when nothing was dropped. */
153
+ export function describeDroppedHosts(res: LeanProjectionHosts): string[] {
154
+ return res.dropped.map(({ id, reason }) =>
155
+ reason === 'not-thinnable'
156
+ ? `lean_projection.hosts: "${id}" is a known host but has no per-rule rule tree — dropped`
157
+ : `lean_projection.hosts: "${id}" is not a thinnable host id — dropped`,
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Does the projector write stubs for this host, under this mode and host set?
163
+ *
164
+ * The single predicate `condense` calls. Mode is checked FIRST and on purpose:
165
+ * with `mode` unset the answer is `false` for every host regardless of what
166
+ * `hosts` says, so a `hosts:` list left behind after a rollback to `eager-all`
167
+ * thins nothing.
168
+ */
169
+ export function thinsHost(mode: LeanProjectionMode, hosts: readonly string[], hostId: string): boolean {
170
+ return writesThinFiles(mode) && hosts.includes(hostId);
171
+ }
@@ -61,6 +61,43 @@
61
61
  * that passed every condition above — never `observed` minus something excused.
62
62
  * A file that fails one condition lowers `allowed`; it never disappears from
63
63
  * `observed`.
64
+ *
65
+ * THE THREAT BOUNDARY — WHO THIS REFUSES, AND WHO IT DOES NOT.
66
+ *
67
+ * Written down because the eleven conditions read as a security control and are
68
+ * only half of one, and a reader could not previously tell that boundary from
69
+ * an oversight. The `release/14.22.0` self-review finding `d1696732ac28` is
70
+ * exactly that misreading: it reached for a signature this repository has no
71
+ * infrastructure to provide (`grep -rniE 'cosign|sigstore|minisign|slsa'`
72
+ * returns nothing in this tree), because nothing here said where the line was.
73
+ *
74
+ * REFUSED. A byte that drifts from what the manifest says — a corrupted copy, a
75
+ * truncated checkout, a refresh that updated the file and forgot the row, a
76
+ * grammar whose ABI moved under a version bump, a second row quietly widening a
77
+ * path. Each of those is one side of the pair changing while the other does
78
+ * not, and every condition above is a re-derivation from the bytes rather than
79
+ * a restatement of the row.
80
+ *
81
+ * NOT REFUSED, DELIBERATELY. An attacker who edits the manifest and the bytes in
82
+ * the SAME COMMIT. Every condition here reads the manifest, so a same-commit
83
+ * pair is internally consistent and passes all eleven. That case is out of
84
+ * scope for this module and is held by review instead: the manifest is a small
85
+ * JSON file whose diff a human reads, and a hash changing next to a binary
86
+ * changing is the most visible shape a diff has.
87
+ *
88
+ * WHAT CLOSES IT, AND WHERE. `_lib/vendored_grammar_upstream.ts` compares the
89
+ * vendored bytes against the `tree-sitter-wasms` release pinned in
90
+ * `package-lock.json` and installed by `npm ci` — a source outside this
91
+ * repository's own diff — and refuses rather than passes when that comparison
92
+ * cannot be made. It is exercised by `tests/scripts/vendored_grammar_upstream.test.ts`,
93
+ * which is where a reader auditing "what enforces these bytes" should go next.
94
+ * Its own docblock states what it may not claim: it is not independent of all
95
+ * repository state, and it does not verify the grammars against the registry
96
+ * independent of local build state.
97
+ *
98
+ * STILL OPEN, AND OWNED BY REVIEW. A `package-lock.json` row that points at a
99
+ * hostile package. No check in this tree defends that; the lock is
100
+ * human-readable text declaring dependencies, and reviewing it is the control.
64
101
  */
65
102
  import { createHash } from 'node:crypto';
66
103
  import * as fs from 'node:fs';
@@ -73,11 +73,49 @@ export function recycle_envelope_rel(session_id: string | null | undefined): str
73
73
  return _sessionKeyed(session_id, RECYCLE_ENVELOPE_REL, 'recycle-envelope', '.json');
74
74
  }
75
75
 
76
+ /**
77
+ * THE KEYING CONTRACT, in one place because its absence is what allowed the
78
+ * 2026-09-09 defect: the record is keyed by the **producing session** — the one
79
+ * that ran `session:recycle` — and it is READ by the **consuming session**,
80
+ * which is a different session with a different id. Those two facts were each
81
+ * true in their own module and were never written down together, so the
82
+ * producer and the consumer agreed on a PATH and disagreed about whose id
83
+ * filled it. {@link resolveContinuityRecord} is where that is now reconciled:
84
+ * it admits the unique record that is NOT the reader's own.
85
+ */
86
+
76
87
  /** Consumed sibling of {@link recycle_envelope_rel}, same keying. */
77
88
  export function recycle_consumed_rel(session_id: string | null | undefined): string {
78
89
  return _sessionKeyed(session_id, RECYCLE_CONSUMED_REL, 'recycle-envelope', '.consumed.json');
79
90
  }
80
91
 
92
+ /**
93
+ * Where an UNUSABLE resident record is moved aside so the slot can be
94
+ * published into — never deleted.
95
+ *
96
+ * `road-to-continuity-writer-activation` step 1.1. The reader already discards
97
+ * a malformed, schema-invalid or expired record loudly
98
+ * ({@link resolveContinuityRecord}'s callers), so nothing is lost by taking it
99
+ * out of the authoritative name. What WOULD be lost by `unlink` is the evidence
100
+ * of why a resume did not happen, and that is the only artefact anyone
101
+ * debugging a missed resume has. Quarantine is one file per session and is
102
+ * pruned by the same janitor as the rest of `agents/runtime/state/`.
103
+ *
104
+ * Deliberately NOT the consumed name: `.consumed.json` is a claim that a
105
+ * successor read the record, and `predecessorTracePresent` reads exactly that
106
+ * file to corroborate a lineage. Writing a never-consumed record there would
107
+ * manufacture a predecessor trace, which is the one thing that check exists to
108
+ * refuse.
109
+ */
110
+ export function recycle_quarantine_rel(session_id: string | null | undefined): string {
111
+ return _sessionKeyed(
112
+ session_id,
113
+ path.join(_STATE_REL, 'recycle-envelope.quarantined.json'),
114
+ 'recycle-envelope',
115
+ '.quarantined.json',
116
+ );
117
+ }
118
+
81
119
  /**
82
120
  * Shared builder. Containment is asserted locally rather than trusted from
83
121
  * `safe_stem`: a guarantee living in another module's implementation is one
@@ -133,44 +171,94 @@ export interface ContinuityResolution {
133
171
  /**
134
172
  * Which record THIS session may read.
135
173
  *
136
- * Three cases, and the third is the one the per-session key exists for:
174
+ * **A resume reads the PREDECESSOR's record, and this function used to look for
175
+ * the reader's own.** Corrected 2026-09-09 after measuring it: the producer
176
+ * writes under the PRODUCING session's id and the consumer arrives with its
177
+ * OWN, so the two never coincided and every successor was told `absent`. The
178
+ * mechanism resolved only for a caller with no session id at all — the one
179
+ * shape no host produces, and the only shape the 19 pre-existing consumer tests
180
+ * exercised. `road-to-a-recycle-envelope-that-is-consumed` carries the
181
+ * five-fixture table; AI council 2026-09-09, 2/2, chose the rule below.
182
+ *
183
+ * Four cases, and the second is the resume:
184
+ *
185
+ * 1. **Own record.** NEVER read. A session re-reading its own state is a loop,
186
+ * not a resume, and a second `session_start` for one session (resume, host
187
+ * reconnect) made it reachable. Excluded by construction here rather than
188
+ * guarded downstream.
189
+ * 2. **Exactly one record that is not the reader's own.** Read it. That is the
190
+ * predecessor.
191
+ * 3. **Several such records.** START CLEAN and say so — the existing refusal,
192
+ * unchanged and now reachable from both branches. Picking the newest is
193
+ * precisely the recency resolution the no-index rule below forbids.
194
+ * 4. **None.** `absent`.
137
195
  *
138
- * 1. **Own id resolves.** Read the record keyed to it, and nothing else. A peer
139
- * session's record is not a candidate at any point, so "neither observes the
140
- * other's" holds by construction rather than by a comparison that could be
141
- * skipped.
142
- * 2. **No id, one candidate.** Read it. A single record in a checkout with no
143
- * resolvable identity is the pre-key situation and is unchanged.
144
- * 3. **No id, several candidates.** START CLEAN and say so. Picking the newest
145
- * is precisely the recency resolution the no-index rule forbids: it would
146
- * hand this session a stranger's state and look like a successful resume.
196
+ * **Why this is not recency, stated because the rule it must not become is one
197
+ * line away:** nothing is sorted and nothing is compared by time. Uniqueness
198
+ * admits; ambiguity refuses. The peer-isolation intent the per-session key was
199
+ * introduced for therefore moves from the KEY to the REFUSAL — a live peer's
200
+ * record is no longer excluded by its filename, so the branch gate in
201
+ * `consume_recycle_envelope` is what keeps an unrelated session from resuming
202
+ * it. That gate is part of this rule, not a decoration on it.
147
203
  */
148
204
  export function resolveContinuityRecord(
149
205
  workspace_root: string,
150
206
  session_id: string | null | undefined,
151
207
  ): ContinuityResolution {
152
208
  const id = String(session_id ?? '').trim();
153
- if (id !== '') {
154
- const own = path.join(workspace_root, recycle_envelope_rel(id));
155
- return fs.existsSync(own)
156
- ? { file: own, reason: `continuity record for this session (${safe_stem(id)})` }
157
- : { file: null, reason: `no continuity record for this session (${safe_stem(id)})` };
158
- }
159
- const candidates = listContinuityRecords(workspace_root);
209
+ const own = id === '' ? null : path.join(workspace_root, recycle_envelope_rel(id));
210
+ const candidates = listContinuityRecords(workspace_root).filter((f) => f !== own);
160
211
  if (candidates.length === 0) {
161
- return { file: null, reason: 'no continuity record present' };
212
+ return {
213
+ file: null,
214
+ reason:
215
+ id === ''
216
+ ? 'no continuity record present'
217
+ : `no predecessor record in this workspace (own: ${safe_stem(id)})`,
218
+ };
162
219
  }
163
220
  if (candidates.length === 1) {
164
- return { file: candidates[0] as string, reason: 'one continuity record, no session id to key on' };
221
+ return {
222
+ file: candidates[0] as string,
223
+ reason:
224
+ id === ''
225
+ ? 'one continuity record, no session id to key on'
226
+ : `predecessor record, one candidate (own: ${safe_stem(id)})`,
227
+ };
165
228
  }
166
229
  return {
167
230
  file: null,
168
231
  reason:
169
- `starting clean: ${candidates.length} continuity records in this workspace and no session id ` +
170
- 'to tell them apart — resuming from the newest would resume a peer session, not this one',
232
+ `starting clean: ${candidates.length} continuity records in this workspace and no way to tell ` +
233
+ 'which is the predecessor — resuming from the newest would resume a peer session, not this one',
171
234
  };
172
235
  }
173
236
 
237
+ /**
238
+ * Which session this one continues — read, never composed.
239
+ *
240
+ * The chain is derivable without any new artifact: when this session started,
241
+ * its reader consumed the predecessor's record and MOVED it to this session's
242
+ * consumed path (`recycle_consumed_rel`). So the file sitting there is, by
243
+ * construction, the record this session resumed from, and its `session_id` is
244
+ * the predecessor.
245
+ *
246
+ * Returns the explicit string `none` when there is nothing there. `none` is a
247
+ * CLAIM — "this session starts a chain" — and is deliberately not an empty
248
+ * value: a reader must be able to tell "no predecessor" from "nobody wrote the
249
+ * field", because only the second one is a reason to go looking.
250
+ */
251
+ export function resolvePredecessor(projectRoot: string, sessionId: string | null): string {
252
+ try {
253
+ const consumed = path.join(projectRoot, recycle_consumed_rel(sessionId));
254
+ const raw = JSON.parse(fs.readFileSync(consumed, 'utf-8')) as { session_id?: unknown };
255
+ const prior = String(raw.session_id ?? '').trim();
256
+ return prior === '' ? 'none' : prior;
257
+ } catch {
258
+ return 'none';
259
+ }
260
+ }
261
+
174
262
  /**
175
263
  * Is there any trace in this workspace of the session a record names as its
176
264
  * predecessor?
@@ -12,30 +12,33 @@
12
12
  * > neighbourhood names, and a fixture proves a neighbour regression is
13
13
  * > caught.**
14
14
  *
15
- * ## Which graph, and why it is not the one the step names
15
+ * TWO GRAPHS, BOTH DELIBERATE — the substitution is retired.
16
16
  *
17
- * The step says "the code graph". This tree has two graph surfaces and only one
18
- * of them resolves here, so the substitution is STATED rather than hidden.
17
+ * The step says "the code graph". Until 2026-09-08 this module could not read
18
+ * it: `agent-config code-graph detect` answered `no code-graph source detected`
19
+ * in a fresh checkout, so it selected over the artefact graph instead and said
20
+ * so. `road-to-a-graph-that-is-shipped` removed that premise — Phase 0.1 ships
21
+ * the parsers, Phase 1 builds the graph on install, and 3.1 added
22
+ * `impact --diff`, which is the reverse-reachability query this selector needs.
19
23
  *
20
- * · `agent-config code-graph` — the native code-graph engine (ADR-124). It
21
- * shipped `hooks.code_graph.enabled: false` (a flag retired 2026-09-07)
22
- * (`src/config/agent-settings.template.yml:1373-1374`), and
23
- * `agent-config code-graph detect` in this checkout answers
24
- * `no code-graph source detected`. There is no index to select against.
25
- * · `src/scripts/discovery_graph.ts` — this suite's OWN artefact relation
26
- * graph, five typed edges extracted from the discovery manifest's
27
- * structured fields, with an `affected` BFS already implemented and
28
- * answering. That is the surface this module builds on.
24
+ * So both surfaces are now first-class, and which one a candidate uses is a
25
+ * property OF THE CANDIDATE rather than of what happened to resolve:
29
26
  *
30
- * The substitution changes what a "neighbourhood" means, and the difference is
31
- * worth naming: the native engine would neighbour a candidate by SYMBOL
32
- * relations, while `discovery_graph` neighbours it by ARTEFACT relations
33
- * (`supersedes`, `routes_to`, `references_adr`, pack and workspace membership).
34
- * For this roadmap's candidates — rules, skills, guidelines — the artefact
35
- * graph is the one that carries the coupling a rewrite can break. For a
36
- * candidate that edits a `.ts` symbol, it is the weaker surface, and this
37
- * module says so through {@link selectionVerdict}: a touched surface absent
38
- * from the graph is REFUSED, never silently neighboured with the empty set.
27
+ * · **Code candidates** — a diff that edits `.ts` / `.php` symbols. Use
28
+ * {@link selectRegressionsFromCode}, which neighbours by SYMBOL relations
29
+ * over the native graph, filtered to accepted mechanisms
30
+ * (`resolved_via` not in `{name-lookup, dynamic}`) so a same-name guess
31
+ * never selects a regression.
32
+ * · **Artefact candidates** — a diff that edits rules, skills, guidelines.
33
+ * Use {@link selectRegressions}, which neighbours by ARTEFACT relations
34
+ * over `src/scripts/discovery_graph.ts` (`supersedes`, `routes_to`,
35
+ * `references_adr`, pack and workspace membership). For a rule rewrite that
36
+ * is the graph carrying the coupling a change can break; the code graph
37
+ * does not model it at all.
38
+ *
39
+ * Neither is a fallback for the other, and neither is silent: both paths REFUSE
40
+ * through {@link selectionVerdict} when a touched surface is absent from the
41
+ * graph they read, because an unknown neighbourhood is not an empty one.
39
42
  *
40
43
  * ## Why an unresolved surface refuses instead of selecting nothing
41
44
  *
@@ -55,6 +58,9 @@
55
58
  * A test asserts that identity rather than an id match, because an id match
56
59
  * would also be satisfied by a synthesized spec carrying a copied id.
57
60
  */
61
+ import type { GraphState } from '../code_graph/detect.js';
62
+ import type { LoadedGraph } from '../code_graph/query.js';
63
+ import { impact } from '../code_graph/verbs.js';
58
64
  import { affected, isSyntheticNode, type Graph } from '../discovery_graph.js';
59
65
 
60
66
  /**
@@ -106,6 +112,48 @@ export interface NeighbourhoodReport {
106
112
  selected: readonly Selection[];
107
113
  /** Registry entries whose guards intersect nothing in the neighbourhood. */
108
114
  skipped: readonly string[];
115
+ /**
116
+ * Which graph answered. Present so a verdict record can never be read as a
117
+ * claim about the other surface — the failure the retired substitution
118
+ * section describes, arriving through the report instead of through the
119
+ * docstring.
120
+ */
121
+ graph: 'artefact' | 'code';
122
+ /**
123
+ * The graph's freshness at selection time: `fresh` / `behind:N` / `absent`
124
+ * on the code path, `null` on the artefact path (which is derived from the
125
+ * committed manifest and has no staleness axis).
126
+ *
127
+ * Added after an independent review found the code path hardcoding
128
+ * `'absent'` and dropping the value: the verbs are required to report
129
+ * staleness, `verbs.ts` argues the whole point is telling "dead" from "dead
130
+ * as of a graph 40 commits behind" — and the one in-repo CONSUMER of those
131
+ * verbs discarded it, so no selection could refuse on a stale graph.
132
+ */
133
+ graph_state: GraphState | null;
134
+ /**
135
+ * Touched files in a language the code graph does not index. NOT a refusal
136
+ * reason: an unindexed `.md` is a file with no symbols, which is a fact
137
+ * about the language set rather than an unknown neighbourhood.
138
+ */
139
+ not_indexed: readonly string[];
140
+ /**
141
+ * The edges that produced the neighbourhood, rendered, sorted.
142
+ *
143
+ * Required by step 3.4: *"the selected regressions and the producing edges
144
+ * enter the verdict record"*. Empty on the artefact path, whose `affected`
145
+ * BFS returns a relation name per hop rather than an edge — stated rather
146
+ * than faked, because an empty list a reader can attribute is better than a
147
+ * synthesised one they cannot.
148
+ */
149
+ producing_edges: readonly string[];
150
+ /**
151
+ * The `resolved_via` histogram of the edges the walk REFUSED, on the code
152
+ * path. A selection that looks narrow because half the graph was a guess
153
+ * reads very differently from one that looks narrow because the code is
154
+ * decoupled, and this is the only field that distinguishes them.
155
+ */
156
+ rejected_via: string;
109
157
  /**
110
158
  * Literal 0. This module selects from a registry; a non-zero value here
111
159
  * would mean it authored a task, which is K9.
@@ -196,6 +244,80 @@ export function selectRegressions(
196
244
  unresolved: unresolvedSurfaces(graph, candidate),
197
245
  selected: selected.sort((a, b) => a.spec.id.localeCompare(b.spec.id)),
198
246
  skipped: skipped.sort((a, b) => a.localeCompare(b)),
247
+ graph: 'artefact',
248
+ graph_state: null,
249
+ not_indexed: [],
250
+ producing_edges: [],
251
+ rejected_via: '(not applicable — the artefact graph has no mechanism axis)',
252
+ authored: 0,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The code-graph path (step 3.4).
258
+ *
259
+ * Neighbours a candidate by the symbols its diff touched, over the native code
260
+ * graph, using the same `impact --diff` reverse walk the CLI verb exposes. The
261
+ * neighbourhood's nodes are the seeds at depth 0 (`touched`) plus every
262
+ * accepted reverse-reachable node at its shallowest depth (`neighbour`), which
263
+ * is the identical shape {@link neighbourhood} produces for the artefact graph
264
+ * — so `selectRegressions`' matching logic is reused rather than forked.
265
+ *
266
+ * `touches` is read as FILE paths here, not artefact ids: a code candidate is a
267
+ * diff, and a diff names files. A touched file with no node in the graph lands
268
+ * in `unresolved` and therefore refuses through {@link selectionVerdict}, on
269
+ * exactly the terms the artefact path already uses.
270
+ */
271
+ export function selectRegressionsFromCode(
272
+ g: LoadedGraph,
273
+ candidate: Candidate,
274
+ registry: readonly RegressionSpec[],
275
+ depth: number = DEFAULT_NEIGHBOURHOOD_DEPTH,
276
+ isTest: (relPath: string) => boolean = () => false,
277
+ graphState: GraphState = 'absent',
278
+ ): NeighbourhoodReport {
279
+ const res = impact(g, candidate.touches, graphState, depth, isTest);
280
+ const best = new Map<string, NeighbourhoodNode>();
281
+ for (const seed of res.seeds) {
282
+ best.set(seed, { node: seed, reason: 'touched', depth: 0, via: '', synthetic: false });
283
+ }
284
+ for (const r of res.reached) {
285
+ if (best.has(r.node)) continue;
286
+ best.set(r.node, {
287
+ node: r.node,
288
+ reason: 'neighbour',
289
+ depth: r.depth,
290
+ via: `${r.via}/${r.resolved_via}`,
291
+ synthetic: false,
292
+ });
293
+ }
294
+ const hood = [...best.values()].sort((a, b) => a.depth - b.depth || a.node.localeCompare(b.node));
295
+ const reasonOf = new Map<string, NeighbourReason>(hood.map((n) => [n.node, n.reason]));
296
+ const selected: Selection[] = [];
297
+ const skipped: string[] = [];
298
+ for (const spec of registry) {
299
+ const matched = spec.guards.filter((x) => reasonOf.has(x)).sort((a, b) => a.localeCompare(b));
300
+ if (matched.length === 0) {
301
+ skipped.push(spec.id);
302
+ continue;
303
+ }
304
+ selected.push({
305
+ spec,
306
+ matched,
307
+ reason: matched.some((m) => reasonOf.get(m) === 'touched') ? 'touched' : 'neighbour',
308
+ });
309
+ }
310
+ return {
311
+ candidate_id: candidate.id,
312
+ neighbourhood: hood,
313
+ unresolved: [...res.unresolved_files].sort((a, b) => a.localeCompare(b)),
314
+ selected: selected.sort((a, b) => a.spec.id.localeCompare(b.spec.id)),
315
+ skipped: skipped.sort((a, b) => a.localeCompare(b)),
316
+ graph: 'code',
317
+ graph_state: graphState,
318
+ not_indexed: [...res.not_indexed_files].sort((a, b) => a.localeCompare(b)),
319
+ producing_edges: [...res.producing_edges].sort(),
320
+ rejected_via: res.rejected_via,
199
321
  authored: 0,
200
322
  };
201
323
  }
@@ -209,9 +331,24 @@ export function selectRegressions(
209
331
  */
210
332
  export function selectionVerdict(report: NeighbourhoodReport): readonly string[] | null {
211
333
  const reasons: string[] = [];
334
+ // An ABSENT graph is the case the artefact path's own docstring warns
335
+ // about in the other direction: a lookup that cannot resolve and then
336
+ // selects nothing reports a clean sheet. `behind:N` is deliberately NOT a
337
+ // refusal — a stale graph still answers, and blocking on staleness would
338
+ // be the blocking-gate-on-a-stale-graph that K7 forbids. It is surfaced
339
+ // through `graph_state` so a caller that wants to refuse can.
340
+ if (report.graph === 'code' && report.graph_state === 'absent') {
341
+ reasons.push(
342
+ 'the code graph is absent — a neighbourhood computed over no graph is empty for a ' +
343
+ 'reason that has nothing to do with the candidate. Run `agent-config code-graph build`.',
344
+ );
345
+ }
212
346
  if (report.unresolved.length > 0) {
347
+ // Named per graph: a code-path refusal that said "relation graph" would
348
+ // point a reader at the wrong surface to go fix.
349
+ const which = report.graph === 'code' ? 'code graph' : 'artefact relation graph';
213
350
  reasons.push(
214
- `touched surfaces absent from the relation graph: ${report.unresolved.join(', ')} — ` +
351
+ `touched surfaces absent from the ${which}: ${report.unresolved.join(', ')} — ` +
215
352
  'their neighbourhood is unknown, and an unknown neighbourhood is not an empty one',
216
353
  );
217
354
  }
@@ -269,6 +269,50 @@ export function readEolCounters(
269
269
  }
270
270
  }
271
271
 
272
+ /**
273
+ * Committed substantive-content floor, in parsed transcript tokens (billable
274
+ * input of the last main-chain assistant record, per `cc_transcript.ts`) — the
275
+ * OR-arm for a session that produced real discussion without ever calling a
276
+ * tool.
277
+ *
278
+ * Derivation (`agents/evidence/analysis/handoff-substantive-threshold.md`, code-comment-allow provenance-comment -- unchanged relocation of a docblock whose whole point is that this threshold may only move on cited evidence
279
+ * 217 sessions of the local Claude store, 2026-08-10): 206 of the 207 sessions
280
+ * carrying an assistant record also carry a `tool_use` block, so this arm
281
+ * serves the ~0.5 % tail and the hosts whose transcripts log no tool blocks at
282
+ * all. 10 000 sits **25× below** the p10 of real working sessions (254 939),
283
+ * so it cannot hide one, and above a single trivial exchange. Changing it is a
284
+ * PR citing evidence, never a drive-by edit.
285
+ */
286
+ export const SUBSTANTIVE_TOKEN_FLOOR = 10_000;
287
+
288
+ /**
289
+ * Does this session hold anything worth resuming?
290
+ *
291
+ * `≥ 1 assistant turn AND (≥ 1 tool call OR parsed tokens ≥ the committed
292
+ * floor)`, read from the counts-only session-eol state.
293
+ *
294
+ * **Fail-open, deliberately** (Phase 1.2): absent, unreadable or mis-shaped
295
+ * state LISTS the candidate rather than filtering it, and a `tool_calls` key
296
+ * missing from a state file written before that counter existed reads as
297
+ * *unknown*, never as zero. A wrongly listed candidate is noise the user
298
+ * scrolls past; a wrongly hidden one is data loss they cannot even see.
299
+ */
300
+ export function is_substantive(counters: StoredEolCounters | null): boolean {
301
+ if (counters === null) return true;
302
+ // Every "unknown" below is a NUMBER test, not an `=== undefined` test.
303
+ // Absent, null and NaN are all unknown, and each one reaches this code by
304
+ // a real path: a state file written before the counter existed is absent,
305
+ // `JSON.stringify` turns NaN into null on the way to disk, and a
306
+ // half-written file yields a parseable object with missing keys. Reading
307
+ // any of them as "counted zero" hides a session that did real work.
308
+ if (!Number.isFinite(counters.assistant_records)) return true;
309
+ if ((counters.assistant_records as number) < 1) return false;
310
+ if (!Number.isFinite(counters.tool_calls)) return true;
311
+ if ((counters.tool_calls as number) >= 1) return true;
312
+ if (!Number.isFinite(counters.final_context_tokens)) return true;
313
+ return (counters.final_context_tokens as number) >= SUBSTANTIVE_TOKEN_FLOOR;
314
+ }
315
+
272
316
  export interface NewLinesRead {
273
317
  /** The complete lines appended since `fromByte` (possibly empty). */
274
318
  text: string;