@ngockhoale/ukit 2.3.8 → 2.3.10
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 +90 -0
- package/package.json +1 -1
- package/src/core/compact/threshold.js +13 -0
- package/src/core/fileOps.js +61 -7
- package/src/index/taskRouting.js +35 -2
- package/templates/.claude/agents/bug-debugger.md +2 -2
- package/templates/.claude/agents/feature-implementer.md +5 -3
- package/templates/.claude/hooks/auto-allow-bash.sh +44 -7
- package/templates/.claude/hooks/auto-prune-bash.sh +44 -7
- package/templates/.claude/hooks/context-hardcap-gate.sh +33 -8
- package/templates/.claude/hooks/context-window-guard.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +47 -7
- package/templates/.claude/hooks/skill-router.sh +80 -7
- package/templates/.claude/hooks/verification-guard.sh +55 -20
- package/templates/.claude/ukit/index/route-task.mjs +36 -3
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +60 -5
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +361 -62
- package/templates/.claude/ukit/runtime/token-utils.mjs +69 -6
- package/templates/.omp/agents/bug-debugger.md +2 -2
- package/templates/.omp/agents/feature-implementer.md +5 -3
- package/templates/.omp/hooks/pre/ukit-bridge.js +68 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,96 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.3.10 - 2026-09-11
|
|
6
|
+
|
|
7
|
+
Freeze-sweep waves 7-10 (post-2.3.9): twelve verified mid-run-freeze defects fixed, each locked
|
|
8
|
+
by a regression reproducing the failure first. All affected template/live mirrors are byte-synced.
|
|
9
|
+
|
|
10
|
+
**P1 — continuation budget reset on every route re-key.** The router re-keys `requestKey` per
|
|
11
|
+
tool call (the target file is part of the key), but the Stop-gate continuation budget was keyed
|
|
12
|
+
on `requestKey` — the count reset to 0 on every re-key, the cap was unreachable, and omp looped
|
|
13
|
+
forever (no `stop_hook_active` valve). Budget identity is now the logical prompt hash
|
|
14
|
+
(`promptKey`) everywhere, and omp gained `stop_hook_active` parity via `noteStopProgress`.
|
|
15
|
+
|
|
16
|
+
**P1 — parallel execution receipts crashed and raced.** Same-process concurrent receipt writers
|
|
17
|
+
shared one temp path and crashed with ENOENT mid-hook; unlocked read-modify-write also dropped
|
|
18
|
+
receipts, and a missing/foreign route state could rebuild a session ledger from blank, erasing
|
|
19
|
+
banked evidence. Temp paths are unique, the whole receipt write runs under `withFileLock`, and
|
|
20
|
+
a missing route no longer blanks the ledger.
|
|
21
|
+
|
|
22
|
+
**P1 — ledger lock waits exceeded the hook child budget.** `withFileLock` waited up to 5s but
|
|
23
|
+
the hook chain kills children at 4s, so a contended lock got the recording hook killed and omp's
|
|
24
|
+
fail-closed Edit|Write chain then blocked the edit. Ledger lock waits now cap at 2.5s and fail
|
|
25
|
+
open — a lost receipt beats a killed hook.
|
|
26
|
+
|
|
27
|
+
**P1 — vibecode had no exit when verification kept failing identically.** Two adjacent identical
|
|
28
|
+
failed verifications (no edit between) now mint a visible `verification-loop` blocker; vibecode
|
|
29
|
+
additionally escapes after the same command fails 4× across edits.
|
|
30
|
+
|
|
31
|
+
**P1 — clean map-impact Stop loop.** A completed analysis-only `map-impact` route still demanded
|
|
32
|
+
write + verification evidence. A narrow visible release valve permits a completed impact read
|
|
33
|
+
with no mutation attempt and no failed verification; incomplete analysis and partial edits stay
|
|
34
|
+
gated.
|
|
35
|
+
|
|
36
|
+
**P1 — omp hook child timeouts falsely blocked edits.** A 4s-killed `protect-files` gate was
|
|
37
|
+
treated as its fail-closed verdict, freezing all edits on a slow machine. Timeouts now fail open
|
|
38
|
+
as infrastructure failures with a visible warning; `block-dangerous.sh` stays fail-closed. An
|
|
39
|
+
unknown mutation-shaped omp tool now normalizes to `Edit` and runs the normal safety chain
|
|
40
|
+
instead of emitting an impossible "add host mapping" block.
|
|
41
|
+
|
|
42
|
+
**P1 — hard-cap grace slots crossed session boundaries.** Global grace slots let one session
|
|
43
|
+
spend or reset another session's landing allowance, stranding it mid-edit. Slots and advisory
|
|
44
|
+
state are now session-keyed.
|
|
45
|
+
|
|
46
|
+
**P2 — transcript fallback rescanned up to 4MB per mutation.** A persisted, session-bound
|
|
47
|
+
2-second boundary-probe cache coalesces mutation bursts to one scan; `skill-router.sh` also
|
|
48
|
+
skips its redundant prompt-only Node spawn on tool payloads.
|
|
49
|
+
|
|
50
|
+
**P1 — lock stale-reclaim could steal a live owner.** The shared runtime lock used mtime-only
|
|
51
|
+
reclaim: a slow-but-alive holder was deleted after 10s, then deleted its successor's lock in
|
|
52
|
+
`finally` — lost receipts re-triggered completion debt. All lock implementations (runtime
|
|
53
|
+
mirror, verification guard, auto-allow, auto-prune, compact-pressure reset) now stamp
|
|
54
|
+
PID + random owner tokens, reclaim only provably-dead owners, and release only their own token.
|
|
55
|
+
|
|
56
|
+
**P3 — stale verification-guard enforcement env var froze runs.** An inherited
|
|
57
|
+
`UKIT_VERIFICATION_GUARD_ENFORCE=1` turned an advisory test-order recommendation into an
|
|
58
|
+
exit-2 block. The guard is now exclusively advisory; real safety gates keep enforcement.
|
|
59
|
+
|
|
60
|
+
Also in wave 7: piped/redirected verification (`yarn test 2>&1 | tail -6`) counts as targeted,
|
|
61
|
+
and delivery-only requests ("push this to git") route `informational` with no fabricated
|
|
62
|
+
mutation debt in all three routing copies.
|
|
63
|
+
|
|
64
|
+
Full suite: 81 files, 1,439 tests green (plus wave 9-10 focused regressions). No npm tag
|
|
65
|
+
policy change; artifact unpacked ceiling raised once to 5,785,000 with dated rationale.
|
|
66
|
+
|
|
67
|
+
## 2.3.9 - 2026-09-10
|
|
68
|
+
|
|
69
|
+
Freeze-sweep wave 5: fixes for completion-gate loops, worker-agent dead ends, and malformed
|
|
70
|
+
context-cap configuration that could otherwise halt all mutation progress.
|
|
71
|
+
|
|
72
|
+
**P1 — clean `find-cause` audits were forced into a Stop-hook loop.** A recommend-only
|
|
73
|
+
investigation that found no actionable bug still required write and verification evidence, so
|
|
74
|
+
the completion gate blocked the honest outcome and demanded a fabricated edit. The execution
|
|
75
|
+
ledger now permits that narrow clean-audit outcome while retaining normal recovery blocks after
|
|
76
|
+
a failed verification or incomplete mutation.
|
|
77
|
+
|
|
78
|
+
**P1 — routed workers could wait for a user they cannot contact.** The Claude Code and omp
|
|
79
|
+
`bug-debugger` / `feature-implementer` agents instructed workers to “ask the user” on unclear
|
|
80
|
+
or non-reproducible work. They now perform one bounded next diagnostic step and return a
|
|
81
|
+
structured `STATUS: BLOCKED` report with the exact missing artifact or decision to the parent
|
|
82
|
+
agent, which owns user communication.
|
|
83
|
+
|
|
84
|
+
**P1 — malformed context-cap configuration could brick mutations.** Invalid
|
|
85
|
+
`compact.hardCapTokens` could collapse the cap to one token, invalid
|
|
86
|
+
`hardCapGraceCalls` could eliminate the landing window, and wrong-shaped present
|
|
87
|
+
`hardCapBlock` values could unexpectedly leave the hard gate enabled. The shared source/runtime
|
|
88
|
+
threshold logic and Claude hook now accept only positive integer budgets, while malformed
|
|
89
|
+
non-boolean `hardCapBlock` values fail open; the documented absent-key default remains enabled.
|
|
90
|
+
The context-window advisory uses the same hard-cap validation. Regression coverage includes
|
|
91
|
+
negative/zero/fraction/string/boolean/null budgets and malformed toggle values.
|
|
92
|
+
|
|
93
|
+
Full suite: 81 files, 1,396 tests green.
|
|
94
|
+
|
|
5
95
|
## 2.3.8 - 2026-09-10
|
|
6
96
|
|
|
7
97
|
Freeze-sweep wave 4: every fix in this release targets the "agent silently stops working"
|
package/package.json
CHANGED
|
@@ -35,6 +35,15 @@ function finiteNumber(value, fallback = 0) {
|
|
|
35
35
|
return Number.isFinite(number) ? number : fallback;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A malformed hard cap must fail back to a safe ceiling, never collapse to one token and
|
|
39
|
+
// brick every Edit/Write/Bash call. Unlike token estimates, configuration is an operator
|
|
40
|
+
// contract: accept only whole positive token counts.
|
|
41
|
+
function positiveInteger(value, fallback) {
|
|
42
|
+
return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
|
|
43
|
+
? value
|
|
44
|
+
: fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
function uniqueStrings(values) {
|
|
39
48
|
const seen = new Set();
|
|
40
49
|
const unique = [];
|
|
@@ -434,11 +443,15 @@ export function buildCompactThresholds(config = {}) {
|
|
|
434
443
|
);
|
|
435
444
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
436
445
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
446
|
+
// Keep the source mirror's config contract aligned with the installed runtime: malformed
|
|
447
|
+
// caps fall back to a safe ceiling rather than turning every mutation into an over-cap call.
|
|
448
|
+
const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, loadShippedCompactBudget().hardCapTokens);
|
|
437
449
|
|
|
438
450
|
return {
|
|
439
451
|
softThreshold,
|
|
440
452
|
hardThreshold,
|
|
441
453
|
baselineTokens,
|
|
454
|
+
hardCapTokens,
|
|
442
455
|
};
|
|
443
456
|
}
|
|
444
457
|
|
package/src/core/fileOps.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -142,17 +143,46 @@ function sleep(ms) {
|
|
|
142
143
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
function isPidAlive(pid) {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, 0);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// EPERM: the process exists but belongs to another user — still alive.
|
|
152
|
+
return error?.code === 'EPERM';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readLockOwner(lockPath) {
|
|
157
|
+
try {
|
|
158
|
+
const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
|
|
159
|
+
const pid = Number(raw?.pid);
|
|
160
|
+
return Number.isInteger(pid) && pid > 0
|
|
161
|
+
? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
|
|
162
|
+
: null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Same-pid holders are parallel async flows whose liveness a pid probe cannot prove.
|
|
169
|
+
const inProcessLockHolders = new Map();
|
|
170
|
+
|
|
145
171
|
/**
|
|
146
172
|
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
147
173
|
* (hook invocations run as separate node processes) and across concurrent async
|
|
148
174
|
* flows in one process (parallel subagents). The lock is a directory created next
|
|
149
175
|
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
150
|
-
*
|
|
176
|
+
* Ownership is recorded in an `owner` file inside the lock dir: stale reclaim first
|
|
177
|
+
* proves the recorded holder is gone (dead pid, or no in-process holder for our own
|
|
178
|
+
* pid — no owner file keeps legacy mtime-only recovery). Release only removes a dir
|
|
179
|
+
* this acquisition still owns, so a reclaimed-then-reacquired lock is never deleted
|
|
180
|
+
* out from under its successor.
|
|
151
181
|
* Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
|
|
152
182
|
* the callback runs anyway (the pre-lock behaviour) — these state files are
|
|
153
183
|
* advisory caches, and losing an update beats freezing a hook mid-flight.
|
|
154
|
-
*
|
|
155
|
-
*
|
|
184
|
+
* Protocol-compatible with the runtime token-utils.mjs lock (same `<file>.lock`
|
|
185
|
+
* path and owner-file format), so CLI and hook processes serialize against each other.
|
|
156
186
|
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
157
187
|
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
158
188
|
* @returns {Promise<*>} whatever fn resolves with
|
|
@@ -160,24 +190,44 @@ function sleep(ms) {
|
|
|
160
190
|
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
161
191
|
const lockPath = `${filePath}.lock`;
|
|
162
192
|
const startedAt = Date.now();
|
|
193
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
163
194
|
let locked = false;
|
|
195
|
+
let ownerStamped = false;
|
|
164
196
|
|
|
165
197
|
while (!locked) {
|
|
166
198
|
try {
|
|
167
199
|
await ensureDir(path.dirname(lockPath));
|
|
168
200
|
await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
|
|
169
201
|
locked = true;
|
|
202
|
+
inProcessLockHolders.set(lockPath, ownerToken);
|
|
203
|
+
try {
|
|
204
|
+
await fs.writeFile(
|
|
205
|
+
path.join(lockPath, 'owner'),
|
|
206
|
+
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
|
|
207
|
+
'utf8',
|
|
208
|
+
);
|
|
209
|
+
ownerStamped = true;
|
|
210
|
+
} catch {
|
|
211
|
+
ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
|
|
212
|
+
}
|
|
170
213
|
break;
|
|
171
214
|
} catch (error) {
|
|
172
215
|
if (error?.code !== 'EEXIST') throw error;
|
|
173
216
|
}
|
|
174
217
|
|
|
175
|
-
// Someone holds the lock. Reclaim it when
|
|
218
|
+
// Someone holds the lock. Reclaim it only when the holder is provably gone.
|
|
176
219
|
try {
|
|
177
220
|
const stat = await fs.stat(lockPath);
|
|
178
221
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
179
|
-
await
|
|
180
|
-
|
|
222
|
+
const owner = await readLockOwner(lockPath);
|
|
223
|
+
const liveInProcess = inProcessLockHolders.has(lockPath);
|
|
224
|
+
const reclaimable = !owner || owner.pid === process.pid
|
|
225
|
+
? !liveInProcess
|
|
226
|
+
: !isPidAlive(owner.pid);
|
|
227
|
+
if (reclaimable) {
|
|
228
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
229
|
+
continue; // the slot is free now — retry immediately
|
|
230
|
+
}
|
|
181
231
|
}
|
|
182
232
|
} catch {
|
|
183
233
|
continue; // lock vanished between mkdir and stat — retry immediately
|
|
@@ -192,7 +242,11 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
|
|
|
192
242
|
} finally {
|
|
193
243
|
if (locked) {
|
|
194
244
|
try {
|
|
195
|
-
await
|
|
245
|
+
const current = ownerStamped ? await readLockOwner(lockPath) : null;
|
|
246
|
+
if (current && current.token === ownerToken) {
|
|
247
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
|
|
196
250
|
} catch {
|
|
197
251
|
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
198
252
|
}
|
package/src/index/taskRouting.js
CHANGED
|
@@ -78,6 +78,7 @@ export async function deriveTaskRoute({
|
|
|
78
78
|
intentMode,
|
|
79
79
|
executionScores,
|
|
80
80
|
executionCandidates,
|
|
81
|
+
signalText: buildRouteSignalText(normalizedPrompt, normalizedCommand),
|
|
81
82
|
});
|
|
82
83
|
const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
|
|
83
84
|
const degradedWarnings = [];
|
|
@@ -412,8 +413,10 @@ function deriveExecutionMode({
|
|
|
412
413
|
intentMode = null,
|
|
413
414
|
executionScores = {},
|
|
414
415
|
executionCandidates = null,
|
|
416
|
+
signalText = '',
|
|
415
417
|
} = {}) {
|
|
416
418
|
const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
|
|
419
|
+
const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
|
|
417
420
|
const scores = executionScores;
|
|
418
421
|
const candidates = executionCandidates ?? buildExecutionModeCandidates({
|
|
419
422
|
promptText,
|
|
@@ -423,14 +426,44 @@ function deriveExecutionMode({
|
|
|
423
426
|
executionScores,
|
|
424
427
|
});
|
|
425
428
|
const explicitReviewLead = /\b(review this|audit this|review\b.*\brelease readiness|verify\b.*\brelease readiness)\b/.test(raw);
|
|
429
|
+
const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
|
|
430
|
+
&& /\b(?:release|tag|publish|push|report)\b/.test(raw);
|
|
426
431
|
const strongImpactLead = scores.sharedRisk
|
|
427
432
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
428
433
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
434
|
+
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
435
|
+
// repository mutation the ledger could ever receipt. With no edit/review/debug/
|
|
436
|
+
// build signal present, routing it to an investigation lane fabricates write debt
|
|
437
|
+
// and the completion gate then demands an edit that cannot exist. Delivery-only
|
|
438
|
+
// requests carry no completion contract instead.
|
|
439
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
440
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
441
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
442
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
443
|
+
const deliveryOnlySignal = deliveryWordSignal
|
|
444
|
+
&& scores.editCertainty === 0
|
|
445
|
+
&& !scores.implementSignal
|
|
446
|
+
&& !scores.reviewSignal
|
|
447
|
+
&& !scores.debugSignal
|
|
448
|
+
&& !scores.failureSignal
|
|
449
|
+
&& !scores.impactSignal
|
|
450
|
+
&& !scores.buildSignal
|
|
451
|
+
&& !scores.directTransformSignal
|
|
452
|
+
&& !scores.smallFixSignal
|
|
453
|
+
&& !scores.sharedRisk
|
|
454
|
+
&& !targetFile;
|
|
429
455
|
|
|
430
|
-
if (
|
|
456
|
+
if (
|
|
457
|
+
releaseVerificationContinuation
|
|
458
|
+
|| ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
|
|
459
|
+
) {
|
|
431
460
|
return 'review-release';
|
|
432
461
|
}
|
|
433
462
|
|
|
463
|
+
if (deliveryOnlySignal) {
|
|
464
|
+
return 'informational';
|
|
465
|
+
}
|
|
466
|
+
|
|
434
467
|
if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
|
|
435
468
|
return 'map-impact';
|
|
436
469
|
}
|
|
@@ -530,7 +563,7 @@ function buildExecutionModeCandidates({
|
|
|
530
563
|
),
|
|
531
564
|
'map-impact': (
|
|
532
565
|
(scores.sharedRisk ? 7 : 0)
|
|
533
|
-
+ (scores.impactSignal ? 5 :
|
|
566
|
+
+ (scores.impactSignal ? 5 : -5)
|
|
534
567
|
+ (scores.ambiguity * 2)
|
|
535
568
|
+ scores.investigationNeed
|
|
536
569
|
),
|
|
@@ -18,7 +18,7 @@ Systematic debugging — understand before fixing.
|
|
|
18
18
|
|
|
19
19
|
- Run the failing command/action.
|
|
20
20
|
- Capture exact error message and stack trace.
|
|
21
|
-
- If not reproducible → document conditions
|
|
21
|
+
- If not reproducible → document conditions, run the next most-discriminating bounded repro, then report any exact missing user-only artifact/permission to the parent agent. Never wait for or ask a user directly — you are a worker.
|
|
22
22
|
|
|
23
23
|
### 2. Trace Root Cause
|
|
24
24
|
|
|
@@ -83,4 +83,4 @@ Daily mode: skip. Handoff mode: set task status `pending_review` in `INDEX.md`;
|
|
|
83
83
|
- non-trivial bug: `docs/MEMORY.md` + `docs/PROJECT.md` + `docs/CODE_MAP.md`
|
|
84
84
|
- read `docs/WORKLOG.md` only recent relevant entries
|
|
85
85
|
- Keep fix scope minimal — no drive-by refactors.
|
|
86
|
-
- If root cause is unclear after 5 minutes of tracing → ask user
|
|
86
|
+
- If root cause is unclear after 5 minutes of tracing → try one bounded alternative hypothesis/repro, then report precise evidence plus the smallest needed missing context to the parent agent. Never wait for or ask a user directly — the parent owns user communication.
|
|
@@ -12,7 +12,9 @@ Implement requested behavior with minimal scope drift.
|
|
|
12
12
|
- **Daily/ad-hoc mode** (DEFAULT): task didn't come from `docs/AI_HANDOFF/` → use the original lightweight workflow. Tests only when touched code already has coverage. No reviewer trigger.
|
|
13
13
|
- **Handoff mode**: task file is `docs/AI_HANDOFF/tasks/TASK-xxx.md` OR user explicitly invokes handoff (e.g. "execute task TASK-001") → activate full Quality Gate: test-first → green → reviewer.
|
|
14
14
|
|
|
15
|
-
If unsure which mode applies,
|
|
15
|
+
If unsure which mode applies, default to Daily/ad-hoc mode unless the task path or prompt
|
|
16
|
+
explicitly selects Handoff. Do not ask a user — you are a worker; report the ambiguity and
|
|
17
|
+
reasoning to the parent agent so it can decide whether to re-route.
|
|
16
18
|
|
|
17
19
|
**In Handoff mode you are running unattended — ask nothing.** You were spawned by an
|
|
18
20
|
orchestrator driving a pipeline; there is no human in your conversation to answer, and a
|
|
@@ -33,7 +35,7 @@ and even then, report it, don't ask about it.
|
|
|
33
35
|
- non-trivial: `docs/MEMORY.md` + `docs/PROJECT.md` + `docs/CODE_MAP.md`
|
|
34
36
|
- Identify target files and existing patterns.
|
|
35
37
|
- If task came from handoff, read `tasks/TASK-xxx.md` and locate its **Test Plan** + **Verification Commands**.
|
|
36
|
-
- Daily mode: if confidence is low or risk is high,
|
|
38
|
+
- Daily mode: if confidence is low or risk is high, inspect the next bounded source/context signal and hand back a concise `STATUS: BLOCKED` report with the exact missing decision or artifact if confidence remains low. Do not ask a user directly. Handoff mode: do not ask — decide and record the decision (see above).
|
|
37
39
|
|
|
38
40
|
### 2. Plan Approach (< 1 minute)
|
|
39
41
|
|
|
@@ -44,7 +46,7 @@ and even then, report it, don't ask about it.
|
|
|
44
46
|
|
|
45
47
|
- Write the test(s) from §2 / from task Test Plan.
|
|
46
48
|
- Run them: must FAIL for the expected reason. Capture output.
|
|
47
|
-
- If test passes immediately → test is wrong or behavior already exists. Fix the test
|
|
49
|
+
- If test passes immediately → test is wrong or behavior already exists. Fix the test; if the intended behavior cannot be inferred, hand back `STATUS: BLOCKED` with the observed behavior and the smallest decision the parent must resolve. Never silently stop.
|
|
48
50
|
- **Daily mode**: skip this step unless touched code already has tests (then follow original rule).
|
|
49
51
|
|
|
50
52
|
|
|
@@ -78,6 +78,7 @@ MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
|
|
|
78
78
|
# Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
|
|
79
79
|
# runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
|
|
80
80
|
node -e '
|
|
81
|
+
const crypto = require("crypto");
|
|
81
82
|
const fs = require("fs");
|
|
82
83
|
const path = require("path");
|
|
83
84
|
|
|
@@ -115,24 +116,57 @@ function appendAudit(event, payload) {
|
|
|
115
116
|
fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
function isPidAlive(pid) {
|
|
120
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
return true;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return error?.code === "EPERM";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readLockOwner(lockPath) {
|
|
130
|
+
const owner = readJson(path.join(lockPath, "owner"), null);
|
|
131
|
+
return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
|
|
132
|
+
? owner
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
async function withLock(lockPath, fn) {
|
|
119
137
|
const staleMs = 10000;
|
|
120
138
|
const maxWaitMs = 5000;
|
|
121
139
|
const startedAt = Date.now();
|
|
140
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
122
141
|
let held = false;
|
|
123
142
|
while (!held) {
|
|
124
143
|
try {
|
|
125
144
|
fs.mkdirSync(lockPath);
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
|
|
147
|
+
pid: process.pid,
|
|
148
|
+
token: ownerToken,
|
|
149
|
+
ts: Date.now(),
|
|
150
|
+
}));
|
|
151
|
+
} catch (error) {
|
|
152
|
+
try {
|
|
153
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
154
|
+
} catch {}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
126
157
|
held = true;
|
|
127
158
|
} catch (error) {
|
|
128
159
|
if (!error || error.code !== "EEXIST") return fn();
|
|
129
160
|
try {
|
|
130
161
|
const stat = fs.statSync(lockPath);
|
|
131
162
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
163
|
+
const owner = readLockOwner(lockPath);
|
|
164
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
165
|
+
try {
|
|
166
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
167
|
+
continue;
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
136
170
|
}
|
|
137
171
|
} catch {}
|
|
138
172
|
if (Date.now() - startedAt > maxWaitMs) break;
|
|
@@ -143,9 +177,12 @@ async function withLock(lockPath, fn) {
|
|
|
143
177
|
return await fn();
|
|
144
178
|
} finally {
|
|
145
179
|
if (held) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
180
|
+
const owner = readLockOwner(lockPath);
|
|
181
|
+
if (owner?.token === ownerToken) {
|
|
182
|
+
try {
|
|
183
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
149
186
|
}
|
|
150
187
|
}
|
|
151
188
|
}
|
|
@@ -14,6 +14,7 @@ fi
|
|
|
14
14
|
NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
15
15
|
|
|
16
16
|
node -e '
|
|
17
|
+
const crypto = require("crypto");
|
|
17
18
|
const fs = require("fs");
|
|
18
19
|
const path = require("path");
|
|
19
20
|
|
|
@@ -55,25 +56,58 @@ function appendAudit(event, payload) {
|
|
|
55
56
|
// writes because Claude Code re-reads settings.local.json — a torn write would drop the
|
|
56
57
|
// ENTIRE allow list and every command would prompt again. Fail-open after 5s: a hook
|
|
57
58
|
// must never hang the chain.
|
|
59
|
+
function isPidAlive(pid) {
|
|
60
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return error?.code === "EPERM";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readLockOwner(lockPath) {
|
|
70
|
+
const owner = readJson(path.join(lockPath, "owner"), null);
|
|
71
|
+
return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
|
|
72
|
+
? owner
|
|
73
|
+
: null;
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
async function withLock(lockPath, fn) {
|
|
59
77
|
const staleMs = 10000;
|
|
60
78
|
const maxWaitMs = 5000;
|
|
61
79
|
const startedAt = Date.now();
|
|
80
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
62
81
|
let held = false;
|
|
63
82
|
while (!held) {
|
|
64
83
|
try {
|
|
65
84
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
66
85
|
fs.mkdirSync(lockPath);
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
|
|
88
|
+
pid: process.pid,
|
|
89
|
+
token: ownerToken,
|
|
90
|
+
ts: Date.now(),
|
|
91
|
+
}));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
95
|
+
} catch {}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
67
98
|
held = true;
|
|
68
99
|
} catch (error) {
|
|
69
100
|
if (!error || error.code !== "EEXIST") return fn();
|
|
70
101
|
try {
|
|
71
102
|
const stat = fs.statSync(lockPath);
|
|
72
103
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
const owner = readLockOwner(lockPath);
|
|
105
|
+
if (!owner || !isPidAlive(owner.pid)) {
|
|
106
|
+
try {
|
|
107
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
108
|
+
continue;
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
77
111
|
}
|
|
78
112
|
} catch {}
|
|
79
113
|
if (Date.now() - startedAt > maxWaitMs) break;
|
|
@@ -84,9 +118,12 @@ async function withLock(lockPath, fn) {
|
|
|
84
118
|
return await fn();
|
|
85
119
|
} finally {
|
|
86
120
|
if (held) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
121
|
+
const owner = readLockOwner(lockPath);
|
|
122
|
+
if (owner?.token === ownerToken) {
|
|
123
|
+
try {
|
|
124
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
90
127
|
}
|
|
91
128
|
}
|
|
92
129
|
}
|
|
@@ -96,7 +96,12 @@ function readRunCursor() {
|
|
|
96
96
|
? payload.session_id.trim()
|
|
97
97
|
: undefined,
|
|
98
98
|
};
|
|
99
|
-
|
|
99
|
+
// This is a liveness backstop, not a security control. A present value with the wrong
|
|
100
|
+
// JSON shape must never leave the gate unexpectedly enabled and brick every mutation once
|
|
101
|
+
// the cap is reached. Only an explicitly configured boolean `true` enables the gate;
|
|
102
|
+
// missing preserves the documented default-on behavior for existing installations.
|
|
103
|
+
const rawHardCapBlock = config?.compact?.hardCapBlock;
|
|
104
|
+
if (rawHardCapBlock === false || (rawHardCapBlock !== undefined && typeof rawHardCapBlock !== 'boolean')) {
|
|
100
105
|
process.exit(0);
|
|
101
106
|
return;
|
|
102
107
|
}
|
|
@@ -123,7 +128,9 @@ function readRunCursor() {
|
|
|
123
128
|
let state = mod.buildCompactPressureState(rawState, sessionConfig);
|
|
124
129
|
try {
|
|
125
130
|
const synced = await mod.syncCompactPressureStateWithTranscript(state, sessionConfig, payload.transcript_path);
|
|
126
|
-
if (synced.reset) {
|
|
131
|
+
if (synced.reset || synced.probed) {
|
|
132
|
+
// Persist a clean probe too: hook processes are ephemeral, so otherwise every
|
|
133
|
+
// mutation would reopen and parse the same 4MB transcript tail.
|
|
127
134
|
state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
|
|
128
135
|
}
|
|
129
136
|
} catch {
|
|
@@ -136,13 +143,24 @@ function readRunCursor() {
|
|
|
136
143
|
// both write 4, silently multiplying the budget), so each spent call is claimed with an
|
|
137
144
|
// exclusive-create sentinel file (openSync 'wx'): the sentinels present ARE the number
|
|
138
145
|
// of grace calls spent, and one claim can never be counted twice.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
// Grace is a landing allowance for THIS session's over-cap episode. The old global
|
|
147
|
+
// `hardcap-grace.json.N` slot pool let session A consume session B's allowance (and A
|
|
148
|
+
// clearing it refilled B's spent pool), so parallel sessions could be hard-blocked mid-
|
|
149
|
+
// landing for no reason. Keep the fallback `default` for hosts that omit session_id: in
|
|
150
|
+
// that legacy shape sibling workers still deliberately share one bounded allowance.
|
|
151
|
+
const rawGraceSessionId = typeof payload.session_id === 'string' && payload.session_id.trim()
|
|
152
|
+
? payload.session_id.trim()
|
|
153
|
+
: 'default';
|
|
154
|
+
const graceSessionKey = rawGraceSessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default';
|
|
155
|
+
const graceDir = path.join(projectRoot, '.ukit', 'storage', 'cache');
|
|
156
|
+
const graceBaseName = `hardcap-grace.${graceSessionKey}.json`;
|
|
157
|
+
const gracePath = path.join(graceDir, graceBaseName);
|
|
158
|
+
const slotPath = (n) => path.join(graceDir, `${graceBaseName}.${n}`);
|
|
159
|
+
const slotPattern = new RegExp(`^${graceBaseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(\\d+)$`);
|
|
142
160
|
const readSlots = () => {
|
|
143
161
|
try {
|
|
144
162
|
return fs.readdirSync(graceDir)
|
|
145
|
-
.map((name) =>
|
|
163
|
+
.map((name) => slotPattern.exec(name))
|
|
146
164
|
.filter(Boolean)
|
|
147
165
|
.map((m) => Number(m[1]))
|
|
148
166
|
.sort((a, b) => a - b);
|
|
@@ -175,8 +193,15 @@ function readRunCursor() {
|
|
|
175
193
|
}
|
|
176
194
|
const resumable = run || ordinaryTask;
|
|
177
195
|
if (resumable) {
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
// Invalid config must not turn the landing allowance negative/zero (which makes every
|
|
197
|
+
// unfinished run hard-block immediately). This hook is a liveness backstop, so malformed
|
|
198
|
+
// values deliberately fall back to the documented default rather than fail closed.
|
|
199
|
+
const configuredGraceCalls = config?.compact?.hardCapGraceCalls;
|
|
200
|
+
const graceCalls = typeof configuredGraceCalls === 'number'
|
|
201
|
+
&& Number.isFinite(configuredGraceCalls)
|
|
202
|
+
&& Number.isInteger(configuredGraceCalls)
|
|
203
|
+
&& configuredGraceCalls > 0
|
|
204
|
+
? configuredGraceCalls
|
|
180
205
|
: 10;
|
|
181
206
|
|
|
182
207
|
let slots = readSlots();
|
|
@@ -73,7 +73,7 @@ function loadHardCap() {
|
|
|
73
73
|
try {
|
|
74
74
|
const raw = fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8');
|
|
75
75
|
const value = JSON.parse(raw)?.compact?.hardCapTokens;
|
|
76
|
-
if (Number.isFinite(value) && value > 0) return value;
|
|
76
|
+
if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0) return value;
|
|
77
77
|
} catch { /* fall through to the default */ }
|
|
78
78
|
return 500_000;
|
|
79
79
|
}
|