@event4u/agent-config 9.29.0 → 9.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +108 -7
- package/README.md +1 -0
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/delegate.md +1 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
- package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_conformance.js +2798 -95
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +86 -6
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1904 -575
- package/dist/install/install.mjs +14 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3760 -726
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +21 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/config/agent-settings.template.yml +36 -0
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +17 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/judgment_ladder.ts +254 -2
- package/src/scripts/_lib/orchestration_record.ts +41 -10
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +194 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +159 -0
- package/src/scripts/_lib/subagent_response.ts +75 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +17 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +20 -0
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +97 -0
- package/src/scripts/self_repair_cli.ts +51 -3
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/server/schemas/settings.ts +14 -0
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-JDAF00bC.js +0 -26
- package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
|
@@ -0,0 +1,507 @@
|
|
|
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 state marker keyed on the LAST USER MESSAGE, never on the reply.
|
|
31
|
+
* After a refusal the model writes a *new* reply, so a reply-keyed
|
|
32
|
+
* marker would let the same turn be refused again, and again. The
|
|
33
|
+
* user prompt does not change within a turn, so the same turn yields
|
|
34
|
+
* the same key and is refused at most once; the next genuine prompt
|
|
35
|
+
* yields a new key and the gate re-arms itself with no cleanup.
|
|
36
|
+
*
|
|
37
|
+
* The failure this ordering prevents is not a loop but a wedge: a turn that
|
|
38
|
+
* can never end. Layer 2 also covers the host that does not send
|
|
39
|
+
* `stop_hook_active` at all.
|
|
40
|
+
*
|
|
41
|
+
* ## Default OFF — and why, given the roadmap says otherwise
|
|
42
|
+
*
|
|
43
|
+
* `road-to-conformance-round5.md` § 3.6 asks for a kill-switch per
|
|
44
|
+
* detector, "default on". The council recorded in round 6 (2026-08-08)
|
|
45
|
+
* asked for the mechanism to be opt-in, "defaulting to off, so the
|
|
46
|
+
* mechanism exists and soaks before it binds" — two prior hook-severity
|
|
47
|
+
* mistakes plus a turn-end blast radius made default-on uninsurable in
|
|
48
|
+
* their reading. Both are satisfied literally: the MASTER switch
|
|
49
|
+
* (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
|
|
50
|
+
* detectors are default ON. A maintainer who opts in gets both without
|
|
51
|
+
* further configuration; until then this concern is a no-op that costs one
|
|
52
|
+
* settings read.
|
|
53
|
+
*
|
|
54
|
+
* CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
|
|
55
|
+
* otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
|
|
56
|
+
* gate must resolve to "let the turn end", never to a wedge; promoting a
|
|
57
|
+
* crash to a block is the outage this whole file is arranged to avoid.
|
|
58
|
+
* Every unreadable input, missing transcript, absent pin, or malformed
|
|
59
|
+
* state resolves to silence.
|
|
60
|
+
*/
|
|
61
|
+
import * as crypto from 'node:crypto';
|
|
62
|
+
import * as fs from 'node:fs';
|
|
63
|
+
import * as path from 'node:path';
|
|
64
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
65
|
+
|
|
66
|
+
import { classify, STATE_FILE as LANGUAGE_STATE_FILE, type Verdict } from '../language_mirror_hook.js';
|
|
67
|
+
import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
|
|
68
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
69
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
70
|
+
import { atomic_write_json, is_replay_mode } from './state_io.js';
|
|
71
|
+
|
|
72
|
+
/** Dispatcher-internal block code. Pinned to 1 by `concern_block_exit_parity`. */
|
|
73
|
+
const EXIT_BLOCK = 1;
|
|
74
|
+
const EXIT_ALLOW = 0;
|
|
75
|
+
|
|
76
|
+
/** Detector identity, used in the state marker and the refusal text. */
|
|
77
|
+
export type DetectorId = 'promissory' | 'language';
|
|
78
|
+
|
|
79
|
+
export interface Finding {
|
|
80
|
+
detector: DetectorId;
|
|
81
|
+
/** The span that triggered it — quoted back so the refusal is actionable. */
|
|
82
|
+
evidence: string;
|
|
83
|
+
reason: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Text extraction — what counts as "user-visible prose"
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Strip everything `language-and-tone` already exempts from the mirror
|
|
92
|
+
* obligation: fenced code, inline code, block quotes (quoted tool output),
|
|
93
|
+
* URLs, and bare paths/identifiers. What remains is the prose the rule
|
|
94
|
+
* actually binds.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately conservative — over-stripping costs a missed detection,
|
|
97
|
+
* under-stripping costs a false refusal, and a blocking guard with a false
|
|
98
|
+
* positive rate teaches users to bypass it.
|
|
99
|
+
*/
|
|
100
|
+
export function visibleProse(reply: string): string {
|
|
101
|
+
let text = reply;
|
|
102
|
+
// Fenced blocks, both ``` and ~~~ (markdown-safe-codeblocks ships both).
|
|
103
|
+
text = text.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm, ' ');
|
|
104
|
+
// An unterminated fence at the end of a reply — drop the tail.
|
|
105
|
+
text = text.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*$/m, ' ');
|
|
106
|
+
// Inline code.
|
|
107
|
+
text = text.replace(/`[^`\n]*`/g, ' ');
|
|
108
|
+
// Block quotes — the shape quoted tool output takes.
|
|
109
|
+
text = text.replace(/^[ \t]*>.*$/gm, ' ');
|
|
110
|
+
// Markdown tables — cells are frequently identifiers and paths.
|
|
111
|
+
text = text.replace(/^[ \t]*\|.*\|[ \t]*$/gm, ' ');
|
|
112
|
+
// URLs.
|
|
113
|
+
text = text.replace(/\bhttps?:\/\/\S+/g, ' ');
|
|
114
|
+
// Path-shaped and identifier-shaped tokens.
|
|
115
|
+
text = text.replace(/\S*\/\S+/g, ' ');
|
|
116
|
+
text = text.replace(/\b[\w.-]+\.(ts|js|md|json|ya?ml|py|php|txt|sh)\b/g, ' ');
|
|
117
|
+
return text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The final user-visible paragraph — where a promissory closing lives. */
|
|
121
|
+
export function finalParagraph(reply: string): string {
|
|
122
|
+
const prose = visibleProse(reply).trim();
|
|
123
|
+
if (!prose) return '';
|
|
124
|
+
const paragraphs = prose
|
|
125
|
+
.split(/\n\s*\n/)
|
|
126
|
+
.map((p) => p.trim())
|
|
127
|
+
.filter((p) => p.length > 0);
|
|
128
|
+
return paragraphs.length === 0 ? '' : paragraphs[paragraphs.length - 1]!;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Detector A — promissory closing
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A commitment to work not yet performed. Both languages, because the
|
|
137
|
+
* measured corpus is bilingual and a German-only list would have caught
|
|
138
|
+
* roughly half of the 20.
|
|
139
|
+
*/
|
|
140
|
+
const PROMISSORY = [
|
|
141
|
+
/\bich melde mich\b/i,
|
|
142
|
+
/\bmelde ich (mich|dir|Dir)\b/i,
|
|
143
|
+
/\bich melde\b/i,
|
|
144
|
+
/\bich berichte\b/i,
|
|
145
|
+
/\bich (sage|gebe) (dir |Dir )?Bescheid\b/i,
|
|
146
|
+
/\bals n(ä|ae)chstes (werde|mache|baue|pr(ü|ue)fe) ich\b/i,
|
|
147
|
+
// German puts the infinitive at the END of the clause ("ich werde jetzt
|
|
148
|
+
// die Tests schreiben"), so the verb cannot be matched adjacent to
|
|
149
|
+
// "werde" — it has to be looked for across the rest of the sentence. The
|
|
150
|
+
// negative lookahead keeps a stated REFUSAL to act ("ich werde das nicht
|
|
151
|
+
// anfassen") out: declining to do something is not a promise to do it.
|
|
152
|
+
/\bich werde\b(?![^.!?\n]*\bnicht\b)[^.!?\n]*\b\w{3,}en\b/i,
|
|
153
|
+
/\bI(?:'| wi)ll (report|let you know|update you|follow up)\b/i,
|
|
154
|
+
/\bI(?:'| wi)ll (now |then )?\w+ (it|that|this|next)\b/i,
|
|
155
|
+
/\bnext,? I(?:'| wi)ll\b/i,
|
|
156
|
+
/\bI am going to\b/i,
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A legitimate hand-back is the opposite speech act: it gives the decision
|
|
161
|
+
* to the user and ends the turn on purpose. `scope-control` requires
|
|
162
|
+
* exactly this shape, so refusing it would put two rules in direct conflict.
|
|
163
|
+
*/
|
|
164
|
+
const HANDBACK = [
|
|
165
|
+
/\bdas entscheidest (du|Du)\b/i,
|
|
166
|
+
/\bdeine Entscheidung\b/i,
|
|
167
|
+
/\bich fasse .{0,40}nicht ungefragt an\b/i,
|
|
168
|
+
/\bsag (Bescheid|ein Wort)\b/i,
|
|
169
|
+
/\bwarte auf (deine|Deine|dein|Dein)\b/i,
|
|
170
|
+
/\byour call\b/i,
|
|
171
|
+
/\byou decide\b/i,
|
|
172
|
+
/\blet me know (which|if|whether)\b/i,
|
|
173
|
+
/\bwaiting for your\b/i,
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fires when the FINAL paragraph promises work, the turn hands nothing
|
|
178
|
+
* back, and no question is put to the user. All three, because the
|
|
179
|
+
* measured false-positive shapes are exactly the ones that fail one of them.
|
|
180
|
+
*/
|
|
181
|
+
export function detectPromissory(reply: string): Finding | null {
|
|
182
|
+
const tail = finalParagraph(reply);
|
|
183
|
+
if (!tail) return null;
|
|
184
|
+
|
|
185
|
+
// A blocking question IS the stop condition — never refuse one.
|
|
186
|
+
if (tail.includes('?')) return null;
|
|
187
|
+
if (HANDBACK.some((re) => re.test(tail))) return null;
|
|
188
|
+
|
|
189
|
+
for (const re of PROMISSORY) {
|
|
190
|
+
const m = tail.match(re);
|
|
191
|
+
if (m) {
|
|
192
|
+
return {
|
|
193
|
+
detector: 'promissory',
|
|
194
|
+
evidence: m[0],
|
|
195
|
+
reason:
|
|
196
|
+
'the closing paragraph promises work that has not been performed, ' +
|
|
197
|
+
'and the turn asks the user nothing — so nothing ends this turn but the promise itself',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Detector B — language mismatch
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
export interface LanguagePin {
|
|
209
|
+
language: Verdict;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Read the pin the language-mirror hook wrote. Absent ⇒ no obligation. */
|
|
213
|
+
export function readLanguagePin(workspaceRoot: string): Verdict {
|
|
214
|
+
try {
|
|
215
|
+
const raw = fs.readFileSync(path.join(workspaceRoot, LANGUAGE_STATE_FILE), 'utf-8');
|
|
216
|
+
const decoded: unknown = JSON.parse(raw);
|
|
217
|
+
if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
|
|
218
|
+
const lang = (decoded as Record<string, unknown>)['language'];
|
|
219
|
+
if (lang === 'de' || lang === 'en') return lang;
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// no pin, unreadable pin, malformed pin — all mean "no obligation"
|
|
223
|
+
}
|
|
224
|
+
return 'und';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Fires only when the classifier is CONFIDENT the prose is the other
|
|
229
|
+
* language. `classify` returns `und` below its marker floor, and `und`
|
|
230
|
+
* never fires — a short reply is not evidence of drift.
|
|
231
|
+
*/
|
|
232
|
+
export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
|
|
233
|
+
if (pinned !== 'de' && pinned !== 'en') return null;
|
|
234
|
+
const prose = visibleProse(reply);
|
|
235
|
+
const verdict = classify(prose);
|
|
236
|
+
if (verdict.language === 'und') return null;
|
|
237
|
+
if (verdict.language === pinned) return null;
|
|
238
|
+
return {
|
|
239
|
+
detector: 'language',
|
|
240
|
+
evidence: prose.trim().slice(0, 120),
|
|
241
|
+
reason:
|
|
242
|
+
`the pinned reply language is "${pinned}" but the user-visible prose classifies as ` +
|
|
243
|
+
`"${verdict.language}" (${verdict.de_markers} de / ${verdict.en_markers} en markers, ` +
|
|
244
|
+
'code, quotes, paths and identifiers already excluded)',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Re-entrancy — the guard, keyed on the turn, not on the reply
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function stateDir(workspaceRoot: string): string {
|
|
253
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function stateFile(workspaceRoot: string, turnKey: string): string {
|
|
257
|
+
return path.join(stateDir(workspaceRoot), `${turnKey}.json`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The turn's identity. Session id plus the last USER message — see the file
|
|
262
|
+
* header for why the reply must not be part of it.
|
|
263
|
+
*/
|
|
264
|
+
export function deriveTurnKey(sessionId: string, lastUserText: string): string {
|
|
265
|
+
return crypto
|
|
266
|
+
.createHash('sha256')
|
|
267
|
+
.update(`${sessionId}\n${lastUserText}`)
|
|
268
|
+
.digest('hex')
|
|
269
|
+
.slice(0, 32);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function alreadyRefusedThisTurn(workspaceRoot: string, turnKey: string): boolean {
|
|
273
|
+
try {
|
|
274
|
+
return fs.existsSync(stateFile(workspaceRoot, turnKey));
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function markRefusedThisTurn(workspaceRoot: string, turnKey: string, detector: DetectorId): void {
|
|
281
|
+
if (is_replay_mode()) return;
|
|
282
|
+
try {
|
|
283
|
+
atomic_write_json(stateFile(workspaceRoot, turnKey), {
|
|
284
|
+
refused_at: new Date().toISOString(),
|
|
285
|
+
detector,
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
// A state-write failure must never wedge the turn. Losing the marker
|
|
289
|
+
// costs at most one extra refusal; failing closed here would cost the
|
|
290
|
+
// session.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Transcript
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export interface TranscriptTail {
|
|
299
|
+
lastAssistant: string;
|
|
300
|
+
lastUser: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Last assistant text and last user text from a Claude JSONL transcript.
|
|
305
|
+
* Mirrors `chat_history._extract_claude_transcript_response` for the
|
|
306
|
+
* assistant half and adds the user half the turn key needs.
|
|
307
|
+
*/
|
|
308
|
+
export function readTranscriptTail(
|
|
309
|
+
transcriptPath: string,
|
|
310
|
+
opts: { homeDir?: string; maxBytes?: number } = {},
|
|
311
|
+
): TranscriptTail {
|
|
312
|
+
const empty: TranscriptTail = { lastAssistant: '', lastUser: '' };
|
|
313
|
+
if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
|
|
314
|
+
let lines: string[];
|
|
315
|
+
try {
|
|
316
|
+
lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
317
|
+
} catch {
|
|
318
|
+
return empty;
|
|
319
|
+
}
|
|
320
|
+
let lastAssistant = '';
|
|
321
|
+
let lastUser = '';
|
|
322
|
+
for (const rawLine of lines) {
|
|
323
|
+
const line = rawLine.trim();
|
|
324
|
+
if (!line) continue;
|
|
325
|
+
let obj: unknown;
|
|
326
|
+
try {
|
|
327
|
+
obj = JSON.parse(line);
|
|
328
|
+
} catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) continue;
|
|
332
|
+
const entry = obj as Record<string, unknown>;
|
|
333
|
+
const role = entry['type'];
|
|
334
|
+
if (role !== 'assistant' && role !== 'user') continue;
|
|
335
|
+
const msg = entry['message'];
|
|
336
|
+
if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
|
|
337
|
+
const text = _messageText((msg as Record<string, unknown>)['content']);
|
|
338
|
+
if (text === null) continue;
|
|
339
|
+
if (role === 'assistant') lastAssistant = text;
|
|
340
|
+
else lastUser = text;
|
|
341
|
+
}
|
|
342
|
+
return { lastAssistant: lastAssistant.trim(), lastUser: lastUser.trim() };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _messageText(content: unknown): string | null {
|
|
346
|
+
if (typeof content === 'string') return content;
|
|
347
|
+
if (!Array.isArray(content)) return null;
|
|
348
|
+
const parts: string[] = [];
|
|
349
|
+
for (const blk of content) {
|
|
350
|
+
if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
|
|
351
|
+
const b = blk as Record<string, unknown>;
|
|
352
|
+
if (b['type'] !== 'text') continue;
|
|
353
|
+
const t = b['text'];
|
|
354
|
+
if (typeof t === 'string') parts.push(t);
|
|
355
|
+
}
|
|
356
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Settings — master OFF, detectors ON within it
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
export interface GateSettings {
|
|
364
|
+
enabled: boolean;
|
|
365
|
+
promissory: boolean;
|
|
366
|
+
language: boolean;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Read the three switches with a line walker rather than the full settings
|
|
371
|
+
* cascade: this concern runs on EVERY turn-end and is default-off, so the
|
|
372
|
+
* common path must not pay for a cascade load it will discard.
|
|
373
|
+
*/
|
|
374
|
+
export function readGateSettings(workspaceRoot: string): GateSettings {
|
|
375
|
+
const off: GateSettings = { enabled: false, promissory: true, language: true };
|
|
376
|
+
let raw: string;
|
|
377
|
+
try {
|
|
378
|
+
raw = fs.readFileSync(path.join(workspaceRoot, '.agent-settings.yml'), 'utf-8');
|
|
379
|
+
} catch {
|
|
380
|
+
return off;
|
|
381
|
+
}
|
|
382
|
+
// Indent-aware: the block ends at the first line indented no deeper than
|
|
383
|
+
// the header. A width-agnostic `^\S` terminator would swallow the next
|
|
384
|
+
// sibling key and read ITS `enabled:` as ours.
|
|
385
|
+
const lines = raw.split('\n');
|
|
386
|
+
const headerIdx = lines.findIndex((l) => /^\s*turn_end_gate:\s*$/.test(l));
|
|
387
|
+
if (headerIdx === -1) return off;
|
|
388
|
+
const headerIndent = (lines[headerIdx]!.match(/^\s*/) ?? [''])[0].length;
|
|
389
|
+
const body: string[] = [];
|
|
390
|
+
for (const line of lines.slice(headerIdx + 1)) {
|
|
391
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
|
|
392
|
+
const indent = (line.match(/^\s*/) ?? [''])[0].length;
|
|
393
|
+
if (indent <= headerIndent) break;
|
|
394
|
+
body.push(line);
|
|
395
|
+
}
|
|
396
|
+
const flag = (name: string, dflt: boolean): boolean => {
|
|
397
|
+
const re = new RegExp(`^\\s+${name}:\\s*(true|false)\\s*$`);
|
|
398
|
+
for (const line of body) {
|
|
399
|
+
const m = line.match(re);
|
|
400
|
+
if (m) return m[1] === 'true';
|
|
401
|
+
}
|
|
402
|
+
return dflt;
|
|
403
|
+
};
|
|
404
|
+
return {
|
|
405
|
+
enabled: flag('enabled', false),
|
|
406
|
+
promissory: flag('promissory', true),
|
|
407
|
+
language: flag('language', true),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// main
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
function str(v: JsonValue | undefined): string {
|
|
416
|
+
return typeof v === 'string' ? v : '';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function main(): number {
|
|
420
|
+
let envelope: JsonObject;
|
|
421
|
+
let payload: JsonObject;
|
|
422
|
+
try {
|
|
423
|
+
[envelope, payload] = unwrap(readHookStdin(), 'claude');
|
|
424
|
+
} catch {
|
|
425
|
+
return EXIT_ALLOW;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Layer 1 — the host's own answer to "did I already refuse this turn?".
|
|
429
|
+
if (payload['stop_hook_active'] === true) return EXIT_ALLOW;
|
|
430
|
+
|
|
431
|
+
const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
|
|
432
|
+
|
|
433
|
+
const settings = readGateSettings(workspaceRoot);
|
|
434
|
+
if (!settings.enabled) return EXIT_ALLOW;
|
|
435
|
+
|
|
436
|
+
const transcriptPath = str(
|
|
437
|
+
(payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
|
|
438
|
+
);
|
|
439
|
+
// `AGENT_CONFIG_TRANSCRIPT_HOME` exists for the test harness only: real
|
|
440
|
+
// transcripts live under `~/.claude/projects/**` and production never sets
|
|
441
|
+
// it. Without it a test cannot exercise the path-safety check at all,
|
|
442
|
+
// which would leave the one security-relevant branch unmeasured.
|
|
443
|
+
const homeOverride = process.env['AGENT_CONFIG_TRANSCRIPT_HOME'];
|
|
444
|
+
const { lastAssistant, lastUser } = readTranscriptTail(
|
|
445
|
+
transcriptPath,
|
|
446
|
+
homeOverride ? { homeDir: homeOverride } : {},
|
|
447
|
+
);
|
|
448
|
+
if (!lastAssistant) return EXIT_ALLOW;
|
|
449
|
+
|
|
450
|
+
// Layer 2 — the turn-keyed marker. Reply-independent by construction.
|
|
451
|
+
const turnKey = deriveTurnKey(
|
|
452
|
+
str(envelope['session_id'] as JsonValue | undefined) || 'unknown-session',
|
|
453
|
+
lastUser,
|
|
454
|
+
);
|
|
455
|
+
if (alreadyRefusedThisTurn(workspaceRoot, turnKey)) return EXIT_ALLOW;
|
|
456
|
+
|
|
457
|
+
const findings: Finding[] = [];
|
|
458
|
+
if (settings.promissory) {
|
|
459
|
+
const f = detectPromissory(lastAssistant);
|
|
460
|
+
if (f) findings.push(f);
|
|
461
|
+
}
|
|
462
|
+
if (settings.language) {
|
|
463
|
+
const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
|
|
464
|
+
if (f) findings.push(f);
|
|
465
|
+
}
|
|
466
|
+
if (findings.length === 0) return EXIT_ALLOW;
|
|
467
|
+
|
|
468
|
+
markRefusedThisTurn(workspaceRoot, turnKey, findings[0]!.detector);
|
|
469
|
+
|
|
470
|
+
const lines = findings.map(
|
|
471
|
+
(f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,
|
|
472
|
+
);
|
|
473
|
+
process.stderr.write(
|
|
474
|
+
`turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
|
|
475
|
+
' Do the promised work now, or correct the language, then end the turn.\n' +
|
|
476
|
+
' This turn will not be refused a second time.\n' +
|
|
477
|
+
' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
|
|
478
|
+
);
|
|
479
|
+
return EXIT_BLOCK;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
483
|
+
// every module shares the bundle's `import.meta.url`.
|
|
484
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
485
|
+
function _isCliEntry(): boolean {
|
|
486
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
if (process.argv[1] === undefined) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
493
|
+
if (import.meta.url === argvUrl) {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
498
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
499
|
+
return here === argv;
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (_isCliEntry()) {
|
|
506
|
+
process.exit(main());
|
|
507
|
+
}
|
|
@@ -145,7 +145,7 @@ fi
|
|
|
145
145
|
# Roadmap dashboard sync — only fires when staged changes touch a roadmap
|
|
146
146
|
# file or the dashboard itself, so unrelated commits stay fast.
|
|
147
147
|
if git diff --cached --name-only | grep -qE '^agents/roadmaps(-progress\.md|/)'; then
|
|
148
|
-
./scripts-run
|
|
148
|
+
./scripts-run src/scripts/roadmap_progress_hook --check
|
|
149
149
|
rstatus=$?
|
|
150
150
|
if [ $rstatus -ne 0 ]; then
|
|
151
151
|
# The check fails for THREE distinct reasons and prints the real one
|
|
@@ -87,6 +87,13 @@ const EXIT_WARN = 2;
|
|
|
87
87
|
|
|
88
88
|
export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Marker written at `pre_compact` and cleared by the single `post_tool_use`
|
|
92
|
+
* re-emit that follows it (round-5 § 6.1). Its existence — not a counter — is
|
|
93
|
+
* what bounds the re-injection to ONE per compaction event.
|
|
94
|
+
*/
|
|
95
|
+
export const PIN_LOST_MARKER = path.join("agents", "state", "language-mirror.pin-lost");
|
|
96
|
+
|
|
90
97
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
91
98
|
type JsonObject = { [k: string]: JsonValue };
|
|
92
99
|
|
|
@@ -380,6 +387,64 @@ function _extractPrompt(payload: JsonObject): string {
|
|
|
380
387
|
return "";
|
|
381
388
|
}
|
|
382
389
|
|
|
390
|
+
/** Record that compaction is about to drop the context copy of the pin. */
|
|
391
|
+
export function _setPinLost(consumer_root: string): void {
|
|
392
|
+
try {
|
|
393
|
+
const target = path.join(consumer_root, PIN_LOST_MARKER);
|
|
394
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
395
|
+
fs.writeFileSync(target, new Date().toISOString());
|
|
396
|
+
} catch {
|
|
397
|
+
// Observability only — a failed marker write costs one un-restored pin,
|
|
398
|
+
// never a broken turn.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function _pinLost(consumer_root: string): boolean {
|
|
403
|
+
try {
|
|
404
|
+
return fs.existsSync(path.join(consumer_root, PIN_LOST_MARKER));
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function _clearPinLost(consumer_root: string): void {
|
|
411
|
+
try {
|
|
412
|
+
fs.rmSync(path.join(consumer_root, PIN_LOST_MARKER), { force: true });
|
|
413
|
+
} catch {
|
|
414
|
+
// If it cannot be cleared the next tool call re-emits once more. One
|
|
415
|
+
// duplicate line is the failure mode; a loop is not, because the marker
|
|
416
|
+
// is only ever SET by a compaction event.
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The `post_tool_use` half. Emits the pin exactly once per compaction event
|
|
422
|
+
* and only when a pin actually exists — an absent or undetermined pin is not
|
|
423
|
+
* an obligation, and inventing one here would be worse than the gap.
|
|
424
|
+
*/
|
|
425
|
+
export function _reEmitAfterCompaction(consumer_root: string): number {
|
|
426
|
+
if (!_pinLost(consumer_root)) {
|
|
427
|
+
return EXIT_ALLOW;
|
|
428
|
+
}
|
|
429
|
+
const previous = _loadState(path.join(consumer_root, STATE_FILE));
|
|
430
|
+
const language = previous.language as Verdict | undefined;
|
|
431
|
+
const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
|
|
432
|
+
// Clear FIRST: if the write below throws, the marker must not survive to
|
|
433
|
+
// re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
|
|
434
|
+
_clearPinLost(consumer_root);
|
|
435
|
+
if (language !== "de" && language !== "en") {
|
|
436
|
+
return EXIT_ALLOW;
|
|
437
|
+
}
|
|
438
|
+
process.stdout.write(
|
|
439
|
+
`${JSON.stringify({
|
|
440
|
+
decision: "warn",
|
|
441
|
+
reason: noticeText(language, source),
|
|
442
|
+
additional_context: pinText(language, source),
|
|
443
|
+
})}\n`,
|
|
444
|
+
);
|
|
445
|
+
return EXIT_WARN;
|
|
446
|
+
}
|
|
447
|
+
|
|
383
448
|
export function run(
|
|
384
449
|
stdin_text: string,
|
|
385
450
|
options: { consumer_root: string; env?: NodeJS.ProcessEnv },
|
|
@@ -397,6 +462,30 @@ export function run(
|
|
|
397
462
|
}
|
|
398
463
|
|
|
399
464
|
const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
|
|
465
|
+
|
|
466
|
+
// round-5 § 6.1 — the compaction boundary.
|
|
467
|
+
//
|
|
468
|
+
// 4 of the 23 post-merge violations happened with NO pin in context: a
|
|
469
|
+
// `compact_boundary` fired and the first violation followed 26 seconds
|
|
470
|
+
// later. Compaction removes the context copy of the pin, not this hook's
|
|
471
|
+
// state file — so this is a state defect with a deterministic fix, aimed
|
|
472
|
+
// only at the subset where the pin was measurably MISSING, never at the 19
|
|
473
|
+
// where it was measurably ignored.
|
|
474
|
+
//
|
|
475
|
+
// The guard IS the design, and is not optional: `pre_compact` sets a
|
|
476
|
+
// marker, `post_tool_use` re-emits once while it is set and clears it. That
|
|
477
|
+
// is one extra injection per compaction EVENT. A re-pin on every tool call
|
|
478
|
+
// is what § 6.2 refuses — "the same failed mechanism running more often" —
|
|
479
|
+
// and this marker is the entire difference between the two.
|
|
480
|
+
const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
|
|
481
|
+
if (event === "pre_compact") {
|
|
482
|
+
_setPinLost(options.consumer_root);
|
|
483
|
+
return EXIT_ALLOW;
|
|
484
|
+
}
|
|
485
|
+
if (event === "post_tool_use") {
|
|
486
|
+
return _reEmitAfterCompaction(options.consumer_root);
|
|
487
|
+
}
|
|
488
|
+
|
|
400
489
|
const prompt = _extractPrompt(payload);
|
|
401
490
|
if (!prompt) {
|
|
402
491
|
return EXIT_ALLOW;
|
|
@@ -41,6 +41,7 @@ import * as path from 'node:path';
|
|
|
41
41
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
42
42
|
import { parse as parseYaml } from 'yaml';
|
|
43
43
|
|
|
44
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
44
45
|
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
45
46
|
import { reportScanned } from './_lib/scan_scope.js';
|
|
46
47
|
|
|
@@ -188,15 +189,26 @@ export function main(argv?: readonly string[]): number {
|
|
|
188
189
|
names = [];
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
// Per-rule completeness: every listed rule reaches exactly one outcome, so
|
|
193
|
+
// the frontmatter-less `continue` below is a named skip, not a silent one.
|
|
194
|
+
const ledger = new GateLedger('lint_decision_impact');
|
|
195
|
+
ledger.plan(names);
|
|
191
196
|
const violations: Violation[] = [];
|
|
192
197
|
let scanned = 0;
|
|
193
198
|
for (const n of names) {
|
|
194
199
|
const fm = frontmatterOf(fs.readFileSync(path.join(dir, n), 'utf-8'));
|
|
195
200
|
if (fm === null) {
|
|
201
|
+
ledger.skip(n, 'not_applicable_kind');
|
|
196
202
|
continue;
|
|
197
203
|
}
|
|
198
204
|
scanned += 1;
|
|
199
|
-
|
|
205
|
+
const found = checkRule(n.slice(0, -3), fm);
|
|
206
|
+
violations.push(...found);
|
|
207
|
+
if (found.length > 0) {
|
|
208
|
+
ledger.fail(n, `${String(found.length)} issue(s)`);
|
|
209
|
+
} else {
|
|
210
|
+
ledger.complete(n);
|
|
211
|
+
}
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
if (violations.length > 0) {
|
|
@@ -214,6 +226,7 @@ export function main(argv?: readonly string[]): number {
|
|
|
214
226
|
);
|
|
215
227
|
}
|
|
216
228
|
|
|
229
|
+
ledger.report();
|
|
217
230
|
reportScanned({
|
|
218
231
|
gate: 'lint_decision_impact',
|
|
219
232
|
scanned,
|