@event4u/agent-config 7.3.0 → 7.4.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 +33 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/fix/pr-comments.md +42 -13
- package/dist/agent-src/rules/cli-output-handling.md +18 -0
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- 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/mcp/registry-manifest.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +3 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/enforcement-by-host.md +9 -0
- package/docs/governance-advantage.md +72 -0
- package/package.json +2 -1
- package/src/config/agent-settings.template.yml +9 -0
- package/src/scripts/_cli/cmd_analyze_session.ts +347 -0
- package/src/scripts/_dispatch.bash +8 -0
- package/src/scripts/_lib/token_count.ts +110 -35
- package/src/scripts/audit_initial_context.ts +37 -12
- package/src/scripts/bench_quality_run.ts +253 -0
- package/src/scripts/bench_rule_load_latency.ts +178 -0
- package/src/scripts/check_kernel_prefix_stability.ts +0 -0
- package/src/scripts/check_quality_regression.ts +244 -0
- package/src/scripts/check_token_quality_golden.ts +253 -0
- package/src/scripts/check_token_regression.ts +266 -0
- package/src/scripts/hook_manifest.yaml +15 -3
- package/src/scripts/hooks/rtk_wrap_hook.ts +228 -0
- package/src/scripts/probe_host_compliance.ts +145 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Token-regression gate (token-saving Phase 0).
|
|
4
|
+
*
|
|
5
|
+
* Fails CI when a tracked always-loaded / projected token surface grows more
|
|
6
|
+
* than the tolerance (default 5%) over a committed baseline. This is the
|
|
7
|
+
* RELATIVE guard — distinct from `audit-tokens-budget` (absolute budgets) and
|
|
8
|
+
* from the cache-prefix byte-stability guard (Phase 5).
|
|
9
|
+
*
|
|
10
|
+
* Source of truth is `internal/bench/reports/projection-cost.json`, which is
|
|
11
|
+
* now produced with EXACT counts via the real tokenizer
|
|
12
|
+
* (`src/scripts/_lib/token_count.ts` → js-tiktoken cl100k_base). Run
|
|
13
|
+
* `task audit-tokens` first so the report reflects the working tree.
|
|
14
|
+
*
|
|
15
|
+
* Gated metrics (all `tokens_gpt`):
|
|
16
|
+
* - eager_rule_load — thin_projection.eager_gpt (the eager always-on load)
|
|
17
|
+
* - thin_rule_load — thin_projection.thin_gpt
|
|
18
|
+
* - skill_descriptions — description_catalog.skills_core_source.tokens_gpt
|
|
19
|
+
* - command_descriptions — description_catalog.commands_core_source.tokens_gpt
|
|
20
|
+
* - mcp_schemas — Σ mcp_tool_schemas[*].tokens_gpt
|
|
21
|
+
*
|
|
22
|
+
* A baseline captured under a different `token_method` (e.g. proxy → exact)
|
|
23
|
+
* is a legitimate reset, not a regression — the gate WARNS and asks for a
|
|
24
|
+
* `--update-baseline` rather than failing on the method switch alone.
|
|
25
|
+
*
|
|
26
|
+
* CLI:
|
|
27
|
+
* ./scripts-run src/scripts/check_token_regression # gate (read-only)
|
|
28
|
+
* ./scripts-run src/scripts/check_token_regression --json # machine output
|
|
29
|
+
* ./scripts-run src/scripts/check_token_regression --tolerance 0.05
|
|
30
|
+
* ./scripts-run src/scripts/check_token_regression --update-baseline # record current as baseline
|
|
31
|
+
*
|
|
32
|
+
* Exit codes:
|
|
33
|
+
* 0 — within tolerance (or baseline written / warmup: no baseline yet)
|
|
34
|
+
* 1 — argument / file error
|
|
35
|
+
* 2 — regression: a metric exceeded baseline by more than the tolerance
|
|
36
|
+
*/
|
|
37
|
+
import * as fs from 'node:fs';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
import process from 'node:process';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
|
|
42
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
43
|
+
// REPO_ROOT = <repo>/src/scripts/<file> → up two.
|
|
44
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
45
|
+
|
|
46
|
+
const PROJECTION_REPORT = path.join(
|
|
47
|
+
REPO_ROOT,
|
|
48
|
+
'internal/bench/reports/projection-cost.json',
|
|
49
|
+
);
|
|
50
|
+
const BASELINE_FILE = path.join(
|
|
51
|
+
REPO_ROOT,
|
|
52
|
+
'internal/bench/reports/token-baseline.json',
|
|
53
|
+
);
|
|
54
|
+
const DEFAULT_TOLERANCE = 0.05;
|
|
55
|
+
|
|
56
|
+
type Json = Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
interface Metrics {
|
|
59
|
+
token_method: string;
|
|
60
|
+
metrics: Record<string, number>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _num(obj: unknown, ...keys: string[]): number {
|
|
64
|
+
let cur: unknown = obj;
|
|
65
|
+
for (const k of keys) {
|
|
66
|
+
if (cur && typeof cur === 'object' && k in (cur as Json)) {
|
|
67
|
+
cur = (cur as Json)[k];
|
|
68
|
+
} else {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return typeof cur === 'number' ? cur : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract the flat gated-metric map from a projection-cost report. */
|
|
76
|
+
function extract_metrics(report: Json): Metrics {
|
|
77
|
+
const mcp = (report['mcp_tool_schemas'] as Json) ?? {};
|
|
78
|
+
let mcp_total = 0;
|
|
79
|
+
for (const server of Object.values(mcp)) {
|
|
80
|
+
mcp_total += _num(server, 'tokens_gpt');
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
token_method: String(report['token_method'] ?? 'unknown'),
|
|
84
|
+
metrics: {
|
|
85
|
+
eager_rule_load: _num(report, 'thin_projection', 'eager_gpt'),
|
|
86
|
+
thin_rule_load: _num(report, 'thin_projection', 'thin_gpt'),
|
|
87
|
+
skill_descriptions: _num(
|
|
88
|
+
report,
|
|
89
|
+
'description_catalog',
|
|
90
|
+
'skills_core_source',
|
|
91
|
+
'tokens_gpt',
|
|
92
|
+
),
|
|
93
|
+
command_descriptions: _num(
|
|
94
|
+
report,
|
|
95
|
+
'description_catalog',
|
|
96
|
+
'commands_core_source',
|
|
97
|
+
'tokens_gpt',
|
|
98
|
+
),
|
|
99
|
+
mcp_schemas: mcp_total,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _readJson(file: string): Json | null {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8')) as Json;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Row {
|
|
113
|
+
id: string;
|
|
114
|
+
current: number;
|
|
115
|
+
baseline: number;
|
|
116
|
+
pct: number; // signed fraction, e.g. 0.07 = +7%
|
|
117
|
+
regressed: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface GateResult {
|
|
121
|
+
status: 'ok' | 'regression' | 'warmup' | 'method-changed';
|
|
122
|
+
tolerance: number;
|
|
123
|
+
current_method: string;
|
|
124
|
+
baseline_method: string | null;
|
|
125
|
+
rows: Row[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function evaluate(
|
|
129
|
+
current: Metrics,
|
|
130
|
+
baseline: Metrics | null,
|
|
131
|
+
tolerance: number,
|
|
132
|
+
): GateResult {
|
|
133
|
+
if (baseline === null) {
|
|
134
|
+
return {
|
|
135
|
+
status: 'warmup',
|
|
136
|
+
tolerance,
|
|
137
|
+
current_method: current.token_method,
|
|
138
|
+
baseline_method: null,
|
|
139
|
+
rows: [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const method_changed = baseline.token_method !== current.token_method;
|
|
143
|
+
const rows: Row[] = [];
|
|
144
|
+
for (const [id, cur] of Object.entries(current.metrics)) {
|
|
145
|
+
const base = baseline.metrics[id] ?? 0;
|
|
146
|
+
const pct = base > 0 ? (cur - base) / base : 0;
|
|
147
|
+
rows.push({
|
|
148
|
+
id,
|
|
149
|
+
current: cur,
|
|
150
|
+
baseline: base,
|
|
151
|
+
pct,
|
|
152
|
+
// A method switch is a legitimate baseline reset, not a regression.
|
|
153
|
+
regressed: !method_changed && base > 0 && pct > tolerance,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
rows.sort((a, b) => b.pct - a.pct);
|
|
157
|
+
const anyRegressed = rows.some((r) => r.regressed);
|
|
158
|
+
const status: GateResult['status'] = anyRegressed
|
|
159
|
+
? 'regression'
|
|
160
|
+
: method_changed
|
|
161
|
+
? 'method-changed'
|
|
162
|
+
: 'ok';
|
|
163
|
+
return {
|
|
164
|
+
status,
|
|
165
|
+
tolerance,
|
|
166
|
+
current_method: current.token_method,
|
|
167
|
+
baseline_method: baseline.token_method,
|
|
168
|
+
rows,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _fmtPct(p: number): string {
|
|
173
|
+
const sign = p >= 0 ? '+' : '';
|
|
174
|
+
return `${sign}${(p * 100).toFixed(1)}%`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function main(argv: string[]): number {
|
|
178
|
+
let tolerance = DEFAULT_TOLERANCE;
|
|
179
|
+
let asJson = false;
|
|
180
|
+
let updateBaseline = false;
|
|
181
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
182
|
+
const a = argv[i];
|
|
183
|
+
if (a === '--json') asJson = true;
|
|
184
|
+
else if (a === '--update-baseline') updateBaseline = true;
|
|
185
|
+
else if (a === '--tolerance') {
|
|
186
|
+
const v = Number(argv[(i += 1)]);
|
|
187
|
+
if (!Number.isFinite(v) || v < 0) {
|
|
188
|
+
process.stderr.write(`error: invalid --tolerance\n`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
tolerance = v;
|
|
192
|
+
} else if (a === '-h' || a === '--help') {
|
|
193
|
+
process.stdout.write(
|
|
194
|
+
'usage: check_token_regression [--json] [--tolerance F] [--update-baseline]\n',
|
|
195
|
+
);
|
|
196
|
+
return 0;
|
|
197
|
+
} else {
|
|
198
|
+
process.stderr.write(`error: unknown argument: ${a}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const report = _readJson(PROJECTION_REPORT);
|
|
204
|
+
if (report === null) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`error: cannot read ${path.relative(REPO_ROOT, PROJECTION_REPORT)} — run \`task audit-tokens\` first\n`,
|
|
207
|
+
);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const current = extract_metrics(report);
|
|
211
|
+
|
|
212
|
+
if (updateBaseline) {
|
|
213
|
+
const payload = JSON.stringify(current, null, 2) + '\n';
|
|
214
|
+
fs.writeFileSync(BASELINE_FILE, payload);
|
|
215
|
+
process.stdout.write(
|
|
216
|
+
`✅ token baseline written: ${path.relative(REPO_ROOT, BASELINE_FILE)} (method: ${current.token_method})\n`,
|
|
217
|
+
);
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const baselineRaw = _readJson(BASELINE_FILE);
|
|
222
|
+
const baseline: Metrics | null =
|
|
223
|
+
baselineRaw === null ? null : (baselineRaw as unknown as Metrics);
|
|
224
|
+
const result = evaluate(current, baseline, tolerance);
|
|
225
|
+
|
|
226
|
+
if (asJson) {
|
|
227
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
228
|
+
} else if (result.status === 'warmup') {
|
|
229
|
+
process.stdout.write(
|
|
230
|
+
`⚠️ no token baseline yet — run \`./scripts-run src/scripts/check_token_regression --update-baseline\` to record one.\n`,
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
for (const r of result.rows) {
|
|
234
|
+
const mark = r.regressed ? '❌' : '✅';
|
|
235
|
+
process.stdout.write(
|
|
236
|
+
`${mark} ${r.id}: ${r.current} vs baseline ${r.baseline} (${_fmtPct(r.pct)})\n`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (result.status === 'method-changed') {
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
`⚠️ token_method changed (${result.baseline_method} → ${result.current_method}); ` +
|
|
242
|
+
`baseline reset is legitimate — run --update-baseline to re-anchor.\n`,
|
|
243
|
+
);
|
|
244
|
+
} else if (result.status === 'regression') {
|
|
245
|
+
process.stdout.write(
|
|
246
|
+
`❌ token regression: a projection grew >${(tolerance * 100).toFixed(0)}% over baseline.\n`,
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
process.stdout.write(
|
|
250
|
+
`✅ token surfaces within +${(tolerance * 100).toFixed(0)}% of baseline.\n`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return result.status === 'regression' ? 2 : 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const _IS_MAIN =
|
|
259
|
+
process.argv[1] !== undefined &&
|
|
260
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
261
|
+
|
|
262
|
+
if (_IS_MAIN) {
|
|
263
|
+
process.exit(main(process.argv.slice(2)));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export { extract_metrics, main };
|
|
@@ -64,6 +64,18 @@ concerns:
|
|
|
64
64
|
script: src/scripts/hooks/block_no_verify.ts
|
|
65
65
|
args: []
|
|
66
66
|
fail_closed: true
|
|
67
|
+
# token-saving Phase 3 — PreToolUse RTK-wrap nudge. DETERMINISTIC: keys off a
|
|
68
|
+
# live `which rtk` probe (not the self-reported flag). When rtk is installed
|
|
69
|
+
# and the agent is about to run a single verbose CLI command that is NOT
|
|
70
|
+
# completeness-critical, warns "re-run wrapped with rtk" (60–90% fewer output
|
|
71
|
+
# tokens). Warn only — never blocks (the v1 contract has no transparent
|
|
72
|
+
# rewrite); never fires when rtk is absent (silent plain-command fallback).
|
|
73
|
+
# Default-OFF (hooks.rtk_wrap.enabled). fail_closed: false — never break a
|
|
74
|
+
# command for a token optimization.
|
|
75
|
+
rtk-wrap:
|
|
76
|
+
script: src/scripts/hooks/rtk_wrap_hook.ts
|
|
77
|
+
args: []
|
|
78
|
+
fail_closed: false
|
|
67
79
|
# Phase 2 of road-to-hooks-actually-fire-in-consumers — session_start
|
|
68
80
|
# gate that surfaces the marketplace-install-but-unscaffolded shape.
|
|
69
81
|
# Writes .augment/.first-run-action-needed.md + one stderr line so
|
|
@@ -96,7 +108,7 @@ platforms:
|
|
|
96
108
|
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
|
|
97
109
|
session_end: [chat-history]
|
|
98
110
|
stop: [chat-history, verify-before-complete]
|
|
99
|
-
pre_tool_use: [block-no-verify]
|
|
111
|
+
pre_tool_use: [block-no-verify, rtk-wrap]
|
|
100
112
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
|
|
101
113
|
|
|
102
114
|
claude:
|
|
@@ -104,7 +116,7 @@ platforms:
|
|
|
104
116
|
session_end: [chat-history]
|
|
105
117
|
stop: [chat-history, verify-before-complete]
|
|
106
118
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
107
|
-
pre_tool_use: [block-no-verify]
|
|
119
|
+
pre_tool_use: [block-no-verify, rtk-wrap]
|
|
108
120
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
|
|
109
121
|
|
|
110
122
|
# Cowork — the Claude desktop app's local-agent-mode runtime, built
|
|
@@ -126,7 +138,7 @@ platforms:
|
|
|
126
138
|
session_end: [chat-history]
|
|
127
139
|
stop: [chat-history, verify-before-complete]
|
|
128
140
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
129
|
-
pre_tool_use: [block-no-verify]
|
|
141
|
+
pre_tool_use: [block-no-verify, rtk-wrap]
|
|
130
142
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
|
|
131
143
|
|
|
132
144
|
# Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse RTK-wrap nudge — deterministic, warn-in-context
|
|
4
|
+
* (token-saving Phase 3).
|
|
5
|
+
*
|
|
6
|
+
* Converts RTK from advisory to DETERMINISTIC: instead of trusting a
|
|
7
|
+
* self-reported flag, this hook keys off a live `which rtk` probe. When rtk is
|
|
8
|
+
* actually installed AND the agent is about to run a single verbose CLI command
|
|
9
|
+
* that is NOT completeness-critical, it surfaces (warn) "re-run wrapped with
|
|
10
|
+
* rtk" so the 60–90% output-token saving is captured. It NEVER blocks (the v1
|
|
11
|
+
* dispatcher contract is allow/block/warn — there is no transparent
|
|
12
|
+
* `updatedInput` rewrite — and, like `injection_scan_hook`, warn preserves
|
|
13
|
+
* agency). It NEVER fires when rtk is absent (silent plain-command fallback,
|
|
14
|
+
* no nag).
|
|
15
|
+
*
|
|
16
|
+
* Default-OFF. Fires only when `hooks.rtk_wrap.enabled: true` in
|
|
17
|
+
* `.agent-settings.yml`. Disabled / missing / rtk-absent → no-op exit 0.
|
|
18
|
+
* fail_closed: false — any parse/probe error returns allow (never break a
|
|
19
|
+
* command for a token optimization).
|
|
20
|
+
*
|
|
21
|
+
* Denylist (never nudged — completeness-critical or already-handled):
|
|
22
|
+
* - `git diff` / `rtk read` (silent truncation risk — see the rtk skill)
|
|
23
|
+
* - already `rtk …` wrapped, or piped / compound (`|`, `&&`, `||`, `;`)
|
|
24
|
+
* - any program not in the verbose-CLI allowlist
|
|
25
|
+
*
|
|
26
|
+
* Exit codes (dispatcher contract): 0 allow · 2 warn (+ JSON reason on stdout).
|
|
27
|
+
*/
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { pathToFileURL } from "node:url";
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
shlexSplit,
|
|
34
|
+
ShlexError,
|
|
35
|
+
_split_subcommands,
|
|
36
|
+
_is_env_assignment,
|
|
37
|
+
} from "./block_no_verify.js";
|
|
38
|
+
|
|
39
|
+
const SETTINGS_FILE = ".agent-settings.yml";
|
|
40
|
+
const EXIT_ALLOW = 0;
|
|
41
|
+
const EXIT_WARN = 2;
|
|
42
|
+
|
|
43
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
44
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
45
|
+
|
|
46
|
+
// Verbose-CLI programs worth wrapping — mirrors the cli-output-handling rule
|
|
47
|
+
// triggers + the rtk skill's "✅ Always" table. Kept in sync with
|
|
48
|
+
// src/rules/cli-output-handling.md.
|
|
49
|
+
const _RTK_PROGRAMS: ReadonlySet<string> = new Set([
|
|
50
|
+
"git", "phpstan", "rector", "phpunit", "composer",
|
|
51
|
+
"npm", "pnpm", "yarn", "eslint", "tsc", "vitest", "jest",
|
|
52
|
+
"pytest", "ruff", "mypy", "pyright", "cargo", "go",
|
|
53
|
+
"golangci-lint", "docker", "kubectl", "terraform",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
function _isObject(v: unknown): v is JsonObject {
|
|
57
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `hooks.rtk_wrap.enabled: true` mini-parser (mirrors injection_scan_hook). */
|
|
61
|
+
function _enabled(root: string): boolean {
|
|
62
|
+
const f = path.join(root, SETTINGS_FILE);
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.statSync(f).isFile()) return false;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
let text: string;
|
|
69
|
+
try {
|
|
70
|
+
text = fs.readFileSync(f, "utf-8");
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
let in_hooks = false;
|
|
75
|
+
let in_rw = false;
|
|
76
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
77
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
78
|
+
for (const raw of lines) {
|
|
79
|
+
const line = raw.replace(/\s+$/, "");
|
|
80
|
+
if (!line || line.replace(/^\s+/, "").startsWith("#")) continue;
|
|
81
|
+
if (!(line.startsWith(" ") || line.startsWith("\t"))) {
|
|
82
|
+
in_hooks = /^hooks\s*:\s*$/.test(line);
|
|
83
|
+
in_rw = false;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (in_hooks) {
|
|
87
|
+
if (/^\s+rtk_wrap\s*:\s*$/.test(line)) {
|
|
88
|
+
in_rw = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (in_rw && /^\s{0,3}\S/.test(line)) in_rw = false;
|
|
92
|
+
}
|
|
93
|
+
if (in_rw && /^\s+enabled\s*:\s*true\b/.test(line)) return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Live `which rtk` — scan PATH for an executable `rtk` (cross-platform). */
|
|
99
|
+
export function rtk_available(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
100
|
+
const pathVar = env.PATH ?? env.Path ?? "";
|
|
101
|
+
if (!pathVar) return false;
|
|
102
|
+
const dirs = pathVar.split(path.delimiter).filter(Boolean);
|
|
103
|
+
// On Windows the launcher is rtk.exe / rtk.cmd / rtk.bat.
|
|
104
|
+
const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
105
|
+
for (const dir of dirs) {
|
|
106
|
+
for (const ext of exts) {
|
|
107
|
+
const candidate = path.join(dir, `rtk${ext}`);
|
|
108
|
+
try {
|
|
109
|
+
const st = fs.statSync(candidate);
|
|
110
|
+
if (st.isFile()) {
|
|
111
|
+
// POSIX: require an executable bit; Windows: extension is enough.
|
|
112
|
+
if (process.platform === "win32" || (st.mode & 0o111) !== 0) return true;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
/* not here — keep scanning */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _extract_command(envelope: JsonObject): string | null {
|
|
123
|
+
const payload = (_isObject(envelope["payload"]) ? envelope["payload"] : {}) as JsonObject;
|
|
124
|
+
const tool_input = (_isObject(payload["tool_input"]) ? payload["tool_input"] : {}) as JsonObject;
|
|
125
|
+
for (const src of [tool_input["command"], payload["command"], envelope["command"]]) {
|
|
126
|
+
if (typeof src === "string") return src;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface Eligibility {
|
|
132
|
+
eligible: boolean;
|
|
133
|
+
program?: string;
|
|
134
|
+
reason_skip?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A command is eligible for an rtk-wrap nudge iff it is a SINGLE simple command
|
|
139
|
+
* (no pipe / compound separators), whose program is a known verbose CLI, and is
|
|
140
|
+
* not a completeness-critical / already-wrapped case.
|
|
141
|
+
*/
|
|
142
|
+
export function classify(command: string): Eligibility {
|
|
143
|
+
const cmd = command.trim();
|
|
144
|
+
if (!cmd) return { eligible: false, reason_skip: "empty" };
|
|
145
|
+
|
|
146
|
+
let tokens: string[];
|
|
147
|
+
try {
|
|
148
|
+
tokens = shlexSplit(cmd);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
if (e instanceof ShlexError) return { eligible: false, reason_skip: "parse-error" };
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Compound / piped → don't wrap (rtk wraps a single command). `_split_subcommands`
|
|
155
|
+
// splits on && || ; | — more than one group means a pipeline/chain.
|
|
156
|
+
const groups = _split_subcommands(tokens);
|
|
157
|
+
if (groups.length !== 1) return { eligible: false, reason_skip: "compound-or-piped" };
|
|
158
|
+
|
|
159
|
+
const sub = groups[0] as string[];
|
|
160
|
+
let i = 0;
|
|
161
|
+
while (i < sub.length && _is_env_assignment(sub[i] as string)) i += 1;
|
|
162
|
+
const program = sub[i];
|
|
163
|
+
if (!program) return { eligible: false, reason_skip: "no-program" };
|
|
164
|
+
if (program === "rtk") return { eligible: false, reason_skip: "already-rtk" };
|
|
165
|
+
if (!_RTK_PROGRAMS.has(program)) return { eligible: false, program, reason_skip: "not-verbose-cli" };
|
|
166
|
+
// `git diff` is completeness-critical (silent truncation) — never wrap.
|
|
167
|
+
if (program === "git" && sub[i + 1] === "diff") {
|
|
168
|
+
return { eligible: false, program, reason_skip: "git-diff-denylisted" };
|
|
169
|
+
}
|
|
170
|
+
return { eligible: true, program };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _readStdin(): string {
|
|
174
|
+
try {
|
|
175
|
+
if (process.stdin.isTTY) return "";
|
|
176
|
+
return fs.readFileSync(0, "utf-8");
|
|
177
|
+
} catch {
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _jsonReason(reason: string): string {
|
|
183
|
+
return JSON.stringify({ decision: "warn", reason });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function main(): number {
|
|
187
|
+
let envelope: JsonValue;
|
|
188
|
+
try {
|
|
189
|
+
const raw = _readStdin();
|
|
190
|
+
envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
|
|
191
|
+
} catch {
|
|
192
|
+
return EXIT_ALLOW; // never break a command on a malformed envelope
|
|
193
|
+
}
|
|
194
|
+
if (!_isObject(envelope)) return EXIT_ALLOW;
|
|
195
|
+
|
|
196
|
+
const cwd = envelope["cwd"];
|
|
197
|
+
const projectRoot = envelope["workspace_root"] ?? envelope["project_root"];
|
|
198
|
+
const root =
|
|
199
|
+
typeof cwd === "string" && cwd
|
|
200
|
+
? cwd
|
|
201
|
+
: typeof projectRoot === "string" && projectRoot
|
|
202
|
+
? projectRoot
|
|
203
|
+
: ".";
|
|
204
|
+
if (!_enabled(root)) return EXIT_ALLOW;
|
|
205
|
+
|
|
206
|
+
// Deterministic gate: the live probe, NOT a self-reported flag.
|
|
207
|
+
if (!rtk_available()) return EXIT_ALLOW;
|
|
208
|
+
|
|
209
|
+
const command = _extract_command(envelope);
|
|
210
|
+
if (!command) return EXIT_ALLOW;
|
|
211
|
+
|
|
212
|
+
const verdict = classify(command);
|
|
213
|
+
if (!verdict.eligible) return EXIT_ALLOW;
|
|
214
|
+
|
|
215
|
+
const reason =
|
|
216
|
+
`rtk is installed and wraps verbose CLI output (60–90% fewer output tokens). ` +
|
|
217
|
+
`Re-run wrapped: \`rtk ${command.trim()}\`. ` +
|
|
218
|
+
`(Skip for completeness-critical output like \`git diff\`.)`;
|
|
219
|
+
process.stdout.write(`${_jsonReason(reason)}\n`);
|
|
220
|
+
return EXIT_WARN;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const _isCliEntry =
|
|
224
|
+
process.argv[1] !== undefined &&
|
|
225
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
226
|
+
if (_isCliEntry) {
|
|
227
|
+
process.exit(main());
|
|
228
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Host-compliance probe for thin projection (token-saving Phase 0).
|
|
4
|
+
*
|
|
5
|
+
* Thin projection is a CONTRACT WITH THE HOST: a non-kernel rule body is
|
|
6
|
+
* demoted to a router pointer, and the host is trusted to load the body only
|
|
7
|
+
* on trigger-match. On a host that reconstructs rule bodies internally (e.g. a
|
|
8
|
+
* plugin loader that re-inlines everything), thin projection is a silent no-op
|
|
9
|
+
* — so it must be falsified per host before the thin flip ships.
|
|
10
|
+
*
|
|
11
|
+
* This probe has two halves:
|
|
12
|
+
*
|
|
13
|
+
* MECHANICAL (here, automated): run the real thin projector (`thin_entry`)
|
|
14
|
+
* on a canary rule fixture and assert the body is replaced by a pointer that
|
|
15
|
+
* still carries the trigger hint (so the router can select it). This proves
|
|
16
|
+
* the projector demotes correctly — the prerequisite for any host test.
|
|
17
|
+
*
|
|
18
|
+
* LIVE (operator gate): install the thin-projected canary into each host,
|
|
19
|
+
* invoke its keyword, and confirm the host shows the POINTER, not the body.
|
|
20
|
+
* This is a human-run step (live trigger-eval is a human gate) — the probe
|
|
21
|
+
* prints the checklist + a results table to fill in.
|
|
22
|
+
*
|
|
23
|
+
* CLI:
|
|
24
|
+
* ./scripts-run src/scripts/probe_host_compliance # mechanical check + checklist
|
|
25
|
+
* ./scripts-run src/scripts/probe_host_compliance --json
|
|
26
|
+
*
|
|
27
|
+
* Exit codes: 0 demotion verified · 1 file error · 2 projector failed to demote.
|
|
28
|
+
*/
|
|
29
|
+
import * as fs from 'node:fs';
|
|
30
|
+
import * as path from 'node:path';
|
|
31
|
+
import process from 'node:process';
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
33
|
+
|
|
34
|
+
import { thin_entry } from './project_thin_rules.js';
|
|
35
|
+
|
|
36
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
37
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
38
|
+
const CANARY = path.join(
|
|
39
|
+
REPO_ROOT,
|
|
40
|
+
'tests/fixtures/host-compliance/host-compliance-canary.md',
|
|
41
|
+
);
|
|
42
|
+
const CANARY_ID = 'host-compliance-canary';
|
|
43
|
+
const SENTINEL = 'CANARY_BODY_SENTINEL_DO_NOT_INLINE';
|
|
44
|
+
const KEYWORD = 'xyzzy-canary-probe';
|
|
45
|
+
const POINTER_MARKER = 'Routed rule — load the body on trigger-match';
|
|
46
|
+
|
|
47
|
+
// The supported hosts whose live thin-compliance must be falsified.
|
|
48
|
+
const HOSTS = ['claude-code', 'cursor', 'augment'];
|
|
49
|
+
|
|
50
|
+
export interface DemotionResult {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
pointer_present: boolean;
|
|
53
|
+
body_removed: boolean;
|
|
54
|
+
trigger_hint_preserved: boolean;
|
|
55
|
+
link_present: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Assert a thinned canary is a valid pointer (body gone, still selectable). */
|
|
59
|
+
export function evaluate_demotion(
|
|
60
|
+
thinned: string,
|
|
61
|
+
opts: { sentinel: string; keyword: string } = { sentinel: SENTINEL, keyword: KEYWORD },
|
|
62
|
+
): DemotionResult {
|
|
63
|
+
const pointer_present = thinned.includes(POINTER_MARKER);
|
|
64
|
+
const body_removed = !thinned.includes(opts.sentinel);
|
|
65
|
+
const trigger_hint_preserved = thinned.includes(opts.keyword);
|
|
66
|
+
const link_present = /Body: \[`[^`]+`\]\(/.test(thinned);
|
|
67
|
+
return {
|
|
68
|
+
ok: pointer_present && body_removed && trigger_hint_preserved && link_present,
|
|
69
|
+
pointer_present,
|
|
70
|
+
body_removed,
|
|
71
|
+
trigger_hint_preserved,
|
|
72
|
+
link_present,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function operator_checklist(): string {
|
|
77
|
+
const rows = HOSTS.map(
|
|
78
|
+
(h) => ` - [ ] ${h}: fires on \`${KEYWORD}\` AND shows the pointer, NOT the body sentinel`,
|
|
79
|
+
).join('\n');
|
|
80
|
+
return (
|
|
81
|
+
'LIVE host-compliance step (operator gate — live trigger-eval is human-run):\n' +
|
|
82
|
+
' 1. Set `lean_projection.mode: thin` and run `task generate-tools && task sync`.\n' +
|
|
83
|
+
' 2. Install the projected canary into each host.\n' +
|
|
84
|
+
` 3. Invoke the keyword \`${KEYWORD}\` in a session on each host.\n` +
|
|
85
|
+
' 4. PASS = the rule fires AND the host surfaces the router pointer;\n' +
|
|
86
|
+
` FAIL = the host surfaces the body sentinel (\`${SENTINEL}\`) → thin\n` +
|
|
87
|
+
' projection is a no-op there; escalate to the host vendor.\n' +
|
|
88
|
+
'Results:\n' +
|
|
89
|
+
rows +
|
|
90
|
+
'\n'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function main(argv: string[]): number {
|
|
95
|
+
const asJson = argv.includes('--json');
|
|
96
|
+
for (const a of argv) {
|
|
97
|
+
if (!['--json', '-h', '--help'].includes(a)) {
|
|
98
|
+
process.stderr.write(`error: unknown argument: ${a}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
if (a === '-h' || a === '--help') {
|
|
102
|
+
process.stdout.write('usage: probe_host_compliance [--json]\n');
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let text: string;
|
|
108
|
+
try {
|
|
109
|
+
text = fs.readFileSync(CANARY, 'utf-8');
|
|
110
|
+
} catch {
|
|
111
|
+
process.stderr.write(`error: cannot read canary fixture ${path.relative(REPO_ROOT, CANARY)}\n`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
const thinned = thin_entry(CANARY_ID, text);
|
|
115
|
+
const result = evaluate_demotion(thinned);
|
|
116
|
+
|
|
117
|
+
if (asJson) {
|
|
118
|
+
process.stdout.write(
|
|
119
|
+
JSON.stringify({ ...result, hosts: HOSTS, keyword: KEYWORD, sentinel: SENTINEL }, null, 2) + '\n',
|
|
120
|
+
);
|
|
121
|
+
return result.ok ? 0 : 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.stdout.write(
|
|
125
|
+
`${result.ok ? '✅' : '❌'} mechanical demotion: ` +
|
|
126
|
+
`pointer=${result.pointer_present} body-removed=${result.body_removed} ` +
|
|
127
|
+
`hint=${result.trigger_hint_preserved} link=${result.link_present}\n`,
|
|
128
|
+
);
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
process.stdout.write('❌ the thin projector did NOT demote the canary correctly.\n');
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
process.stdout.write('\n' + operator_checklist());
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const _IS_MAIN =
|
|
138
|
+
process.argv[1] !== undefined &&
|
|
139
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
140
|
+
|
|
141
|
+
if (_IS_MAIN) {
|
|
142
|
+
process.exit(main(process.argv.slice(2)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { main };
|