@ngockhoale/ukit 2.4.1 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/manifests/platform.full.yaml +19 -111
  3. package/package.json +2 -1
  4. package/scripts/index/refresh-index.mjs +48 -18
  5. package/src/cli/commands/doctor.js +59 -2
  6. package/src/core/compact/threshold.js +36 -6
  7. package/src/core/gatewayProbe.js +143 -15
  8. package/src/core/gatewayResilienceEnv.js +136 -7
  9. package/src/diagnostics/classifyHang.js +246 -0
  10. package/src/index/buildIndex.js +1096 -75
  11. package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
  12. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  13. package/templates/.claude/hooks/block-dangerous.sh +46 -1
  14. package/templates/.claude/hooks/completion-gate.sh +65 -7
  15. package/templates/.claude/hooks/compress-output.sh +49 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
  17. package/templates/.claude/hooks/context-window-guard.sh +204 -71
  18. package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
  19. package/templates/.claude/hooks/handoff-resume.sh +47 -3
  20. package/templates/.claude/hooks/post-edit-verify.sh +45 -2
  21. package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
  22. package/templates/.claude/hooks/protect-files.sh +46 -1
  23. package/templates/.claude/hooks/record-execution.sh +46 -2
  24. package/templates/.claude/hooks/reinject-context.sh +1 -1
  25. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  26. package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
  27. package/templates/.claude/hooks/skill-router.sh +59 -5
  28. package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
  29. package/templates/.claude/hooks/task-watchdog.sh +129 -126
  30. package/templates/.claude/hooks/verification-guard.sh +136 -106
  31. package/templates/.claude/hooks/vision-router.sh +138 -18
  32. package/templates/.claude/settings.json +0 -5
  33. package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
  34. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  35. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  36. package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
  37. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  38. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  40. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  41. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  42. package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
  43. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  44. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  45. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  46. package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
  47. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  48. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  49. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  51. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  52. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  53. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  54. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  55. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
  56. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -0,0 +1,60 @@
1
+ # hook-telemetry.sh — advisory direct-hook latency telemetry (TASK-019).
2
+ #
3
+ # Sourced by hook wrappers right after hook-input.sh, INSIDE the successful
4
+ # source branch. Arming is free: no clock is read here — the staged payload
5
+ # file's mtime is the envelope start marker, and hook-telemetry.mjs derives
6
+ # elapsedMs from it at finish time.
7
+ #
8
+ # The finish marker flows through the shared runner exit path: hook-input.sh's
9
+ # ukit_cleanup_hook_input (already the single EXIT trap target in every
10
+ # wrapper) calls ukit_hook_telemetry_finish BEFORE deleting the staged
11
+ # payload. Finish spawns at most one bounded node process, discards all output,
12
+ # and never changes the hook's exit status or verdict.
13
+ #
14
+ # Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
15
+ # source this file with `|| true` and the armed flag stays unset.
16
+
17
+ UKIT_TEL_ARMED=1
18
+ UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
19
+ UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+
21
+ ukit_hook_telemetry_finish() {
22
+ # The hook's exit status arrives via UKIT_TEL_RC (ukit_cleanup_hook_input
23
+ # captured it before its own conditionals could reset $?); $? is only the
24
+ # fallback for direct debug invocation.
25
+ local rc="${UKIT_TEL_RC:-$?}"
26
+ command -v node >/dev/null 2>&1 || return 0
27
+ # UKIT_INPUT_FILE is a plain shell variable (never exported), so the staged
28
+ # payload path — and through it the envelope start mtime — is passed here
29
+ # explicitly, exactly like the wrappers pass it to their own node heredocs.
30
+ # The telemetry child is advisory and must never make the hook's EXIT trap
31
+ # unbounded. Use a portable background watchdog rather than `timeout`, which
32
+ # is not available on a stock macOS install. SIGKILL is intentional: the
33
+ # child has no hook verdict to preserve, and a wedged filesystem call may not
34
+ # yield to a graceful signal. The child only writes telemetry, so killing it
35
+ # after the deadline loses at most this row.
36
+ local timeout_ms timeout_s child watchdog
37
+ timeout_ms="${UKIT_HOOK_TELEMETRY_TIMEOUT_MS:-250}"
38
+ case "$timeout_ms" in
39
+ ''|*[!0-9]*) timeout_ms=250 ;;
40
+ esac
41
+ [ "$timeout_ms" -gt 0 ] 2>/dev/null || timeout_ms=250
42
+ [ "$timeout_ms" -le 1000 ] 2>/dev/null || timeout_ms=1000
43
+ printf -v timeout_s '%d.%03d' $((timeout_ms / 1000)) $((timeout_ms % 1000))
44
+
45
+ UKIT_TEL_RC="$rc" \
46
+ UKIT_TEL_HOOK="$UKIT_TEL_HOOK" \
47
+ UKIT_INPUT_FILE="${UKIT_INPUT_FILE:-}" \
48
+ PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-}}" \
49
+ node "$UKIT_TEL_RUNTIME_DIR/hook-telemetry.mjs" --finish </dev/null >/dev/null 2>&1 &
50
+ child=$!
51
+ (
52
+ sleep "$timeout_s" 2>/dev/null
53
+ kill -9 "$child" 2>/dev/null
54
+ ) <&- >/dev/null 2>&1 &
55
+ watchdog=$!
56
+ if wait "$child" 2>/dev/null; then :; fi
57
+ kill "$watchdog" 2>/dev/null || true
58
+ if wait "$watchdog" 2>/dev/null; then :; fi
59
+ return 0
60
+ }
@@ -16,6 +16,14 @@ import {
16
16
  } from './token-utils.mjs';
17
17
  import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
18
18
 
19
+ // Hook-context self-deadline (2.4.1 orphan-leak class): the compress-output hook passes
20
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
21
+ // budget. Non-hook usage never sets it and is never self-killed.
22
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
23
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
24
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
25
+ }
26
+
19
27
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
20
28
  const DEFAULT_MAX_OUTPUT_TOKENS = 180;
21
29
  const DEFAULT_OUTPUT_HISTORY_MAX_ENTRIES = 25;
@@ -19,6 +19,14 @@ import {
19
19
  writeThresholdCompactPlan,
20
20
  } from './compact-threshold.mjs';
21
21
 
22
+ // Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
23
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
24
+ // budget. Non-hook usage never sets it and is never self-killed.
25
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
26
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
27
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
28
+ }
29
+
22
30
  const STATE_TTL_MS = 30 * 60 * 1000;
23
31
  const STOPWORDS = new Set([
24
32
  'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
@@ -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
+ }