@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +108 -7
- package/README.md +1 -0
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/delegate.md +1 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
- package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_conformance.js +2798 -95
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +86 -6
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1904 -575
- package/dist/install/install.mjs +14 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3760 -726
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +21 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/config/agent-settings.template.yml +36 -0
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +17 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/judgment_ladder.ts +254 -2
- package/src/scripts/_lib/orchestration_record.ts +41 -10
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +194 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +159 -0
- package/src/scripts/_lib/subagent_response.ts +75 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +17 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +20 -0
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +97 -0
- package/src/scripts/self_repair_cli.ts +51 -3
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/server/schemas/settings.ts +14 -0
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-JDAF00bC.js +0 -26
- 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 (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|