@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "description": "Source-of-truth catalog of consumer-relevant MCP tools. Read by the stdio server (scripts/mcp_server/) and packed into the Cloud Worker bundle (internal/workers/mcp/). The 'implemented_on' field lists transports where the real handler is wired. Entries marked '[stub — implemented on demand]' are DOCUMENTATION of the on-demand backlog: per ADR-132 they are NOT registered on the stdio tools/list (the wire carries implemented tools only); the 'not_implemented' envelope contract (docs/contracts/mcp-tool-stub-envelope.md) remains for Worker parity. Generated by `build_mcp_catalog` — never hand-edit this file.",
4
- "install_hint_stdio": "npx -y @event4u/agent-config mcp-server",
3
+ "description": "Source-of-truth catalog of consumer-relevant MCP tools. Read by the stdio server (scripts/mcp_server/) and packed into the Cloud Worker bundle (internal/workers/mcp/). The 'implemented_on' field lists transports where the real handler is wired. Entries marked '[stub — implemented on demand]' are DOCUMENTATION of the on-demand backlog: per ADR-132 they are NOT registered on the stdio tools/list (the wire carries implemented tools only); the 'not_implemented' envelope contract (docs/contracts/mcp-tool-stub-envelope.md) remains for Worker parity. The 'install_hint_stdio' field is the INSTALLED-BINARY form and therefore assumes `agent-config` is on PATH (a global install, or an npx-resolved one the client already has); it deliberately carries no `npx` invocation, because the un-prompted form of that command is the one string this file's own gate forbids and the prompted form hangs a non-interactive client start. Generated by `build_mcp_catalog` — never hand-edit this file.",
4
+ "install_hint_stdio": "agent-config mcp-server",
5
5
  "tools": [
6
6
  {
7
7
  "name": "capabilities_index",
@@ -222,6 +222,156 @@
222
222
  "readOnlyHint": true
223
223
  }
224
224
  },
225
+ {
226
+ "name": "graph_dead",
227
+ "description": "Symbols with no accepted reference that no declared entry point names. REFUSES (status `refused`) while a source is unreadable: with no exportedness recorded, an exported-but-unimported symbol is indistinguishable from a dead one. Never a deletion recommendation.",
228
+ "side_effect": "ro",
229
+ "implemented_on": [
230
+ "stdio"
231
+ ],
232
+ "input_schema": {
233
+ "type": "object",
234
+ "properties": {
235
+ "entry_points": {
236
+ "type": "string",
237
+ "description": "Path under this root to a newline-separated id/label list; `#` comments ok."
238
+ },
239
+ "accept_missing_exports": {
240
+ "type": "boolean",
241
+ "default": false,
242
+ "description": "Answer anyway, accepting the gap. Never a default."
243
+ }
244
+ },
245
+ "additionalProperties": false
246
+ },
247
+ "annotations": {
248
+ "readOnlyHint": true
249
+ }
250
+ },
251
+ {
252
+ "name": "graph_impact",
253
+ "description": "What a diff can break: callers, dependents and test files reaching the changed symbols. Walks only stated-fact edges, never a same-name guess, and reports the guesses refused. Runs `git diff` for the rev.",
254
+ "side_effect": "shell",
255
+ "implemented_on": [
256
+ "stdio"
257
+ ],
258
+ "input_schema": {
259
+ "type": "object",
260
+ "required": [
261
+ "diff"
262
+ ],
263
+ "properties": {
264
+ "diff": {
265
+ "type": "string",
266
+ "description": "Git rev to diff HEAD against, e.g. `HEAD~1`."
267
+ },
268
+ "depth": {
269
+ "type": "integer",
270
+ "minimum": 1,
271
+ "default": 2,
272
+ "description": "Hop limit."
273
+ },
274
+ "untested_only": {
275
+ "type": "boolean",
276
+ "default": false,
277
+ "description": "Instead return changed symbols no test imports."
278
+ }
279
+ },
280
+ "additionalProperties": false
281
+ },
282
+ "annotations": {
283
+ "readOnlyHint": false
284
+ }
285
+ },
286
+ {
287
+ "name": "graph_path",
288
+ "description": "The shortest relation chain between two symbols, direction preserved. Answers \"how does A reach B\" without reading the files between. `(no path found)` when unconnected is an answer.",
289
+ "side_effect": "ro",
290
+ "implemented_on": [
291
+ "stdio"
292
+ ],
293
+ "input_schema": {
294
+ "type": "object",
295
+ "required": [
296
+ "from",
297
+ "to"
298
+ ],
299
+ "properties": {
300
+ "from": {
301
+ "type": "string",
302
+ "description": "Start node id, label, or free text."
303
+ },
304
+ "to": {
305
+ "type": "string",
306
+ "description": "Goal node id, label, or free text."
307
+ },
308
+ "budget": {
309
+ "type": "integer",
310
+ "minimum": 1,
311
+ "default": 1500,
312
+ "description": "Token budget for lines."
313
+ }
314
+ },
315
+ "additionalProperties": false
316
+ },
317
+ "annotations": {
318
+ "readOnlyHint": true
319
+ }
320
+ },
321
+ {
322
+ "name": "graph_query",
323
+ "description": "A symbol's direct outgoing relations — calls, imports, uses, inherits — each line carrying its confidence and resolution mechanism. For \"what does this touch\" before reading a file.",
324
+ "side_effect": "ro",
325
+ "implemented_on": [
326
+ "stdio"
327
+ ],
328
+ "input_schema": {
329
+ "type": "object",
330
+ "required": [
331
+ "symbol"
332
+ ],
333
+ "properties": {
334
+ "symbol": {
335
+ "type": "string",
336
+ "description": "Node id, label, or free text."
337
+ },
338
+ "budget": {
339
+ "type": "integer",
340
+ "minimum": 1,
341
+ "default": 1500,
342
+ "description": "Token budget for lines."
343
+ }
344
+ },
345
+ "additionalProperties": false
346
+ },
347
+ "annotations": {
348
+ "readOnlyHint": true
349
+ }
350
+ },
351
+ {
352
+ "name": "graph_tests_for",
353
+ "description": "Which test files exercise a symbol, derived from a test file importing it or its module. Empty means no test imports it — a finding, not an error.",
354
+ "side_effect": "ro",
355
+ "implemented_on": [
356
+ "stdio"
357
+ ],
358
+ "input_schema": {
359
+ "type": "object",
360
+ "required": [
361
+ "symbol"
362
+ ],
363
+ "properties": {
364
+ "symbol": {
365
+ "type": "string",
366
+ "description": "Node id `<relpath>#<sym>`, label, or free text."
367
+ }
368
+ },
369
+ "additionalProperties": false
370
+ },
371
+ "annotations": {
372
+ "readOnlyHint": true
373
+ }
374
+ },
225
375
  {
226
376
  "name": "lint_skills",
227
377
  "description": "Lint skill, rule, command, guideline, and persona markdown files for frontmatter and structural errors. Use before committing or opening a PR that adds or edits any of those artifacts, to catch schema violations early. Read-only — never writes files or spawns git. Returns the `scripts/skill_linter.py --format json` payload: a `summary` object (pass / pass_with_warnings / fail / total counts) and a per-file `results` array with severity-tagged findings. Pass `paths` to lint a subset; omit for a full tree scan.",
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The five code-graph MCP tools (`road-to-a-graph-that-is-shipped` 4.1).
3
+ *
4
+ * `graph_impact`, `graph_tests_for`, `graph_dead`, `graph_query`, `graph_path`.
5
+ * They are the agent-facing half of Phase 3: the verbs exist on the CLI and an
6
+ * agent that has to shell out to reach them will not, so the engine's answers
7
+ * were unreachable from the surface the consumer actually talks to. Risk-Register
8
+ * rank 1 is precisely "the install grows and the graph is still unread"; these
9
+ * are the readers.
10
+ *
11
+ * REGISTERED HERE, NOT IN `tools.ts`.
12
+ *
13
+ * `tools.ts` sits ~500 lines past the 1500-line ceiling
14
+ * `check_source_size_budget` enforces as a shrink-only ratchet, so five tool
15
+ * records with their schemas would cost ~150 units of excess there and nothing
16
+ * here. `tools.ts` spreads {@link GRAPH_TOOLS} into `ALLOWLIST`, which is two
17
+ * lines — paid for by moving `_strip` / `_resolvePath` out to `path_util.ts` in
18
+ * the same change.
19
+ *
20
+ * TELEMETRY NEEDS NO PER-TOOL WORK.
21
+ *
22
+ * `ToolCache.dispatch` records every `tools/call` centrally, so these five emit
23
+ * the identical line the other 31 do by being in `ALLOWLIST` at all — which is
24
+ * what the step's "same telemetry line as the other 31" asks for, and is
25
+ * stronger than a per-tool emit because it cannot be forgotten.
26
+ *
27
+ * READ-ONLY, WITH ONE HONEST EXCEPTION.
28
+ *
29
+ * Four are `side_effect: 'ro'`. `graph_impact` is `'shell'`, because it resolves
30
+ * its `diff` argument by running `git diff --name-only`. That subprocess is
31
+ * read-only in effect, and the enum has no value that says so — between
32
+ * understating the mechanism as `'ro'` and naming it, naming it is the only
33
+ * choice a capability enum exists to support.
34
+ */
35
+ import * as fs from 'node:fs';
36
+ import * as path from 'node:path';
37
+
38
+ import { changedFiles } from '../code_graph/cli.js';
39
+ import { detectSources, graphState, NATIVE_CACHE_REL, pickSource } from '../code_graph/detect.js';
40
+ import { resolvePath } from './path_util.js';
41
+ import { isTestFile } from '../code_graph/build.js';
42
+ import { loadGraph, type LoadedGraph, path as graphPathVerb, query as graphQueryVerb } from '../code_graph/query.js';
43
+ import {
44
+ dead,
45
+ entryPointsFromFile,
46
+ impact,
47
+ repoEntryPoints,
48
+ testsFor,
49
+ untested,
50
+ } from '../code_graph/verbs.js';
51
+ import type { BuiltinTool } from './tools.js';
52
+
53
+ /** The `no graph here` answer, in one place so all five agree on its shape. */
54
+ function noGraph(root: string): Record<string, unknown> {
55
+ return {
56
+ status: 'unavailable',
57
+ staleness: 'absent',
58
+ error:
59
+ 'no code-graph source found under this root — run `agent-config code-graph build` ' +
60
+ 'to build one, or ship a graph.json',
61
+ root,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Resolve the graph, run `fn`, release the handle.
67
+ *
68
+ * The handle owns an open SQLite database on the indexed path, so the `finally`
69
+ * is not hygiene — a long-lived MCP server that leaked one per call would hold
70
+ * a descriptor for the life of the process.
71
+ */
72
+ function withGraph<T extends Record<string, unknown>>(
73
+ root: string,
74
+ fn: (g: LoadedGraph) => T,
75
+ ): Record<string, unknown> {
76
+ const picked = pickSource(detectSources(root, path.join(root, NATIVE_CACHE_REL)));
77
+ if (!picked || picked.kind === 'scip') return noGraph(root);
78
+ const g = loadGraph(picked.path, `${picked.kind}:${path.relative(root, picked.path)}`);
79
+ try {
80
+ const out = fn(g);
81
+ // `status` is DERIVED from the answer, never prefixed onto it. An
82
+ // independent review found `graph_dead`'s refusal returning
83
+ // `{status:'ok', refusal:'…', dead:[]}` — the same status a real answer
84
+ // carries, so a caller branching on `status` (which every fixture here
85
+ // trains it to do) read the refusal as "nothing is dead". That is the
86
+ // false negative the CLI's exit 1 exists to prevent, arriving over the
87
+ // wire instead. A refusal is its own status.
88
+ const refused = typeof out['refusal'] === 'string' && out['refusal'] !== '';
89
+ return { status: refused ? 'refused' : 'ok', ...out };
90
+ } finally {
91
+ g.close();
92
+ }
93
+ }
94
+
95
+ function str(args: Record<string, unknown>, key: string): string {
96
+ const v = args[key];
97
+ return typeof v === 'string' ? v.trim() : '';
98
+ }
99
+
100
+ function num(args: Record<string, unknown>, key: string, dflt: number): number {
101
+ const v = args[key];
102
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : dflt;
103
+ }
104
+
105
+ export const GRAPH_TOOLS: Record<string, BuiltinTool> = {
106
+ graph_impact: {
107
+ name: 'graph_impact',
108
+ side_effect: 'shell',
109
+ description:
110
+ 'What a diff can break: callers, dependents and test files reaching the ' +
111
+ 'changed symbols. Walks only stated-fact edges, never a same-name guess, ' +
112
+ 'and reports the guesses refused. Runs `git diff` for the rev.',
113
+ input_schema: {
114
+ type: 'object',
115
+ required: ['diff'],
116
+ properties: {
117
+ diff: { type: 'string', description: 'Git rev to diff HEAD against, e.g. `HEAD~1`.' },
118
+ depth: { type: 'integer', minimum: 1, default: 2, description: 'Hop limit.' },
119
+ untested_only: {
120
+ type: 'boolean',
121
+ default: false,
122
+ description: 'Instead return changed symbols no test imports.',
123
+ },
124
+ },
125
+ additionalProperties: false,
126
+ },
127
+ handler: async (args, root) => {
128
+ const rev = str(args, 'diff');
129
+ if (rev === '') return { status: 'error', error: 'diff must be a non-empty git rev' };
130
+ const files = changedFiles(root, rev);
131
+ if (files === null) return { status: 'error', error: `cannot resolve rev '${rev}' in ${root}` };
132
+ const state = graphState(root);
133
+ return withGraph(root, (g) =>
134
+ args['untested_only'] === true
135
+ ? { ...untested(g, files, state), changed_files: files.length }
136
+ : { ...impact(g, files, state, num(args, 'depth', 2), isTestFile), changed_files: files.length },
137
+ );
138
+ },
139
+ },
140
+
141
+ graph_tests_for: {
142
+ name: 'graph_tests_for',
143
+ side_effect: 'ro',
144
+ description:
145
+ 'Which test files exercise a symbol, derived from a test file importing ' +
146
+ 'it or its module. Empty means no test imports it — a finding, not an error.',
147
+ input_schema: {
148
+ type: 'object',
149
+ required: ['symbol'],
150
+ properties: {
151
+ symbol: { type: 'string', description: 'Node id `<relpath>#<sym>`, label, or free text.' },
152
+ },
153
+ additionalProperties: false,
154
+ },
155
+ handler: async (args, root) => {
156
+ const symbol = str(args, 'symbol');
157
+ if (symbol === '') return { status: 'error', error: 'symbol must be a non-empty string' };
158
+ const state = graphState(root);
159
+ return withGraph(root, (g) => ({ ...testsFor(g, symbol, state) }));
160
+ },
161
+ },
162
+
163
+ graph_dead: {
164
+ name: 'graph_dead',
165
+ side_effect: 'ro',
166
+ description:
167
+ 'Symbols with no accepted reference that no declared entry point names. ' +
168
+ 'REFUSES (status `refused`) while a source is unreadable: with no ' +
169
+ 'exportedness recorded, an exported-but-unimported symbol is ' +
170
+ 'indistinguishable from a dead one. Never a deletion recommendation.',
171
+ input_schema: {
172
+ type: 'object',
173
+ properties: {
174
+ entry_points: {
175
+ type: 'string',
176
+ description: 'Path under this root to a newline-separated id/label list; `#` comments ok.',
177
+ },
178
+ accept_missing_exports: {
179
+ type: 'boolean',
180
+ default: false,
181
+ description: 'Answer anyway, accepting the gap. Never a default.',
182
+ },
183
+ },
184
+ additionalProperties: false,
185
+ },
186
+ handler: async (args, root) => {
187
+ const state = graphState(root);
188
+ const listArg = str(args, 'entry_points');
189
+ let sources = repoEntryPoints(root);
190
+ if (listArg !== '') {
191
+ // REALPATH containment, matching the sibling pattern in
192
+ // `tools.ts` rather than reimplementing a weaker one — an
193
+ // independent review found this check using `path.resolve` +
194
+ // `startsWith`, which a symlink inside the root walks straight
195
+ // through, in the same change that extracted `resolvePath` for
196
+ // reuse. `fs.readFileSync` would then follow the link, and
197
+ // because the file's lines used to be echoed back in the
198
+ // response (see `entry_count` below) that was an
199
+ // arbitrary-file-read path.
200
+ const abs = resolvePath(path.resolve(root, listArg));
201
+ const rootReal = resolvePath(path.resolve(root));
202
+ if (abs !== rootReal && !abs.startsWith(rootReal + path.sep)) {
203
+ return { status: 'error', error: `path escapes consumer_root: ${abs}` };
204
+ }
205
+ if (!fs.existsSync(abs)) return { status: 'error', error: `entry-point list not found: ${listArg}` };
206
+ sources = [
207
+ ...sources.map((s) =>
208
+ s.name === 'exports'
209
+ ? { ...s, status: 'read' as const, detail: `supplied by ${listArg}`, entries: [] }
210
+ : s,
211
+ ),
212
+ entryPointsFromFile(abs),
213
+ ];
214
+ }
215
+ return withGraph(root, (g) => {
216
+ const r = dead(g, sources, state, {
217
+ acceptMissingExports: args['accept_missing_exports'] === true,
218
+ });
219
+ // The `entries` arrays are NOT returned. On this repository they
220
+ // are ~2,595 identifier strings — an unbounded per-call payload
221
+ // beside the standing-cost row this change itself adds — and
222
+ // returning a file the caller named is what made the
223
+ // containment check above a read primitive. A count answers the
224
+ // question a caller actually has ("was this source read, and did
225
+ // it have anything in it") without shipping the contents.
226
+ return {
227
+ ...r,
228
+ sources: r.sources.map((srcRow) => ({
229
+ name: srcRow.name,
230
+ status: srcRow.status,
231
+ ...(srcRow.detail === undefined ? {} : { detail: srcRow.detail }),
232
+ entry_count: srcRow.entries.length,
233
+ })),
234
+ };
235
+ });
236
+ },
237
+ },
238
+
239
+ graph_query: {
240
+ name: 'graph_query',
241
+ side_effect: 'ro',
242
+ description:
243
+ "A symbol's direct outgoing relations — calls, imports, uses, inherits — " +
244
+ 'each line carrying its confidence and resolution mechanism. For "what ' +
245
+ 'does this touch" before reading a file.',
246
+ input_schema: {
247
+ type: 'object',
248
+ required: ['symbol'],
249
+ properties: {
250
+ symbol: { type: 'string', description: 'Node id, label, or free text.' },
251
+ budget: { type: 'integer', minimum: 1, default: 1500, description: 'Token budget for lines.' },
252
+ },
253
+ additionalProperties: false,
254
+ },
255
+ handler: async (args, root) => {
256
+ const symbol = str(args, 'symbol');
257
+ if (symbol === '') return { status: 'error', error: 'symbol must be a non-empty string' };
258
+ const staleness = graphState(root);
259
+ return withGraph(root, (g) => ({
260
+ staleness,
261
+ ...graphQueryVerb(g, symbol, num(args, 'budget', 1500)),
262
+ }));
263
+ },
264
+ },
265
+
266
+ graph_path: {
267
+ name: 'graph_path',
268
+ side_effect: 'ro',
269
+ description:
270
+ 'The shortest relation chain between two symbols, direction preserved. ' +
271
+ 'Answers "how does A reach B" without reading the files between. ' +
272
+ '`(no path found)` when unconnected is an answer.',
273
+ input_schema: {
274
+ type: 'object',
275
+ required: ['from', 'to'],
276
+ properties: {
277
+ from: { type: 'string', description: 'Start node id, label, or free text.' },
278
+ to: { type: 'string', description: 'Goal node id, label, or free text.' },
279
+ budget: { type: 'integer', minimum: 1, default: 1500, description: 'Token budget for lines.' },
280
+ },
281
+ additionalProperties: false,
282
+ },
283
+ handler: async (args, root) => {
284
+ const from = str(args, 'from');
285
+ const to = str(args, 'to');
286
+ if (from === '' || to === '') return { status: 'error', error: 'from and to must be non-empty strings' };
287
+ const staleness = graphState(root);
288
+ return withGraph(root, (g) => ({
289
+ staleness,
290
+ ...graphPathVerb(g, from, to, num(args, 'budget', 1500)),
291
+ }));
292
+ },
293
+ },
294
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Two string/path helpers extracted from `tools.ts`.
3
+ *
4
+ * They are generic — a whitespace trim and a tolerant realpath — and `tools.ts`
5
+ * sits ~525 lines past the 1500-line ceiling `check_source_size_budget`
6
+ * enforces as a shrink-only ratchet, so every line there costs one unit of
7
+ * excess and the gate names re-pinning the baseline a defect rather than a fix.
8
+ * Moving them here pays for the graph-tool registration that arrives with
9
+ * `road-to-a-graph-that-is-shipped` 4.1, and puts them in a file where their
10
+ * prose is free.
11
+ *
12
+ * They are imported under their original underscore-prefixed names, so the
13
+ * 28 call sites in `tools.ts` are unchanged — a rename would have made a
14
+ * two-function move read as a sweep.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+
19
+ /** Mirror Python `str.strip()` (also strips the same Unicode whitespace set we care about here). */
20
+ export function strip(s: string): string {
21
+ return s.replace(/^\s+/, '').replace(/\s+$/, '');
22
+ }
23
+
24
+ /** Mirror Python `Path(...).resolve()` — absolutize + realpath, tolerating a missing tail. */
25
+ export function resolvePath(p: string): string {
26
+ try {
27
+ return fs.realpathSync(p);
28
+ } catch {
29
+ return path.resolve(p);
30
+ }
31
+ }
@@ -33,7 +33,13 @@ export const CATALOG_DESCRIPTION =
33
33
  'backlog: per ADR-132 they are NOT registered on the stdio tools/list ' +
34
34
  "(the wire carries implemented tools only); the 'not_implemented' " +
35
35
  'envelope contract (docs/contracts/mcp-tool-stub-envelope.md) remains ' +
36
- 'for Worker parity. Generated by `build_mcp_catalog` — ' +
36
+ 'for Worker parity. ' +
37
+ "The 'install_hint_stdio' field is the INSTALLED-BINARY form and " +
38
+ 'therefore assumes `agent-config` is on PATH (a global install, or an ' +
39
+ 'npx-resolved one the client already has); it deliberately carries no ' +
40
+ '`npx` invocation, because the un-prompted form of that command is the ' +
41
+ "one string this file's own gate forbids and the prompted form hangs a " +
42
+ 'non-interactive client start. Generated by `build_mcp_catalog` — ' +
37
43
  'never hand-edit this file.';
38
44
 
39
45
  export interface StubToolSource {
@@ -54,6 +54,8 @@ import {
54
54
  load_catalog,
55
55
  not_implemented_envelope,
56
56
  } from './catalog.js';
57
+ import { GRAPH_TOOLS } from './graph_tools.js';
58
+ import { resolvePath as _resolvePath, strip as _strip } from './path_util.js';
57
59
  import { type Outcome, record_call } from './telemetry.js';
58
60
  import type { PromptCache } from './prompts.js';
59
61
  import type { ResourceCache } from './resources.js';
@@ -111,19 +113,6 @@ export interface BuiltinTool {
111
113
  readonly handler: ToolHandler;
112
114
  }
113
115
 
114
- /** Mirror Python `str.strip()` (also strips the same Unicode whitespace set we care about here). */
115
- function _strip(s: string): string {
116
- return s.replace(/^\s+/, '').replace(/\s+$/, '');
117
- }
118
-
119
- /** Mirror Python `Path(...).resolve()` — absolutize + realpath, tolerating a missing tail. */
120
- function _resolvePath(p: string): string {
121
- try {
122
- return fs.realpathSync(p);
123
- } catch {
124
- return path.resolve(p);
125
- }
126
- }
127
116
 
128
117
  /**
129
118
  * Pick the consumer-project root.
@@ -1212,6 +1201,7 @@ async function _readResourceBodyHandler(
1212
1201
  // ---------------------------------------------------------------------
1213
1202
 
1214
1203
  export const ALLOWLIST: Record<string, BuiltinTool> = {
1204
+ ...GRAPH_TOOLS,
1215
1205
  suggest_skill_for_task: {
1216
1206
  name: 'suggest_skill_for_task',
1217
1207
  side_effect: 'ro',
@@ -60,6 +60,7 @@ import {
60
60
  loadRuleBody,
61
61
  matchTierRules,
62
62
  pathCapableRuleIds,
63
+ pathOnlyRuleIds,
63
64
  selectForInjection,
64
65
  triggerlessRuleIds,
65
66
  type Router,
@@ -101,6 +102,18 @@ export interface CorpusCase {
101
102
  rule: string;
102
103
  prompt: string;
103
104
  openFiles: string[] | null;
105
+ /**
106
+ * The slash command the case declares, or `null` (R2 finding 1).
107
+ *
108
+ * The loader DROPPED this field, so every `command:` trigger scored as an
109
+ * unmatched plain prompt. Two entries in the frozen corpus carry one —
110
+ * `roadmap-progress-sync` and `user-interrupt-priority` — and the shipped
111
+ * concern DOES reach command triggers: `hooks/rule_inject_hook.ts` extracts
112
+ * a leading `/command` from the prompt text and passes it to
113
+ * `matchTierRules`. Dropping the field made the shipped-reach reading
114
+ * falsely low in exactly the two places it mattered.
115
+ */
116
+ command: string | null;
104
117
  label: 'positive' | 'near_miss';
105
118
  file: string;
106
119
  }
@@ -119,10 +132,12 @@ function _asCases(
119
132
  const prompt = typeof o['prompt'] === 'string' ? o['prompt'] : null;
120
133
  if (prompt === null) continue;
121
134
  const of = o['open_files'];
135
+ const cmd = o['command'];
122
136
  out.push({
123
137
  rule,
124
138
  prompt,
125
139
  openFiles: Array.isArray(of) ? of.map((x) => String(x)) : null,
140
+ command: typeof cmd === 'string' && cmd !== '' ? cmd : null,
126
141
  label,
127
142
  file,
128
143
  });
@@ -618,17 +633,54 @@ export function runEndpoints(corpusDir: string): EndpointResult[] {
618
633
  }
619
634
 
620
635
  // (b) No labelled rule may end with ZERO matched positives.
621
- const byRule = new Map<string, { hit: number; total: number }>();
636
+ //
637
+ // TWO READINGS SINCE 2026-09-08 (R2 finding 1), and the second one is the
638
+ // honest half. The pre-registered bar is scored with `open_files` HONOURED,
639
+ // which is the reach of the MECHANISM. It is not the reach of the SHIPPED
640
+ // BINDING: `rule-inject` is bound on `user_prompt_submit` + `pre_compact`
641
+ // only (the `pre_tool_use` binding was removed under owner ruling E2), and
642
+ // `user_prompt_submit` never populates `openFiles` — the payload carries no
643
+ // open-file list. So a rule whose only matching positives carry
644
+ // `open_files` is credited as reachable while no shipped slot can deliver
645
+ // it on that route.
646
+ //
647
+ // The bar is UNCHANGED (it is pre-registered; see
648
+ // `internal/bench/thin-inject-PREREG.md`). What is added is the column: the
649
+ // same recall computed with `open_files` ignored, and the names of the rules
650
+ // that differ between the two. A rule in that difference is either kept
651
+ // full-bodied by the always-eager residue — in which case it is reachable
652
+ // by projection rather than by delivery — or it is thinned, in which case it
653
+ // reaches a Claude Code session on the prompt route only.
654
+ const reach = (c: CorpusCase, honourOpenFiles: boolean): boolean =>
655
+ matchTierRules(
656
+ router,
657
+ c.prompt,
658
+ honourOpenFiles ? c.openFiles : null,
659
+ c.command,
660
+ ).some((m) => m.id === c.rule);
661
+
662
+ const byRule = new Map<string, { hit: number; total: number; shipped: number }>();
622
663
  for (const c of positives) {
623
- const e = byRule.get(c.rule) ?? { hit: 0, total: 0 };
664
+ const e = byRule.get(c.rule) ?? { hit: 0, total: 0, shipped: 0 };
624
665
  e.total += 1;
625
- if (matchTierRules(router, c.prompt, c.openFiles).some((m) => m.id === c.rule)) e.hit += 1;
666
+ if (reach(c, true)) e.hit += 1;
667
+ if (reach(c, false)) e.shipped += 1;
626
668
  byRule.set(c.rule, e);
627
669
  }
628
670
  const unreachable = [...byRule.entries()].filter(([, v]) => v.hit === 0).map(([k]) => k);
629
671
  const partial = [...byRule.entries()]
630
672
  .filter(([, v]) => v.hit > 0 && v.hit < v.total)
631
673
  .map(([k, v]) => `${k} ${v.hit}/${v.total}`);
674
+ const pathOnlyReach = [...byRule.entries()]
675
+ .filter(([, v]) => v.hit > 0 && v.shipped === 0)
676
+ .map(([k]) => k)
677
+ .sort();
678
+ const alwaysEager = new Set([
679
+ ...triggerlessRuleIds(router),
680
+ ...pathOnlyRuleIds(router),
681
+ ...kernelIds(router),
682
+ ]);
683
+ const thinnedAndPathOnly = pathOnlyReach.filter((id) => !alwaysEager.has(id));
632
684
 
633
685
  // (c) A near-miss must never deliver its labelled rule.
634
686
  const exact = scoreExact(router, cases, true);
@@ -659,7 +711,12 @@ export function runEndpoints(corpusDir: string): EndpointResult[] {
659
711
  reading:
660
712
  `${byRule.size - unreachable.length}/${byRule.size} rules reachable; ` +
661
713
  `unreachable: ${unreachable.length === 0 ? 'none' : unreachable.join(', ')}; ` +
662
- `partial: ${partial.length === 0 ? 'none' : partial.join(', ')}`,
714
+ `partial: ${partial.length === 0 ? 'none' : partial.join(', ')}` +
715
+ ` | shipped user_prompt_submit reach (open_files IGNORED): ` +
716
+ `${byRule.size - pathOnlyReach.length}/${byRule.size}; reachable only via a ` +
717
+ `path trigger: ${pathOnlyReach.length === 0 ? 'none' : pathOnlyReach.join(', ')}` +
718
+ ` (of which thinned, i.e. at no scope on that route: ` +
719
+ `${thinnedAndPathOnly.length === 0 ? 'none' : thinnedAndPathOnly.join(', ')})`,
663
720
  bar: 'unreachable == 0 (a rule with zero matched positives is a rule the mode removed)',
664
721
  passed: unreachable.length === 0,
665
722
  },