@ngockhoale/ukit 2.6.0 → 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.
- package/CHANGELOG.md +47 -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 +99 -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
|
@@ -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; Node gets a path, never the payload.
|
|
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 "verification-guard"
|
|
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 "verification-guard"
|
|
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 verification-guard: 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
|
|
@@ -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 "vision-router"
|
|
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 "vision-router"
|
|
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 vision-router: 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
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
{
|
|
104
104
|
"type": "command",
|
|
105
105
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
|
|
106
|
-
"timeout":
|
|
106
|
+
"timeout": 12
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"type": "command",
|
|
@@ -124,6 +124,11 @@
|
|
|
124
124
|
"type": "command",
|
|
125
125
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
|
|
126
126
|
"timeout": 8
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"type": "command",
|
|
130
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\"",
|
|
131
|
+
"timeout": 8
|
|
127
132
|
}
|
|
128
133
|
]
|
|
129
134
|
}
|
|
@@ -135,7 +140,7 @@
|
|
|
135
140
|
{
|
|
136
141
|
"type": "command",
|
|
137
142
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
|
|
138
|
-
"timeout":
|
|
143
|
+
"timeout": 8
|
|
139
144
|
}
|
|
140
145
|
]
|
|
141
146
|
},
|
|
@@ -150,12 +155,12 @@
|
|
|
150
155
|
{
|
|
151
156
|
"type": "command",
|
|
152
157
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
|
|
153
|
-
"timeout":
|
|
158
|
+
"timeout": 8
|
|
154
159
|
},
|
|
155
160
|
{
|
|
156
161
|
"type": "command",
|
|
157
162
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
|
|
158
|
-
"timeout":
|
|
163
|
+
"timeout": 8
|
|
159
164
|
}
|
|
160
165
|
]
|
|
161
166
|
},
|
|
@@ -165,12 +170,12 @@
|
|
|
165
170
|
{
|
|
166
171
|
"type": "command",
|
|
167
172
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\"",
|
|
168
|
-
"timeout":
|
|
173
|
+
"timeout": 12
|
|
169
174
|
},
|
|
170
175
|
{
|
|
171
176
|
"type": "command",
|
|
172
177
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
|
|
173
|
-
"timeout":
|
|
178
|
+
"timeout": 8
|
|
174
179
|
}
|
|
175
180
|
]
|
|
176
181
|
}
|
|
@@ -191,7 +196,7 @@
|
|
|
191
196
|
{
|
|
192
197
|
"type": "command",
|
|
193
198
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\"",
|
|
194
|
-
"timeout":
|
|
199
|
+
"timeout": 12
|
|
195
200
|
},
|
|
196
201
|
{
|
|
197
202
|
"type": "command",
|
|
@@ -207,7 +212,7 @@
|
|
|
207
212
|
{
|
|
208
213
|
"type": "command",
|
|
209
214
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
|
|
210
|
-
"timeout":
|
|
215
|
+
"timeout": 8
|
|
211
216
|
}
|
|
212
217
|
]
|
|
213
218
|
}
|
|
@@ -234,12 +239,12 @@
|
|
|
234
239
|
{
|
|
235
240
|
"type": "command",
|
|
236
241
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-prune-bash.sh\"",
|
|
237
|
-
"timeout":
|
|
242
|
+
"timeout": 12
|
|
238
243
|
},
|
|
239
244
|
{
|
|
240
245
|
"type": "command",
|
|
241
246
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/reset-compact-pressure.sh\"",
|
|
242
|
-
"timeout":
|
|
247
|
+
"timeout": 12
|
|
243
248
|
},
|
|
244
249
|
{
|
|
245
250
|
"type": "command",
|
|
@@ -19,6 +19,27 @@ import fs from 'node:fs';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { spawnSync } from 'node:child_process';
|
|
21
21
|
|
|
22
|
+
// FR-005 (BUG-C21-08): every spawnSync below must carry an explicit timeout — a
|
|
23
|
+
// wedged `git` (hung credential helper, stalled volume) otherwise blocks this
|
|
24
|
+
// process forever. 15s is generous for the two metadata probes used here;
|
|
25
|
+
// UKIT_PROVISION_GIT_TIMEOUT_MS overrides it for tests and pathological hosts.
|
|
26
|
+
const GIT_CALL_TIMEOUT_MS = (() => {
|
|
27
|
+
const raw = Number(process.env.UKIT_PROVISION_GIT_TIMEOUT_MS);
|
|
28
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 15000;
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
// Runs a git probe with the bounded budget. Returns the spawnSync result, or
|
|
32
|
+
// { error, timedOut: true } shape already present on result.error when the
|
|
33
|
+
// child was killed for exceeding GIT_CALL_TIMEOUT_MS.
|
|
34
|
+
function gitProbe(args, root) {
|
|
35
|
+
const res = spawnSync('git', ['-C', root, ...args], {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
timeout: GIT_CALL_TIMEOUT_MS,
|
|
38
|
+
killSignal: 'SIGKILL',
|
|
39
|
+
});
|
|
40
|
+
return res;
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function fail(msg) {
|
|
23
44
|
process.stderr.write(`provision-worktree: ${msg}\n`);
|
|
24
45
|
process.exit(1);
|
|
@@ -86,7 +107,8 @@ if (mainPkg && wtPkg) {
|
|
|
86
107
|
// idempotent, untracked local-metadata change — never part of any commit.
|
|
87
108
|
function excludeNodeModulesFromGit(root) {
|
|
88
109
|
try {
|
|
89
|
-
const common =
|
|
110
|
+
const common = gitProbe(['rev-parse', '--git-common-dir'], root);
|
|
111
|
+
if (common.error) return false; // wedged/timed-out git — best effort, skip the exclude
|
|
90
112
|
if (common.status !== 0) return false; // not a git repo (e.g. unit-test fixture) — best effort
|
|
91
113
|
const gitCommonDir = path.resolve(root, common.stdout.trim());
|
|
92
114
|
const excludePath = path.join(gitCommonDir, 'info', 'exclude');
|
|
@@ -109,7 +131,13 @@ function excludeNodeModulesFromGit(root) {
|
|
|
109
131
|
// worktree's tracked file with main's unreviewed local state.
|
|
110
132
|
function trackedClaudeFiles(root) {
|
|
111
133
|
try {
|
|
112
|
-
const res =
|
|
134
|
+
const res = gitProbe(['ls-files', '--', '.claude'], root);
|
|
135
|
+
// A timed-out/wedged git must surface loudly: returning an empty set here
|
|
136
|
+
// would let the copy clobber a git-tracked file under .claude/. A non-zero
|
|
137
|
+
// status only means "not a git repo" (unit-test fixture) and stays benign.
|
|
138
|
+
if (res.error) {
|
|
139
|
+
fail(`git ls-files probe failed (${res.error.code || res.error.message || res.error}); cannot verify tracked .claude files — aborting rather than risk clobbering them`);
|
|
140
|
+
}
|
|
113
141
|
if (res.status !== 0 || !res.stdout) return new Set();
|
|
114
142
|
return new Set(res.stdout.split('\n').map((s) => s.trim()).filter(Boolean));
|
|
115
143
|
} catch {
|
|
@@ -115,6 +115,99 @@ function crashCounterPath(projectRoot) {
|
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// --- exec-ledger dir bound (BUG-C21-11, FR-006) -------------------------------
|
|
119
|
+
// One ledger file per session identity plus journals/quarantines used to
|
|
120
|
+
// accumulate forever (no GC, no sweep). The sweep is SAMPLED off the mutation
|
|
121
|
+
// path (~1/16 locked writes, UKIT_LEDGER_SWEEP_PROBABILITY to force/disable)
|
|
122
|
+
// and BOUNDED — <= maxEntries stats and <= maxRemovals unlinks per sweep, so a
|
|
123
|
+
// pre-existing oversized dir is amortized down instead of stalling a hook.
|
|
124
|
+
// Count-cap removals only touch files older than LEDGER_COUNT_CAP_MIN_AGE_MS so
|
|
125
|
+
// a live session's ledger/journal is never evicted by recency ordering.
|
|
126
|
+
const LEDGER_SWEEP_PROBABILITY_DEFAULT = 1 / 16;
|
|
127
|
+
const LEDGER_SWEEP_MAX_ENTRIES = 128;
|
|
128
|
+
const LEDGER_SWEEP_MAX_REMOVALS = 64;
|
|
129
|
+
const LEDGER_MAX_FILES = 200;
|
|
130
|
+
const LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
131
|
+
const LEDGER_COUNT_CAP_MIN_AGE_MS = 24 * 60 * 60 * 1000;
|
|
132
|
+
|
|
133
|
+
function ledgerSweepProbabilityFromEnv() {
|
|
134
|
+
const raw = Number(process.env.UKIT_LEDGER_SWEEP_PROBABILITY);
|
|
135
|
+
if (!Number.isFinite(raw)) return LEDGER_SWEEP_PROBABILITY_DEFAULT;
|
|
136
|
+
return Math.min(1, Math.max(0, raw));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// sweepExecLedgerDir(dir, {now, maxAgeMs, maxFiles, maxEntries, maxRemovals}) ->
|
|
140
|
+
// { scanned, removed }
|
|
141
|
+
//
|
|
142
|
+
// Removes ledger/journal/quarantine files older than maxAgeMs; while the dir
|
|
143
|
+
// exceeds maxFiles, removes the oldest scanned entries that are ALSO older than
|
|
144
|
+
// LEDGER_COUNT_CAP_MIN_AGE_MS — fresh files for live sessions are never evicted.
|
|
145
|
+
export async function sweepExecLedgerDir(dir, {
|
|
146
|
+
now = Date.now,
|
|
147
|
+
maxAgeMs = LEDGER_MAX_AGE_MS,
|
|
148
|
+
maxFiles = LEDGER_MAX_FILES,
|
|
149
|
+
maxEntries = LEDGER_SWEEP_MAX_ENTRIES,
|
|
150
|
+
maxRemovals = LEDGER_SWEEP_MAX_REMOVALS,
|
|
151
|
+
} = {}) {
|
|
152
|
+
let names;
|
|
153
|
+
try {
|
|
154
|
+
names = await fs.readdir(dir);
|
|
155
|
+
} catch {
|
|
156
|
+
return { scanned: 0, removed: 0 };
|
|
157
|
+
}
|
|
158
|
+
const cutoff = now() - maxAgeMs;
|
|
159
|
+
const countCapCutoff = now() - LEDGER_COUNT_CAP_MIN_AGE_MS;
|
|
160
|
+
const entries = [];
|
|
161
|
+
let scanned = 0;
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
if (scanned >= maxEntries) break;
|
|
164
|
+
scanned += 1;
|
|
165
|
+
try {
|
|
166
|
+
const stats = await fs.stat(path.join(dir, name));
|
|
167
|
+
if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
|
|
168
|
+
} catch { /* raced away — fine */ }
|
|
169
|
+
}
|
|
170
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
171
|
+
// Overflow counts eligible FILES only — foreign entries (`.lock` dirs, other
|
|
172
|
+
// state files sharing the dir) inflate `names.length` and would evict real
|
|
173
|
+
// ledgers while the dir is actually under the cap.
|
|
174
|
+
const overflow = Math.max(0, entries.length - maxFiles);
|
|
175
|
+
let removed = 0;
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (removed >= maxRemovals) break;
|
|
178
|
+
const stale = entry.mtimeMs < cutoff;
|
|
179
|
+
const evictable = entry.mtimeMs < countCapCutoff;
|
|
180
|
+
// Journals/quarantines are the ONLY copy of a fail-closed event until
|
|
181
|
+
// drained, and the crash counter is stuck-loop state — evicting them by
|
|
182
|
+
// recency silently loses receipts the protocol promises to keep. They
|
|
183
|
+
// still age out via the 30d stale cutoff, never via the count cap.
|
|
184
|
+
const receiptProtected = entry.name.endsWith('.journal')
|
|
185
|
+
|| entry.name.endsWith('.quarantine')
|
|
186
|
+
|| entry.name === 'gate-crash-counter.json';
|
|
187
|
+
if (stale || (removed < overflow && evictable && !receiptProtected)) {
|
|
188
|
+
try {
|
|
189
|
+
await fs.rm(path.join(dir, entry.name), { force: true });
|
|
190
|
+
removed += 1;
|
|
191
|
+
} catch { /* raced away — fine */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { scanned, removed };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sampled gate — by default only ~1 in 16 ledger mutations pays for a bounded
|
|
198
|
+
// sweep at all. Advisory: a sweep failure must never lose a ledger event.
|
|
199
|
+
export async function maybeSweepExecLedgerDir(dir, {
|
|
200
|
+
probability,
|
|
201
|
+
random = Math.random,
|
|
202
|
+
...options
|
|
203
|
+
} = {}) {
|
|
204
|
+
const p = Number.isFinite(probability)
|
|
205
|
+
? Math.min(1, Math.max(0, probability))
|
|
206
|
+
: ledgerSweepProbabilityFromEnv();
|
|
207
|
+
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
208
|
+
return { sampled: true, ...(await sweepExecLedgerDir(dir, options)) };
|
|
209
|
+
}
|
|
210
|
+
|
|
118
211
|
function resumeIntentPath(projectRoot, sessionId) {
|
|
119
212
|
return path.join(
|
|
120
213
|
projectRoot,
|
|
@@ -1082,7 +1175,12 @@ export async function recordLedgerEvent(event, {
|
|
|
1082
1175
|
if (drained.commit) await drained.commit();
|
|
1083
1176
|
return { committed: true, eventId, value, drained: drained.applied, quarantined: drained.quarantined };
|
|
1084
1177
|
});
|
|
1085
|
-
if (outcome.ok)
|
|
1178
|
+
if (outcome.ok) {
|
|
1179
|
+
// Sampled bounded dir sweep (BUG-C21-11): runs outside the ledger lock so a
|
|
1180
|
+
// contended acquisition never pays sweep latency. Advisory — never throws.
|
|
1181
|
+
try { await maybeSweepExecLedgerDir(path.dirname(target)); } catch { /* advisory */ }
|
|
1182
|
+
return outcome.value;
|
|
1183
|
+
}
|
|
1086
1184
|
|
|
1087
1185
|
// Fail-closed: the acquisition budget expired or the wait aborted. The main ledger is
|
|
1088
1186
|
// NEVER mutated unlocked — journal the event once; the next acquired lock reconciles it.
|
|
@@ -121,6 +121,31 @@ ukit_stage_hook_input() {
|
|
|
121
121
|
return 0
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
# BUG-C21-01 (SPEC §8 announcement contract): a staged truncation or staging
|
|
125
|
+
# stall means the hook is about to inspect an INCOMPLETE payload. Gated hooks
|
|
126
|
+
# must announce that instead of silently passing on input they never fully
|
|
127
|
+
# read:
|
|
128
|
+
# ukit_input_degraded — predicate: staging cut the payload
|
|
129
|
+
# ukit_emit_input_degraded <posture> <hook-name>
|
|
130
|
+
# failclosed → PreToolUse "ask" decision JSON on stdout, then exit 2
|
|
131
|
+
# (fail-closed gates defer the uninspectable call to a human)
|
|
132
|
+
# advisory → systemMessage degrade notice on stdout, then exit 0
|
|
133
|
+
# Both shapes use the §8 contract keys only; payload bytes are never echoed.
|
|
134
|
+
ukit_input_degraded() {
|
|
135
|
+
[ "${UKIT_INPUT_TRUNCATED:-0}" = "1" ] || [ "${UKIT_HOOK_INPUT_STALLED:-0}" = "1" ]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
ukit_emit_input_degraded() {
|
|
139
|
+
local posture="${1:-advisory}" hook_name="${2:-hook}"
|
|
140
|
+
if [ "$posture" = "failclosed" ]; then
|
|
141
|
+
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 ${hook_name} cannot prove it safe. UKit defers this to a human decision.\"}}"
|
|
142
|
+
echo "BLOCKED: ${hook_name} could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
143
|
+
exit 2
|
|
144
|
+
fi
|
|
145
|
+
printf '%s\n' "{\"systemMessage\":\"UKit ${hook_name}: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input.\"}"
|
|
146
|
+
exit 0
|
|
147
|
+
}
|
|
148
|
+
|
|
124
149
|
ukit_cleanup_hook_input() {
|
|
125
150
|
# TASK-019: emit the telemetry finish marker while the staged payload file
|
|
126
151
|
# still exists (its mtime is the envelope start). Strictly advisory — the
|
|
@@ -87,8 +87,8 @@ export function telemetryDirFor(projectRoot) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// Bounded work on THIS session's file only: stat, at most one read of a file
|
|
90
|
-
// already capped at maxBytes, one rewrite of the kept half.
|
|
91
|
-
//
|
|
90
|
+
// already capped at maxBytes, one rewrite of the kept half. Directory growth is
|
|
91
|
+
// bounded separately by the sampled sweepTelemetryDir below (BUG-C21-10).
|
|
92
92
|
function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
93
93
|
let size = 0;
|
|
94
94
|
try {
|
|
@@ -115,6 +115,87 @@ function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// --- hook-latency dir bound (BUG-C21-10, FR-006) -----------------------------
|
|
119
|
+
// Per-session files were byte-capped but the directory was never swept, so file
|
|
120
|
+
// count grew with every session. The sweep is SAMPLED off the append path
|
|
121
|
+
// (default ~1/16 appends, UKIT_TELEMETRY_SWEEP_PROBABILITY to force/disable)
|
|
122
|
+
// and BOUNDED — at most maxEntries stats and maxRemovals unlinks per sweep, so
|
|
123
|
+
// a pre-existing oversized dir is amortized down rather than stalling a hook.
|
|
124
|
+
const TELEMETRY_SWEEP_PROBABILITY_DEFAULT = 1 / 16;
|
|
125
|
+
const TELEMETRY_SWEEP_MAX_ENTRIES = 128;
|
|
126
|
+
const TELEMETRY_SWEEP_MAX_REMOVALS = 64;
|
|
127
|
+
const TELEMETRY_MAX_FILES = 200;
|
|
128
|
+
const TELEMETRY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
129
|
+
|
|
130
|
+
function telemetrySweepProbabilityFromEnv() {
|
|
131
|
+
const raw = Number(process.env.UKIT_TELEMETRY_SWEEP_PROBABILITY);
|
|
132
|
+
if (!Number.isFinite(raw)) return TELEMETRY_SWEEP_PROBABILITY_DEFAULT;
|
|
133
|
+
return Math.min(1, Math.max(0, raw));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// sweepTelemetryDir(dir, {now, maxAgeMs, maxFiles, maxEntries, maxRemovals}) ->
|
|
137
|
+
// { scanned, removed }
|
|
138
|
+
//
|
|
139
|
+
// Removes .jsonl entries older than maxAgeMs; while the dir exceeds maxFiles,
|
|
140
|
+
// removes the oldest scanned entries (bounded by maxRemovals). Fresh session
|
|
141
|
+
// files under the count cap are never touched.
|
|
142
|
+
export function sweepTelemetryDir(dir, {
|
|
143
|
+
now = Date.now,
|
|
144
|
+
maxAgeMs = TELEMETRY_MAX_AGE_MS,
|
|
145
|
+
maxFiles = TELEMETRY_MAX_FILES,
|
|
146
|
+
maxEntries = TELEMETRY_SWEEP_MAX_ENTRIES,
|
|
147
|
+
maxRemovals = TELEMETRY_SWEEP_MAX_REMOVALS,
|
|
148
|
+
} = {}) {
|
|
149
|
+
let names;
|
|
150
|
+
try {
|
|
151
|
+
names = fs.readdirSync(dir);
|
|
152
|
+
} catch {
|
|
153
|
+
return { scanned: 0, removed: 0 };
|
|
154
|
+
}
|
|
155
|
+
const cutoff = now() - maxAgeMs;
|
|
156
|
+
const entries = [];
|
|
157
|
+
let scanned = 0;
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
if (scanned >= maxEntries) break;
|
|
160
|
+
scanned += 1;
|
|
161
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
162
|
+
try {
|
|
163
|
+
const stats = fs.statSync(path.join(dir, name));
|
|
164
|
+
if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
|
|
165
|
+
} catch { /* raced away — fine */ }
|
|
166
|
+
}
|
|
167
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
168
|
+
// Overflow counts eligible FILES only — foreign entries (non-matching names,
|
|
169
|
+
// dirs) inflate `names.length` and would evict real entries while the dir is
|
|
170
|
+
// actually under the cap.
|
|
171
|
+
const overflow = Math.max(0, entries.length - maxFiles);
|
|
172
|
+
let removed = 0;
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (removed >= maxRemovals) break;
|
|
175
|
+
if (removed < overflow || entry.mtimeMs < cutoff) {
|
|
176
|
+
try {
|
|
177
|
+
fs.rmSync(path.join(dir, entry.name), { force: true });
|
|
178
|
+
removed += 1;
|
|
179
|
+
} catch { /* raced away — fine */ }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { scanned, removed };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sampled gate — same shape as the payload-store sweep: by default ~1 in 16
|
|
186
|
+
// appends pays for a bounded sweep at all.
|
|
187
|
+
export function maybeSweepTelemetryDir(dir, {
|
|
188
|
+
probability,
|
|
189
|
+
random = Math.random,
|
|
190
|
+
...options
|
|
191
|
+
} = {}) {
|
|
192
|
+
const p = Number.isFinite(probability)
|
|
193
|
+
? Math.min(1, Math.max(0, probability))
|
|
194
|
+
: telemetrySweepProbabilityFromEnv();
|
|
195
|
+
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
196
|
+
return { sampled: true, ...sweepTelemetryDir(dir, options) };
|
|
197
|
+
}
|
|
198
|
+
|
|
118
199
|
export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
|
|
119
200
|
try {
|
|
120
201
|
if (!row || typeof row !== 'object') return false;
|
|
@@ -124,6 +205,9 @@ export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
|
|
|
124
205
|
const line = `${JSON.stringify(row)}\n`;
|
|
125
206
|
rotateIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), capBytes(options.maxBytes));
|
|
126
207
|
fs.appendFileSync(filePath, line, 'utf8');
|
|
208
|
+
// Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
|
|
209
|
+
// never alter the row this call just appended.
|
|
210
|
+
try { maybeSweepTelemetryDir(dir); } catch { /* advisory */ }
|
|
127
211
|
return true;
|
|
128
212
|
} catch {
|
|
129
213
|
// Advisory: an unwritable or corrupt telemetry target must never alter a
|
|
@@ -376,6 +376,90 @@ function buildRecoveryHintSummary(summary, rawPath, { tokensBefore = 0 } = {}) {
|
|
|
376
376
|
return null;
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
// --- tee/ cache bound (BUG-C21-07, FR-006) -----------------------------------
|
|
380
|
+
// persistRawOutput used to write one preserved-output file per compression and
|
|
381
|
+
// nothing ever deleted from tee/ (live dir: 3,642 files / 26M). The bound below
|
|
382
|
+
// mirrors the hook-payload-store sweep pattern: sweeps are SAMPLED off the hot
|
|
383
|
+
// path (default ~1/16 writes, UKIT_TEE_SWEEP_PROBABILITY to force/disable) and
|
|
384
|
+
// BOUNDED — at most TEE_SWEEP_MAX_ENTRIES stats and TEE_SWEEP_MAX_REMOVALS
|
|
385
|
+
// unlinks per sweep, so a pre-existing oversized dir is amortized down instead
|
|
386
|
+
// of stalling a single PostToolUse.
|
|
387
|
+
const TEE_SWEEP_PROBABILITY_DEFAULT = 1 / 16;
|
|
388
|
+
const TEE_SWEEP_MAX_ENTRIES = 128;
|
|
389
|
+
const TEE_SWEEP_MAX_REMOVALS = 64;
|
|
390
|
+
const TEE_MAX_FILES = 500;
|
|
391
|
+
const TEE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
392
|
+
|
|
393
|
+
function teeSweepProbabilityFromEnv() {
|
|
394
|
+
const raw = Number(process.env.UKIT_TEE_SWEEP_PROBABILITY);
|
|
395
|
+
if (!Number.isFinite(raw)) return TEE_SWEEP_PROBABILITY_DEFAULT;
|
|
396
|
+
return Math.min(1, Math.max(0, raw));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// sweepTeeCache(dir, {now, maxAgeMs, maxFiles, maxEntries, maxRemovals}) ->
|
|
400
|
+
// { sampled, scanned, removed }
|
|
401
|
+
//
|
|
402
|
+
// Removes entries older than maxAgeMs; if the directory still exceeds maxFiles
|
|
403
|
+
// (count from the single readdir listing), removes the oldest scanned entries —
|
|
404
|
+
// capped by maxRemovals — so each sampled sweep makes bounded progress toward
|
|
405
|
+
// the count cap on a pre-existing oversized dir.
|
|
406
|
+
export async function sweepTeeCache(dir, {
|
|
407
|
+
now = Date.now,
|
|
408
|
+
maxAgeMs = TEE_MAX_AGE_MS,
|
|
409
|
+
maxFiles = TEE_MAX_FILES,
|
|
410
|
+
maxEntries = TEE_SWEEP_MAX_ENTRIES,
|
|
411
|
+
maxRemovals = TEE_SWEEP_MAX_REMOVALS,
|
|
412
|
+
} = {}) {
|
|
413
|
+
let names;
|
|
414
|
+
try {
|
|
415
|
+
names = await fs.readdir(dir);
|
|
416
|
+
} catch {
|
|
417
|
+
return { sampled: true, scanned: 0, removed: 0 };
|
|
418
|
+
}
|
|
419
|
+
const cutoff = now() - maxAgeMs;
|
|
420
|
+
const entries = [];
|
|
421
|
+
let scanned = 0;
|
|
422
|
+
for (const name of names) {
|
|
423
|
+
if (scanned >= maxEntries) break;
|
|
424
|
+
scanned += 1;
|
|
425
|
+
try {
|
|
426
|
+
const stats = await fs.stat(path.join(dir, name));
|
|
427
|
+
if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
|
|
428
|
+
} catch { /* raced away — fine */ }
|
|
429
|
+
}
|
|
430
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
431
|
+
// Overflow counts eligible FILES only — foreign entries (non-matching names,
|
|
432
|
+
// dirs) inflate `names.length` and would evict real entries while the dir is
|
|
433
|
+
// actually under the cap.
|
|
434
|
+
const overflow = Math.max(0, entries.length - maxFiles);
|
|
435
|
+
let removed = 0;
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
if (removed >= maxRemovals) break;
|
|
438
|
+
if (removed < overflow || entry.mtimeMs < cutoff) {
|
|
439
|
+
try {
|
|
440
|
+
await fs.rm(path.join(dir, entry.name), { force: true });
|
|
441
|
+
removed += 1;
|
|
442
|
+
} catch { /* raced away — fine */ }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { sampled: true, scanned, removed };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Sampled gate identical in shape to maybeSweepStalePayloads: only ~1 in 16
|
|
449
|
+
// writes pays for a bounded sweep at all. UKIT_TEE_SWEEP_PROBABILITY (clamped to
|
|
450
|
+
// [0,1]) forces or disables sweeping without editing code.
|
|
451
|
+
export async function maybeSweepTeeCache(dir, {
|
|
452
|
+
probability,
|
|
453
|
+
random = Math.random,
|
|
454
|
+
...options
|
|
455
|
+
} = {}) {
|
|
456
|
+
const p = Number.isFinite(probability)
|
|
457
|
+
? Math.min(1, Math.max(0, probability))
|
|
458
|
+
: teeSweepProbabilityFromEnv();
|
|
459
|
+
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
460
|
+
return sweepTeeCache(dir, options);
|
|
461
|
+
}
|
|
462
|
+
|
|
379
463
|
async function persistRawOutput(projectRoot, {
|
|
380
464
|
command = '',
|
|
381
465
|
summary = '',
|
|
@@ -402,6 +486,9 @@ async function persistRawOutput(projectRoot, {
|
|
|
402
486
|
|
|
403
487
|
await fs.mkdir(teeCacheDir, { recursive: true });
|
|
404
488
|
await fs.writeFile(absolutePath, rawOutputText, 'utf8');
|
|
489
|
+
// Bounded, sampled tee/ prune (BUG-C21-07): advisory only — a sweep failure
|
|
490
|
+
// must never lose the preserved output this call just wrote.
|
|
491
|
+
await maybeSweepTeeCache(teeCacheDir).catch(() => {});
|
|
405
492
|
|
|
406
493
|
return {
|
|
407
494
|
rawSaved: true,
|