@event4u/agent-config 12.1.0 → 14.0.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 +164 -186
- package/README.md +1 -1
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +36 -14
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +34 -12
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +70 -4
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +5 -5
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-7PZ4FSZB.js} +368 -191
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
- package/dist/cli-delegate/cmd_conformance.js +224 -104
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- 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 +73 -30
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +2653 -1365
- package/dist/install/install.mjs +12 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +2679 -887
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +70 -4
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +140 -12
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/hook-token-budget.json +8 -1
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +278 -5
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +101 -164
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +369 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_pr_ci_current.ts +409 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +99 -6
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/release.ts +43 -58
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/command.schema.json +1 -1
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/schemas/skill.schema.json +18 -1
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/server/schemas/settings.ts +2 -2
- package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
- package/src/config/budget-routing.json +0 -7
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `ship-diff-volume` — a shipping-intent gate on diff VOLUME.
|
|
4
|
+
*
|
|
5
|
+
* Complementary to `minimal-safe-diff`, not a duplicate of it, and the
|
|
6
|
+
* distinction is the reason this exists: that concern counts unique FILES
|
|
7
|
+
* TOUCHED PER TURN, which is a within-session shape signal. This one measures
|
|
8
|
+
* total changed LINES on the branch AT A SHIP VERB — the moment the change
|
|
9
|
+
* leaves for review. A twelve-turn change of four files each time trips
|
|
10
|
+
* neither; a single-turn 3,000-line change trips only this one.
|
|
11
|
+
*
|
|
12
|
+
* THE THRESHOLD IS DERIVED FROM THIS REPOSITORY'S OWN HISTORY, never copied
|
|
13
|
+
* from the reference that suggested the gate. Replay of the 40 most recently
|
|
14
|
+
* merged PRs (agents/evidence/eval-findings/metric-loop-s04.md) put the
|
|
15
|
+
* corrected p90 at 1,695 lines.
|
|
16
|
+
*
|
|
17
|
+
* THE EXCLUSION SET IS PART OF THE DEFINITION, NOT A TUNING PARAMETER. The
|
|
18
|
+
* same replay found the naive metric double-counting: four of the five largest
|
|
19
|
+
* PRs were dominated by `*.review-input/diff.patch` — a committed copy of the
|
|
20
|
+
* very diff being measured — and the fifth by a generated index. Precision at
|
|
21
|
+
* the raw p90 was 2/5, below the pre-registered 0.50 floor; excluding the
|
|
22
|
+
* repository's own bookkeeping moved it to 5/5. Removing an input that was
|
|
23
|
+
* never shipping volume is a correction. Growing this set until a firing goes
|
|
24
|
+
* away would be tuning, and the kill criterion applies to that.
|
|
25
|
+
*
|
|
26
|
+
* WARN-LEVEL FIRST. It never blocks; escalation waits for one release of
|
|
27
|
+
* recorded firings, per the phase. Exit code is always 0 on the advisory path;
|
|
28
|
+
* 2 is the warn-in-context channel this estate's dispatcher reads.
|
|
29
|
+
*/
|
|
30
|
+
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
36
|
+
import { atomic_write_json } from './state_io.js';
|
|
37
|
+
|
|
38
|
+
export const STATE_FILE = path.join('agents', 'state', 'ship-diff-volume.json');
|
|
39
|
+
|
|
40
|
+
/** Derived: corrected p90 over the last 40 merged PRs (spike s04). */
|
|
41
|
+
export const DEFAULT_THRESHOLD = 1695;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Paths excluded from the volume because they are not shipping volume.
|
|
45
|
+
*
|
|
46
|
+
* Each entry is a category of artefact the change did not author: a snapshot of
|
|
47
|
+
* the diff itself, a generated index, or a projection tree regenerated from
|
|
48
|
+
* source. None is a threshold in disguise.
|
|
49
|
+
*/
|
|
50
|
+
export const EXCLUDED = [
|
|
51
|
+
'.review-input/', // a committed copy of the diff being measured
|
|
52
|
+
'agents/roadmaps/archive/index.json', // generated index
|
|
53
|
+
'dist/', // projection of src/
|
|
54
|
+
'.augment/',
|
|
55
|
+
'.claude/',
|
|
56
|
+
'.cursor/',
|
|
57
|
+
'.clinerules/',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/** Ship verbs — the moment a change leaves for review. */
|
|
61
|
+
const SHIP_PATTERNS = [/\bgit\s+push\b/, /\bgh\s+pr\s+create\b/] as const;
|
|
62
|
+
|
|
63
|
+
export function isShipCommand(cmd: string): boolean {
|
|
64
|
+
return SHIP_PATTERNS.some((re) => re.test(cmd));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isExcluded(p: string): boolean {
|
|
68
|
+
return EXCLUDED.some((e) => (e.endsWith('/') ? p.startsWith(e) || p.includes(e) : p === e));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `git diff --numstat` output → corrected volume plus what was excluded. */
|
|
72
|
+
export function correctedVolume(numstat: string): { volume: number; excluded: number; files: number } {
|
|
73
|
+
let volume = 0;
|
|
74
|
+
let excluded = 0;
|
|
75
|
+
let files = 0;
|
|
76
|
+
for (const line of numstat.split('\n')) {
|
|
77
|
+
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
78
|
+
if (m === null) continue;
|
|
79
|
+
const add = m[1] === '-' ? 0 : Number(m[1]);
|
|
80
|
+
const del = m[2] === '-' ? 0 : Number(m[2]);
|
|
81
|
+
const p = m[3]!;
|
|
82
|
+
if (isExcluded(p)) {
|
|
83
|
+
excluded += add + del;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
volume += add + del;
|
|
87
|
+
files += 1;
|
|
88
|
+
}
|
|
89
|
+
return { volume, excluded, files };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractCommand(envelope: Record<string, unknown>): string {
|
|
93
|
+
const ti = envelope['tool_input'];
|
|
94
|
+
if (ti !== null && typeof ti === 'object') {
|
|
95
|
+
const c = (ti as Record<string, unknown>)['command'];
|
|
96
|
+
if (typeof c === 'string') return c;
|
|
97
|
+
}
|
|
98
|
+
const c = envelope['command'];
|
|
99
|
+
return typeof c === 'string' ? c : '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function main(argv?: string[]): number {
|
|
103
|
+
const args = argv ?? process.argv.slice(2);
|
|
104
|
+
const idx = args.indexOf('--command');
|
|
105
|
+
let cmd = idx >= 0 && args[idx + 1] !== undefined ? args[idx + 1]! : '';
|
|
106
|
+
if (!cmd) {
|
|
107
|
+
const raw = readHookStdin();
|
|
108
|
+
let envelope: Record<string, unknown> = {};
|
|
109
|
+
if (raw.trim()) {
|
|
110
|
+
try {
|
|
111
|
+
envelope = JSON.parse(raw) as Record<string, unknown>;
|
|
112
|
+
} catch {
|
|
113
|
+
envelope = {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
cmd = extractCommand(envelope);
|
|
117
|
+
}
|
|
118
|
+
if (!isShipCommand(cmd)) return 0;
|
|
119
|
+
|
|
120
|
+
let numstat: string;
|
|
121
|
+
let base: string;
|
|
122
|
+
try {
|
|
123
|
+
base = execFileSync('git', ['merge-base', 'HEAD', 'origin/main'], { encoding: 'utf-8' }).trim();
|
|
124
|
+
numstat = execFileSync('git', ['diff', '--numstat', `${base}...HEAD`], {
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// No merge base, no origin, a detached probe — nothing to say. An
|
|
130
|
+
// advisory concern that guesses on missing input is worse than silent.
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { volume, excluded, files } = correctedVolume(numstat);
|
|
135
|
+
const over = volume >= DEFAULT_THRESHOLD;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
139
|
+
atomic_write_json(STATE_FILE, {
|
|
140
|
+
schema_version: 1,
|
|
141
|
+
base,
|
|
142
|
+
corrected_volume: volume,
|
|
143
|
+
excluded_volume: excluded,
|
|
144
|
+
files_counted: files,
|
|
145
|
+
threshold: DEFAULT_THRESHOLD,
|
|
146
|
+
warning: over,
|
|
147
|
+
checked_at: new Date().toISOString(),
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
// State is observability, never the gate. A read-only tree must not
|
|
151
|
+
// turn an advisory warning into a failed ship.
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!over) return 0;
|
|
155
|
+
|
|
156
|
+
process.stderr.write(
|
|
157
|
+
`ship-diff-volume: ${String(volume)} changed lines against ${DEFAULT_THRESHOLD} ` +
|
|
158
|
+
`(${String(files)} file(s); ${String(excluded)} line(s) excluded as bookkeeping).\n` +
|
|
159
|
+
`Every PR above this line in the last 40 named two or more concerns in its own title. ` +
|
|
160
|
+
`Splitting is a suggestion, not a requirement — this warns and never blocks.\n`,
|
|
161
|
+
);
|
|
162
|
+
return 2; // warn-in-context; advisory severity means the dispatcher does not block
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _isCliEntry(): boolean {
|
|
166
|
+
if (process.argv[1] === undefined) return false;
|
|
167
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
|
|
168
|
+
try {
|
|
169
|
+
return (
|
|
170
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
171
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
172
|
+
);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (_isCliEntry()) {
|
|
179
|
+
process.exit(main());
|
|
180
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Skill route — `user_prompt_submit` concern
|
|
4
|
+
* (road-to-inbox-harvest-2026-08-d-runtime-skill-routing Phase 2).
|
|
5
|
+
*
|
|
6
|
+
* THE DEFECT THIS CLOSES. A deterministic task→skill ranker has shipped in
|
|
7
|
+
* this tree for months and is bound to nothing: `score_skill_relevance.rank`
|
|
8
|
+
* and its `suggest_skill_for_task` wrapper have no production caller, and
|
|
9
|
+
* `hook_manifest.yaml` had no concern that reached either. A capability that
|
|
10
|
+
* ships and never runs is indistinguishable from one that was never built —
|
|
11
|
+
* the same shape `delegation-nudge` was created to end for `classifyTask`, and
|
|
12
|
+
* this concern is deliberately its twin rather than a new mechanism.
|
|
13
|
+
*
|
|
14
|
+
* POINTERS, NEVER BODIES. The injected line names at most three skill IDs and
|
|
15
|
+
* their scores. It does not carry descriptions, excerpts, or file contents, and
|
|
16
|
+
* it never instructs the agent to load anything. The reason is the risk this
|
|
17
|
+
* roadmap's own register ranks first: a pointer line the agent trusts is worse
|
|
18
|
+
* than no line when the ranking is poor, so what is injected is the cheapest
|
|
19
|
+
* thing that can still be acted on — and the cheapest thing to ignore.
|
|
20
|
+
*
|
|
21
|
+
* SILENCE IS THE DEFAULT, AND BOTH FLOORS ARE MEASURED, NOT GUESSED. `rank`
|
|
22
|
+
* already drops every zero-scoring skill, but on real prompts it almost always
|
|
23
|
+
* returns SOMETHING, so "non-empty" is not a usable trigger. Measured over the
|
|
24
|
+
* routing-matrix corpus (496 prompt lines, `tests/eval/routing-matrix/*.yaml`,
|
|
25
|
+
* 2026-08-16): top-score median 18, p75 28, p90 30, max 70, and only 4 prompts
|
|
26
|
+
* score nothing at all.
|
|
27
|
+
*
|
|
28
|
+
* TWO floors are needed, because the score alone cannot express the failure at
|
|
29
|
+
* the short end. `MIN_TOP_SCORE` gates confidence; `MIN_TASK_TERMS` gates the
|
|
30
|
+
* denominator that confidence is computed over — the scorer divides by the task
|
|
31
|
+
* term count, so a one-term prompt scores 70/100 against whatever shares that
|
|
32
|
+
* term. Together they fire on 9.1 % of the corpus. A score floor at the median
|
|
33
|
+
* with no term floor would fire on half of all turns AND still hand 70/100 to
|
|
34
|
+
* `"fix it"` — the per-turn-reminder shape this estate has already measured
|
|
35
|
+
* failing at a 24/29 miss rate. Each constant carries its own derivation below.
|
|
36
|
+
*
|
|
37
|
+
* COST, MEASURED RATHER THAN ASSUMED. Ranking re-reads the skill catalogue per
|
|
38
|
+
* prompt, uncached. Measured 2026-08-16 on this tree: **12.3 ms warm** for a
|
|
39
|
+
* prompt that reaches the ranker, against the slot's 250 ms p95 budget — about
|
|
40
|
+
* 5 % of it. A prompt below `MIN_TASK_TERMS` costs **0 ms**, because the term
|
|
41
|
+
* floor is checked BEFORE the catalogue read; that ordering is deliberate and
|
|
42
|
+
* is what keeps conversational turns free. No cache is added: a cache would
|
|
43
|
+
* need an invalidation story for a tree that changes under the session, and
|
|
44
|
+
* 12.3 ms does not buy one.
|
|
45
|
+
*
|
|
46
|
+
* NO ADOPTION THRESHOLD IS COMMITTED HERE. Whether the line changes behaviour
|
|
47
|
+
* is a question for data, not for this header: `skill_route_pointer_rate` is
|
|
48
|
+
* registered in `hook-token-budget.json` under the same owner/review discipline
|
|
49
|
+
* as its sibling advisories, with the same kill standard — a nudge whose
|
|
50
|
+
* verdicts are measurably ignored gets its trigger tightened or its line
|
|
51
|
+
* removed. The score floor above is a TRIGGER calibration and is measured; the
|
|
52
|
+
* adoption threshold is an OUTCOME claim and is deliberately absent.
|
|
53
|
+
*
|
|
54
|
+
* DELIVERY PATH — the verified one, copied rather than re-derived. A
|
|
55
|
+
* `user_prompt_submit` concern returning `{decision:"allow", context:…}` at
|
|
56
|
+
* exit 0 produces zero stdout: `host_semantics.emitFor` drops the field for
|
|
57
|
+
* severity `"allow"`. The only shape that survives to
|
|
58
|
+
* `hookSpecificOutput.additionalContext` is `{decision:"warn", reason,
|
|
59
|
+
* additional_context}` at exit 2, which `emitFor` reduces to a real process
|
|
60
|
+
* exit of 0 — it never blocks the turn. `delegation_nudge_hook.ts` carries the
|
|
61
|
+
* full proof of that mechanism and this concern follows it exactly.
|
|
62
|
+
*
|
|
63
|
+
* PLATFORM SCOPE — `claude` only, for the same reason its twin gives:
|
|
64
|
+
* `host_semantics.VERIFIED_PLATFORMS` covers only `claude`, `cowork`'s
|
|
65
|
+
* trampoline discards dispatcher stdout unconditionally, and the other
|
|
66
|
+
* trampolines were never inspected for that property. Extending an exit-2
|
|
67
|
+
* delivery to an unverified propagation path is the speculative mapping
|
|
68
|
+
* `host_semantics.ts` exists to prevent.
|
|
69
|
+
*/
|
|
70
|
+
import fs from "node:fs";
|
|
71
|
+
import path from "node:path";
|
|
72
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
73
|
+
|
|
74
|
+
import { readHookStdin } from "./hook_stdin.js";
|
|
75
|
+
import { isSyntheticPrompt } from "../_lib/prompt_shape.js";
|
|
76
|
+
import { resolveSkillsRoot } from "../_lib/skill_catalogue.js";
|
|
77
|
+
import { _tokenize, rank, type RankRow } from "../skill_tools/score_skill_relevance.js";
|
|
78
|
+
|
|
79
|
+
const EXIT_ALLOW = 0;
|
|
80
|
+
/** See the header: reduced to a real exit of 0 by `emitFor`'s warn branch. */
|
|
81
|
+
const EXIT_WARN = 2;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Minimum top-1 ranker score before this concern speaks.
|
|
85
|
+
*
|
|
86
|
+
* **31, not the measured p90 of 30, and the extra point is load-bearing.** The
|
|
87
|
+
* scorer is `overlap * 70 + personaHit * 30`, so a skill whose PERSONA slug
|
|
88
|
+
* appears in the prompt scores exactly 30 with zero keyword overlap. A floor of
|
|
89
|
+
* 30 with a `>=` test therefore admits a pure persona coincidence as if it were
|
|
90
|
+
* a ranked match. 31 is the smallest floor strictly above that value; it moves
|
|
91
|
+
* the corpus fire rate from 13.9 % to 9.1 %.
|
|
92
|
+
*/
|
|
93
|
+
export const MIN_TOP_SCORE = 31;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Minimum distinct task terms before the score is trusted at all.
|
|
97
|
+
*
|
|
98
|
+
* The scorer divides by `|task_terms|`, so the score is inversely proportional
|
|
99
|
+
* to prompt length and degenerates at the short end: `"fix it"` tokenizes to
|
|
100
|
+
* ONE term and hands 70/100 to whichever skills happen to share it, which on
|
|
101
|
+
* this catalogue means the alphabetically first three. `MIN_TOP_SCORE` cannot
|
|
102
|
+
* fix that — the number is not too low, the denominator is too small.
|
|
103
|
+
*
|
|
104
|
+
* **3 is the routing-matrix corpus's own minimum**, so this floor excludes
|
|
105
|
+
* ZERO of the 496 prompt lines the score calibration rests on while removing
|
|
106
|
+
* every degenerate short prompt measured (`"fix it"` 1, `"mach das"` 2,
|
|
107
|
+
* `"was denkst du dazu"` 2, `"weiter"` 1). A floor of 4 would have cost 10 real
|
|
108
|
+
* corpus prompts for one tenth of a percentage point.
|
|
109
|
+
*/
|
|
110
|
+
export const MIN_TASK_TERMS = 3;
|
|
111
|
+
|
|
112
|
+
/** Pointers per line. Three is the ranker's own default `--top`. */
|
|
113
|
+
export const TOP_K = 3;
|
|
114
|
+
|
|
115
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
116
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
117
|
+
|
|
118
|
+
function _isObject(v: unknown): v is JsonObject {
|
|
119
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rank a prompt and return the pointers worth injecting, or `[]` for silence.
|
|
124
|
+
*
|
|
125
|
+
* Total by construction: a missing root, an unreadable catalogue, or a ranker
|
|
126
|
+
* error all resolve to `[]`. A routing advisory that can crash a turn is worse
|
|
127
|
+
* than one that says nothing.
|
|
128
|
+
*/
|
|
129
|
+
export function routePointers(prompt: string, skillsDir: string | null): RankRow[] {
|
|
130
|
+
if (skillsDir === null) return [];
|
|
131
|
+
// Denominator floor BEFORE the catalogue read: a prompt too short to score
|
|
132
|
+
// meaningfully cannot produce a pointer worth 337 file reads either.
|
|
133
|
+
try {
|
|
134
|
+
if (_tokenize(prompt).size < MIN_TASK_TERMS) return [];
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
let rows: RankRow[];
|
|
139
|
+
try {
|
|
140
|
+
rows = rank(prompt, skillsDir);
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const top = rows[0];
|
|
145
|
+
if (top === undefined || top[1] < MIN_TOP_SCORE) return [];
|
|
146
|
+
return rows.slice(0, TOP_K);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The injected line. Names the floor it cleared, so a reader can tell a
|
|
151
|
+
* confident pointer from a lucky one without opening this file.
|
|
152
|
+
*/
|
|
153
|
+
export function buildRouteLine(rows: readonly RankRow[]): string {
|
|
154
|
+
const pointers = rows.map(([name, score]) => `${name} (${score})`).join(", ");
|
|
155
|
+
return (
|
|
156
|
+
`<skill-route>ranked skill pointers for this prompt: ${pointers}. ` +
|
|
157
|
+
`Deterministic keyword ranker, top-1 at or above ${MIN_TOP_SCORE}/100 — ` +
|
|
158
|
+
`pointers only, no bodies loaded. Open one if it fits; ignore the line if it does not.</skill-route>`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _extractPrompt(payload: JsonObject): string {
|
|
163
|
+
for (const key of ["prompt", "userPrompt", "user_prompt", "message", "text"]) {
|
|
164
|
+
const v = payload[key];
|
|
165
|
+
if (typeof v === "string" && v.trim()) return v;
|
|
166
|
+
}
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _workspaceRoot(envelope: JsonObject): string {
|
|
171
|
+
const v = envelope["workspace_root"];
|
|
172
|
+
return typeof v === "string" && v ? v : process.cwd();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function main(): number {
|
|
176
|
+
let envelope: JsonValue = {};
|
|
177
|
+
try {
|
|
178
|
+
const raw = readHookStdin();
|
|
179
|
+
envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
|
|
180
|
+
} catch {
|
|
181
|
+
return EXIT_ALLOW; // malformed envelope — never block
|
|
182
|
+
}
|
|
183
|
+
const env: JsonObject = _isObject(envelope) ? envelope : {};
|
|
184
|
+
|
|
185
|
+
const event = env["event"];
|
|
186
|
+
const slot = typeof event === "string" && event !== "" ? event : "user_prompt_submit";
|
|
187
|
+
if (slot !== "user_prompt_submit") return EXIT_ALLOW;
|
|
188
|
+
|
|
189
|
+
const payload = _isObject(env["payload"]) ? (env["payload"] as JsonObject) : env;
|
|
190
|
+
const prompt = _extractPrompt(payload);
|
|
191
|
+
if (!prompt) return EXIT_ALLOW;
|
|
192
|
+
if (isSyntheticPrompt(prompt)) return EXIT_ALLOW; // harness turn, not a task
|
|
193
|
+
|
|
194
|
+
const rows = routePointers(prompt, resolveSkillsRoot(_workspaceRoot(env)));
|
|
195
|
+
if (rows.length === 0) return EXIT_ALLOW; // silence is the default
|
|
196
|
+
|
|
197
|
+
process.stdout.write(
|
|
198
|
+
`${JSON.stringify({
|
|
199
|
+
decision: "warn",
|
|
200
|
+
reason: `skill-route: ${rows.length} pointer(s), top ${rows[0]![1]}/100`,
|
|
201
|
+
additional_context: buildRouteLine(rows),
|
|
202
|
+
})}\n`,
|
|
203
|
+
);
|
|
204
|
+
return EXIT_WARN;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
208
|
+
// every module shares the bundle's `import.meta.url`.
|
|
209
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
210
|
+
function _isCliEntry(): boolean {
|
|
211
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) return false;
|
|
212
|
+
if (process.argv[1] === undefined) return false;
|
|
213
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
214
|
+
if (import.meta.url === argvUrl) return true;
|
|
215
|
+
try {
|
|
216
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
217
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
218
|
+
return here === argv;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (_isCliEntry()) {
|
|
225
|
+
process.exit(main());
|
|
226
|
+
}
|
|
@@ -47,9 +47,11 @@
|
|
|
47
47
|
* non-sidechain user entries — is distinct for every real turn whatever
|
|
48
48
|
* the user typed, and stable across harness injections.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* No longer true, and corrected here rather than left as a stale admission:
|
|
51
|
+
* the files ARE pruned. `road-to-stop-gate-honesty` step 1.2 added a
|
|
52
|
+
* 90-day retention (`pruneAgedRefusalState`), run at `session_start` by the
|
|
53
|
+
* session-register concern. The record also COUNTS now instead of
|
|
54
|
+
* overwriting itself — see `markRefusedTurn`.
|
|
53
55
|
*
|
|
54
56
|
* The failure this ordering prevents is not a loop but a wedge: a turn that
|
|
55
57
|
* can never end. Layer 2 also covers the host that does not send
|
|
@@ -111,7 +113,6 @@
|
|
|
111
113
|
* not an advisory, so there is no advisory path here to put on the wrong code.
|
|
112
114
|
* Probed, not assumed.
|
|
113
115
|
*/
|
|
114
|
-
import * as crypto from 'node:crypto';
|
|
115
116
|
import * as fs from 'node:fs';
|
|
116
117
|
import * as path from 'node:path';
|
|
117
118
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -130,6 +131,14 @@ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
|
130
131
|
import { readHookStdin } from './hook_stdin.js';
|
|
131
132
|
import { atomic_write_json, is_replay_mode } from './state_io.js';
|
|
132
133
|
import { openRecordStats } from './subagent_ledger_hook.js';
|
|
134
|
+
import {
|
|
135
|
+
deriveSessionKey,
|
|
136
|
+
foldRefusal,
|
|
137
|
+
parseRecord,
|
|
138
|
+
readInstallBoundary,
|
|
139
|
+
sessionRefusalFile,
|
|
140
|
+
type RefusalRecord,
|
|
141
|
+
} from '../_lib/turn_end_refusals.js';
|
|
133
142
|
|
|
134
143
|
/** Dispatcher-internal block code. Pinned to 1 by `concern_block_exit_parity`. */
|
|
135
144
|
const EXIT_BLOCK = 1;
|
|
@@ -294,8 +303,12 @@ const PROMISSORY = [
|
|
|
294
303
|
* A legitimate hand-back is the opposite speech act: it gives the decision
|
|
295
304
|
* to the user and ends the turn on purpose. `scope-control` requires
|
|
296
305
|
* exactly this shape, so refusing it would put two rules in direct conflict.
|
|
306
|
+
*
|
|
307
|
+
* EXPORTED because `interruption_ledger_hook` classifies the same shape as a
|
|
308
|
+
* synchronous contact. Narrowing this list therefore changes a measurement as
|
|
309
|
+
* well as a refusal — check that hook before you touch it.
|
|
297
310
|
*/
|
|
298
|
-
const HANDBACK = [
|
|
311
|
+
export const HANDBACK = [
|
|
299
312
|
/\bdas entscheidest (du|Du)\b/i,
|
|
300
313
|
/\bdeine Entscheidung\b/i,
|
|
301
314
|
/\bich fasse .{0,40}nicht ungefragt an\b/i,
|
|
@@ -527,9 +540,49 @@ const _EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
|
527
540
|
* detection when a project verifies by some command not listed here, and that is
|
|
528
541
|
* the safe direction for a gate that can refuse a turn: a false negative costs
|
|
529
542
|
* one unguarded turn, a false positive teaches the user to switch the gate off.
|
|
543
|
+
*
|
|
544
|
+
* ## Audited 2026-08-17 — `road-to-stop-gate-honesty` step 2.2
|
|
545
|
+
*
|
|
546
|
+
* 52 commands this project's own surface actually produces (its `package.json`
|
|
547
|
+
* scripts, its `Taskfile` targets, `./scripts-run`, `./agent-config`, and the
|
|
548
|
+
* PHP / Python / Go / Rust toolchains) were run through
|
|
549
|
+
* `isVerificationCommand`. The table of every one, with its verdict, is
|
|
550
|
+
* `turn_end_verify_allowlist.test.ts`; that file is the fixture step 2.2
|
|
551
|
+
* requires, and every addition below has its own row in it.
|
|
552
|
+
*
|
|
553
|
+
* The draft that opened the roadmap expected to add `phpunit` and `pest`. Claim
|
|
554
|
+
* 4 had already overtaken that — both matched before this audit, as did
|
|
555
|
+
* `composer test` and `php artisan test` — so what the audit actually found was
|
|
556
|
+
* two different gaps:
|
|
557
|
+
*
|
|
558
|
+
* · `phpstan` — a static analyser of exactly the class already listed
|
|
559
|
+
* (`mypy`, `pyright`, `clippy`). Added.
|
|
560
|
+
* · `lint` followed by a WORD character — `lint_persistence`,
|
|
561
|
+
* `lint_provenance`, and every other `lint_*` script in `src/scripts/`
|
|
562
|
+
* missed, because `\blint\b` needs a boundary and `_` is a word character.
|
|
563
|
+
* `lint[-_:a-z]*` is the exact mirror of the `check[-_:a-z]*` this list
|
|
564
|
+
* already carried, which is why it is the narrow fix rather than a new idea.
|
|
565
|
+
*
|
|
566
|
+
* Four further misses were found and deliberately NOT added, because Risk 2 of
|
|
567
|
+
* that roadmap is that every addition is a way to satisfy the gate without
|
|
568
|
+
* verifying anything:
|
|
569
|
+
*
|
|
570
|
+
* · `npm run prepack` — a lifecycle hook whose content is per-project. Here it
|
|
571
|
+
* validates; elsewhere it copies files. Recognising it would clear an
|
|
572
|
+
* unverified edit in any repo whose prepack is a build step.
|
|
573
|
+
* · `task sync` / `task generate-tools` / `agent-config roadmap:progress` —
|
|
574
|
+
* GENERATORS. They rewrite the tree; they check nothing. A generator that
|
|
575
|
+
* cleared the gate would be the rubber stamp in its purest form.
|
|
576
|
+
* · `agent-config gates --all` — enumerates gates, runs none.
|
|
577
|
+
* · `vendor/bin/rector process --dry-run` — a refactoring tool. Its dry run
|
|
578
|
+
* prints a diff; it does not assert anything holds.
|
|
579
|
+
*
|
|
580
|
+
* `psalm` is the obvious sibling of `phpstan` and is also NOT added: nothing in
|
|
581
|
+
* the audited surface runs it, and "the team actually runs" is the step's own
|
|
582
|
+
* standard. It is named here so the next audit does not re-derive the question.
|
|
530
583
|
*/
|
|
531
584
|
const _VERIFY_RE =
|
|
532
|
-
/\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|typecheck|lint
|
|
585
|
+
/\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|phpstan|typecheck|lint[-_:a-z]*|build|ci|preflight|smoke[-_:a-z]*|check[-_:a-z]*|validate[-_:a-z]*)\b|\b(task|npm|pnpm|yarn|composer|cargo|go|make|php artisan|bun)\s+(run\s+)?\S*(test|check|lint|build|ci|typecheck)/i;
|
|
533
586
|
|
|
534
587
|
export function isVerificationCommand(command: string): boolean {
|
|
535
588
|
return _VERIFY_RE.test(command);
|
|
@@ -579,29 +632,30 @@ export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding |
|
|
|
579
632
|
// Re-entrancy — the guard, keyed on the turn, not on the reply
|
|
580
633
|
// ---------------------------------------------------------------------------
|
|
581
634
|
|
|
582
|
-
function stateDir(workspaceRoot: string): string {
|
|
583
|
-
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
|
|
584
|
-
}
|
|
585
|
-
|
|
586
635
|
/**
|
|
587
636
|
* ONE file per session, not one per refused turn. R2 round 1, finding 17:
|
|
588
637
|
* per-turn files accumulated without bound and without a TTL, because "re-arms
|
|
589
638
|
* itself with no cleanup" described the key rotating, not the files being
|
|
590
639
|
* removed.
|
|
591
640
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
641
|
+
* Both halves are now closed. The per-turn MULTIPLICITY went first; the missing
|
|
642
|
+
* TTL was the other half and `road-to-stop-gate-honesty` step 1.2 shipped it as
|
|
643
|
+
* `pruneAgedRefusalState`, run at `session_start` by the session-register
|
|
644
|
+
* concern — the one slot that already prunes and therefore adds no spawn. The
|
|
645
|
+
* path and the retention constant live with the reader
|
|
646
|
+
* (`_lib/turn_end_refusals.ts`), because a pruner in one module and a writer in
|
|
647
|
+
* another must not each own their own idea of where the files are.
|
|
597
648
|
*/
|
|
598
649
|
function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
|
|
599
|
-
return
|
|
650
|
+
return sessionRefusalFile(workspaceRoot, sessionKey);
|
|
600
651
|
}
|
|
601
652
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
653
|
+
/**
|
|
654
|
+
* Re-exported, not re-derived: the session register reads this session's own
|
|
655
|
+
* refusal record back for live per-session visibility, so the stem has two
|
|
656
|
+
* consumers and exactly one definition (`_lib/turn_end_refusals.ts`).
|
|
657
|
+
*/
|
|
658
|
+
export { deriveSessionKey };
|
|
605
659
|
|
|
606
660
|
/**
|
|
607
661
|
* A turn's identity is its ORDINAL — how many genuine user prompts the
|
|
@@ -641,19 +695,53 @@ export function alreadyRefusedTurn(
|
|
|
641
695
|
return false;
|
|
642
696
|
}
|
|
643
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Write the re-entrancy marker AND count the refusal.
|
|
700
|
+
*
|
|
701
|
+
* The marker half is unchanged: `refused_turn` is what `alreadyRefusedTurn`
|
|
702
|
+
* reads, and nothing about the counting can alter whether a turn is refused
|
|
703
|
+
* twice. The counting half is `road-to-stop-gate-honesty` step 1.1 — the old
|
|
704
|
+
* record overwrote itself, so a session refused nine times looked exactly like a
|
|
705
|
+
* session refused once, and D-2's "refusal frequency is invisible" was true of
|
|
706
|
+
* the state file as much as of the absent reader.
|
|
707
|
+
*
|
|
708
|
+
* EVERY finding's detector is counted, not just the first. The first still lands
|
|
709
|
+
* in `detector` for compatibility with the 36 field records written before this,
|
|
710
|
+
* but a turn that trips B and C at once is two observations, and pooling them
|
|
711
|
+
* into one is what step 1.1 forbids in the reader — doing it in the writer would
|
|
712
|
+
* put the same defect somewhere the reader cannot fix.
|
|
713
|
+
*/
|
|
644
714
|
function markRefusedTurn(
|
|
645
715
|
workspaceRoot: string,
|
|
646
716
|
sessionKey: string,
|
|
647
717
|
turnOrdinal: number,
|
|
648
|
-
|
|
718
|
+
detectors: readonly DetectorId[],
|
|
649
719
|
): void {
|
|
650
720
|
if (is_replay_mode()) return;
|
|
721
|
+
const file = sessionStateFile(workspaceRoot, sessionKey);
|
|
722
|
+
let prev: RefusalRecord | null = null;
|
|
723
|
+
try {
|
|
724
|
+
prev = parseRecord(fs.readFileSync(file, 'utf-8'));
|
|
725
|
+
} catch {
|
|
726
|
+
// Absent or unreadable prior state starts a fresh count. Never a reason
|
|
727
|
+
// to skip the marker — the marker is the wedge guard.
|
|
728
|
+
}
|
|
729
|
+
let version: string | undefined;
|
|
730
|
+
try {
|
|
731
|
+
version = readInstallBoundary().version ?? undefined;
|
|
732
|
+
} catch {
|
|
733
|
+
version = undefined;
|
|
734
|
+
}
|
|
651
735
|
try {
|
|
652
|
-
atomic_write_json(
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
736
|
+
atomic_write_json(
|
|
737
|
+
file,
|
|
738
|
+
foldRefusal(prev, {
|
|
739
|
+
detectors,
|
|
740
|
+
turnOrdinal,
|
|
741
|
+
at: new Date().toISOString(),
|
|
742
|
+
version,
|
|
743
|
+
}) as unknown as Record<string, unknown>,
|
|
744
|
+
);
|
|
657
745
|
} catch {
|
|
658
746
|
// A state-write failure must never wedge the turn. Losing the marker
|
|
659
747
|
// costs at most one extra refusal; failing closed here would cost the
|
|
@@ -934,7 +1022,12 @@ export function main(): number {
|
|
|
934
1022
|
}
|
|
935
1023
|
if (findings.length === 0) return EXIT_ALLOW;
|
|
936
1024
|
|
|
937
|
-
markRefusedTurn(
|
|
1025
|
+
markRefusedTurn(
|
|
1026
|
+
workspaceRoot,
|
|
1027
|
+
sessionKey,
|
|
1028
|
+
turnOrdinal,
|
|
1029
|
+
findings.map((f) => f.detector),
|
|
1030
|
+
);
|
|
938
1031
|
|
|
939
1032
|
const lines = findings.map(
|
|
940
1033
|
(f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,
|
|
@@ -30,9 +30,12 @@
|
|
|
30
30
|
* degradation valve `design_slop_hook` carries, for the same reason: an agent
|
|
31
31
|
* that has decided to proceed must not be re-prompted into a loop.
|
|
32
32
|
*
|
|
33
|
-
* HOST BOUNDARY, STATED RATHER THAN IMPLIED. `pre_tool_use`
|
|
34
|
-
* hosts. Everywhere else
|
|
35
|
-
*
|
|
33
|
+
* HOST BOUNDARY, STATED RATHER THAN IMPLIED. `pre_tool_use` is BOUND on three
|
|
34
|
+
* hosts. Everywhere else the obligation stays model-carried, exactly as the two
|
|
35
|
+
* rules already declare. Corrected 2026-08-17: this comment said the concern has
|
|
36
|
+
* "nowhere to bind" elsewhere, which the manifest's own `native_event_aliases`
|
|
37
|
+
* table refutes for cursor, cline and gemini — unbound, not unbindable. See
|
|
38
|
+
* `docs/contracts/hook-architecture-v1.md` § Which hosts carry pre_tool_use. Run
|
|
36
39
|
* `agent-config hooks:status` for the host you are actually on.
|
|
37
40
|
*
|
|
38
41
|
* Default-OFF: no-ops unless `hooks.ui_route_nudge.enabled: true`.
|