@ngockhoale/ukit 2.4.2 → 2.4.3
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/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/core/compact/threshold.js +36 -6
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stop-coordinator.mjs — the ONE Stop decision coordinator (TASK-025, PLAN §2 H17).
|
|
4
|
+
*
|
|
5
|
+
* H17: `.claude/settings.json` used to register BOTH `completion-gate.sh` and
|
|
6
|
+
* `task-watchdog.sh` on the Stop event. Each hook evaluated its own policy and each
|
|
7
|
+
* could emit `decision: block`, so a single Stop could produce two continuation
|
|
8
|
+
* decisions and two state mutations (completion gate continuation counter + watchdog
|
|
9
|
+
* hard-block counter). This module replaces that with one coordinator:
|
|
10
|
+
*
|
|
11
|
+
* - it evaluates the completion policy by spawning execution-ledger.mjs
|
|
12
|
+
* `--evaluate-stop` exactly once (the ledger owns its own counting semantics);
|
|
13
|
+
* - it evaluates the watchdog policy in-process through task-watchdog.mjs's
|
|
14
|
+
* exported `evaluateStopWatchdog` exactly once;
|
|
15
|
+
* - it merges the two evaluator results with a deterministic owner order —
|
|
16
|
+
* fail-closed completion failure > completion block > watchdog block > merged
|
|
17
|
+
* advisory > silent release — and emits AT MOST ONE Stop JSON line;
|
|
18
|
+
* - a lock-scoped once-per-stop guard makes a legacy double-registered Stop
|
|
19
|
+
* (old settings.json still listing both hooks) inert: the second coordinator
|
|
20
|
+
* invocation for the same session inside the dedupe window emits nothing and
|
|
21
|
+
* mutates nothing, so counters still move at most once.
|
|
22
|
+
*
|
|
23
|
+
* Failure posture (security test row):
|
|
24
|
+
* - The completion gate is the fail-closed blocker. If it fails to produce a
|
|
25
|
+
* verdict (missing script, spawn error, non-zero exit, unparseable output,
|
|
26
|
+
* budget overrun) the coordinator BLOCKS fail-closed with a fixed, redacted
|
|
27
|
+
* infrastructure reason — raw failure detail goes to stderr only, never into
|
|
28
|
+
* the parsed decision (mirrors execution-ledger's own crash counter posture).
|
|
29
|
+
* - The watchdog is an advisory lane. Its failure never decides anything; the
|
|
30
|
+
* other evaluator's decision stands and the failure is reported verbatim in
|
|
31
|
+
* the emitted `systemMessage` (stderr carries it too).
|
|
32
|
+
*
|
|
33
|
+
* Exports (independently testable — tests import the module directly):
|
|
34
|
+
* DEDUPE_WINDOW_MS — same-session Stop invocations closer than this are
|
|
35
|
+
* treated as one event (a recovery turn takes >>2s).
|
|
36
|
+
* redactInfrastructureReason — the fixed redacted block reason for a failed
|
|
37
|
+
* fail-closed blocker; never repeats its input.
|
|
38
|
+
* redactDeadlineReason — the fixed redacted block reason for a deadline
|
|
39
|
+
* overrun (budget, not crash — same redaction).
|
|
40
|
+
* emitDeadlineBlock — the deadline path's single fail-closed emission.
|
|
41
|
+
* normalizeEvaluatorStdout — ledger stdout → { kind, reason?, systemMessage? }.
|
|
42
|
+
* coordinateStopDecisions — the pure ordered merge; owns ordering + provenance
|
|
43
|
+
* only (evaluators stay independently testable).
|
|
44
|
+
* evaluateCompletionPolicy — spawns the ledger once, returns the normalized
|
|
45
|
+
* completion evaluator result (throws on failure).
|
|
46
|
+
* runStopCoordinator — event guard + dedupe + both evaluators + merge.
|
|
47
|
+
*
|
|
48
|
+
* Like every UKit hook runtime this fails loud and stays inside the hook deadline:
|
|
49
|
+
* a self-kill timer honours UKIT_HOOK_DEADLINE_MS (default 3000ms), the ledger child
|
|
50
|
+
* gets a bounded budget, and every path exits 0.
|
|
51
|
+
*
|
|
52
|
+
* Deadline posture (fix round 1 — reviewer critical): the deadline is the LAST line
|
|
53
|
+
* of defence for the fail-closed blocker, so it must not be a silent exit. The
|
|
54
|
+
* wrapper (`completion-gate.sh`) reads "exit 0 with empty stdout" as a clean release,
|
|
55
|
+
* which meant a coordinator that hit its own deadline while the completion evaluator
|
|
56
|
+
* hung RELEASED Stop instead of blocking it. The deadline now emits the same fixed
|
|
57
|
+
* redacted fail-closed block (synchronously, so process.exit cannot drop a buffered
|
|
58
|
+
* pipe write) and reaps any in-flight evaluator child before exiting.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import fs from 'node:fs/promises';
|
|
62
|
+
import fsSync from 'node:fs';
|
|
63
|
+
import path from 'node:path';
|
|
64
|
+
import { spawn } from 'node:child_process';
|
|
65
|
+
|
|
66
|
+
import { withAsyncLock } from './async-lock.mjs';
|
|
67
|
+
|
|
68
|
+
// Orphan-leak guard (2.4.1 / TASK-025): completion-gate.sh spawns this as
|
|
69
|
+
// `node "$SCRIPT"`, so a host that kills the bash wrapper would reparent a
|
|
70
|
+
// wedged coordinator to launchd. The env-gated arm is hook-only — CLI/tests
|
|
71
|
+
// never set UKIT_HOOK_DEADLINE_MS, so this stays inert there.
|
|
72
|
+
//
|
|
73
|
+
// A hung completion evaluator keeps the event loop alive, which is exactly when this
|
|
74
|
+
// timer fires — and firing it without a decision used to hand the wrapper a silent
|
|
75
|
+
// release. The canonical self-kill line below is kept verbatim (the shipped-hook
|
|
76
|
+
// watchdog gate in tests/consistency/hookWatchdogCoverage.test.js enforces it), so the
|
|
77
|
+
// fail-closed block is emitted from a synchronous `exit` handler registered alongside
|
|
78
|
+
// it: `process.exit` discards any write that comes after it, but `exit` listeners run
|
|
79
|
+
// before the process actually terminates.
|
|
80
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
81
|
+
|
|
82
|
+
// Set by a timer scheduled 1ms ahead of the canonical self-kill. The exit handler uses
|
|
83
|
+
// it to tell a genuine deadline exit apart from a naturally-finished-but-slow run: only
|
|
84
|
+
// the former is a budget overrun of the fail-closed blocker and must block. Without this
|
|
85
|
+
// distinction a slow (but healthy) evaluation that happened to finish just past the
|
|
86
|
+
// budget would be blocked instead of released.
|
|
87
|
+
let deadlineReached = false;
|
|
88
|
+
|
|
89
|
+
// Evaluator children still running. The deadline path SIGKILLs them before exiting: a
|
|
90
|
+
// `process.exit(0)` that abandons a spawned ledger would reparent it to launchd, the
|
|
91
|
+
// same orphan class the deadline exists to prevent.
|
|
92
|
+
const liveEvaluatorChildren = new Set();
|
|
93
|
+
|
|
94
|
+
// Set once a decision has left this process (deadline block or normal main() output).
|
|
95
|
+
// Idempotence matters because the deadline may fire after a normal emission, and two
|
|
96
|
+
// JSON lines on one Stop would be two decisions — the exact thing this module removes.
|
|
97
|
+
let decisionEmitted = false;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The ONLY reason text the deadline path may put on stdout. Fixed string: no
|
|
101
|
+
* interpolation, no error messages, no paths. Distinct from
|
|
102
|
+
* `redactInfrastructureReason()` only so telemetry can tell a budget overrun from an
|
|
103
|
+
* evaluator crash without weakening redaction.
|
|
104
|
+
*/
|
|
105
|
+
export function redactDeadlineReason() {
|
|
106
|
+
return 'UKit stop coordinator: this stop exceeded the hook deadline before the fail-closed completion evaluator could decide, so the stop is blocked fail-closed (details withheld). Run: ukit install, then re-send the task in a new message.';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Emit exactly one JSON decision line, synchronously, at most once per process.
|
|
111
|
+
* `fs.writeSync` on fd 1 is required on BOTH paths: stdout is a pipe here, and a
|
|
112
|
+
* `process.exit()` — whether from the deadline timer or the natural end of main() —
|
|
113
|
+
* discards a buffered async write. An async write that the deadline won the race for
|
|
114
|
+
* would leave stdout empty, restoring the silent release this module exists to prevent.
|
|
115
|
+
*/
|
|
116
|
+
function writeDecisionLine(payload) {
|
|
117
|
+
if (decisionEmitted) return false;
|
|
118
|
+
decisionEmitted = true;
|
|
119
|
+
try {
|
|
120
|
+
fsSync.writeSync(1, `${JSON.stringify(payload)}\n`);
|
|
121
|
+
} catch {}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Emit the fixed fail-closed block for the deadline path, exactly once, synchronously.
|
|
127
|
+
* Exported so the deadline behaviour is directly testable.
|
|
128
|
+
*/
|
|
129
|
+
export function emitDeadlineBlock() {
|
|
130
|
+
return writeDecisionLine({
|
|
131
|
+
decision: 'block',
|
|
132
|
+
reason: redactDeadlineReason(),
|
|
133
|
+
policyOwner: 'fail-closed',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
138
|
+
process.on('exit', () => {
|
|
139
|
+
// A run that finished inside its budget already emitted its decision (or decided to
|
|
140
|
+
// release) — main() owns that path. Only a run the deadline actually cut short is a
|
|
141
|
+
// budget overrun of the fail-closed blocker, and only that run blocks.
|
|
142
|
+
if (!deadlineReached) return;
|
|
143
|
+
emitDeadlineBlock();
|
|
144
|
+
for (const child of liveEvaluatorChildren) {
|
|
145
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// Flipped strictly before the canonical self-kill below, so the exit handler sees it.
|
|
149
|
+
setTimeout(() => { deadlineReached = true; }, Math.max(0, HOOK_DEADLINE_MS - 1)).unref();
|
|
150
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Two hook scripts registered on the same Stop fire within milliseconds of each
|
|
154
|
+
// other; the shortest real recovery turn is many seconds. 2s only ever catches
|
|
155
|
+
// same-event double registration, never legitimate consecutive Stops.
|
|
156
|
+
export const DEDUPE_WINDOW_MS = 2000;
|
|
157
|
+
|
|
158
|
+
// Fixed budget slices inside the default 3000ms hook deadline: the ledger child
|
|
159
|
+
// gets the larger slice, each watchdog lock acquisition gets a short one.
|
|
160
|
+
const LEDGER_BUDGET_MS = 1500;
|
|
161
|
+
const LOCK_BUDGET_MS = 600;
|
|
162
|
+
|
|
163
|
+
const WATCHDOG_PROVENANCE_PREFIX = '[ukit-stop-coordinator] task-watchdog also requested a block: ';
|
|
164
|
+
const WATCHDOG_FAILURE_PREFIX = '[ukit-stop-coordinator] task-watchdog evaluator failed (advisory lane, reported verbatim): ';
|
|
165
|
+
|
|
166
|
+
// ─── Redaction ────────────────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* The ONLY reason text a failed fail-closed blocker may put on stdout. Fixed
|
|
169
|
+
* string: no interpolation, no error messages, no paths — raw detail stays on
|
|
170
|
+
* stderr where hook telemetry can see it but the parsed decision cannot leak it.
|
|
171
|
+
*/
|
|
172
|
+
export function redactInfrastructureReason() {
|
|
173
|
+
return 'UKit stop coordinator: the fail-closed completion evaluator failed while this stop was being evaluated, so the stop is blocked fail-closed (failure details withheld). Run: ukit install, then re-send the task in a new message.';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Normalization ────────────────────────────────────────────────────────
|
|
177
|
+
/**
|
|
178
|
+
* Parse one evaluator's stdout into a normalized result.
|
|
179
|
+
* { kind: 'block', reason } | { kind: 'advisory', systemMessage } | { kind: 'complete' }
|
|
180
|
+
* Empty stdout (exit 0) is the ledger's silent-success shape. Unparseable output
|
|
181
|
+
* from an exit-0 evaluator is an infrastructure failure: throw, caller decides.
|
|
182
|
+
*/
|
|
183
|
+
export function normalizeEvaluatorStdout(stdout) {
|
|
184
|
+
const line = String(stdout || '')
|
|
185
|
+
.split('\n')
|
|
186
|
+
.map((s) => s.trim())
|
|
187
|
+
.find(Boolean);
|
|
188
|
+
if (!line) return { kind: 'complete' };
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(line);
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error(`completion evaluator emitted unparseable output: ${line.slice(0, 120)}`);
|
|
194
|
+
}
|
|
195
|
+
if (parsed && parsed.decision === 'block') {
|
|
196
|
+
return {
|
|
197
|
+
kind: 'block',
|
|
198
|
+
reason: typeof parsed.reason === 'string' && parsed.reason
|
|
199
|
+
? parsed.reason
|
|
200
|
+
: 'UKit completion gate: blocked this stop without a reason.',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (parsed && typeof parsed.systemMessage === 'string' && parsed.systemMessage) {
|
|
204
|
+
return { kind: 'advisory', systemMessage: parsed.systemMessage };
|
|
205
|
+
}
|
|
206
|
+
return { kind: 'complete' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── Ordered decision merge (pure) ────────────────────────────────────────
|
|
210
|
+
/**
|
|
211
|
+
* Merge the two evaluator results into AT MOST ONE Stop decision.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} [input]
|
|
214
|
+
* @param {{ kind: 'block'|'advisory'|'complete'|'none', reason?: string, systemMessage?: string } | null} [input.completion]
|
|
215
|
+
* @param {{ kind: 'block'|'advisory'|'none', reason?: string, systemMessage?: string } | null} [input.watchdog]
|
|
216
|
+
* @param {{ completion?: string, watchdog?: string }} [input.failures] verbatim failure messages
|
|
217
|
+
* @returns {{ decision: 'block'|null, reason?: string, systemMessage?: string,
|
|
218
|
+
* policyOwner: 'fail-closed'|'completion-gate'|'task-watchdog'|'none',
|
|
219
|
+
* policies: string[] }}
|
|
220
|
+
* Deterministic owner order: fail-closed completion failure > completion block >
|
|
221
|
+
* watchdog block > merged advisory > silent. Reasons keep policy provenance (the
|
|
222
|
+
* losing block's reason travels inside the winning reason; losing advisories and
|
|
223
|
+
* advisory-lane failures travel in the single systemMessage) — one decision, no
|
|
224
|
+
* duplicate prompts.
|
|
225
|
+
*/
|
|
226
|
+
export function coordinateStopDecisions({ completion = null, watchdog = null, failures = {} } = {}) {
|
|
227
|
+
const completionFailed = typeof failures?.completion === 'string' && failures.completion.length > 0;
|
|
228
|
+
const watchdogFailed = typeof failures?.watchdog === 'string' && failures.watchdog.length > 0;
|
|
229
|
+
const watchdogFailureNote = watchdogFailed
|
|
230
|
+
? `${WATCHDOG_FAILURE_PREFIX}${failures.watchdog}`
|
|
231
|
+
: null;
|
|
232
|
+
const watchdogAdvisory = watchdog?.kind === 'advisory' ? watchdog.systemMessage : null;
|
|
233
|
+
const policies = [];
|
|
234
|
+
|
|
235
|
+
// 1. The fail-closed blocker itself failed → block, redacted, detail on stderr only.
|
|
236
|
+
if (completionFailed) {
|
|
237
|
+
policies.push('completion-gate');
|
|
238
|
+
let reason = redactInfrastructureReason();
|
|
239
|
+
if (watchdog?.kind === 'block') {
|
|
240
|
+
policies.push('task-watchdog');
|
|
241
|
+
reason += `\n${WATCHDOG_PROVENANCE_PREFIX}${watchdog.reason}`;
|
|
242
|
+
}
|
|
243
|
+
const notes = [watchdogFailureNote, watchdogAdvisory].filter(Boolean);
|
|
244
|
+
return {
|
|
245
|
+
decision: 'block',
|
|
246
|
+
reason,
|
|
247
|
+
systemMessage: notes.length > 0 ? notes.join('\n') : undefined,
|
|
248
|
+
policyOwner: 'fail-closed',
|
|
249
|
+
policies,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 2. Completion block → completion owns continuation semantics (evidence/caps).
|
|
254
|
+
if (completion?.kind === 'block') {
|
|
255
|
+
policies.push('completion-gate');
|
|
256
|
+
let reason = completion.reason;
|
|
257
|
+
if (watchdog?.kind === 'block') {
|
|
258
|
+
policies.push('task-watchdog');
|
|
259
|
+
reason += `\n${WATCHDOG_PROVENANCE_PREFIX}${watchdog.reason}`;
|
|
260
|
+
}
|
|
261
|
+
const notes = [watchdogFailureNote, watchdogAdvisory].filter(Boolean);
|
|
262
|
+
return {
|
|
263
|
+
decision: 'block',
|
|
264
|
+
reason,
|
|
265
|
+
systemMessage: notes.length > 0 ? notes.join('\n') : undefined,
|
|
266
|
+
policyOwner: 'completion-gate',
|
|
267
|
+
policies,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 3. Watchdog block → watchdog owns this stop; completion advisory rides along.
|
|
272
|
+
if (watchdog?.kind === 'block') {
|
|
273
|
+
policies.push('task-watchdog');
|
|
274
|
+
if (completion?.kind === 'advisory') policies.push('completion-gate');
|
|
275
|
+
const notes = [completion?.systemMessage, watchdogFailureNote].filter(Boolean);
|
|
276
|
+
return {
|
|
277
|
+
decision: 'block',
|
|
278
|
+
reason: watchdog.reason,
|
|
279
|
+
systemMessage: notes.length > 0 ? notes.join('\n') : undefined,
|
|
280
|
+
policyOwner: 'task-watchdog',
|
|
281
|
+
policies,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 4. No block → merge advisories + verbatim failures into ONE systemMessage, or stay silent.
|
|
286
|
+
if (completion?.kind === 'advisory') policies.push('completion-gate');
|
|
287
|
+
if (watchdog?.kind === 'advisory') policies.push('task-watchdog');
|
|
288
|
+
const notes = [completion?.systemMessage, watchdogAdvisory, watchdogFailureNote].filter(Boolean);
|
|
289
|
+
return {
|
|
290
|
+
decision: null,
|
|
291
|
+
reason: undefined,
|
|
292
|
+
systemMessage: notes.length > 0 ? notes.join('\n') : undefined,
|
|
293
|
+
policyOwner: 'none',
|
|
294
|
+
policies,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Completion evaluator (spawn once) ────────────────────────────────────
|
|
299
|
+
function runEvaluatorChild(scriptPath, input, budgetMs) {
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
const child = spawn(process.execPath, [scriptPath, '--evaluate-stop'], {
|
|
302
|
+
// The child gets the same environment plus its own (smaller) self-kill budget.
|
|
303
|
+
env: { ...process.env, UKIT_HOOK_DEADLINE_MS: String(Math.max(250, budgetMs)) },
|
|
304
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
305
|
+
});
|
|
306
|
+
// Tracked so the hook-deadline path can reap a still-running evaluator instead of
|
|
307
|
+
// orphaning it to launchd when it exits.
|
|
308
|
+
liveEvaluatorChildren.add(child);
|
|
309
|
+
let stdout = '';
|
|
310
|
+
let stderr = '';
|
|
311
|
+
let settled = false;
|
|
312
|
+
let timedOut = false;
|
|
313
|
+
const timer = setTimeout(() => {
|
|
314
|
+
timedOut = true;
|
|
315
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
316
|
+
}, budgetMs);
|
|
317
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
318
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
319
|
+
child.stdin.on('error', () => {}); // a stub evaluator that never reads stdin must not crash us
|
|
320
|
+
child.on('error', (error) => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
liveEvaluatorChildren.delete(child);
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
reject(error);
|
|
326
|
+
});
|
|
327
|
+
child.on('close', (code) => {
|
|
328
|
+
if (settled) return;
|
|
329
|
+
settled = true;
|
|
330
|
+
liveEvaluatorChildren.delete(child);
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
if (timedOut) {
|
|
333
|
+
reject(new Error(`completion evaluator exceeded its ${budgetMs}ms budget and was killed`));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (code !== 0) {
|
|
337
|
+
reject(new Error(`completion evaluator exited with status ${code}: ${stderr.trim().slice(-400) || 'no stderr'}`));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
resolve({ stdout, stderr });
|
|
341
|
+
});
|
|
342
|
+
child.stdin.end(input);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Run the completion policy exactly once by spawning execution-ledger.mjs
|
|
348
|
+
* `--evaluate-stop`. Throws on any infrastructure failure — the coordinator
|
|
349
|
+
* turns that into the fail-closed redacted block.
|
|
350
|
+
*/
|
|
351
|
+
export async function evaluateCompletionPolicy({ projectRoot, rawInput, budgetMs = LEDGER_BUDGET_MS }) {
|
|
352
|
+
const ledgerPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
|
|
353
|
+
try {
|
|
354
|
+
await fs.access(ledgerPath);
|
|
355
|
+
} catch {
|
|
356
|
+
throw new Error('completion evaluator unavailable: execution-ledger.mjs is missing (run: ukit install)');
|
|
357
|
+
}
|
|
358
|
+
const { stdout } = await runEvaluatorChild(ledgerPath, String(rawInput || ''), budgetMs);
|
|
359
|
+
return normalizeEvaluatorStdout(stdout);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Once-per-stop guard ──────────────────────────────────────────────────
|
|
363
|
+
/**
|
|
364
|
+
* Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
|
|
365
|
+
* invocation is a same-session duplicate inside the window. Fail-open — an
|
|
366
|
+
* unreadable/unlockable dedupe state must never disable the Stop coordinator.
|
|
367
|
+
*/
|
|
368
|
+
async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
|
|
369
|
+
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
370
|
+
try {
|
|
371
|
+
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
|
|
372
|
+
let current = { lastStop: null };
|
|
373
|
+
try {
|
|
374
|
+
current = JSON.parse(await fs.readFile(statePath, 'utf8')) || current;
|
|
375
|
+
} catch {}
|
|
376
|
+
const last = current.lastStop;
|
|
377
|
+
const lastTs = Number(last?.ts);
|
|
378
|
+
if (last && last.key === sessionKey && Number.isFinite(lastTs) && now - lastTs >= 0 && now - lastTs < dedupeWindowMs) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
382
|
+
await fs.writeFile(statePath, `${JSON.stringify({ lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
|
|
383
|
+
return false;
|
|
384
|
+
});
|
|
385
|
+
return outcome.ok ? outcome.value === true : false;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─── Orchestration ────────────────────────────────────────────────────────
|
|
392
|
+
/**
|
|
393
|
+
* One coordinator invocation: event guard → once-per-stop dedupe → completion
|
|
394
|
+
* evaluator (once) → watchdog evaluator (once) → ordered merge. Returns
|
|
395
|
+
* `{ skip, merged }`; `skip` is a reason string when nothing may be emitted.
|
|
396
|
+
*/
|
|
397
|
+
export async function runStopCoordinator({
|
|
398
|
+
projectRoot,
|
|
399
|
+
payload = {},
|
|
400
|
+
rawInput = '',
|
|
401
|
+
now = Date.now(),
|
|
402
|
+
dedupeWindowMs = DEDUPE_WINDOW_MS,
|
|
403
|
+
lockBudgetMs = LOCK_BUDGET_MS,
|
|
404
|
+
ledgerBudgetMs = LEDGER_BUDGET_MS,
|
|
405
|
+
} = {}) {
|
|
406
|
+
const event = payload?.hook_event_name;
|
|
407
|
+
if (typeof event === 'string' && event && event !== 'Stop') {
|
|
408
|
+
return { skip: `ignored event: ${event}`, merged: null };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const sessionKey = typeof payload?.session_id === 'string' && payload.session_id
|
|
412
|
+
? payload.session_id
|
|
413
|
+
: (typeof payload?.transcript_path === 'string' && payload.transcript_path ? payload.transcript_path : 'no-session');
|
|
414
|
+
if (await alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs })) {
|
|
415
|
+
return { skip: 'duplicate Stop invocation for this session inside the dedupe window', merged: null };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let completion = null;
|
|
419
|
+
const failures = {};
|
|
420
|
+
try {
|
|
421
|
+
completion = await evaluateCompletionPolicy({ projectRoot, rawInput, budgetMs: ledgerBudgetMs });
|
|
422
|
+
} catch (error) {
|
|
423
|
+
failures.completion = error?.message || String(error);
|
|
424
|
+
// Raw detail is loud on stderr; the parsed decision stays redacted.
|
|
425
|
+
process.stderr.write(`[ukit-stop-coordinator] completion evaluator failed: ${failures.completion}\n`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let watchdog = null;
|
|
429
|
+
try {
|
|
430
|
+
const watchdogRuntime = await import(new URL('./task-watchdog.mjs', import.meta.url).href);
|
|
431
|
+
watchdog = await watchdogRuntime.evaluateStopWatchdog({ projectRoot, now, lockBudgetMs });
|
|
432
|
+
} catch (error) {
|
|
433
|
+
failures.watchdog = error?.message || String(error);
|
|
434
|
+
process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return { skip: null, merged: coordinateStopDecisions({ completion, watchdog, failures }) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────
|
|
441
|
+
async function readStdin() {
|
|
442
|
+
if (process.stdin.isTTY) return '';
|
|
443
|
+
const chunks = [];
|
|
444
|
+
for await (const chunk of process.stdin) chunks.push(String(chunk));
|
|
445
|
+
return chunks.join('');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function main() {
|
|
449
|
+
// The self-deadline is armed once at module load (env-gated). A bare CLI run
|
|
450
|
+
// leaves it off, matching execution-ledger.mjs's posture.
|
|
451
|
+
const rawInput = await readStdin();
|
|
452
|
+
let payload = {};
|
|
453
|
+
try {
|
|
454
|
+
payload = JSON.parse(rawInput || '{}') || {};
|
|
455
|
+
} catch {
|
|
456
|
+
payload = {}; // malformed payload: forward the raw text to the ledger, which owns crash counting
|
|
457
|
+
}
|
|
458
|
+
const projectRoot = process.env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
|
|
459
|
+
const { skip, merged } = await runStopCoordinator({ projectRoot, payload, rawInput, now: Date.now() });
|
|
460
|
+
if (skip || !merged) return;
|
|
461
|
+
|
|
462
|
+
// Synchronous writes through the shared emission gate: the deadline timer can fire
|
|
463
|
+
// between any two turns of the event loop, and an already-written decision must never
|
|
464
|
+
// be followed by a second (deadline) block on the same Stop.
|
|
465
|
+
if (merged.decision === 'block') {
|
|
466
|
+
// One JSON line: the single decision, its provenance, and — when an advisory
|
|
467
|
+
// lane failed or has news — the verbatim note. undefined fields are omitted.
|
|
468
|
+
writeDecisionLine({
|
|
469
|
+
decision: 'block',
|
|
470
|
+
reason: merged.reason,
|
|
471
|
+
systemMessage: merged.systemMessage,
|
|
472
|
+
policyOwner: merged.policyOwner,
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (merged.systemMessage) {
|
|
477
|
+
writeDecisionLine({ systemMessage: merged.systemMessage, policyOwner: merged.policyOwner });
|
|
478
|
+
}
|
|
479
|
+
// Neither → silent release: both policies decided "complete".
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Compare real paths (same shape as execution-ledger.mjs): a project under a
|
|
483
|
+
// symlinked root makes import.meta.url resolve to the real path while argv[1]
|
|
484
|
+
// keeps the symlinked spelling. Importing this module must never run main().
|
|
485
|
+
function isDirectRun() {
|
|
486
|
+
const argvPath = process.argv[1];
|
|
487
|
+
if (!argvPath) return false;
|
|
488
|
+
const selfPath = path.resolve(argvPath);
|
|
489
|
+
let selfUrlPath = null;
|
|
490
|
+
try {
|
|
491
|
+
selfUrlPath = new URL(import.meta.url).pathname;
|
|
492
|
+
} catch {
|
|
493
|
+
selfUrlPath = null;
|
|
494
|
+
}
|
|
495
|
+
if (selfUrlPath && selfUrlPath === selfPath) return true;
|
|
496
|
+
try {
|
|
497
|
+
return fsSync.realpathSync(selfUrlPath) === fsSync.realpathSync(selfPath);
|
|
498
|
+
} catch {
|
|
499
|
+
return selfUrlPath === selfPath;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (isDirectRun()) {
|
|
504
|
+
main().catch((error) => {
|
|
505
|
+
// Last-resort failure INSIDE the coordinator: still fail-closed, still redacted.
|
|
506
|
+
process.stderr.write(`[ukit-stop-coordinator] ${error?.message || error}\n`);
|
|
507
|
+
writeDecisionLine({ decision: 'block', reason: redactInfrastructureReason() });
|
|
508
|
+
});
|
|
509
|
+
}
|