@ngockhoale/ukit 2.3.5 → 2.3.7
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 +62 -0
- package/package.json +2 -2
- package/src/core/compact/threshold.js +23 -13
- package/src/core/fileOps.js +69 -0
- package/src/core/output/index.js +16 -11
- package/src/core/uninstall.js +53 -4
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +24 -13
- package/templates/.claude/ukit/runtime/output-compression.mjs +19 -17
- package/templates/.claude/ukit/runtime/token-utils.mjs +83 -2
- package/templates/AGENTS.md +8 -0
- package/templates/CLAUDE.md +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.3.7 - 2026-09-10
|
|
6
|
+
|
|
7
|
+
Bug-sweep wave 3: a data-loss escape in uninstall, cross-process races on shared runtime
|
|
8
|
+
state, one flaky test, and one vulnerable runtime dependency — all found by verified
|
|
9
|
+
reproducers and fixed with regression coverage.
|
|
10
|
+
|
|
11
|
+
**P0 — uninstall could delete files outside the project root.** Tracked-path ownership
|
|
12
|
+
checks were purely lexical: with `.claude` (or any parent directory) symlinked to an
|
|
13
|
+
external location, uninstall's recursive remove resolved through the symlink and deleted
|
|
14
|
+
whatever it pointed at — e.g. a shared dotfiles directory. Uninstall now inspects every
|
|
15
|
+
directory component between the project root and each tracked entry (the entry itself may
|
|
16
|
+
still be a tracked symlink, which is unlinked, never followed); entries with a symlinked
|
|
17
|
+
parent are refused, warned, and reported in a new `skippedSymlinkParents` field in both
|
|
18
|
+
dry-run and real results.
|
|
19
|
+
|
|
20
|
+
**P1 — concurrent state mutations silently lost updates.** `appendOutputHistory` and the
|
|
21
|
+
compact-pressure writers (prompt, output, plan, and the plain state write) performed
|
|
22
|
+
unsynchronized read-modify-write cycles on files under `.ukit/storage/cache/`. With
|
|
23
|
+
parallel subagents firing hooks in separate processes, 30 concurrent updates left a single
|
|
24
|
+
surviving output-history entry (of a 25 cap) and 17 of 510 accumulated sessionTokens —
|
|
25
|
+
understating context pressure and delaying compaction. All mutations now run under
|
|
26
|
+
`withFileLock`, a directory-lock protocol beside each state file (atomic `mkdir`, stale
|
|
27
|
+
reclaim after 10s, fail-open after 5s so a stuck lock can never freeze a hook). The lock
|
|
28
|
+
ships in both `src/core/fileOps.js` and the runtime mirror `token-utils.mjs` using the
|
|
29
|
+
same `<file>.lock` path, so CLI processes and hook processes serialize against each
|
|
30
|
+
other; mirror state writes also became atomic (tmp + rename). An interop test runs both
|
|
31
|
+
implementations against one project root to keep the protocols locked together.
|
|
32
|
+
|
|
33
|
+
**Flaky test** — the `--no-outline` index-tools test queued a `getFileOutline` once-mock
|
|
34
|
+
its own flag guaranteed would never be consumed; `vi.clearAllMocks()` drains no once-queues,
|
|
35
|
+
so test order leaked the stale value into the next test (18/30 shuffled solo runs failed).
|
|
36
|
+
The dead queue is gone; the full suite is now 3/3 green under `--sequence.shuffle`.
|
|
37
|
+
|
|
38
|
+
**Dependency** — runtime dependency `yaml` 2.8.2 → 2.9.0 (GHSA-48c2-rrv3-qjmp, stack
|
|
39
|
+
overflow via deeply nested YAML — UKit parses project-supplied manifests). Production
|
|
40
|
+
dependency audit is now 0 findings.
|
|
41
|
+
|
|
42
|
+
Full suite: 78 files, 1,359 tests green.
|
|
43
|
+
|
|
44
|
+
## 2.3.6 - 2026-09-10
|
|
45
|
+
|
|
46
|
+
Anti-stall coverage parity release: the compact-recovery and stop-reason directives that
|
|
47
|
+
UKit hooks inject at runtime on Claude Code and omp now also reach harnesses that have no
|
|
48
|
+
hook system at all.
|
|
49
|
+
|
|
50
|
+
A new `## Long-Run Continuity` section ships in both `templates/CLAUDE.md` and
|
|
51
|
+
`templates/AGENTS.md` (byte-identical, enforced by the per-section context-docs parity
|
|
52
|
+
test). Codex and OpenCode load no hooks, so root `AGENTS.md` is their only instruction
|
|
53
|
+
channel — before this release those sessions never saw the near-cap recovery order (LAND
|
|
54
|
+
one thing → DEFER the rest to `docs/STATUS.md` → DELEGATE broad work to subagents → only
|
|
55
|
+
then compact) or the post-compact discipline (no rereads of pre-compact context; if the
|
|
56
|
+
first turn after a compact still sits at ≥60% of the cap, recover by delegating or starting
|
|
57
|
+
fresh from disk state instead of burning the window again).
|
|
58
|
+
|
|
59
|
+
The same session closed the long-running stall investigation with debug-log evidence: the
|
|
60
|
+
observed "runs a while, then goes silent" behaviour is UNIC-gateway/network streaming
|
|
61
|
+
latency (repeated 30s slow-first-byte, one 300s byte-idle abort), not UKit hooks —
|
|
62
|
+
hook-error cache empty, exec ledgers healthy. When a dead stream ends a turn early, the
|
|
63
|
+
existing ≥2.3.3 Stop-hook completion gate blocks the premature stop, so staying current
|
|
64
|
+
(`ukit update`) plus restarting stale sessions (hook config loads at session start) is the
|
|
65
|
+
full remediation available in-repo; gateway-side idle/failover fixes belong upstream.
|
|
66
|
+
|
|
5
67
|
## 2.3.5 - 2026-09-10
|
|
6
68
|
|
|
7
69
|
Source-audit hardening release: the code index cannot corrupt itself, and a corrupt runtime
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ngockhoale/ukit",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.7",
|
|
4
4
|
"description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"node": ">=20"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"yaml": "^2.
|
|
68
|
+
"yaml": "^2.9.0"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"vitest": "^2"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
1
|
+
import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
|
|
2
2
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
3
3
|
import { buildCompactMachineKey, compressLine, estimateTokenCount } from '../token/index.js';
|
|
4
4
|
import { compactContextBlock } from './index.js';
|
|
@@ -937,27 +937,37 @@ export async function readCompactPressureState(projectRoot, config = {}) {
|
|
|
937
937
|
return buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
|
|
938
938
|
}
|
|
939
939
|
|
|
940
|
+
// All compact-pressure mutations share one lock on the state file: without it,
|
|
941
|
+
// concurrent hook processes (parallel subagents) and same-process flows interleave
|
|
942
|
+
// their read-modify-write cycles and silently drop each other's sessionTokens.
|
|
943
|
+
async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
944
|
+
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
945
|
+
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
946
|
+
const current = buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
|
|
947
|
+
const next = mutator(current);
|
|
948
|
+
const normalized = buildCompactPressureState(next, config);
|
|
949
|
+
await writeJson(runtimePaths.compactPressurePath, normalized);
|
|
950
|
+
return normalized;
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
940
954
|
export async function writeCompactPressureState(projectRoot, state, config = {}) {
|
|
941
955
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
956
|
+
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
957
|
+
const normalized = buildCompactPressureState(state, config);
|
|
958
|
+
await writeJson(runtimePaths.compactPressurePath, normalized);
|
|
959
|
+
return normalized;
|
|
960
|
+
});
|
|
945
961
|
}
|
|
946
962
|
|
|
947
963
|
export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
|
|
948
|
-
|
|
949
|
-
const next = registerPromptPressure(current, payload, config);
|
|
950
|
-
return writeCompactPressureState(projectRoot, next, config);
|
|
964
|
+
return mutateCompactPressureState(projectRoot, (current) => registerPromptPressure(current, payload, config), config);
|
|
951
965
|
}
|
|
952
966
|
|
|
953
967
|
export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
|
|
954
|
-
|
|
955
|
-
const next = registerOutputPressure(current, payload, config);
|
|
956
|
-
return writeCompactPressureState(projectRoot, next, config);
|
|
968
|
+
return mutateCompactPressureState(projectRoot, (current) => registerOutputPressure(current, payload, config), config);
|
|
957
969
|
}
|
|
958
970
|
|
|
959
971
|
export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
|
|
960
|
-
|
|
961
|
-
const next = registerThresholdCompactPlan(current, plan, config);
|
|
962
|
-
return writeCompactPressureState(projectRoot, next, config);
|
|
972
|
+
return mutateCompactPressureState(projectRoot, (current) => registerThresholdCompactPlan(current, plan, config), config);
|
|
963
973
|
}
|
package/src/core/fileOps.js
CHANGED
|
@@ -131,6 +131,75 @@ export async function writeJson(filePath, data) {
|
|
|
131
131
|
await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
const LOCK_STALE_MS = 10_000;
|
|
135
|
+
const LOCK_MAX_WAIT_MS = 5_000;
|
|
136
|
+
|
|
137
|
+
function lockBackoffDelayMs() {
|
|
138
|
+
return 3 + Math.floor(Math.random() * 9);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sleep(ms) {
|
|
142
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
147
|
+
* (hook invocations run as separate node processes) and across concurrent async
|
|
148
|
+
* flows in one process (parallel subagents). The lock is a directory created next
|
|
149
|
+
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
150
|
+
* A crashed holder is reclaimed once the directory's mtime exceeds staleMs.
|
|
151
|
+
* Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
|
|
152
|
+
* the callback runs anyway (the pre-lock behaviour) — these state files are
|
|
153
|
+
* advisory caches, and losing an update beats freezing a hook mid-flight.
|
|
154
|
+
* Holders must keep their critical section far below staleMs; nothing refreshes
|
|
155
|
+
* the lock mtime, so a section that somehow runs longer can have its lock stolen.
|
|
156
|
+
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
157
|
+
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
158
|
+
* @returns {Promise<*>} whatever fn resolves with
|
|
159
|
+
*/
|
|
160
|
+
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
161
|
+
const lockPath = `${filePath}.lock`;
|
|
162
|
+
const startedAt = Date.now();
|
|
163
|
+
let locked = false;
|
|
164
|
+
|
|
165
|
+
while (!locked) {
|
|
166
|
+
try {
|
|
167
|
+
await ensureDir(path.dirname(lockPath));
|
|
168
|
+
await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
|
|
169
|
+
locked = true;
|
|
170
|
+
break;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Someone holds the lock. Reclaim it when it looks abandoned; otherwise back off.
|
|
176
|
+
try {
|
|
177
|
+
const stat = await fs.stat(lockPath);
|
|
178
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
179
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
180
|
+
continue; // the slot is free now — retry immediately
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
continue; // lock vanished between mkdir and stat — retry immediately
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
187
|
+
await sleep(lockBackoffDelayMs());
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
return await fn();
|
|
192
|
+
} finally {
|
|
193
|
+
if (locked) {
|
|
194
|
+
try {
|
|
195
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
196
|
+
} catch {
|
|
197
|
+
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
134
203
|
/**
|
|
135
204
|
* Create a directory symlink (macOS/Linux) or junction (Windows).
|
|
136
205
|
* Junctions on Windows don't require elevated privileges.
|
package/src/core/output/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
3
|
+
import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
|
|
4
4
|
import { recordCompaction } from '../compact/index.js';
|
|
5
5
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
6
6
|
import {
|
|
@@ -1075,16 +1075,21 @@ export async function appendOutputHistory(projectRoot, entry, options = {}) {
|
|
|
1075
1075
|
}
|
|
1076
1076
|
|
|
1077
1077
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
entries
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1078
|
+
// The read-merge-write cycle runs under the state-file lock: concurrent tool-output
|
|
1079
|
+
// captures (parallel subagents fire hooks in separate processes) otherwise read the
|
|
1080
|
+
// same old history and overwrite each other, leaving only one survivor entry.
|
|
1081
|
+
return withFileLock(runtimePaths.outputHistoryPath, async () => {
|
|
1082
|
+
const history = await readOutputHistory(projectRoot, options);
|
|
1083
|
+
const normalizedKey = buildOutputHistoryDedupeKey(normalizedEntry);
|
|
1084
|
+
const dedupedExistingEntries = history.entries.filter((candidate) => !(
|
|
1085
|
+
buildOutputHistoryDedupeKey(candidate) === normalizedKey
|
|
1086
|
+
));
|
|
1087
|
+
const nextDocument = normalizeOutputHistoryDocument({
|
|
1088
|
+
entries: [normalizedEntry, ...dedupedExistingEntries],
|
|
1089
|
+
}, options);
|
|
1090
|
+
await writeJson(runtimePaths.outputHistoryPath, nextDocument);
|
|
1091
|
+
return nextDocument;
|
|
1092
|
+
});
|
|
1088
1093
|
}
|
|
1089
1094
|
|
|
1090
1095
|
export function summarizeOutputHistory(rawHistory) {
|
package/src/core/uninstall.js
CHANGED
|
@@ -64,6 +64,37 @@ function isSameOrDescendantProjectPath(candidatePath, parentPath) {
|
|
|
64
64
|
return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// A tracked path is only safe to delete when every directory between the project root
|
|
68
|
+
// and the entry itself is a real directory. The prefix allowlist above is purely
|
|
69
|
+
// lexical: if `.claude` (or any parent) is a symlink to somewhere outside the project,
|
|
70
|
+
// `fs.rm(recursive)` resolves THROUGH it and would delete whatever it points at —
|
|
71
|
+
// e.g. a shared dotfiles directory linked in as .claude. The entry itself may be a
|
|
72
|
+
// symlink (tracked links are unlinked, never followed); only its parents matter.
|
|
73
|
+
async function hasSymlinkedParent(projectRoot, absolutePath) {
|
|
74
|
+
const root = path.resolve(projectRoot);
|
|
75
|
+
const relative = path.relative(root, path.resolve(absolutePath));
|
|
76
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
77
|
+
// Outside the project root entirely — nothing here is safe to delete.
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
const segments = relative.split(path.sep);
|
|
81
|
+
segments.pop(); // the entry itself is allowed to be a symlink (removeLinkOnly unlinks it)
|
|
82
|
+
let current = root;
|
|
83
|
+
for (const segment of segments) {
|
|
84
|
+
current = path.join(current, segment);
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = await fs.lstat(current);
|
|
88
|
+
} catch {
|
|
89
|
+
return false; // missing parent — nothing to resolve through
|
|
90
|
+
}
|
|
91
|
+
if (stat.isSymbolicLink()) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
67
98
|
// Hardcoded fallback for installs that predate file tracking (no 'files' field
|
|
68
99
|
// in install.json). New installs always have a 'files' list, so this fallback
|
|
69
100
|
// only applies when upgrading from a very old UKit version.
|
|
@@ -205,18 +236,36 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
205
236
|
...linkPaths.map((abs) => ({ abs, useLink: true })),
|
|
206
237
|
];
|
|
207
238
|
|
|
239
|
+
// Never delete through a symlinked parent — see hasSymlinkedParent. Skipped entries
|
|
240
|
+
// are reported (dryRun and real run alike) so the user sees exactly what was refused.
|
|
241
|
+
const skippedSymlinkParents = [];
|
|
242
|
+
const safeEntries = [];
|
|
243
|
+
for (const entry of allEntries) {
|
|
244
|
+
if (await hasSymlinkedParent(projectRoot, entry.abs)) {
|
|
245
|
+
skippedSymlinkParents.push(entry.abs);
|
|
246
|
+
} else {
|
|
247
|
+
safeEntries.push(entry);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (skippedSymlinkParents.length > 0) {
|
|
251
|
+
console.warn(
|
|
252
|
+
'[UKit] Refusing to remove paths whose parent directories are symlinks (deleting them would escape the project root):',
|
|
253
|
+
skippedSymlinkParents,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
208
257
|
if (dryRun) {
|
|
209
258
|
// Use lstat (not access) so broken symlinks are included in the report.
|
|
210
259
|
const wouldRemove = [];
|
|
211
|
-
for (const { abs } of
|
|
260
|
+
for (const { abs } of safeEntries) {
|
|
212
261
|
if (await pathExistsLstat(abs)) wouldRemove.push(abs);
|
|
213
262
|
}
|
|
214
|
-
return { removed: 0, attempted: allEntries.length, wasInstalled: true, wouldRemove };
|
|
263
|
+
return { removed: 0, attempted: allEntries.length, wasInstalled: true, wouldRemove, skippedSymlinkParents };
|
|
215
264
|
}
|
|
216
265
|
|
|
217
266
|
// Remove all paths in parallel
|
|
218
267
|
const results = await Promise.all(
|
|
219
|
-
|
|
268
|
+
safeEntries.map(({ abs, useLink }) => {
|
|
220
269
|
const remove = useLink ? removeLinkOnly : removeLinkOrDir;
|
|
221
270
|
return remove(abs).then((didRemove) => ({ abs, didRemove }));
|
|
222
271
|
}),
|
|
@@ -240,5 +289,5 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
240
289
|
await cleanupEmptyParents(removedPath, projectRoot);
|
|
241
290
|
}
|
|
242
291
|
|
|
243
|
-
return { removed, attempted: allEntries.length, wasInstalled: true };
|
|
292
|
+
return { removed, attempted: allEntries.length, wasInstalled: true, skippedSymlinkParents };
|
|
244
293
|
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
compressLine,
|
|
9
9
|
estimateTokenCount,
|
|
10
10
|
readJson,
|
|
11
|
+
withFileLock,
|
|
12
|
+
writeJson,
|
|
11
13
|
} from './token-utils.mjs';
|
|
12
14
|
|
|
13
15
|
const DEFAULT_MAX_PROMPT_ENTRIES = 12;
|
|
@@ -1051,30 +1053,39 @@ export async function readCompactPressureState(projectRoot, config = {}) {
|
|
|
1051
1053
|
return buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
|
|
1052
1054
|
}
|
|
1053
1055
|
|
|
1056
|
+
// All compact-pressure mutations share one lock on the state file: without it,
|
|
1057
|
+
// concurrent hook processes (parallel subagents) and same-process flows interleave
|
|
1058
|
+
// their read-modify-write cycles and silently drop each other's sessionTokens.
|
|
1059
|
+
async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
1060
|
+
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1061
|
+
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1062
|
+
const current = buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
|
|
1063
|
+
const next = mutator(current);
|
|
1064
|
+
const normalized = buildCompactPressureState(next, config);
|
|
1065
|
+
await writeJson(runtimePaths.compactPressurePath, normalized);
|
|
1066
|
+
return normalized;
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1054
1070
|
export async function writeCompactPressureState(projectRoot, state, config = {}) {
|
|
1055
1071
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1072
|
+
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1073
|
+
const normalized = buildCompactPressureState(state, config);
|
|
1074
|
+
await writeJson(runtimePaths.compactPressurePath, normalized);
|
|
1075
|
+
return normalized;
|
|
1076
|
+
});
|
|
1060
1077
|
}
|
|
1061
1078
|
|
|
1062
1079
|
export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
|
|
1063
|
-
|
|
1064
|
-
const next = registerPromptPressure(current, payload, config);
|
|
1065
|
-
return writeCompactPressureState(projectRoot, next, config);
|
|
1080
|
+
return mutateCompactPressureState(projectRoot, (current) => registerPromptPressure(current, payload, config), config);
|
|
1066
1081
|
}
|
|
1067
1082
|
|
|
1068
1083
|
export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
|
|
1069
|
-
|
|
1070
|
-
const next = registerOutputPressure(current, payload, config);
|
|
1071
|
-
return writeCompactPressureState(projectRoot, next, config);
|
|
1084
|
+
return mutateCompactPressureState(projectRoot, (current) => registerOutputPressure(current, payload, config), config);
|
|
1072
1085
|
}
|
|
1073
1086
|
|
|
1074
1087
|
export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
|
|
1075
|
-
|
|
1076
|
-
const next = registerThresholdCompactPlan(current, plan, config);
|
|
1077
|
-
return writeCompactPressureState(projectRoot, next, config);
|
|
1088
|
+
return mutateCompactPressureState(projectRoot, (current) => registerThresholdCompactPlan(current, plan, config), config);
|
|
1078
1089
|
}
|
|
1079
1090
|
|
|
1080
1091
|
async function loadRuntimeConfig(projectRoot) {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
readJson,
|
|
11
11
|
readPromptCacheEntry,
|
|
12
12
|
recordCompaction,
|
|
13
|
+
withFileLock,
|
|
14
|
+
writeJson,
|
|
13
15
|
writePromptCacheEntry,
|
|
14
16
|
} from './token-utils.mjs';
|
|
15
17
|
import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
|
|
@@ -1113,26 +1115,26 @@ function normalizeOutputHistoryDocument(raw) {
|
|
|
1113
1115
|
};
|
|
1114
1116
|
}
|
|
1115
1117
|
|
|
1116
|
-
async function writeJson(filePath, value) {
|
|
1117
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1118
|
-
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
1118
|
async function appendOutputHistory(projectRoot, entry) {
|
|
1122
1119
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1120
|
+
// The read-merge-write cycle runs under the state-file lock: concurrent tool-output
|
|
1121
|
+
// captures (parallel subagents fire hooks in separate processes) otherwise read the
|
|
1122
|
+
// same old history and overwrite each other, leaving only one survivor entry.
|
|
1123
|
+
return withFileLock(runtimePaths.outputHistoryPath, async () => {
|
|
1124
|
+
const current = normalizeOutputHistoryDocument(await readJson(runtimePaths.outputHistoryPath, { entries: [] }));
|
|
1125
|
+
const normalizedEntry = normalizeOutputHistoryEntry(entry);
|
|
1126
|
+
if (!normalizedEntry) return current;
|
|
1127
|
+
const normalizedKey = buildOutputHistoryDedupeKey(normalizedEntry);
|
|
1128
|
+
const dedupedExistingEntries = current.entries.filter((candidate) => !(
|
|
1129
|
+
buildOutputHistoryDedupeKey(candidate) === normalizedKey
|
|
1130
|
+
));
|
|
1131
|
+
|
|
1132
|
+
const nextDocument = normalizeOutputHistoryDocument({
|
|
1133
|
+
entries: [normalizedEntry, ...dedupedExistingEntries],
|
|
1134
|
+
});
|
|
1135
|
+
await writeJson(runtimePaths.outputHistoryPath, nextDocument);
|
|
1136
|
+
return nextDocument;
|
|
1133
1137
|
});
|
|
1134
|
-
await writeJson(runtimePaths.outputHistoryPath, nextDocument);
|
|
1135
|
-
return nextDocument;
|
|
1136
1138
|
}
|
|
1137
1139
|
|
|
1138
1140
|
async function findOutputHistoryEntry(projectRoot, command, summary) {
|
|
@@ -54,9 +54,90 @@ export async function readJson(filePath, fallback = null) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
async function writeJson(filePath, value) {
|
|
57
|
+
export async function writeJson(filePath, value) {
|
|
58
|
+
// tmp + rename: parallel hook processes must never observe a half-written state file.
|
|
58
59
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
59
|
-
|
|
60
|
+
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
61
|
+
try {
|
|
62
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
63
|
+
await fs.rename(tempPath, filePath);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
try {
|
|
66
|
+
await fs.rm(tempPath, { force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore cleanup errors
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LOCK_STALE_MS = 10_000;
|
|
75
|
+
const LOCK_MAX_WAIT_MS = 5_000;
|
|
76
|
+
|
|
77
|
+
function lockBackoffDelayMs() {
|
|
78
|
+
return 3 + Math.floor(Math.random() * 9);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sleep(ms) {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
87
|
+
* (hook invocations run as separate node processes) and across concurrent async
|
|
88
|
+
* flows in one process (parallel subagents). The lock is a directory created next
|
|
89
|
+
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
90
|
+
* A crashed holder is reclaimed once the directory's mtime exceeds staleMs.
|
|
91
|
+
* Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
|
|
92
|
+
* the callback runs anyway (the pre-lock behaviour) — these state files are
|
|
93
|
+
* advisory caches, and losing an update beats freezing a hook mid-flight.
|
|
94
|
+
* Protocol-compatible with src/core/fileOps.js withFileLock (same `<file>.lock`
|
|
95
|
+
* path), so CLI processes and hook processes serialize against each other.
|
|
96
|
+
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
97
|
+
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
98
|
+
* @returns {Promise<*>} whatever fn resolves with
|
|
99
|
+
*/
|
|
100
|
+
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
101
|
+
const lockPath = `${filePath}.lock`;
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
let locked = false;
|
|
104
|
+
|
|
105
|
+
while (!locked) {
|
|
106
|
+
try {
|
|
107
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
108
|
+
await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
|
|
109
|
+
locked = true;
|
|
110
|
+
break;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Someone holds the lock. Reclaim it when it looks abandoned; otherwise back off.
|
|
116
|
+
try {
|
|
117
|
+
const stat = await fs.stat(lockPath);
|
|
118
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
119
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
120
|
+
continue; // the slot is free now — retry immediately
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
continue; // lock vanished between mkdir and stat — retry immediately
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
127
|
+
await sleep(lockBackoffDelayMs());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return await fn();
|
|
132
|
+
} finally {
|
|
133
|
+
if (locked) {
|
|
134
|
+
try {
|
|
135
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
136
|
+
} catch {
|
|
137
|
+
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
60
141
|
}
|
|
61
142
|
|
|
62
143
|
export function buildCompactMachineKey(prefix, payload = {}) {
|
package/templates/AGENTS.md
CHANGED
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
- **Do NOT say "done", "applied", or "fixed" after Read/Grep/analysis alone.** Completion wording requires concrete Edit/Write evidence in the current turn, and verification when the scope is risky.
|
|
27
27
|
- **Every stop says why — no silent idle.** When a turn ends because only the user can act (login, approval, protected-file edit), open the reply with one line naming the exact action: `WAITING ON YOU: <command/action>`, and schedule a one-shot wakeup (~20-30 min) when the harness provides one so the session re-checks and auto-continues once the user has acted. An ended turn cannot observe external/auth changes by itself, so without that line (and the wakeup) the idle session looks identical to a stall. Any error — failed command, hook, test, publish — is reported verbatim in the same turn, never silently retried past the user.
|
|
28
28
|
|
|
29
|
+
## Long-Run Continuity
|
|
30
|
+
|
|
31
|
+
Mirrors what UKit hooks inject at runtime on Claude Code and omp; on harnesses without hooks (Codex, OpenCode) this section is the only carrier — keep it in sync with `.claude/hooks/context-window-guard.sh`.
|
|
32
|
+
|
|
33
|
+
- Near token-cap: **LAND one thing** — finish the smallest in-flight item end-to-end (edit + verify, ≤3 tool calls) and report it done. **DEFER the rest** — one line per remaining step into `docs/STATUS.md`, or split into bounded `docs/AI_HANDOFF/` tasks. **DELEGATE** broad work (searches, big reads, multi-file edits) to subagents whose tool output lives in their own windows. Only then compact.
|
|
34
|
+
- After any compact or handoff: do not reread pre-compact context — continue from the persisted disk state, delegate broad work, keep replies short. If the first turn after a compact still sits at ≥60% of the cap, stop rereading immediately and recover by delegating or starting a fresh session from the disk state; do not burn the window again.
|
|
35
|
+
- A run ends only on completion evidence, a genuine blocker, or a user-only action — and per the Execution Contract above, every such stop names its reason in the final reply.
|
|
36
|
+
|
|
29
37
|
## Index-First Loop
|
|
30
38
|
|
|
31
39
|
For any task that needs code context:
|
package/templates/CLAUDE.md
CHANGED
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
- **Do NOT say "done", "applied", or "fixed" after Read/Grep/analysis alone.** Completion wording requires concrete Edit/Write evidence in the current turn, and verification when the scope is risky.
|
|
27
27
|
- **Every stop says why — no silent idle.** When a turn ends because only the user can act (login, approval, protected-file edit), open the reply with one line naming the exact action: `WAITING ON YOU: <command/action>`, and schedule a one-shot wakeup (~20-30 min) when the harness provides one so the session re-checks and auto-continues once the user has acted. An ended turn cannot observe external/auth changes by itself, so without that line (and the wakeup) the idle session looks identical to a stall. Any error — failed command, hook, test, publish — is reported verbatim in the same turn, never silently retried past the user.
|
|
28
28
|
|
|
29
|
+
## Long-Run Continuity
|
|
30
|
+
|
|
31
|
+
Mirrors what UKit hooks inject at runtime on Claude Code and omp; on harnesses without hooks (Codex, OpenCode) this section is the only carrier — keep it in sync with `.claude/hooks/context-window-guard.sh`.
|
|
32
|
+
|
|
33
|
+
- Near token-cap: **LAND one thing** — finish the smallest in-flight item end-to-end (edit + verify, ≤3 tool calls) and report it done. **DEFER the rest** — one line per remaining step into `docs/STATUS.md`, or split into bounded `docs/AI_HANDOFF/` tasks. **DELEGATE** broad work (searches, big reads, multi-file edits) to subagents whose tool output lives in their own windows. Only then compact.
|
|
34
|
+
- After any compact or handoff: do not reread pre-compact context — continue from the persisted disk state, delegate broad work, keep replies short. If the first turn after a compact still sits at ≥60% of the cap, stop rereading immediately and recover by delegating or starting a fresh session from the disk state; do not burn the window again.
|
|
35
|
+
- A run ends only on completion evidence, a genuine blocker, or a user-only action — and per the Execution Contract above, every such stop names its reason in the final reply.
|
|
36
|
+
|
|
29
37
|
## Index-First Loop
|
|
30
38
|
|
|
31
39
|
For any task that needs code context:
|