@event4u/agent-config 9.29.0 → 9.30.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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +108 -7
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
  18. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
  19. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
  20. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
  21. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  22. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  23. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  24. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  25. package/dist/cli-delegate/cmd_conformance.js +2798 -95
  26. package/dist/cli-delegate/cmd_doctor.js +2 -1
  27. package/dist/cli-delegate/cmd_export.js +2 -2
  28. package/dist/cli-delegate/cmd_handoff.js +86 -6
  29. package/dist/cli-delegate/cmd_prune.js +2 -2
  30. package/dist/cli-delegate/cmd_route_audit.js +2 -1
  31. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  32. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  33. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +7 -7
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/orphan-report.md +1 -1
  43. package/dist/discovery/packs.json +2 -2
  44. package/dist/discovery/trust-report.md +1 -1
  45. package/dist/discovery/workspaces.json +2 -2
  46. package/dist/hooks/dispatch.js +1904 -575
  47. package/dist/install/install.mjs +14 -0
  48. package/dist/mcp/registry-manifest.json +2 -2
  49. package/dist/mcp/server.json +2 -2
  50. package/dist/mcp/server.mjs +3760 -726
  51. package/dist/server/schemas/settings.js +6 -0
  52. package/dist/server/schemas/settings.js.map +1 -1
  53. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  54. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  55. package/dist/ui/index.html +1 -1
  56. package/docs/contracts/settings-classes.md +21 -5
  57. package/package.json +1 -1
  58. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  59. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  60. package/src/config/agent-settings.template.yml +36 -0
  61. package/src/config/dispatch-economy-metrics.json +61 -0
  62. package/src/config/evaluator-budgets.json +4 -4
  63. package/src/config/hook-token-budget.json +91 -0
  64. package/src/config/recycle-threshold-budget.json +12 -0
  65. package/src/config/rule-enforcement-baseline.json +86 -0
  66. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  67. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  68. package/src/scripts/_dispatch.bash +23 -0
  69. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  70. package/src/scripts/_lib/orchestration_record.ts +41 -10
  71. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  72. package/src/scripts/_lib/self_repair.ts +194 -0
  73. package/src/scripts/_lib/session_eol.ts +230 -0
  74. package/src/scripts/_lib/session_role.ts +42 -0
  75. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  76. package/src/scripts/_lib/subagent_response.ts +75 -0
  77. package/src/scripts/ai_council/clients.ts +50 -1
  78. package/src/scripts/ask_transport.ts +221 -0
  79. package/src/scripts/bench_hook_injection.ts +234 -0
  80. package/src/scripts/check_adr_frontmatter.ts +84 -40
  81. package/src/scripts/check_branch_freshness.ts +1 -0
  82. package/src/scripts/check_corpus_staleness.ts +18 -4
  83. package/src/scripts/check_detector_corpus.ts +17 -0
  84. package/src/scripts/check_gitignore_freshness.ts +42 -8
  85. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  86. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  87. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  88. package/src/scripts/check_static_layer_stability.ts +154 -0
  89. package/src/scripts/check_suppression_hygiene.ts +48 -0
  90. package/src/scripts/dispatch_economy_report.ts +448 -0
  91. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  92. package/src/scripts/explain_run.ts +206 -4
  93. package/src/scripts/generate_settings_reference.ts +1 -0
  94. package/src/scripts/handoff_context_hook.ts +112 -4
  95. package/src/scripts/hook_manifest.yaml +117 -10
  96. package/src/scripts/hooks/concern_registry.ts +8 -0
  97. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  98. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  99. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  100. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  101. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  102. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  103. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  105. package/src/scripts/install-hooks.sh +1 -1
  106. package/src/scripts/language_mirror_hook.ts +89 -0
  107. package/src/scripts/lint_decision_impact.ts +14 -1
  108. package/src/scripts/lint_hook_manifest.ts +82 -0
  109. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  110. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  111. package/src/scripts/measure_turn_end_gate.ts +194 -0
  112. package/src/scripts/orchestration_record.ts +4 -1
  113. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  114. package/src/scripts/report_conformance_funnel.ts +284 -0
  115. package/src/scripts/report_imperative_density.ts +1 -0
  116. package/src/scripts/routing_doctor.ts +97 -0
  117. package/src/scripts/self_repair_cli.ts +51 -3
  118. package/src/scripts/session_eol_report.ts +261 -0
  119. package/src/server/schemas/settings.ts +14 -0
  120. package/src/templates/compact-instructions.md +40 -0
  121. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  122. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Re-read guard + post-edit scope hint — `pre_tool_use` concern
4
+ * (road-to-token-economy-cache Phase 6.1/6.2).
5
+ *
6
+ * Two signals, one session-scoped read ledger:
7
+ *
8
+ * 6.1 A full-file `Read` of a path that was ALREADY fully read this session
9
+ * AND is unchanged since (mtime + size compare against the ledger) gets
10
+ * one advisory line naming the earlier read and the ranged-read
11
+ * alternative. A ranged read (any `offset`/`limit` in the tool input)
12
+ * is already the cheap form and never fires — nor is it recorded as a
13
+ * full read.
14
+ * 6.2 A full-file `Read` immediately following THIS session's own
15
+ * `Edit`/`Write` to the same path (last-write tracked in the same
16
+ * ledger — PreToolUse sees those tool calls too) gets the same advisory
17
+ * shape pointing at a ranged re-read of the edited hunk.
18
+ *
19
+ * ## The ledger — paths and numbers only, never content
20
+ *
21
+ * `<workspace_root>/agents/runtime/state/reread-guard/<sha256(session)>.json`
22
+ * (gitignored — `agents/runtime/` is blanket-ignored), keyed by session id
23
+ * the same way `end_review_nudge_hook.deriveSessionKey` does (hashed, never
24
+ * raw). Per path it stores `{mtime, size, ts}` for reads, a `ts` for the last
25
+ * own write, and a per-path fired flag — NO field can hold file content, by
26
+ * construction (same PII/content-exclusion-by-construction discipline as the
27
+ * telemetry schemas).
28
+ *
29
+ * Population: PreToolUse sees the Read BEFORE it happens, so the ledger is
30
+ * populated in this same hook — after the staleness check, the current
31
+ * `{mtime, size}` of the path is recorded (the read is about to happen;
32
+ * recording pre-read is equivalent for every subsequent comparison).
33
+ *
34
+ * ## ADVISORY ONLY — never a block, never exit 1
35
+ *
36
+ * This concern binds on `pre_tool_use`, the slot that carries the blocking
37
+ * guards — but it is advisory by construction and never returns exit 1: a
38
+ * stale-ledger false positive on a BLOCK would corrupt work (the agent would
39
+ * be denied a read it genuinely needs over a bookkeeping artifact). The
40
+ * escalation decision waits on the 6.4 telemetry, per the roadmap.
41
+ *
42
+ * ## Path exemptions (committed constant — shared list with `edit-shape`)
43
+ *
44
+ * `dist/**`, any path containing `/generated/`, `*.lock`, `*.min.*`, and
45
+ * `agents/runtime/**`. Deliberately DUPLICATED from `edit_shape_hook.ts`
46
+ * (noted there too): the two concerns share the list by convention, and a
47
+ * shared module would couple two independently-rollback-able hooks.
48
+ *
49
+ * ## Delivery — `{decision:"warn", reason, additional_context}` at exit 2
50
+ *
51
+ * Mirrors `delegation_nudge_hook.ts` exactly. WHY EXIT 2 NEVER BLOCKS
52
+ * (copied from that hook's verified trace): `host_semantics.emitFor`'s
53
+ * `severity === "warn"` branch returns `{exit: 0, stdout:
54
+ * claudeAdditionalContext(event, reason)}` UNCONDITIONALLY — it does not
55
+ * consult `CLAUDE_BLOCK_CAPABLE_EVENTS` (that check only gates the `"block"`
56
+ * severity branch). On the verified `claude` platform the host-facing exit is
57
+ * always 0 for a warn verdict; `EXIT_WARN` is dispatcher-internal
58
+ * bookkeeping, never the byte that reaches the host.
59
+ *
60
+ * ROBUSTNESS: malformed payload → exit 0 silently; every fs error swallowed;
61
+ * an unreadable/corrupt ledger resets to empty (worst case: one missed or
62
+ * repeated advisory, never a lost read).
63
+ */
64
+ import * as crypto from 'node:crypto';
65
+ import * as fs from 'node:fs';
66
+ import * as path from 'node:path';
67
+ import { fileURLToPath, pathToFileURL } from 'node:url';
68
+
69
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
70
+ import { readHookStdin } from './hook_stdin.js';
71
+ import { atomic_write_json } from './state_io.js';
72
+
73
+ const EXIT_ALLOW = 0;
74
+ // Severity is taken from the EXIT CODE, not from the `decision` field — see
75
+ // the file header for why this never blocks on `claude`. This concern never
76
+ // returns exit 1 on any path.
77
+ const EXIT_WARN = 2;
78
+
79
+ /**
80
+ * Committed exemption list — deliberately duplicated from
81
+ * `edit_shape_hook.ts` (see the header note there and here).
82
+ */
83
+ export const EXEMPT_PATH_PATTERNS: readonly RegExp[] = [
84
+ /(^|\/)dist\//i, // dist/**
85
+ /\/generated\//i, // any path containing /generated/
86
+ /\.lock$/i, // *.lock
87
+ /\.min\.[^/]+$/i, // *.min.*
88
+ /(^|\/)agents\/runtime\//i, // agents/runtime/**
89
+ ];
90
+
91
+ /** Tool names this concern reacts to (manifest `tools:` twin). */
92
+ export const READ_TOOLS: ReadonlySet<string> = new Set(['Read']);
93
+ export const WRITE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write']);
94
+
95
+ function isObject(v: unknown): v is JsonObject {
96
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
97
+ }
98
+
99
+ function str(v: JsonValue | undefined): string {
100
+ return typeof v === 'string' ? v : '';
101
+ }
102
+
103
+ /** True when `filePath` (absolute or repo-relative) matches the exemption list. */
104
+ export function isExemptPath(filePath: string): boolean {
105
+ const normalized = filePath.replace(/\\/g, '/');
106
+ return EXEMPT_PATH_PATTERNS.some((re) => re.test(normalized));
107
+ }
108
+
109
+ /** Per-path read record — NUMBERS ONLY (mtime ms, byte size, epoch-ms ts). */
110
+ export interface ReadRecord {
111
+ mtime: number;
112
+ size: number;
113
+ ts: number;
114
+ }
115
+
116
+ /** The session ledger — paths as keys, numbers as values, no content field. */
117
+ export interface Ledger {
118
+ reads: Record<string, ReadRecord>;
119
+ /** Last own Edit/Write per path, epoch ms. */
120
+ writes: Record<string, number>;
121
+ /** Per-path once-per-session advisory latch. */
122
+ fired: Record<string, boolean>;
123
+ }
124
+
125
+ function emptyLedger(): Ledger {
126
+ return { reads: {}, writes: {}, fired: {} };
127
+ }
128
+
129
+ /** Hashed session key — mirrors `end_review_nudge_hook.deriveSessionKey`. */
130
+ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
131
+ const raw =
132
+ str(envelope['session_id'] as JsonValue | undefined) ||
133
+ str((payload['session_id'] ?? payload['sessionId']) as JsonValue | undefined);
134
+ const material = raw || 'unknown-session';
135
+ return crypto.createHash('sha256').update(material).digest('hex');
136
+ }
137
+
138
+ export function ledgerFile(workspaceRoot: string, sessionKey: string): string {
139
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'reread-guard', `${sessionKey}.json`);
140
+ }
141
+
142
+ export function readLedger(workspaceRoot: string, sessionKey: string): Ledger {
143
+ try {
144
+ const parsed = JSON.parse(fs.readFileSync(ledgerFile(workspaceRoot, sessionKey), 'utf8'));
145
+ if (!isObject(parsed)) return emptyLedger();
146
+ return {
147
+ reads: isObject(parsed['reads']) ? (parsed['reads'] as unknown as Record<string, ReadRecord>) : {},
148
+ writes: isObject(parsed['writes']) ? (parsed['writes'] as Record<string, number>) : {},
149
+ fired: isObject(parsed['fired']) ? (parsed['fired'] as Record<string, boolean>) : {},
150
+ };
151
+ } catch {
152
+ return emptyLedger(); // missing/corrupt ledger → fresh; never a crash
153
+ }
154
+ }
155
+
156
+ function writeLedger(workspaceRoot: string, sessionKey: string, ledger: Ledger): void {
157
+ try {
158
+ atomic_write_json(ledgerFile(workspaceRoot, sessionKey), ledger);
159
+ } catch {
160
+ // a state-write failure must never affect the tool call
161
+ }
162
+ }
163
+
164
+ function extractToolName(payload: JsonObject): string {
165
+ return str((payload['tool_name'] ?? payload['toolName'] ?? payload['tool']) as JsonValue | undefined);
166
+ }
167
+
168
+ function extractToolInput(payload: JsonObject): JsonObject {
169
+ const ti = payload['tool_input'] ?? payload['toolInput'] ?? payload['input'];
170
+ return isObject(ti) ? ti : {};
171
+ }
172
+
173
+ function extractFilePath(toolInput: JsonObject): string {
174
+ return str((toolInput['file_path'] ?? toolInput['path'] ?? toolInput['filePath']) as JsonValue | undefined);
175
+ }
176
+
177
+ /** True when the incoming Read is already narrowed (offset/limit present). */
178
+ export function isRangedRead(toolInput: JsonObject): boolean {
179
+ for (const key of ['offset', 'limit', 'start_line', 'end_line', 'startLine', 'endLine']) {
180
+ const v = toolInput[key];
181
+ if (typeof v === 'number' || (typeof v === 'string' && v !== '')) return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ /** 6.1 advisory — duplicate full read of an unchanged, already-read path. */
187
+ export function buildDuplicateReadLine(relPath: string, earlierTs: number): string {
188
+ const when = new Date(earlierTs).toISOString();
189
+ return (
190
+ `re-read guard: ${relPath} was already fully read this session (${when}) and is ` +
191
+ `unchanged since (same mtime+size) — the content is still in context; if you only ` +
192
+ `need part of it, a ranged Read (offset/limit) pays for the range, not the file.`
193
+ );
194
+ }
195
+
196
+ /** 6.2 advisory — full re-read right after this session's own edit. */
197
+ export function buildPostEditReadLine(relPath: string): string {
198
+ return (
199
+ `re-read guard: this session just edited ${relPath} — a full-file re-read re-pays ` +
200
+ `for content you already have; a ranged Read (offset/limit) over the edited hunk ` +
201
+ `is enough to verify the change.`
202
+ );
203
+ }
204
+
205
+ export function main(): number {
206
+ try {
207
+ const [envelope, payload] = unwrap(readHookStdin(), 'claude');
208
+
209
+ const event = str(envelope['event'] as JsonValue | undefined);
210
+ if (event !== '' && event !== 'pre_tool_use') return EXIT_ALLOW;
211
+
212
+ const toolName = extractToolName(payload);
213
+ const isRead = READ_TOOLS.has(toolName);
214
+ const isWrite = WRITE_TOOLS.has(toolName);
215
+ if (!isRead && !isWrite) return EXIT_ALLOW;
216
+
217
+ const toolInput = extractToolInput(payload);
218
+ const filePath = extractFilePath(toolInput);
219
+ if (!filePath) return EXIT_ALLOW;
220
+
221
+ const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined).trim() || process.cwd();
222
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
223
+ const relPath = path.relative(workspaceRoot, absPath);
224
+ if (isExemptPath(relPath) || isExemptPath(absPath)) return EXIT_ALLOW;
225
+
226
+ const sessionKey = deriveSessionKey(envelope, payload);
227
+ const ledger = readLedger(workspaceRoot, sessionKey);
228
+ // Ledger keys are workspace-relative where possible (stable across
229
+ // absolute-vs-relative host payloads), absolute otherwise.
230
+ const key = relPath.startsWith('..') ? absPath : relPath;
231
+
232
+ if (isWrite) {
233
+ // 6.2 bookkeeping: remember this session's own write. The write has
234
+ // not happened yet, but for every later comparison "about to write"
235
+ // and "wrote" are equivalent.
236
+ ledger.writes[key] = Date.now();
237
+ writeLedger(workspaceRoot, sessionKey, ledger);
238
+ return EXIT_ALLOW;
239
+ }
240
+
241
+ // ── Read path ────────────────────────────────────────────────────
242
+ if (isRangedRead(toolInput)) {
243
+ return EXIT_ALLOW; // already the cheap form; not recorded as a full read
244
+ }
245
+
246
+ let mtime: number;
247
+ let size: number;
248
+ try {
249
+ const st = fs.statSync(absPath);
250
+ mtime = st.mtimeMs;
251
+ size = st.size;
252
+ } catch {
253
+ return EXIT_ALLOW; // unreadable/missing target — nothing to compare
254
+ }
255
+
256
+ const prior = ledger.reads[key];
257
+ const ownWrite = ledger.writes[key];
258
+ const alreadyFired = ledger.fired[key] === true;
259
+
260
+ let advisory: string | null = null;
261
+ if (!alreadyFired && ownWrite !== undefined) {
262
+ advisory = buildPostEditReadLine(key); // 6.2 wins when both hold
263
+ } else if (!alreadyFired && prior !== undefined && prior.mtime === mtime && prior.size === size) {
264
+ advisory = buildDuplicateReadLine(key, prior.ts);
265
+ }
266
+
267
+ // Record the read that is about to happen (numbers only), consume the
268
+ // one-shot own-write marker, and latch the per-path fire flag.
269
+ ledger.reads[key] = { mtime, size, ts: Date.now() };
270
+ if (ownWrite !== undefined) delete ledger.writes[key];
271
+ if (advisory !== null) ledger.fired[key] = true;
272
+ writeLedger(workspaceRoot, sessionKey, ledger);
273
+
274
+ if (advisory === null) return EXIT_ALLOW;
275
+
276
+ process.stdout.write(
277
+ `${JSON.stringify({
278
+ decision: 'warn',
279
+ reason: `reread-guard: full-file Read of ${key} the session already paid for`,
280
+ additional_context: advisory,
281
+ })}\n`,
282
+ );
283
+ return EXIT_WARN;
284
+ } catch {
285
+ return EXIT_ALLOW; // malformed payload / any error — never touch the tool call
286
+ }
287
+ }
288
+
289
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
290
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
291
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
292
+ function _isCliEntry(): boolean {
293
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
294
+ return false;
295
+ }
296
+ if (process.argv[1] === undefined) {
297
+ return false;
298
+ }
299
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
300
+ if (import.meta.url === argvUrl) {
301
+ return true;
302
+ }
303
+ try {
304
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
305
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
306
+ return here === argv;
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ if (_isCliEntry()) {
313
+ process.exit(main());
314
+ }
@@ -26,6 +26,11 @@
26
26
  * - already `rtk …` wrapped, or piped / compound (`|`, `&&`, `||`, `;`)
27
27
  * - any program not in the verbose-CLI allowlist
28
28
  *
29
+ * Second branch (token-economy-cache Phase 4.2, advisory-degraded): for
30
+ * commands rtk cannot wrap — an unbounded tree-wide search per the committed
31
+ * OUTPUT_CAP_TABLE below — emit a warn naming the bounded alternative. No
32
+ * rewrite, no truncation; see the table's comment for the degradation record.
33
+ *
29
34
  * Exit codes (dispatcher contract): 0 allow · 2 warn (+ JSON reason on stdout).
30
35
  */
31
36
  import fs from "node:fs";
@@ -175,6 +180,108 @@ export function classify(command: string): Eligibility {
175
180
  return { eligible: true, program };
176
181
  }
177
182
 
183
+ // ── Unbounded-output cap ADVISORY (token-economy-cache Phase 4.2) ──────────
184
+ // The roadmap step asked for a deterministic PreToolUse cap REWRITE; the v1
185
+ // dispatcher contract carries no `updatedInput`, so per the roadmap's
186
+ // pre-registered consequence this degrades to an ADVISORY: a committed
187
+ // per-command cap table (this constant — rows individually removable,
188
+ // per-row opt-out via `enabled`) and a warn naming the bounded alternative.
189
+ // Nothing is ever truncated; ignoring the advisory IS the uncapped re-run,
190
+ // and repeated fires in the dispatcher's warn counters are the demand
191
+ // signal that calibrates the table. Table scope is evidence-bound: only the
192
+ // class the 2026-08-10 corpus run measured (tree-wide search — 302 KB raw
193
+ // vs 21 KB bounded on this repo; internal/bench/rtk-savings/RESULTS.md).
194
+ export interface CapRow {
195
+ enabled: boolean;
196
+ /** long flags whose presence means the output is already bounded */
197
+ bounded_long_flags: readonly string[];
198
+ /** short-option letters (combined forms like `-rln` count) that bound output */
199
+ bounded_short_letters: string;
200
+ /** fire only when a recursive/tree-wide flag is present ("" = always tree-wide) */
201
+ recursive_short_letters: string;
202
+ recursive_long_flags: readonly string[];
203
+ hint: string;
204
+ }
205
+
206
+ export const OUTPUT_CAP_TABLE: Readonly<Record<string, CapRow>> = {
207
+ grep: {
208
+ enabled: true,
209
+ bounded_long_flags: ["--files-with-matches", "--count", "--max-count"],
210
+ bounded_short_letters: "lcm",
211
+ recursive_short_letters: "rR",
212
+ recursive_long_flags: ["--recursive", "--dereference-recursive"],
213
+ hint: "add `-l` (files only) or `-m 5` (per-file cap), or pipe through `| head -n 100`",
214
+ },
215
+ rg: {
216
+ enabled: true,
217
+ bounded_long_flags: ["--files-with-matches", "--count", "--max-count", "--count-matches"],
218
+ bounded_short_letters: "lcm",
219
+ recursive_short_letters: "",
220
+ recursive_long_flags: [],
221
+ hint: "add `-l` (files only) or `--max-count 5`, or pipe through `| head -n 100`",
222
+ },
223
+ };
224
+
225
+ /** Pipeline members that already bound or consume the stream. */
226
+ const _BOUNDING_PROGRAMS: ReadonlySet<string> = new Set(["head", "tail", "wc", "rtk"]);
227
+
228
+ function _shortLetters(token: string): string | null {
229
+ return /^-[A-Za-z]+$/.test(token) ? token.slice(1) : null;
230
+ }
231
+
232
+ function _hasAnyFlag(group: readonly string[], longFlags: readonly string[], shortLetters: string): boolean {
233
+ for (const t of group) {
234
+ if (longFlags.some((f) => t === f || t.startsWith(`${f}=`))) return true;
235
+ const letters = _shortLetters(t);
236
+ if (letters && shortLetters && [...shortLetters].some((c) => letters.includes(c))) return true;
237
+ }
238
+ return false;
239
+ }
240
+
241
+ export interface CapVerdict {
242
+ program: string;
243
+ hint: string;
244
+ }
245
+
246
+ /**
247
+ * An unbounded tree-wide search in the command (any pipeline segment) with no
248
+ * bounding flag and no bounding pipeline member → advisory verdict. Fail-open:
249
+ * parse errors and anything ambiguous return null.
250
+ */
251
+ export function classifyCap(
252
+ command: string,
253
+ table: Readonly<Record<string, CapRow>> = OUTPUT_CAP_TABLE,
254
+ ): CapVerdict | null {
255
+ const cmd = command.trim();
256
+ if (!cmd) return null;
257
+ let tokens: string[];
258
+ try {
259
+ tokens = shlexSplit(cmd);
260
+ } catch (e) {
261
+ if (e instanceof ShlexError) return null;
262
+ throw e;
263
+ }
264
+ const groups = _split_subcommands(tokens);
265
+ let hit: CapVerdict | null = null;
266
+ for (const group of groups) {
267
+ let i = 0;
268
+ while (i < group.length && _is_env_assignment(group[i] as string)) i += 1;
269
+ const program = group[i];
270
+ if (!program) continue;
271
+ // A bounding consumer anywhere in the chain (head/tail/wc/rtk) → bounded.
272
+ if (_BOUNDING_PROGRAMS.has(program)) return null;
273
+ const row = table[program];
274
+ if (!row || !row.enabled) continue;
275
+ const needsRecursive = row.recursive_short_letters !== "" || row.recursive_long_flags.length > 0;
276
+ if (needsRecursive && !_hasAnyFlag(group, row.recursive_long_flags, row.recursive_short_letters)) {
277
+ continue; // single-file / non-tree invocation — not the measured class
278
+ }
279
+ if (_hasAnyFlag(group, row.bounded_long_flags, row.bounded_short_letters)) continue;
280
+ if (hit === null) hit = { program, hint: row.hint };
281
+ }
282
+ return hit;
283
+ }
284
+
178
285
  function _readStdin(): string {
179
286
  return readHookStdin();
180
287
  }
@@ -203,6 +310,9 @@ export function main(): number {
203
310
  : ".";
204
311
  if (!_enabled(root)) return EXIT_ALLOW;
205
312
 
313
+ const command = _extract_command(envelope);
314
+ if (!command) return EXIT_ALLOW;
315
+
206
316
  // Deterministic gate: the live probe, NOT a self-reported flag — and an
207
317
  // IDENTITY probe, not bare presence: the `rtk` binary name collides with
208
318
  // the unrelated Rust Type Kit, and nudging the agent to wrap commands with
@@ -211,21 +321,30 @@ export function main(): number {
211
321
  // NOT — fail closed for behavior, fail open for the command itself.
212
322
  // Identity is cached user-globally keyed on the binary's path+mtime+size,
213
323
  // so the `rtk gain` probe runs once per installed binary, not per command.
214
- if (!rtk_available()) return EXIT_ALLOW;
215
- if (detectRtkCached().identity !== "token-killer") return EXIT_ALLOW;
216
-
217
- const command = _extract_command(envelope);
218
- if (!command) return EXIT_ALLOW;
219
-
220
- const verdict = classify(command);
221
- if (!verdict.eligible) return EXIT_ALLOW;
324
+ if (rtk_available() && detectRtkCached().identity === "token-killer") {
325
+ const verdict = classify(command);
326
+ if (verdict.eligible) {
327
+ const reason =
328
+ `rtk is installed and wraps verbose CLI output (upstream reports 60–90% fewer output tokens). ` +
329
+ `Re-run wrapped: \`rtk ${command.trim()}\`. ` +
330
+ `(Skip for completeness-critical output like \`git diff\`.)`;
331
+ process.stdout.write(`${_jsonReason(reason)}\n`);
332
+ return EXIT_WARN;
333
+ }
334
+ }
222
335
 
223
- const reason =
224
- `rtk is installed and wraps verbose CLI output (upstream reports 60–90% fewer output tokens). ` +
225
- `Re-run wrapped: \`rtk ${command.trim()}\`. ` +
226
- `(Skip for completeness-critical output like \`git diff\`.)`;
227
- process.stdout.write(`${_jsonReason(reason)}\n`);
228
- return EXIT_WARN;
336
+ // Cap advisory needs no rtk — it covers exactly the commands rtk cannot
337
+ // wrap (compound/piped, or outside its allowlist).
338
+ const cap = classifyCap(command);
339
+ if (cap) {
340
+ const reason =
341
+ `Unbounded \`${cap.program}\` output: a tree-wide search can return hundreds of KB into context ` +
342
+ `(measured here: 302 KB raw vs 21 KB bounded — internal/bench/rtk-savings/RESULTS.md). ` +
343
+ `Prefer a bounded form — ${cap.hint}. Re-running unbounded is fine when completeness is required.`;
344
+ process.stdout.write(`${_jsonReason(reason)}\n`);
345
+ return EXIT_WARN;
346
+ }
347
+ return EXIT_ALLOW;
229
348
  }
230
349
 
231
350
  // Bundle-safety: never auto-run when inlined into an esbuild bundle, where