@ngockhoale/ukit 2.6.0 → 2.6.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/CHANGELOG.md +59 -0
- package/manifests/platform.full.yaml +16 -0
- package/package.json +1 -1
- package/src/cli/commands/install.js +49 -2
- package/src/cli/commands/update.js +5 -0
- package/src/core/output/index.js +77 -0
- package/src/core/update.js +36 -5
- package/templates/.claude/commands/ukit/handoff-clear.md +2 -2
- package/templates/.claude/commands/ukit/handoff-fullstack.md +10 -7
- package/templates/.claude/hooks/auto-allow-bash.sh +17 -5
- package/templates/.claude/hooks/block-dangerous.sh +18 -5
- package/templates/.claude/hooks/completion-gate.sh +17 -5
- package/templates/.claude/hooks/compress-output.sh +15 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +77 -46
- package/templates/.claude/hooks/context-window-guard.sh +41 -27
- package/templates/.claude/hooks/handoff-model-guard.sh +41 -17
- package/templates/.claude/hooks/handoff-resume.sh +20 -7
- package/templates/.claude/hooks/post-edit-verify.sh +17 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +17 -5
- package/templates/.claude/hooks/project-important.sh +18 -1
- package/templates/.claude/hooks/protect-files.sh +18 -5
- package/templates/.claude/hooks/record-execution.sh +17 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +48 -9
- package/templates/.claude/hooks/skill-router.sh +124 -86
- package/templates/.claude/hooks/stale-spec-guard.sh +22 -6
- package/templates/.claude/hooks/task-watchdog.sh +22 -7
- package/templates/.claude/hooks/verification-guard.sh +17 -5
- package/templates/.claude/hooks/vision-router.sh +17 -5
- package/templates/.claude/settings.json +15 -10
- package/templates/.claude/ukit/index/provision-worktree.mjs +30 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +115 -1
- package/templates/.claude/ukit/runtime/hook-input.sh +25 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +86 -2
- package/templates/.claude/ukit/runtime/output-compression.mjs +87 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +110 -3
- package/templates/ukit/storage/config.json +1 -0
|
@@ -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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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(
|
|
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`
|
|
119
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
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 =
|
|
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
|
|
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 {
|
|
221
|
-
for (const n of readSlots()) {
|
|
222
|
-
try {
|
|
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
|
-
|
|
307
|
+
await fsp.mkdir(graceDir, { recursive: true });
|
|
282
308
|
for (let n = spent + 1; n <= graceCalls; n += 1) {
|
|
283
309
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
292
|
-
fs.
|
|
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.
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
141
|
-
const currentContent = fileExists ?
|
|
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,7 +199,7 @@ 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 =
|
|
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.`);
|
|
@@ -187,17 +210,17 @@ if (toolName === 'Write' || toolName === 'Edit') {
|
|
|
187
210
|
// shipped template. Disable per project: handoff.fullstack.specRequired=false.
|
|
188
211
|
let specRequired = true;
|
|
189
212
|
try {
|
|
190
|
-
const cfg = JSON.parse(
|
|
213
|
+
const cfg = JSON.parse(await fsp.readFile(path.join(projectRoot, '.ukit/storage/config.json'), 'utf8'));
|
|
191
214
|
if (cfg?.handoff?.fullstack?.specRequired === false) specRequired = false;
|
|
192
215
|
} catch {}
|
|
193
216
|
if (specRequired) {
|
|
194
217
|
let specOk = false;
|
|
195
218
|
try {
|
|
196
219
|
const specPath = path.join(projectRoot, 'docs/AI_HANDOFF/SPEC.md');
|
|
197
|
-
const specText =
|
|
220
|
+
const specText = await fsp.readFile(specPath, 'utf8');
|
|
198
221
|
let templateText = null;
|
|
199
222
|
try {
|
|
200
|
-
templateText =
|
|
223
|
+
templateText = await fsp.readFile(path.join(projectRoot, 'templates/docs/AI_HANDOFF/SPEC.md'), 'utf8');
|
|
201
224
|
} catch {}
|
|
202
225
|
specOk = specText.length > 500 && specText !== templateText;
|
|
203
226
|
} catch {}
|
|
@@ -298,17 +321,17 @@ if (toolName === 'Bash') {
|
|
|
298
321
|
|
|
299
322
|
const activePath = path.join(projectRoot, 'docs/AI_HANDOFF/ACTIVE.md');
|
|
300
323
|
const indexPath = path.join(projectRoot, 'docs/AI_HANDOFF/INDEX.md');
|
|
301
|
-
if (!
|
|
324
|
+
if (!(await pathExists(activePath)) || !(await pathExists(indexPath))) process.exit(0); // no handoff cycle here
|
|
302
325
|
|
|
303
|
-
const taskIds = [...
|
|
326
|
+
const taskIds = [...(await fsp.readFile(indexPath, 'utf8')).matchAll(/TASK-\d+/g)]
|
|
304
327
|
.map((m) => m[0])
|
|
305
328
|
.filter((v, i, a) => a.indexOf(v) === i);
|
|
306
329
|
|
|
307
330
|
const problems = [];
|
|
308
331
|
for (const taskId of taskIds) {
|
|
309
332
|
const taskPath = path.join(projectRoot, `docs/AI_HANDOFF/tasks/${taskId}.md`);
|
|
310
|
-
if (!
|
|
311
|
-
const text = stripHtmlComments(
|
|
333
|
+
if (!(await pathExists(taskPath))) continue;
|
|
334
|
+
const text = stripHtmlComments(await fsp.readFile(taskPath, 'utf8'));
|
|
312
335
|
if (!text.includes('## Reviewer Verdict')) continue; // not yet reviewed, not this push's concern
|
|
313
336
|
|
|
314
337
|
// A documented, human-approved exception (e.g. an opus-tier executor escalation left no
|
|
@@ -338,6 +361,7 @@ if (toolName === 'Bash') {
|
|
|
338
361
|
}
|
|
339
362
|
|
|
340
363
|
process.exit(0);
|
|
364
|
+
})();
|
|
341
365
|
NODE
|
|
342
366
|
|
|
343
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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`
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|