@event4u/agent-config 9.29.0 → 9.31.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 +32 -214
- 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-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
- package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
- package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
- package/dist/cli-delegate/chunk-EJITJBJX.js +172 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
- package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +2861 -96
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +87 -7
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +3 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +3 -56
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- 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 +2143 -641
- package/dist/install/install.mjs +133 -14
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3967 -761
- package/dist/scripts/_lib/agent_settings.js +111 -2
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- 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/pack-size-budget.json +3 -2
- 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/_cli/explain_last/inputs.ts +13 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/agent_settings.ts +119 -2
- package/src/scripts/_lib/carrier_divergence.ts +47 -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 +715 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +143 -1
- 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 +272 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_carrier_divergence.ts +72 -24
- package/src/scripts/report_conformance_funnel.ts +291 -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,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `turn-end-gate` — the suite's FIRST concern that can refuse a turn-end.
|
|
3
|
+
*
|
|
4
|
+
* Round 5 of the conformance audit measured the thing this exists for: the
|
|
5
|
+
* two BLOCKING carriers reached zero violations, neither ADVISORY carrier
|
|
6
|
+
* did, and 19 language violations survived a pin that had fired 26-35
|
|
7
|
+
* seconds earlier. The council read the same split from both sides —
|
|
8
|
+
* "refusal-capable intercepts enforce; context injection requests". So this
|
|
9
|
+
* is deliberately not another reminder. It is a check at the point of
|
|
10
|
+
* delivery that can say no.
|
|
11
|
+
*
|
|
12
|
+
* Two detectors ride on one guard, because building the unsafe part twice
|
|
13
|
+
* is how a second detector becomes a second outage:
|
|
14
|
+
*
|
|
15
|
+
* A — promissory closing (FC-5, 20 measured occurrences)
|
|
16
|
+
* B — language mismatch (19 measured occurrences, fresh pin present)
|
|
17
|
+
*
|
|
18
|
+
* ## Re-entrancy — the shape, stated before registration
|
|
19
|
+
*
|
|
20
|
+
* `road-to-conformance-round6.md` § blocker records the hole this must not
|
|
21
|
+
* fall into: "the re-entrancy guard is specified and unverified. What
|
|
22
|
+
* happens when the refusal *itself* triggers the turn-end event?" Two
|
|
23
|
+
* layers, each sufficient alone:
|
|
24
|
+
*
|
|
25
|
+
* 1. `stop_hook_active` — Claude sets it when a Stop follows a
|
|
26
|
+
* stop-hook block. Set ⇒ this turn was already refused ⇒ allow.
|
|
27
|
+
* Nothing else in `src/` reads this field today; it is the host's own
|
|
28
|
+
* answer to the question above.
|
|
29
|
+
*
|
|
30
|
+
* 2. A per-session state file holding the ORDINAL of the last refused turn —
|
|
31
|
+
* never the prompt's text, and never the reply. One file per session, not
|
|
32
|
+
* one per refused turn.
|
|
33
|
+
*
|
|
34
|
+
* The first version keyed on sha256(session_id + last user text) with a
|
|
35
|
+
* file per turn, and R2 found that wrong in three directions at once: a
|
|
36
|
+
* REPEATED prompt ("weiter", "ok", "1") collided with the earlier refused
|
|
37
|
+
* turn and was allowed unconditionally; the key drifted WITHIN a turn
|
|
38
|
+
* because a compaction summary, a `<system-reminder>` and a sidechain
|
|
39
|
+
* prompt all arrive in the user role; and the files accumulated with no
|
|
40
|
+
* TTL. The ordinal — a count of `isSyntheticPrompt`-filtered,
|
|
41
|
+
* non-sidechain user entries — is distinct for every real turn whatever
|
|
42
|
+
* the user typed, and stable across harness injections.
|
|
43
|
+
*
|
|
44
|
+
* Still true and still worth stating: per-session files are bounded per
|
|
45
|
+
* session but not pruned, so a long-lived workspace accumulates one small
|
|
46
|
+
* file per session. No TTL ships here.
|
|
47
|
+
*
|
|
48
|
+
* The failure this ordering prevents is not a loop but a wedge: a turn that
|
|
49
|
+
* can never end. Layer 2 also covers the host that does not send
|
|
50
|
+
* `stop_hook_active` at all — with one honest limit: a host that sends no
|
|
51
|
+
* `session_id` either falls into a shared bucket where an unrelated session's
|
|
52
|
+
* matching ordinal reads as already-refused, so on such a host layer 2
|
|
53
|
+
* degrades to "may under-refuse" and layer 1 is the real guard.
|
|
54
|
+
*
|
|
55
|
+
* ## Default OFF — and why, given the roadmap says otherwise
|
|
56
|
+
*
|
|
57
|
+
* `road-to-conformance-round5.md` § 3.6 asks for a kill-switch per
|
|
58
|
+
* detector, "default on". The council recorded in round 6 (2026-08-08)
|
|
59
|
+
* asked for the mechanism to be opt-in, "defaulting to off, so the
|
|
60
|
+
* mechanism exists and soaks before it binds" — two prior hook-severity
|
|
61
|
+
* mistakes plus a turn-end blast radius made default-on uninsurable in
|
|
62
|
+
* their reading. Both are satisfied literally: the MASTER switch
|
|
63
|
+
* (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
|
|
64
|
+
* detectors are default ON. A maintainer who opts in gets both without
|
|
65
|
+
* further configuration; until then this concern is a no-op that costs one
|
|
66
|
+
* settings-cascade read per turn-end.
|
|
67
|
+
*
|
|
68
|
+
* CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
|
|
69
|
+
* otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
|
|
70
|
+
* gate must resolve to "let the turn end", never to a wedge; promoting a
|
|
71
|
+
* crash to a block is the outage this whole file is arranged to avoid.
|
|
72
|
+
* Every unreadable input, missing transcript, absent pin, or malformed
|
|
73
|
+
* state resolves to silence.
|
|
74
|
+
*/
|
|
75
|
+
import * as crypto from 'node:crypto';
|
|
76
|
+
import * as fs from 'node:fs';
|
|
77
|
+
import * as path from 'node:path';
|
|
78
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
79
|
+
|
|
80
|
+
import { load_agent_settings } from '../_lib/agent_settings.js';
|
|
81
|
+
import {
|
|
82
|
+
classify,
|
|
83
|
+
isSyntheticPrompt,
|
|
84
|
+
STATE_FILE as LANGUAGE_STATE_FILE,
|
|
85
|
+
type Verdict,
|
|
86
|
+
} from '../language_mirror_hook.js';
|
|
87
|
+
import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
|
|
88
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
89
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
90
|
+
import { atomic_write_json, is_replay_mode } from './state_io.js';
|
|
91
|
+
|
|
92
|
+
/** Dispatcher-internal block code. Pinned to 1 by `concern_block_exit_parity`. */
|
|
93
|
+
const EXIT_BLOCK = 1;
|
|
94
|
+
const EXIT_ALLOW = 0;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Transcript-read ceiling for this hook, passed at the call site in `main()`.
|
|
98
|
+
* Deliberately well under `isSafeTranscriptPath`'s own 50 MB refusal: the
|
|
99
|
+
* ordinal is a count over every entry, so the whole file is walked once per
|
|
100
|
+
* turn-end, and a session file grows for the life of the session. Past this the
|
|
101
|
+
* gate lets the turn end rather than pay an unbounded read — fail-open, like
|
|
102
|
+
* every other unreadable-transcript case here.
|
|
103
|
+
*/
|
|
104
|
+
export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
|
105
|
+
|
|
106
|
+
/** Detector identity, used in the state marker and the refusal text. */
|
|
107
|
+
export type DetectorId = 'promissory' | 'language';
|
|
108
|
+
|
|
109
|
+
export interface Finding {
|
|
110
|
+
detector: DetectorId;
|
|
111
|
+
/** The span that triggered it — quoted back so the refusal is actionable. */
|
|
112
|
+
evidence: string;
|
|
113
|
+
reason: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Text extraction — what counts as "user-visible prose"
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Strip everything `language-and-tone` already exempts from the mirror
|
|
122
|
+
* obligation: fenced code, inline code, block quotes (quoted tool output),
|
|
123
|
+
* URLs, and bare paths/identifiers. What remains is the prose the rule
|
|
124
|
+
* actually binds.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately conservative — over-stripping costs a missed detection,
|
|
127
|
+
* under-stripping costs a false refusal, and a blocking guard with a false
|
|
128
|
+
* positive rate teaches users to bypass it.
|
|
129
|
+
*/
|
|
130
|
+
/**
|
|
131
|
+
* Remove fenced code blocks, line by line, tracking open/close state.
|
|
132
|
+
*
|
|
133
|
+
* This is deliberately NOT a regex, and the reason is a measured regression.
|
|
134
|
+
* Two regex attempts each dropped a reply's whole tail on a different valid
|
|
135
|
+
* shape: a `\1` backreference misses CommonMark's longer closing fence (R2
|
|
136
|
+
* round 1, finding 5), and the character-matching replacement that fixed THAT
|
|
137
|
+
* stopped at the first closer-shaped line — so on a `~~~` block containing a
|
|
138
|
+
* ``` block, the inner line was taken for the closer, the replacer declined it
|
|
139
|
+
* on the character mismatch, and the greedy tail-drop then deleted everything
|
|
140
|
+
* from the opener onward (R2 round 2, finding 1). That mixed-character nesting
|
|
141
|
+
* is the shape `markdown-safe-codeblocks` prescribes as its DEFAULT, so the
|
|
142
|
+
* second attempt was worse than the first on the more common input.
|
|
143
|
+
*
|
|
144
|
+
* A scanner has the state a regex lacks. CommonMark, applied here:
|
|
145
|
+
* · an opener may carry an info string; a closer may not;
|
|
146
|
+
* · only a fence of the SAME character and at least the opener's length
|
|
147
|
+
* closes the block — anything else inside it is content;
|
|
148
|
+
* · an unterminated opener runs to end of input, so a truncated reply loses
|
|
149
|
+
* its tail, which is the correct reading of a truncated reply.
|
|
150
|
+
*/
|
|
151
|
+
export function stripFencedBlocks(text: string): string {
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
let open: { ch: string; len: number } | null = null;
|
|
154
|
+
for (const line of text.split('\n')) {
|
|
155
|
+
const m = /^[ \t]{0,3}([`~]{3,})(.*)$/.exec(line);
|
|
156
|
+
if (open === null) {
|
|
157
|
+
if (m) {
|
|
158
|
+
open = { ch: m[1]![0]!, len: m[1]!.length };
|
|
159
|
+
out.push(' ');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
out.push(line);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
m &&
|
|
167
|
+
m[1]![0] === open.ch &&
|
|
168
|
+
m[1]!.length >= open.len &&
|
|
169
|
+
(m[2] ?? '').trim() === ''
|
|
170
|
+
) {
|
|
171
|
+
open = null;
|
|
172
|
+
}
|
|
173
|
+
// Everything between opener and closer is dropped, closer included.
|
|
174
|
+
}
|
|
175
|
+
return out.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function visibleProse(reply: string): string {
|
|
179
|
+
let text = stripFencedBlocks(reply);
|
|
180
|
+
// Inline code.
|
|
181
|
+
text = text.replace(/`[^`\n]*`/g, ' ');
|
|
182
|
+
// Block quotes — the shape quoted tool output takes.
|
|
183
|
+
text = text.replace(/^[ \t]*>.*$/gm, ' ');
|
|
184
|
+
// Markdown tables — cells are frequently identifiers and paths.
|
|
185
|
+
text = text.replace(/^[ \t]*\|.*\|[ \t]*$/gm, ' ');
|
|
186
|
+
// URLs.
|
|
187
|
+
text = text.replace(/\bhttps?:\/\/\S+/g, ' ');
|
|
188
|
+
// Path-shaped and identifier-shaped tokens.
|
|
189
|
+
text = text.replace(/\S*\/\S+/g, ' ');
|
|
190
|
+
text = text.replace(/\b[\w.-]+\.(ts|js|md|json|ya?ml|py|php|txt|sh)\b/g, ' ');
|
|
191
|
+
return text;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The final user-visible paragraph — where a promissory closing lives. */
|
|
195
|
+
export function finalParagraph(reply: string): string {
|
|
196
|
+
const prose = visibleProse(reply).trim();
|
|
197
|
+
if (!prose) return '';
|
|
198
|
+
const paragraphs = prose
|
|
199
|
+
.split(/\n\s*\n/)
|
|
200
|
+
.map((p) => p.trim())
|
|
201
|
+
.filter((p) => p.length > 0);
|
|
202
|
+
return paragraphs.length === 0 ? '' : paragraphs[paragraphs.length - 1]!;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Detector A — promissory closing
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A commitment to work not yet performed. Both languages, because the
|
|
211
|
+
* measured corpus is bilingual and a German-only list would have caught
|
|
212
|
+
* roughly half of the 20.
|
|
213
|
+
*/
|
|
214
|
+
const PROMISSORY = [
|
|
215
|
+
/\bich melde mich\b/i,
|
|
216
|
+
/\bmelde ich (mich|dir|Dir)\b/i,
|
|
217
|
+
/\bich melde\b/i,
|
|
218
|
+
/\bich berichte\b/i,
|
|
219
|
+
/\bich (sage|gebe) (dir |Dir )?Bescheid\b/i,
|
|
220
|
+
/\bals n(ä|ae)chstes (werde|mache|baue|pr(ü|ue)fe) ich\b/i,
|
|
221
|
+
// German puts the infinitive at the END of the clause ("ich werde jetzt
|
|
222
|
+
// die Tests schreiben"), so the verb cannot be matched adjacent to
|
|
223
|
+
// "werde" — it has to be looked for across the rest of the sentence.
|
|
224
|
+
//
|
|
225
|
+
// The lookahead excludes a stated REFUSAL to act: declining to do
|
|
226
|
+
// something is not a promise to do it. R2 finding 4 reproduced four live
|
|
227
|
+
// false refusals against the old `\bnicht\b`-only version — "Ich werde
|
|
228
|
+
// nichts anfassen" (no word boundary after `nicht`), "keine Tests
|
|
229
|
+
// schreiben", "niemals raten", and the passive non-promise "Ich werde
|
|
230
|
+
// gefragt, ob …". Each was a false refusal on a BLOCKING guard, which is
|
|
231
|
+
// the precision failure the council warning is about.
|
|
232
|
+
// `\p{L}` with the `u` flag, NOT `\w`: R2 round 2, finding 15 verified that
|
|
233
|
+
// `\b\w{3,}en\b` cannot reach an infinitive whose post-umlaut fragment is
|
|
234
|
+
// short, because `ü`/`ö`/`ä` are not `\w` and create a word boundary — so
|
|
235
|
+
// "prüfen" and "lösen" were silent while the comment above claimed the
|
|
236
|
+
// verb-final construction was handled.
|
|
237
|
+
//
|
|
238
|
+
// Honest about what this proxy matches: any word of 3+ letters ending in
|
|
239
|
+
// "en" within the clause, which is the infinitive in practice but also hits
|
|
240
|
+
// a plural noun ("die Zeilen zählen" matches on "Zeilen"). That is
|
|
241
|
+
// acceptable here — the clause already requires "ich werde" and excludes
|
|
242
|
+
// negations and passives, so a forward commitment is what remains — but it
|
|
243
|
+
// is a proxy for the infinitive, not a parse of one.
|
|
244
|
+
/\bich werde\b(?![^.!?\n]*\b(?:nicht|nichts|kein(?:e|en|em|er|es)?|niemals|nie)\b)(?![^.!?\n]*\b(?:gefragt|gebeten|informiert|benachrichtigt)\b)[^.!?\n]*(?<!\p{L})\p{L}{3,}en(?!\p{L})/iu,
|
|
245
|
+
/\bI(?:'| wi)ll (report|let you know|update you|follow up)\b/i,
|
|
246
|
+
/\bI(?:'| wi)ll (now |then )?\w+ (it|that|this|next)\b/i,
|
|
247
|
+
/\bnext,? I(?:'| wi)ll\b/i,
|
|
248
|
+
/\bI am going to\b/i,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A legitimate hand-back is the opposite speech act: it gives the decision
|
|
253
|
+
* to the user and ends the turn on purpose. `scope-control` requires
|
|
254
|
+
* exactly this shape, so refusing it would put two rules in direct conflict.
|
|
255
|
+
*/
|
|
256
|
+
const HANDBACK = [
|
|
257
|
+
/\bdas entscheidest (du|Du)\b/i,
|
|
258
|
+
/\bdeine Entscheidung\b/i,
|
|
259
|
+
/\bich fasse .{0,40}nicht ungefragt an\b/i,
|
|
260
|
+
/\bsag (Bescheid|ein Wort)\b/i,
|
|
261
|
+
/\bwarte auf (deine|Deine|dein|Dein)\b/i,
|
|
262
|
+
/\byour call\b/i,
|
|
263
|
+
/\byou decide\b/i,
|
|
264
|
+
/\blet me know (which|if|whether)\b/i,
|
|
265
|
+
/\bwaiting for your\b/i,
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fires when the FINAL paragraph promises work, the turn hands nothing
|
|
270
|
+
* back, and no question is put to the user. All three, because the
|
|
271
|
+
* measured false-positive shapes are exactly the ones that fail one of them.
|
|
272
|
+
*/
|
|
273
|
+
export function detectPromissory(reply: string): Finding | null {
|
|
274
|
+
const tail = finalParagraph(reply);
|
|
275
|
+
if (!tail) return null;
|
|
276
|
+
|
|
277
|
+
// A blocking question IS the stop condition — never refuse one. But only a
|
|
278
|
+
// question that ENDS the paragraph is that: R2 round 2, finding 16 called
|
|
279
|
+
// `includes('?')` a one-character, trivially learnable bypass, since a
|
|
280
|
+
// rhetorical or quoted question anywhere alongside a promise disabled a
|
|
281
|
+
// blocking guard. The hand-back list below still covers the phrasings that
|
|
282
|
+
// yield without a question mark.
|
|
283
|
+
if (/\?["'’)\]]*\s*$/.test(tail)) return null;
|
|
284
|
+
if (HANDBACK.some((re) => re.test(tail))) return null;
|
|
285
|
+
|
|
286
|
+
for (const re of PROMISSORY) {
|
|
287
|
+
const m = tail.match(re);
|
|
288
|
+
if (m) {
|
|
289
|
+
return {
|
|
290
|
+
detector: 'promissory',
|
|
291
|
+
evidence: m[0],
|
|
292
|
+
reason:
|
|
293
|
+
'the closing paragraph promises work that has not been performed, ' +
|
|
294
|
+
'and the turn asks the user nothing — so nothing ends this turn but the promise itself',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Detector B — language mismatch
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
export interface LanguagePin {
|
|
306
|
+
language: Verdict;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Read the pin the language-mirror hook wrote. Absent ⇒ no obligation. */
|
|
310
|
+
export function readLanguagePin(workspaceRoot: string): Verdict {
|
|
311
|
+
try {
|
|
312
|
+
const raw = fs.readFileSync(path.join(workspaceRoot, LANGUAGE_STATE_FILE), 'utf-8');
|
|
313
|
+
const decoded: unknown = JSON.parse(raw);
|
|
314
|
+
if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
|
|
315
|
+
const lang = (decoded as Record<string, unknown>)['language'];
|
|
316
|
+
if (lang === 'de' || lang === 'en') return lang;
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
// no pin, unreadable pin, malformed pin — all mean "no obligation"
|
|
320
|
+
}
|
|
321
|
+
return 'und';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Fires only when the classifier is CONFIDENT the prose is the other
|
|
326
|
+
* language. `classify` returns `und` below its marker floor, and `und`
|
|
327
|
+
* never fires — a short reply is not evidence of drift.
|
|
328
|
+
*
|
|
329
|
+
* WHAT IS ACTUALLY CLASSIFIED, stated accurately because R2 finding 9 caught
|
|
330
|
+
* this file claiming otherwise in three places. `visibleProse` strips code,
|
|
331
|
+
* quotes, tables, URLs and paths from the whole reply — but `classify` then
|
|
332
|
+
* applies `instructionText` (which drops output-shaped lines and their
|
|
333
|
+
* followers) and `humanAuthoredLead`, and RETURNS ON THE LEAD ALONE when the
|
|
334
|
+
* lead is determined. So in the common case the verdict comes from the reply's
|
|
335
|
+
* opening chunk, not from all of it, and indented prose is discarded before
|
|
336
|
+
* scoring.
|
|
337
|
+
*
|
|
338
|
+
* That is deliberately left as-is rather than swapped for a full-text scorer:
|
|
339
|
+
* sharing one classifier with `language_mirror_hook` is what stops the pin and
|
|
340
|
+
* the detector disagreeing about what language a turn is in, and changing the
|
|
341
|
+
* scoring surface would change the measured rate, which needs its own
|
|
342
|
+
* measurement rather than a quiet edit. What is fixed here is the CLAIM —
|
|
343
|
+
* including the one this function used to put in its own refusal text, where an
|
|
344
|
+
* inflated description of the evidence is worst.
|
|
345
|
+
*/
|
|
346
|
+
export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
|
|
347
|
+
if (pinned !== 'de' && pinned !== 'en') return null;
|
|
348
|
+
const prose = visibleProse(reply);
|
|
349
|
+
const verdict = classify(prose);
|
|
350
|
+
if (verdict.language === 'und') return null;
|
|
351
|
+
if (verdict.language === pinned) return null;
|
|
352
|
+
return {
|
|
353
|
+
detector: 'language',
|
|
354
|
+
evidence: prose.trim().slice(0, 120),
|
|
355
|
+
reason:
|
|
356
|
+
`the pinned reply language is "${pinned}" but the reply classifies as ` +
|
|
357
|
+
`"${verdict.language}" (${verdict.de_markers} de / ${verdict.en_markers} en markers; ` +
|
|
358
|
+
'code, quotes, tables, URLs and paths excluded, then scored lead-first ' +
|
|
359
|
+
'by the same classifier that sets the pin)',
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Re-entrancy — the guard, keyed on the turn, not on the reply
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
function stateDir(workspaceRoot: string): string {
|
|
368
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* ONE file per session, not one per refused turn. R2 round 1, finding 17:
|
|
373
|
+
* per-turn files accumulated without bound and without a TTL, because "re-arms
|
|
374
|
+
* itself with no cleanup" described the key rotating, not the files being
|
|
375
|
+
* removed.
|
|
376
|
+
*
|
|
377
|
+
* HALF of that is fixed and half is not, stated rather than implied (R2 round 2,
|
|
378
|
+
* finding 12): the per-turn MULTIPLICITY is gone, so a session no longer leaves
|
|
379
|
+
* one file per refused turn. There is still no TTL and no pruning, so a
|
|
380
|
+
* long-lived workspace accumulates one small file per session that ever had a
|
|
381
|
+
* turn refused. Adding expiry is new behaviour and belongs with its own test.
|
|
382
|
+
*/
|
|
383
|
+
function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
|
|
384
|
+
return path.join(stateDir(workspaceRoot), `${sessionKey}.json`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function deriveSessionKey(sessionId: string): string {
|
|
388
|
+
return crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* A turn's identity is its ORDINAL — how many genuine user prompts the
|
|
393
|
+
* transcript has carried — never the prompt's text.
|
|
394
|
+
*
|
|
395
|
+
* R2 findings 2 and 3 killed the text-keyed version, in both directions:
|
|
396
|
+
*
|
|
397
|
+
* · keying on sha256(session + last user text) made every REPEAT of a prompt
|
|
398
|
+
* collide with the earlier refused turn, so the second "weiter" / "ok" /
|
|
399
|
+
* "1" was allowed unconditionally. Repeated short continuations are the
|
|
400
|
+
* dominant prompt shape in the corpus this gate was built from, so the
|
|
401
|
+
* gate disabled itself exactly where it was needed;
|
|
402
|
+
* · taking the last user-role entry made the key drift WITHIN one turn,
|
|
403
|
+
* because a compaction summary and a `<system-reminder>` both arrive in the
|
|
404
|
+
* user role. A new key mid-turn re-arms the marker, which is the wedge
|
|
405
|
+
* layer 2 exists to prevent on hosts that send no `stop_hook_active`.
|
|
406
|
+
*
|
|
407
|
+
* An ordinal over `isSyntheticPrompt`-filtered entries fixes both at once: it
|
|
408
|
+
* is distinct for every real turn regardless of what the user typed, and it
|
|
409
|
+
* does not move when the harness injects a user-role entry.
|
|
410
|
+
*/
|
|
411
|
+
export function alreadyRefusedTurn(
|
|
412
|
+
workspaceRoot: string,
|
|
413
|
+
sessionKey: string,
|
|
414
|
+
turnOrdinal: number,
|
|
415
|
+
): boolean {
|
|
416
|
+
try {
|
|
417
|
+
const raw = fs.readFileSync(sessionStateFile(workspaceRoot, sessionKey), 'utf-8');
|
|
418
|
+
const decoded: unknown = JSON.parse(raw);
|
|
419
|
+
if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
|
|
420
|
+
return (decoded as Record<string, unknown>)['refused_turn'] === turnOrdinal;
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
// absent, unreadable, or malformed state means "not refused yet" —
|
|
424
|
+
// fail-open, per this hook's contract.
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function markRefusedTurn(
|
|
430
|
+
workspaceRoot: string,
|
|
431
|
+
sessionKey: string,
|
|
432
|
+
turnOrdinal: number,
|
|
433
|
+
detector: DetectorId,
|
|
434
|
+
): void {
|
|
435
|
+
if (is_replay_mode()) return;
|
|
436
|
+
try {
|
|
437
|
+
atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), {
|
|
438
|
+
refused_at: new Date().toISOString(),
|
|
439
|
+
refused_turn: turnOrdinal,
|
|
440
|
+
detector,
|
|
441
|
+
});
|
|
442
|
+
} catch {
|
|
443
|
+
// A state-write failure must never wedge the turn. Losing the marker
|
|
444
|
+
// costs at most one extra refusal; failing closed here would cost the
|
|
445
|
+
// session.
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Transcript
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
export interface TranscriptTail {
|
|
454
|
+
lastAssistant: string;
|
|
455
|
+
/**
|
|
456
|
+
* How many GENUINE user prompts the transcript carries — harness-injected
|
|
457
|
+
* user-role entries excluded via `isSyntheticPrompt`. This is the turn's
|
|
458
|
+
* identity; see `alreadyRefusedTurn` for why the prompt's text is not.
|
|
459
|
+
*/
|
|
460
|
+
turnOrdinal: number;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The last assistant text, plus the turn ordinal, from a Claude JSONL
|
|
465
|
+
* transcript. Mirrors `chat_history._extract_claude_transcript_response` for
|
|
466
|
+
* the assistant half.
|
|
467
|
+
*
|
|
468
|
+
* `isSyntheticPrompt` is applied to every user-role entry — the same filter
|
|
469
|
+
* `language_mirror_hook` uses, and for the same reason it was added there
|
|
470
|
+
* (round-5 § 6.5): a background-task notification and a `<system-reminder>`
|
|
471
|
+
* both occupy the user role without being chat messages. Counting them would
|
|
472
|
+
* move the turn ordinal mid-turn, which is R2 finding 3.
|
|
473
|
+
*/
|
|
474
|
+
export function readTranscriptTail(
|
|
475
|
+
transcriptPath: string,
|
|
476
|
+
opts: { homeDir?: string; maxBytes?: number } = {},
|
|
477
|
+
): TranscriptTail {
|
|
478
|
+
const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0 };
|
|
479
|
+
if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
|
|
480
|
+
let lines: string[];
|
|
481
|
+
try {
|
|
482
|
+
// R2 finding 12: `maxBytes` was accepted in the options type and never
|
|
483
|
+
// used, so the declared cap was decoration. It is enforced here as well
|
|
484
|
+
// as inside `isSafeTranscriptPath`, because this is the read it bounds.
|
|
485
|
+
// The whole file still has to be walked — the turn ordinal is a count
|
|
486
|
+
// over all entries, not something a tail can answer — so the cap is the
|
|
487
|
+
// guard, not an optimisation.
|
|
488
|
+
if (opts.maxBytes !== undefined && fs.statSync(transcriptPath).size > opts.maxBytes) {
|
|
489
|
+
return empty;
|
|
490
|
+
}
|
|
491
|
+
lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
492
|
+
} catch {
|
|
493
|
+
return empty;
|
|
494
|
+
}
|
|
495
|
+
let lastAssistant = '';
|
|
496
|
+
let turnOrdinal = 0;
|
|
497
|
+
for (const rawLine of lines) {
|
|
498
|
+
const line = rawLine.trim();
|
|
499
|
+
if (!line) continue;
|
|
500
|
+
let obj: unknown;
|
|
501
|
+
try {
|
|
502
|
+
obj = JSON.parse(line);
|
|
503
|
+
} catch {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) continue;
|
|
507
|
+
const entry = obj as Record<string, unknown>;
|
|
508
|
+
// Sidechain entries are a SUBAGENT's conversation recorded in the same
|
|
509
|
+
// JSONL. A subagent prompt is a genuine-looking user-role text entry
|
|
510
|
+
// appended mid-turn, so counting it moves the ordinal within the turn —
|
|
511
|
+
// finding 3's failure class in a new shape (R2 round 2, finding 3).
|
|
512
|
+
if (entry['isSidechain'] === true) continue;
|
|
513
|
+
const role = entry['type'];
|
|
514
|
+
if (role !== 'assistant' && role !== 'user') continue;
|
|
515
|
+
const msg = entry['message'];
|
|
516
|
+
if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
|
|
517
|
+
const text = _messageText((msg as Record<string, unknown>)['content']);
|
|
518
|
+
if (text === null) continue;
|
|
519
|
+
if (role === 'assistant') {
|
|
520
|
+
lastAssistant = text;
|
|
521
|
+
} else if (!isSyntheticPrompt(text)) {
|
|
522
|
+
turnOrdinal += 1;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return { lastAssistant: lastAssistant.trim(), turnOrdinal };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function _messageText(content: unknown): string | null {
|
|
529
|
+
if (typeof content === 'string') return content;
|
|
530
|
+
if (!Array.isArray(content)) return null;
|
|
531
|
+
const parts: string[] = [];
|
|
532
|
+
for (const blk of content) {
|
|
533
|
+
if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
|
|
534
|
+
const b = blk as Record<string, unknown>;
|
|
535
|
+
if (b['type'] !== 'text') continue;
|
|
536
|
+
const t = b['text'];
|
|
537
|
+
if (typeof t === 'string') parts.push(t);
|
|
538
|
+
}
|
|
539
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Settings — master OFF, detectors ON within it
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
export interface GateSettings {
|
|
547
|
+
enabled: boolean;
|
|
548
|
+
promissory: boolean;
|
|
549
|
+
language: boolean;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* YAML spellings a human writes for "on". The `yaml` package parses to the 1.2
|
|
554
|
+
* core schema, where `yes` is the STRING "yes" rather than a boolean — so a
|
|
555
|
+
* maintainer who writes `enabled: yes` means it, and a reader that silently
|
|
556
|
+
* treats it as off is the R2 finding-6b defect wearing a parser instead of a
|
|
557
|
+
* regex. Anything not in this set is off; there is no truthiness guessing.
|
|
558
|
+
*/
|
|
559
|
+
function _isOn(v: unknown): boolean | null {
|
|
560
|
+
if (typeof v === 'boolean') return v;
|
|
561
|
+
if (typeof v !== 'string') return null;
|
|
562
|
+
const s = v.trim().toLowerCase();
|
|
563
|
+
if (s === 'true' || s === 'yes' || s === 'on') return true;
|
|
564
|
+
if (s === 'false' || s === 'no' || s === 'off') return false;
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Read the three switches through the real settings cascade.
|
|
570
|
+
*
|
|
571
|
+
* The previous version hand-walked `<workspaceRoot>/.agent-settings.yml` for
|
|
572
|
+
* speed, and R2 finding 6 found three defects in that trade: it ignored the
|
|
573
|
+
* user-global layer entirely (so a maintainer who opts in globally got no gate
|
|
574
|
+
* AND no diagnostic), its end-of-line-anchored regex read
|
|
575
|
+
* `enabled: true # soak opt-in` as FALSE, and it accepted a `turn_end_gate:`
|
|
576
|
+
* block under ANY parent as ON because a text walker has no notion of a parent.
|
|
577
|
+
*
|
|
578
|
+
* The stated reason for skipping the cascade does not survive contact with the
|
|
579
|
+
* sibling concerns: `delegation-nudge` calls `load_agent_settings` on
|
|
580
|
+
* `user_prompt_submit`, i.e. every turn. Correctness on a gate that can refuse
|
|
581
|
+
* a turn-end outranks one settings read per turn-end.
|
|
582
|
+
*/
|
|
583
|
+
export function readGateSettings(workspaceRoot: string): GateSettings {
|
|
584
|
+
const off: GateSettings = { enabled: false, promissory: true, language: true };
|
|
585
|
+
let settings: Record<string, unknown>;
|
|
586
|
+
try {
|
|
587
|
+
settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
|
|
588
|
+
} catch {
|
|
589
|
+
return off;
|
|
590
|
+
}
|
|
591
|
+
const hooks = settings['hooks'];
|
|
592
|
+
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return off;
|
|
593
|
+
const block = (hooks as Record<string, unknown>)['turn_end_gate'];
|
|
594
|
+
if (typeof block !== 'object' || block === null || Array.isArray(block)) return off;
|
|
595
|
+
const gate = block as Record<string, unknown>;
|
|
596
|
+
const flag = (name: string, dflt: boolean): boolean => {
|
|
597
|
+
const v = _isOn(gate[name]);
|
|
598
|
+
return v === null ? dflt : v;
|
|
599
|
+
};
|
|
600
|
+
return {
|
|
601
|
+
enabled: flag('enabled', false),
|
|
602
|
+
promissory: flag('promissory', true),
|
|
603
|
+
language: flag('language', true),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// main
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
function str(v: JsonValue | undefined): string {
|
|
612
|
+
return typeof v === 'string' ? v : '';
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function main(): number {
|
|
616
|
+
let envelope: JsonObject;
|
|
617
|
+
let payload: JsonObject;
|
|
618
|
+
try {
|
|
619
|
+
[envelope, payload] = unwrap(readHookStdin(), 'claude');
|
|
620
|
+
} catch {
|
|
621
|
+
return EXIT_ALLOW;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Layer 1 — the host's own answer to "did I already refuse this turn?".
|
|
625
|
+
if (payload['stop_hook_active'] === true) return EXIT_ALLOW;
|
|
626
|
+
|
|
627
|
+
const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
|
|
628
|
+
|
|
629
|
+
const settings = readGateSettings(workspaceRoot);
|
|
630
|
+
if (!settings.enabled) return EXIT_ALLOW;
|
|
631
|
+
|
|
632
|
+
const transcriptPath = str(
|
|
633
|
+
(payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
|
|
634
|
+
);
|
|
635
|
+
// No override of the home-confinement check on the production path. R2
|
|
636
|
+
// finding 13: the previous `AGENT_CONFIG_TRANSCRIPT_HOME` widened the one
|
|
637
|
+
// security-relevant predicate in this hook, and an env var that relaxes a
|
|
638
|
+
// path-confinement check IS a bypass of it, whatever the comment says. The
|
|
639
|
+
// tests set `HOME` on the spawned process instead — `os.homedir()` honours
|
|
640
|
+
// it — so the branch is still exercised with no product-code seam.
|
|
641
|
+
// The cap is passed HERE, at the only production call site. R2 round 2,
|
|
642
|
+
// finding 7: enforcing it inside the function while `main()` called
|
|
643
|
+
// `readTranscriptTail(transcriptPath)` with no options left the guard dead
|
|
644
|
+
// in production while its comment claimed it was enforced — the same
|
|
645
|
+
// fixed-the-definition-not-the-caller shape as two other findings in that
|
|
646
|
+
// round, which is why this line exists rather than a default parameter.
|
|
647
|
+
const { lastAssistant, turnOrdinal } = readTranscriptTail(transcriptPath, {
|
|
648
|
+
maxBytes: TRANSCRIPT_READ_MAX_BYTES,
|
|
649
|
+
});
|
|
650
|
+
if (!lastAssistant) return EXIT_ALLOW;
|
|
651
|
+
|
|
652
|
+
// Layer 2 — keyed on the turn's ORDINAL, never on the prompt's text.
|
|
653
|
+
// A host that sends no `session_id` shares one bucket AND one small-integer
|
|
654
|
+
// ordinal namespace, so an unrelated session whose ordinal matches a stored
|
|
655
|
+
// `refused_turn` reads as already-refused and goes unguarded. R2 round 2,
|
|
656
|
+
// finding 16: the sibling hook documents its own bucket as degrading rather
|
|
657
|
+
// than colliding, and this one collides. It is named here rather than
|
|
658
|
+
// papered over — the degradation is toward UNDER-refusing, which is the safe
|
|
659
|
+
// direction, and layer 1 still covers the host that sends the flag.
|
|
660
|
+
const sessionKey = deriveSessionKey(
|
|
661
|
+
str(envelope['session_id'] as JsonValue | undefined) || 'unknown-session',
|
|
662
|
+
);
|
|
663
|
+
if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
|
|
664
|
+
|
|
665
|
+
const findings: Finding[] = [];
|
|
666
|
+
if (settings.promissory) {
|
|
667
|
+
const f = detectPromissory(lastAssistant);
|
|
668
|
+
if (f) findings.push(f);
|
|
669
|
+
}
|
|
670
|
+
if (settings.language) {
|
|
671
|
+
const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
|
|
672
|
+
if (f) findings.push(f);
|
|
673
|
+
}
|
|
674
|
+
if (findings.length === 0) return EXIT_ALLOW;
|
|
675
|
+
|
|
676
|
+
markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
|
|
677
|
+
|
|
678
|
+
const lines = findings.map(
|
|
679
|
+
(f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,
|
|
680
|
+
);
|
|
681
|
+
process.stderr.write(
|
|
682
|
+
`turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
|
|
683
|
+
' Do the promised work now, or correct the language, then end the turn.\n' +
|
|
684
|
+
' This turn will not be refused a second time.\n' +
|
|
685
|
+
' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
|
|
686
|
+
);
|
|
687
|
+
return EXIT_BLOCK;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
691
|
+
// every module shares the bundle's `import.meta.url`.
|
|
692
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
693
|
+
function _isCliEntry(): boolean {
|
|
694
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
if (process.argv[1] === undefined) {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
701
|
+
if (import.meta.url === argvUrl) {
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
706
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
707
|
+
return here === argv;
|
|
708
|
+
} catch {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (_isCliEntry()) {
|
|
714
|
+
process.exit(main());
|
|
715
|
+
}
|