@ngockhoale/ukit 2.5.1 → 2.6.2

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 (53) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/manifests/platform.full.yaml +16 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/install.js +49 -2
  5. package/src/cli/commands/update.js +5 -0
  6. package/src/core/output/index.js +77 -0
  7. package/src/core/update.js +36 -5
  8. package/templates/.claude/agents/code-reviewer.md +12 -1
  9. package/templates/.claude/agents/feature-implementer.md +4 -2
  10. package/templates/.claude/agents/handoff-planner.md +36 -1
  11. package/templates/.claude/commands/ukit/handoff-clear.md +4 -0
  12. package/templates/.claude/commands/ukit/handoff-create.md +23 -7
  13. package/templates/.claude/commands/ukit/handoff-fullstack.md +181 -17
  14. package/templates/.claude/commands/ukit/handoff-implement.md +9 -2
  15. package/templates/.claude/commands/ukit/handoff-review.md +4 -1
  16. package/templates/.claude/commands/ukit/handoff-status.md +6 -2
  17. package/templates/.claude/hooks/auto-allow-bash.sh +17 -5
  18. package/templates/.claude/hooks/block-dangerous.sh +18 -5
  19. package/templates/.claude/hooks/completion-gate.sh +17 -5
  20. package/templates/.claude/hooks/compress-output.sh +15 -6
  21. package/templates/.claude/hooks/context-hardcap-gate.sh +77 -46
  22. package/templates/.claude/hooks/context-window-guard.sh +41 -27
  23. package/templates/.claude/hooks/handoff-model-guard.sh +62 -14
  24. package/templates/.claude/hooks/handoff-resume.sh +20 -7
  25. package/templates/.claude/hooks/post-edit-verify.sh +17 -5
  26. package/templates/.claude/hooks/pre-edit-backup.sh +17 -5
  27. package/templates/.claude/hooks/project-important.sh +18 -1
  28. package/templates/.claude/hooks/protect-files.sh +18 -5
  29. package/templates/.claude/hooks/record-execution.sh +17 -5
  30. package/templates/.claude/hooks/sensitive-data-guard.sh +48 -9
  31. package/templates/.claude/hooks/skill-router.sh +124 -86
  32. package/templates/.claude/hooks/stale-spec-guard.sh +22 -6
  33. package/templates/.claude/hooks/task-watchdog.sh +22 -7
  34. package/templates/.claude/hooks/verification-guard.sh +17 -5
  35. package/templates/.claude/hooks/vision-router.sh +17 -5
  36. package/templates/.claude/settings.json +15 -10
  37. package/templates/.claude/ukit/index/provision-worktree.mjs +30 -2
  38. package/templates/.claude/ukit/runtime/execution-ledger.mjs +99 -1
  39. package/templates/.claude/ukit/runtime/hook-input.sh +25 -0
  40. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +86 -2
  41. package/templates/.claude/ukit/runtime/output-compression.mjs +87 -0
  42. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +201 -11
  43. package/templates/.omp/RULES.md +9 -1
  44. package/templates/.omp/agents/code-reviewer.md +12 -1
  45. package/templates/.omp/agents/feature-implementer.md +4 -2
  46. package/templates/.omp/agents/handoff-planner.md +36 -1
  47. package/templates/.omp/hooks/pre/ukit-bridge.js +110 -3
  48. package/templates/AGENTS.md +14 -0
  49. package/templates/CLAUDE.md +14 -0
  50. package/templates/docs/AI_HANDOFF/RULES.md +37 -4
  51. package/templates/docs/AI_HANDOFF/SPEC.md +98 -0
  52. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +4 -1
  53. package/templates/ukit/storage/config.json +48 -9
@@ -39,7 +39,15 @@ if source "$HOOK_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
39
39
  source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
40
40
  trap ukit_cleanup_hook_input EXIT
41
41
  # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
42
- ukit_stage_hook_input 2097152 truncate || exit 0
42
+ ukit_stage_hook_input 2097152 truncate
43
+ __ukit_main_stage_rc=$?
44
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
45
+ # Infra failure during staging (mktemp/truncate): payload was never
46
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
47
+ ukit_emit_input_degraded failclosed "context hard-cap gate"
48
+ fi
49
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
50
+ ukit_input_degraded && ukit_emit_input_degraded failclosed "context hard-cap gate"
43
51
  else
44
52
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
45
53
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -66,10 +74,15 @@ else
66
74
  wait "$__ukit_waiter" 2>/dev/null
67
75
  exec 8<&-
68
76
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
69
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
70
- # already degrades on - the shared helper's truncate posture (R4.5).
71
- if [ "$__ukit_size" -gt 2097152 ]; then
72
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
77
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
78
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
79
+ # must be announced, never silently acted on — same shape as the helper
80
+ # path's ukit_emit_input_degraded.
81
+ rm -f "$UKIT_INPUT_FILE"
82
+ UKIT_INPUT_FILE=""
83
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe. UKit defers this to a human decision."}}'
84
+ echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; deferred to human." >&2
85
+ exit 2
73
86
  fi
74
87
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
75
88
  fi
@@ -78,23 +91,12 @@ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR"
78
91
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
79
92
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
80
93
  const fs = require('fs');
94
+ const fsp = fs.promises;
81
95
  const path = require('path');
82
96
  const { pathToFileURL } = require('url');
83
97
 
84
- let rawInput = '';
85
- try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
86
- const payload = (() => {
87
- try {
88
- const parsed = JSON.parse(rawInput);
89
- return parsed && typeof parsed === 'object' ? parsed : {};
90
- } catch {
91
- return {};
92
- }
93
- })();
94
-
95
98
  const projectRoot = process.env.PROJECT_ROOT;
96
99
  const hookDir = process.env.HOOK_DIR;
97
- const toolName = payload?.tool_name || '';
98
100
 
99
101
  // Gate only mutating/costly tools. Everything else (Read, Grep, Glob, TodoWrite, ...)
100
102
  // stays free so the agent can still respond and tell the user to compact.
@@ -102,25 +104,34 @@ const toolName = payload?.tool_name || '';
102
104
  // matches NotebookEdit/MultiEdit -- an exact !== comparison would let those slip through
103
105
  // the gate while still firing the hook.
104
106
  const GATED_TOOLS = new Set(['Edit', 'Write', 'Bash', 'NotebookEdit', 'MultiEdit']);
105
- if (!GATED_TOOLS.has(toolName)) {
106
- process.exit(0);
107
- }
108
107
 
109
- function readJsonSafe(filePath, fallback = null) {
108
+ // Every fs access below is async on purpose: a synchronous read on a stalled mount
109
+ // parks the event loop and the unref'd self-deadline above can never fire (C21-04).
110
+ async function readJsonSafe(filePath, fallback = null) {
110
111
  try {
111
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
112
+ return JSON.parse(await fsp.readFile(filePath, 'utf8'));
112
113
  } catch {
113
114
  return fallback;
114
115
  }
115
116
  }
116
117
 
118
+ async function pathExists(filePath) {
119
+ try {
120
+ await fsp.access(filePath);
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
117
127
  // An unfinished handoff run — same cursor file the resume hook and the advisory
118
- // context guard read. `Phase: done` (or no file) means there is nothing in flight.
119
- function readRunCursor() {
128
+ // context guard read. `Phase: done` and `Phase: blocked` are both terminal (the
129
+ // same posture stop-coordinator.mjs uses); only other phases mean a run is in flight.
130
+ async function readRunCursor() {
120
131
  try {
121
- const text = fs.readFileSync(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
132
+ const text = await fsp.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
122
133
  const runPhase = (text.match(/^Phase:\s*(.+)$/m)?.[1] || '').trim();
123
- if (!runPhase || /^done\b/i.test(runPhase)) return null;
134
+ if (!runPhase || /^(done|blocked)\b/i.test(runPhase)) return null;
124
135
  return {
125
136
  phase: runPhase,
126
137
  cursor: (text.match(/^Cursor:\s*(.+)$/m)?.[1] || '').trim(),
@@ -131,7 +142,22 @@ function readRunCursor() {
131
142
  }
132
143
 
133
144
  (async () => {
134
- const config = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), {}) || {};
145
+ let rawInput = '';
146
+ try { rawInput = await fsp.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
147
+ const payload = (() => {
148
+ try {
149
+ const parsed = JSON.parse(rawInput);
150
+ return parsed && typeof parsed === 'object' ? parsed : {};
151
+ } catch {
152
+ return {};
153
+ }
154
+ })();
155
+ const toolName = payload?.tool_name || '';
156
+ if (!GATED_TOOLS.has(toolName)) {
157
+ process.exit(0);
158
+ }
159
+
160
+ const config = await readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), {}) || {};
135
161
  // Session-scoped pressure bookkeeping: the gate decides for THIS session's record, so a
136
162
  // sibling session starting (SessionStart reset) or compacting never trips or clears
137
163
  // this gate for the wrong session.
@@ -152,18 +178,18 @@ function readRunCursor() {
152
178
  }
153
179
 
154
180
  const thresholdModulePath = path.join(hookDir, '..', 'ukit', 'runtime', 'compact-threshold.mjs');
155
- if (!fs.existsSync(thresholdModulePath)) {
181
+ if (!(await pathExists(thresholdModulePath))) {
156
182
  process.exit(0);
157
183
  return;
158
184
  }
159
185
 
160
186
  const mod = await import(pathToFileURL(thresholdModulePath).href);
161
187
  const ledgerModulePath = path.join(hookDir, '..', 'ukit', 'runtime', 'execution-ledger.mjs');
162
- const ledgerMod = fs.existsSync(ledgerModulePath)
188
+ const ledgerMod = (await pathExists(ledgerModulePath))
163
189
  ? await import(pathToFileURL(ledgerModulePath).href)
164
190
  : null;
165
191
  const pressurePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'compact-pressure.json');
166
- const rawState = readJsonSafe(pressurePath, null);
192
+ const rawState = await readJsonSafe(pressurePath, null);
167
193
 
168
194
  // A compaction the PreCompact hook missed (SDK/VSCode auto-compact) leaves the tracked
169
195
  // counter counting history that was already summarised away — the exact failure that
@@ -205,9 +231,9 @@ function readRunCursor() {
205
231
  const gracePath = path.join(graceDir, graceBaseName);
206
232
  const slotPath = (n) => path.join(graceDir, `${graceBaseName}.${n}`);
207
233
  const slotPattern = new RegExp(`^${graceBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(\\d+)$`);
208
- const readSlots = () => {
234
+ const readSlots = async () => {
209
235
  try {
210
- return fs.readdirSync(graceDir)
236
+ return (await fsp.readdir(graceDir))
211
237
  .map((name) => slotPattern.exec(name))
212
238
  .filter(Boolean)
213
239
  .map((m) => Number(m[1]))
@@ -216,21 +242,21 @@ function readRunCursor() {
216
242
  return [];
217
243
  }
218
244
  };
219
- const clearGrace = () => {
220
- try { fs.rmSync(gracePath, { force: true }); } catch { /* advisory only */ }
221
- for (const n of readSlots()) {
222
- try { fs.rmSync(slotPath(n), { force: true }); } catch { /* advisory only */ }
245
+ const clearGrace = async () => {
246
+ try { await fsp.rm(gracePath, { force: true }); } catch { /* advisory only */ }
247
+ for (const n of await readSlots()) {
248
+ try { await fsp.rm(slotPath(n), { force: true }); } catch { /* advisory only */ }
223
249
  }
224
250
  };
225
251
 
226
252
  if (state.estimatedTotalTokens < thresholds.hardCapTokens) {
227
253
  // Back under the cap — the episode is over, so the next one starts with a full budget.
228
- clearGrace();
254
+ await clearGrace();
229
255
  process.exit(0);
230
256
  return;
231
257
  }
232
258
 
233
- const run = readRunCursor();
259
+ const run = await readRunCursor();
234
260
  let ordinaryTask = null;
235
261
  if (!run && ledgerMod) {
236
262
  try {
@@ -252,10 +278,10 @@ function readRunCursor() {
252
278
  ? configuredGraceCalls
253
279
  : 10;
254
280
 
255
- let slots = readSlots();
281
+ let slots = await readSlots();
256
282
  let startedAtTokens = null;
257
283
  for (const n of slots) {
258
- const data = readJsonSafe(slotPath(n), null);
284
+ const data = await readJsonSafe(slotPath(n), null);
259
285
  if (data && Number.isFinite(data.startedAtTokens)) {
260
286
  startedAtTokens = Math.max(startedAtTokens ?? -Infinity, data.startedAtTokens);
261
287
  }
@@ -264,7 +290,7 @@ function readRunCursor() {
264
290
  // real compaction/shed happened. Advancing the cursor alone must NOT top the budget up,
265
291
  // otherwise a long run gets unlimited grace and the ceiling stops meaning anything.
266
292
  if (startedAtTokens !== null && state.estimatedTotalTokens < startedAtTokens) {
267
- clearGrace();
293
+ await clearGrace();
268
294
  slots = [];
269
295
  startedAtTokens = null;
270
296
  }
@@ -278,12 +304,17 @@ function readRunCursor() {
278
304
  let used = 0;
279
305
  let counterFailed = false;
280
306
  try {
281
- fs.mkdirSync(graceDir, { recursive: true });
307
+ await fsp.mkdir(graceDir, { recursive: true });
282
308
  for (let n = spent + 1; n <= graceCalls; n += 1) {
283
309
  try {
284
- const fd = fs.openSync(slotPath(n), 'wx');
285
- fs.writeSync(fd, JSON.stringify({ startedAtTokens, used: n, ts: Date.now() }));
286
- fs.closeSync(fd);
310
+ // Exclusive-create via 'wx' keeps the EEXIST race semantics of the old
311
+ // openSync('wx') claim: the sentinel exists exactly once per spent call.
312
+ const handle = await fsp.open(slotPath(n), 'wx');
313
+ try {
314
+ await handle.writeFile(JSON.stringify({ startedAtTokens, used: n, ts: Date.now() }));
315
+ } finally {
316
+ await handle.close();
317
+ }
287
318
  used = n;
288
319
  break;
289
320
  } catch (err) {
@@ -297,7 +328,7 @@ function readRunCursor() {
297
328
  if (used === 0 && counterFailed) used = spent + 1;
298
329
  if (used > 0 && used <= graceCalls) {
299
330
  try {
300
- fs.writeFileSync(gracePath, JSON.stringify({ startedAtTokens, used }));
331
+ await fsp.writeFile(gracePath, JSON.stringify({ startedAtTokens, used }));
301
332
  if (ordinaryTask && ledgerMod && used === 1) {
302
333
  await ledgerMod.writeResumeIntent(projectRoot, payload);
303
334
  }
@@ -31,7 +31,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
31
31
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
32
32
  trap ukit_cleanup_hook_input EXIT
33
33
  # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
34
- ukit_stage_hook_input 2097152 truncate || exit 0
34
+ ukit_stage_hook_input 2097152 truncate
35
+ __ukit_main_stage_rc=$?
36
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
37
+ # Infra failure during staging (mktemp/truncate): payload was never
38
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
39
+ ukit_emit_input_degraded advisory "context-window guard"
40
+ fi
41
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
42
+ ukit_input_degraded && ukit_emit_input_degraded advisory "context-window guard"
35
43
  else
36
44
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
37
45
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -58,10 +66,14 @@ else
58
66
  wait "$__ukit_waiter" 2>/dev/null
59
67
  exec 8<&-
60
68
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
61
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
62
- # already degrades on - the shared helper's truncate posture (R4.5).
63
- if [ "$__ukit_size" -gt 2097152 ]; then
64
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
69
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
70
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
71
+ # must be announced, never silently acted on — same shape as the helper
72
+ # path's ukit_emit_input_degraded.
73
+ rm -f "$UKIT_INPUT_FILE"
74
+ UKIT_INPUT_FILE=""
75
+ printf '%s\n' '{"systemMessage":"UKit context-window guard: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
76
+ exit 0
65
77
  fi
66
78
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
67
79
  fi
@@ -92,7 +104,9 @@ import { pathToFileURL } from 'node:url';
92
104
  const CHARS_PER_TOKEN = 4;
93
105
 
94
106
  let rawInput = '';
95
- try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
107
+ // All fs access in this block is async on purpose: a synchronous read on a stalled
108
+ // mount parks the event loop and the unref'd self-deadline above can never fire.
109
+ try { rawInput = await fs.promises.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
96
110
  const payload = (() => {
97
111
  try {
98
112
  const parsed = JSON.parse(rawInput);
@@ -109,7 +123,7 @@ if (!transcriptPath) process.exit(0);
109
123
  // Operator config is read once; a missing/corrupt file just means the defaults.
110
124
  let runtimeConfig = {};
111
125
  try {
112
- runtimeConfig = JSON.parse(fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'));
126
+ runtimeConfig = JSON.parse(await fs.promises.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'));
113
127
  } catch { /* fall through to the defaults */ }
114
128
  if (!runtimeConfig || typeof runtimeConfig !== 'object') runtimeConfig = {};
115
129
 
@@ -247,8 +261,8 @@ const ratio = estimatedTokens / hardCap;
247
261
  const CAPACITY_RECORD_PATH = path.join(projectRoot, '.ukit', 'storage', 'cache', 'context-capacity.json');
248
262
  if (negotiatedCapacity) {
249
263
  try {
250
- fs.mkdirSync(path.dirname(CAPACITY_RECORD_PATH), { recursive: true });
251
- const previous = readCapacityRecord();
264
+ await fs.promises.mkdir(path.dirname(CAPACITY_RECORD_PATH), { recursive: true });
265
+ const previous = await readCapacityRecord();
252
266
  // Only rewrite when the evidence changed, so prompt caching sees a stable file.
253
267
  if (!previous
254
268
  || previous.tokens !== negotiatedCapacity.tokens
@@ -261,14 +275,14 @@ if (negotiatedCapacity) {
261
275
  model: currentModel ?? null,
262
276
  updatedAt: Date.now(),
263
277
  };
264
- fs.writeFileSync(CAPACITY_RECORD_PATH, `${JSON.stringify(record)}\n`, 'utf8');
278
+ await fs.promises.writeFile(CAPACITY_RECORD_PATH, `${JSON.stringify(record)}\n`, 'utf8');
265
279
  }
266
280
  } catch { /* fail open: negotiation is advisory */ }
267
281
  }
268
282
 
269
- function readCapacityRecord() {
283
+ async function readCapacityRecord() {
270
284
  try {
271
- const parsed = JSON.parse(fs.readFileSync(CAPACITY_RECORD_PATH, 'utf8'));
285
+ const parsed = JSON.parse(await fs.promises.readFile(CAPACITY_RECORD_PATH, 'utf8'));
272
286
  return parsed && typeof parsed === 'object' ? parsed : null;
273
287
  } catch {
274
288
  return null;
@@ -278,22 +292,22 @@ function readCapacityRecord() {
278
292
  // Debounce/state file shared by the context warning and the gateway model-swap note.
279
293
  const GUARD_STATE_PATH = path.join(projectRoot, '.ukit', 'storage', 'cache', 'context-guard-state.json');
280
294
 
281
- function readGuardState() {
295
+ async function readGuardState() {
282
296
  try {
283
- return JSON.parse(fs.readFileSync(GUARD_STATE_PATH, 'utf8'));
297
+ return JSON.parse(await fs.promises.readFile(GUARD_STATE_PATH, 'utf8'));
284
298
  } catch {
285
299
  return { lastPhase: null, lastActionAt: 0, lastSwapModel: null, lastSwapNoteAt: 0 };
286
300
  }
287
301
  }
288
302
 
289
- function writeGuardState(state) {
303
+ async function writeGuardState(state) {
290
304
  try {
291
- fs.mkdirSync(path.dirname(GUARD_STATE_PATH), { recursive: true });
292
- fs.writeFileSync(GUARD_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
305
+ await fs.promises.mkdir(path.dirname(GUARD_STATE_PATH), { recursive: true });
306
+ await fs.promises.writeFile(GUARD_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
293
307
  } catch { /* best-effort only; never block the prompt over this */ }
294
308
  }
295
309
 
296
- const guardState = readGuardState();
310
+ const guardState = await readGuardState();
297
311
  let persistedState = { ...guardState };
298
312
 
299
313
  // ── Gateway model-swap note ──
@@ -322,12 +336,12 @@ if (swapDetected && persistedState.lastSwapModel !== currentModel) {
322
336
  const withinSwapCooldown = (Date.now() - Number(persistedState.lastSwapNoteAt || 0)) < SWAP_NOTE_COOLDOWN_MS;
323
337
  if (!withinSwapCooldown) {
324
338
  persistedState.lastSwapNoteAt = Date.now();
325
- writeGuardState(persistedState);
339
+ await writeGuardState(persistedState);
326
340
  process.stdout.write(
327
341
  `UKIT GATEWAY — routine backend model swap ("${currentModel}" after "${previousModel}"): keep working, do not restart or re-plan; if a tool call errors after a swap, re-check the tool (providers differ) and simply call it again — never stop over it.\n`,
328
342
  );
329
343
  } else {
330
- writeGuardState(persistedState);
344
+ await writeGuardState(persistedState);
331
345
  }
332
346
  }
333
347
 
@@ -344,7 +358,7 @@ const boundaryAgeMs = boundaryTs !== null ? Date.now() - boundaryTs : null;
344
358
  const justCompacted = boundaryAgeMs !== null && boundaryAgeMs >= 0 && boundaryAgeMs < POST_COMPACT_WINDOW_MS;
345
359
  if (justCompacted && ratio >= POST_COMPACT_RATIO && persistedState.lastPostCompactBoundary !== String(boundaryTs)) {
346
360
  persistedState.lastPostCompactBoundary = String(boundaryTs);
347
- writeGuardState(persistedState);
361
+ await writeGuardState(persistedState);
348
362
  process.stdout.write(
349
363
  [
350
364
  `UKIT POST-COMPACT CHECK — compaction ~${Math.max(1, Math.round(boundaryAgeMs / 60000))} min ago, but live context is still ~${estimatedTokens.toLocaleString()} tokens (${Math.round(ratio * 100)}% of the ${hardCap.toLocaleString()} cap).`,
@@ -378,11 +392,11 @@ const withinCooldown = guardState.lastPhase === phase
378
392
  // resumable, and a mid-cycle halt is what used to strand a cycle for days. The right
379
393
  // directive during a run is "shed context at the next wave boundary and keep going" —
380
394
  // compaction is requested once, at the cycle boundary, by the command's Final Report.
381
- function readRunCursor() {
395
+ async function readRunCursor() {
382
396
  try {
383
- const text = fs.readFileSync(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
397
+ const text = await fs.promises.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
384
398
  const runPhase = (text.match(/^Phase:\s*(.+)$/m)?.[1] || '').trim();
385
- if (!runPhase || /^done\b/i.test(runPhase)) return null;
399
+ if (!runPhase || /^(done|blocked)\b/i.test(runPhase)) return null;
386
400
  return {
387
401
  phase: runPhase,
388
402
  cursor: (text.match(/^Cursor:\s*(.+)$/m)?.[1] || '').trim(),
@@ -392,7 +406,7 @@ function readRunCursor() {
392
406
  }
393
407
  }
394
408
 
395
- const run = readRunCursor();
409
+ const run = await readRunCursor();
396
410
 
397
411
  const lines_out = [];
398
412
  if (ratio >= 1) {
@@ -418,7 +432,7 @@ if (withinCooldown) {
418
432
  if (sidechainEntries > 0) {
419
433
  lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch. Keep concurrency at or below handoff.maxParallelAgents and keep returns short; do not widen the batch while this warning stands.`);
420
434
  }
421
- writeGuardState({ ...persistedState, lastPhase: phase, lastActionAt: now });
435
+ await writeGuardState({ ...persistedState, lastPhase: phase, lastActionAt: now });
422
436
  } else {
423
437
  lines_out.push('ACTION THIS TURN, before starting new investigation/subagents/pipeline phases:');
424
438
  lines_out.push('1) LAND ONE THING: pick the smallest in-flight item you can finish end-to-end in ≤3 tool calls (edit + verify), complete exactly that one, and report it done. Do not open anything new.');
@@ -428,7 +442,7 @@ if (withinCooldown) {
428
442
  if (sidechainEntries > 0) {
429
443
  lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch — each teammate carries its own context window, and every finished report is injected back here, so running many at once is the fastest way to overflow this session. Avoid spawning more until context drops back under the cap.`);
430
444
  }
431
- writeGuardState({ ...persistedState, lastPhase: phase, lastActionAt: now });
445
+ await writeGuardState({ ...persistedState, lastPhase: phase, lastActionAt: now });
432
446
  }
433
447
 
434
448
  process.stdout.write(`${lines_out.join('\n')}\n`);
@@ -20,7 +20,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
20
20
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
21
21
  trap ukit_cleanup_hook_input EXIT
22
22
  # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
23
- ukit_stage_hook_input 2097152 truncate || exit 0
23
+ ukit_stage_hook_input 2097152 truncate
24
+ __ukit_main_stage_rc=$?
25
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
26
+ # Infra failure during staging (mktemp/truncate): payload was never
27
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
28
+ ukit_emit_input_degraded advisory "handoff model-tier guard"
29
+ fi
30
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
31
+ ukit_input_degraded && ukit_emit_input_degraded advisory "handoff model-tier guard"
24
32
  else
25
33
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
26
34
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -47,10 +55,14 @@ else
47
55
  wait "$__ukit_waiter" 2>/dev/null
48
56
  exec 8<&-
49
57
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
50
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
51
- # already degrades on - the shared helper's truncate posture (R4.5).
52
- if [ "$__ukit_size" -gt 2097152 ]; then
53
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
58
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
59
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
60
+ # must be announced, never silently acted on — same shape as the helper
61
+ # path's ukit_emit_input_degraded.
62
+ rm -f "$UKIT_INPUT_FILE"
63
+ UKIT_INPUT_FILE=""
64
+ printf '%s\n' '{"systemMessage":"UKit handoff model-tier guard: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
65
+ exit 0
54
66
  fi
55
67
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
56
68
  fi
@@ -73,11 +85,22 @@ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
73
85
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
74
86
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
75
87
  const fs = require('fs');
88
+ const fsp = fs.promises;
76
89
  const path = require('path');
77
90
 
91
+ // BUG-C21-04: every fs call in this deadline-guarded block is async — a sync
92
+ // read on a stalled mount parks the event loop and the deadline can never fire.
93
+ void (async () => {
94
+ async function pathExists(filePath) {
95
+ try { await fsp.access(filePath); return true; } catch { return false; }
96
+ }
97
+ async function readTextSafe(filePath) {
98
+ try { return await fsp.readFile(filePath, 'utf8'); } catch { return ''; }
99
+ }
100
+
101
+ let raw = '';
102
+ try { raw = await fsp.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
78
103
  const payload = (() => {
79
- let raw = '';
80
- try { raw = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
81
104
  try { return JSON.parse(raw || '{}'); } catch { return {}; }
82
105
  })();
83
106
  const projectRoot = process.env.PROJECT_ROOT;
@@ -137,8 +160,8 @@ if (toolName === 'Write' || toolName === 'Edit') {
137
160
  const taskMatch = relPath.match(/^docs\/AI_HANDOFF\/tasks\/(TASK-\d+)\.md$/);
138
161
  if (!isPlan && !taskMatch) process.exit(0);
139
162
 
140
- const fileExists = fs.existsSync(filePath);
141
- const currentContent = fileExists ? fs.readFileSync(filePath, 'utf8') : '';
163
+ const fileExists = await pathExists(filePath);
164
+ const currentContent = fileExists ? await readTextSafe(filePath) : '';
142
165
 
143
166
  function resultingContent() {
144
167
  if (toolName === 'Write') {
@@ -176,11 +199,35 @@ if (toolName === 'Write' || toolName === 'Edit') {
176
199
  const hasReviewerVerdict = /## Reviewer Verdict/.test(newVisible);
177
200
  const isFreshTaskFile = !fileExists;
178
201
  if (isFreshTaskFile) {
179
- const planContent = fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
202
+ const planContent = (await pathExists(planPath)) ? await readTextSafe(planPath) : '';
180
203
  const plannerModel = extractField(stripHtmlComments(planContent), 'PLANNER_MODEL');
181
204
  if (!plannerModel || tierOf(plannerModel) !== 'smart') {
182
205
  block(`Cannot create ${taskId}.md — PLAN.md has no valid smart-tier PLANNER_MODEL yet. Run planning via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) first.`);
183
206
  }
207
+
208
+ // Spec gate (handoff-fullstack v2): tasks are decomposed FROM a detailed spec, so a
209
+ // fresh task file requires docs/AI_HANDOFF/SPEC.md to exist and to differ from the
210
+ // shipped template. Disable per project: handoff.fullstack.specRequired=false.
211
+ let specRequired = true;
212
+ try {
213
+ const cfg = JSON.parse(await fsp.readFile(path.join(projectRoot, '.ukit/storage/config.json'), 'utf8'));
214
+ if (cfg?.handoff?.fullstack?.specRequired === false) specRequired = false;
215
+ } catch {}
216
+ if (specRequired) {
217
+ let specOk = false;
218
+ try {
219
+ const specPath = path.join(projectRoot, 'docs/AI_HANDOFF/SPEC.md');
220
+ const specText = await fsp.readFile(specPath, 'utf8');
221
+ let templateText = null;
222
+ try {
223
+ templateText = await fsp.readFile(path.join(projectRoot, 'templates/docs/AI_HANDOFF/SPEC.md'), 'utf8');
224
+ } catch {}
225
+ specOk = specText.length > 500 && specText !== templateText;
226
+ } catch {}
227
+ if (!specOk) {
228
+ block(`Cannot create ${taskId}.md — docs/AI_HANDOFF/SPEC.md is missing or still the untouched template. handoff-create must write a detailed spec (15 sections) before tasks are created.`);
229
+ }
230
+ }
184
231
  }
185
232
 
186
233
  // A section already on disk only counts as validated when its model is real —
@@ -274,17 +321,17 @@ if (toolName === 'Bash') {
274
321
 
275
322
  const activePath = path.join(projectRoot, 'docs/AI_HANDOFF/ACTIVE.md');
276
323
  const indexPath = path.join(projectRoot, 'docs/AI_HANDOFF/INDEX.md');
277
- if (!fs.existsSync(activePath) || !fs.existsSync(indexPath)) process.exit(0); // no handoff cycle here
324
+ if (!(await pathExists(activePath)) || !(await pathExists(indexPath))) process.exit(0); // no handoff cycle here
278
325
 
279
- const taskIds = [...fs.readFileSync(indexPath, 'utf8').matchAll(/TASK-\d+/g)]
326
+ const taskIds = [...(await fsp.readFile(indexPath, 'utf8')).matchAll(/TASK-\d+/g)]
280
327
  .map((m) => m[0])
281
328
  .filter((v, i, a) => a.indexOf(v) === i);
282
329
 
283
330
  const problems = [];
284
331
  for (const taskId of taskIds) {
285
332
  const taskPath = path.join(projectRoot, `docs/AI_HANDOFF/tasks/${taskId}.md`);
286
- if (!fs.existsSync(taskPath)) continue;
287
- const text = stripHtmlComments(fs.readFileSync(taskPath, 'utf8'));
333
+ if (!(await pathExists(taskPath))) continue;
334
+ const text = stripHtmlComments(await fsp.readFile(taskPath, 'utf8'));
288
335
  if (!text.includes('## Reviewer Verdict')) continue; // not yet reviewed, not this push's concern
289
336
 
290
337
  // A documented, human-approved exception (e.g. an opus-tier executor escalation left no
@@ -314,6 +361,7 @@ if (toolName === 'Bash') {
314
361
  }
315
362
 
316
363
  process.exit(0);
364
+ })();
317
365
  NODE
318
366
 
319
367
  exit $?
@@ -28,7 +28,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
28
28
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
29
29
  trap ukit_cleanup_hook_input EXIT
30
30
  # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
31
- ukit_stage_hook_input 2097152 truncate || exit 0
31
+ ukit_stage_hook_input 2097152 truncate
32
+ __ukit_main_stage_rc=$?
33
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
34
+ # Infra failure during staging (mktemp/truncate): payload was never
35
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
36
+ ukit_emit_input_degraded advisory "handoff-resume"
37
+ fi
38
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
39
+ ukit_input_degraded && ukit_emit_input_degraded advisory "handoff-resume"
32
40
  else
33
41
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
34
42
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -55,10 +63,14 @@ else
55
63
  wait "$__ukit_waiter" 2>/dev/null
56
64
  exec 8<&-
57
65
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
58
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
59
- # already degrades on - the shared helper's truncate posture (R4.5).
60
- if [ "$__ukit_size" -gt 2097152 ]; then
61
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
66
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
67
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
68
+ # must be announced, never silently acted on — same shape as the helper
69
+ # path's ukit_emit_input_degraded.
70
+ rm -f "$UKIT_INPUT_FILE"
71
+ UKIT_INPUT_FILE=""
72
+ printf '%s\n' '{"systemMessage":"UKit handoff-resume: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
73
+ exit 0
62
74
  fi
63
75
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
64
76
  fi
@@ -131,8 +143,9 @@ async function emitOrdinaryResume() {
131
143
  const field = (name) => (text.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
132
144
 
133
145
  const phase = field('Phase');
134
- // `done` means the last cycle finished cleanly. Anything else means a step was in flight.
135
- if (!phase || /^done\b/i.test(phase)) {
146
+ // `done` and `blocked` are both terminal (blocked = closed with instructions, the
147
+ // same posture stop-coordinator.mjs uses). Anything else means a step was in flight.
148
+ if (!phase || /^(done|blocked)\b/i.test(phase)) {
136
149
  await emitOrdinaryResume();
137
150
  process.exit(0);
138
151
  }
@@ -11,7 +11,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
11
11
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
12
  trap ukit_cleanup_hook_input EXIT
13
13
  # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
14
- ukit_stage_hook_input 2097152 truncate || exit 0
14
+ ukit_stage_hook_input 2097152 truncate
15
+ __ukit_main_stage_rc=$?
16
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
17
+ # Infra failure during staging (mktemp/truncate): payload was never
18
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
19
+ ukit_emit_input_degraded advisory "post-edit-verify"
20
+ fi
21
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
22
+ ukit_input_degraded && ukit_emit_input_degraded advisory "post-edit-verify"
15
23
  else
16
24
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
25
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -38,10 +46,14 @@ else
38
46
  wait "$__ukit_waiter" 2>/dev/null
39
47
  exec 8<&-
40
48
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
- # already degrades on - the shared helper's truncate posture (R4.5).
43
- if [ "$__ukit_size" -gt 2097152 ]; then
44
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
49
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
50
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
51
+ # must be announced, never silently acted on — same shape as the helper
52
+ # path's ukit_emit_input_degraded.
53
+ rm -f "$UKIT_INPUT_FILE"
54
+ UKIT_INPUT_FILE=""
55
+ printf '%s\n' '{"systemMessage":"UKit post-edit-verify: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
56
+ exit 0
45
57
  fi
46
58
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
47
59
  fi