@ngockhoale/ukit 3.4.1 → 3.4.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 +19 -0
- package/package.json +1 -1
- package/src/cli/commands/code.js +29 -5
- package/src/cli/commands/decision.js +18 -4
- package/src/cli/commands/doctor.js +7 -3
- package/src/cli/commands/install.js +29 -4
- package/src/cli/commands/memory.js +25 -5
- package/src/cli/commands/telemetry.js +18 -1
- package/src/cli/commands/vm.js +7 -1
- package/src/context/detectProjectContext.js +7 -2
- package/src/core/agentRuntime/contract.js +5 -1
- package/src/core/agentRuntime/eventStore.js +54 -7
- package/src/core/agentRuntime/recovery.js +22 -15
- package/src/core/agentRuntime/supervisor.js +71 -13
- package/src/core/applyPlan.js +11 -1
- package/src/core/codeintel/compiler.js +51 -8
- package/src/core/codeintel/diagnostics.js +124 -33
- package/src/core/codeintel/freshness.js +25 -12
- package/src/core/codeintel/invalidation.js +11 -3
- package/src/core/codeintel/retriever.js +53 -20
- package/src/core/codeintel/router.js +19 -9
- package/src/core/codeintel/summaries.js +4 -3
- package/src/core/codeintel/vectorProvider.js +30 -4
- package/src/core/compact/index.js +24 -7
- package/src/core/compact/threshold.js +49 -14
- package/src/core/diffPlan.js +51 -23
- package/src/core/ensureGitignore.js +19 -2
- package/src/core/fileOps.js +61 -0
- package/src/core/memory/hygiene.js +51 -1
- package/src/core/memory/migrate.js +41 -21
- package/src/core/memory/store.js +96 -61
- package/src/core/metadata.js +37 -2
- package/src/core/observability/adapters/ingest.js +30 -2
- package/src/core/observability/emit/config.js +19 -4
- package/src/core/observability/emit/crash.js +3 -1
- package/src/core/observability/emit/recorder.js +15 -7
- package/src/core/observability/privacy/sanitizeObserved.js +3 -1
- package/src/core/observability/segments/internal.js +36 -8
- package/src/core/observability/segments/retention.js +11 -0
- package/src/core/observability/support/import.js +27 -1
- package/src/core/output/index.js +16 -1
- package/src/core/permissionDoctor.js +72 -9
- package/src/core/repairBrokenHooks.js +15 -2
- package/src/core/reviewPanelAggregate.js +26 -10
- package/src/core/runInstallPipeline.js +71 -22
- package/src/core/runtimeConfig.js +2 -0
- package/src/core/status.js +2 -0
- package/src/core/taskBudgetValidator.js +7 -1
- package/src/core/taskProgressGuard.js +11 -1
- package/src/core/unattendedDoctor.js +36 -5
- package/src/core/uninstall.js +52 -12
- package/src/core/update.js +5 -1
- package/src/decision/client.js +158 -27
- package/src/decision/reviewVerdict.js +23 -7
- package/src/diagnostics/failurePatterns.js +1 -1
- package/src/diagnostics/feedbackEvents.js +1 -1
- package/src/diagnostics/routeOutcomes.js +42 -4
- package/src/diagnostics/skillAccuracy.js +35 -4
- package/src/index/buildIndex.js +123 -26
- package/src/index/fixLoopEscalation.js +3 -0
- package/src/index/gitHooks.js +99 -29
- package/src/index/importResolution.js +7 -1
- package/src/index/playbookRegistry.js +15 -11
- package/src/index/queryIndex.js +28 -10
- package/src/index/routeResolver.js +8 -3
- package/src/index/taskRouting.js +37 -2
- package/src/learning/codeProposals.js +24 -5
- package/src/learning/selfImprove.js +29 -5
- package/src/learning/tunedOverlay.js +18 -7
- package/src/learning/tuning.js +10 -4
- package/src/skill/auditSkill.js +46 -7
- package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
- package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
- package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
- package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
- package/template_project/.claude/hooks/session-episode.sh +60 -28
- package/template_project/.claude/hooks/verification-guard.sh +26 -15
- package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
- package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
- package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
- package/template_project/.claude/ukit/index/query-index.mjs +13 -6
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
- package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
- package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
- package/template_project/.claude/ukit/index/route-task.mjs +15 -0
- package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
- package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
- package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
- package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
- package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
- package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
- package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
- package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
|
@@ -93,9 +93,78 @@ export function telemetryDirFor(projectRoot) {
|
|
|
93
93
|
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// --- session-file lock (C90-19) ----------------------------------------------
|
|
97
|
+
// Synchronous variant of the `<file>.lock` convention async-lock.mjs owns for
|
|
98
|
+
// the async twins (mkdir is atomic, so exactly one process wins; `owner` stamps
|
|
99
|
+
// pid+token so stale reclaim is validated). The telemetry writers are all
|
|
100
|
+
// short-lived, fully-sync hook processes — the async lock would need an event
|
|
101
|
+
// loop these callers deliberately avoid — so the sync copy stays minimal:
|
|
102
|
+
// bounded spin, stale reclaim when the recorded pid is provably gone, and a
|
|
103
|
+
// fail-closed return (a missed budget drops the row — telemetry is advisory).
|
|
104
|
+
const TEL_LOCK_STALE_MS = 10_000;
|
|
105
|
+
const TEL_LOCK_BUDGET_MS = 300;
|
|
106
|
+
|
|
107
|
+
function isLockOwnerAlive(lockPath) {
|
|
108
|
+
try {
|
|
109
|
+
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
|
|
110
|
+
if (!Number.isInteger(owner?.pid) || owner.pid <= 0) return false;
|
|
111
|
+
process.kill(owner.pid, 0);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// EPERM: the process exists but belongs to another user — still alive.
|
|
115
|
+
return error?.code === 'EPERM';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function withTelemetryFileLock(filePath, fn) {
|
|
120
|
+
const lockPath = `${filePath}.lock`;
|
|
121
|
+
const deadline = Date.now() + TEL_LOCK_BUDGET_MS;
|
|
122
|
+
let held = false;
|
|
123
|
+
while (!held && Date.now() < deadline) {
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(lockPath);
|
|
126
|
+
held = true;
|
|
127
|
+
try {
|
|
128
|
+
fs.writeFileSync(
|
|
129
|
+
path.join(lockPath, 'owner'),
|
|
130
|
+
`${JSON.stringify({ pid: process.pid, token: `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, ts: Date.now() })}\n`,
|
|
131
|
+
);
|
|
132
|
+
} catch {
|
|
133
|
+
// Owner stamp is advisory (stale reclaim degrades to the mtime
|
|
134
|
+
// threshold); failing to write it must not drop the held lock.
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (!error || error.code !== 'EEXIST') return undefined;
|
|
138
|
+
try {
|
|
139
|
+
const stat = fs.statSync(lockPath);
|
|
140
|
+
if (Date.now() - stat.mtimeMs > TEL_LOCK_STALE_MS && !isLockOwnerAlive(lockPath)) {
|
|
141
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// stat raced away — fall through to the bounded spin
|
|
146
|
+
}
|
|
147
|
+
const spinUntil = Date.now() + 5;
|
|
148
|
+
while (Date.now() < spinUntil) { /* bounded spin — sync callers only */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!held) return undefined;
|
|
152
|
+
try {
|
|
153
|
+
return fn();
|
|
154
|
+
} finally {
|
|
155
|
+
try {
|
|
156
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
157
|
+
} catch {
|
|
158
|
+
// Leftover lock self-heals via the stale threshold above.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
96
163
|
// Bounded work on THIS session's file only: stat, at most one read of a file
|
|
97
164
|
// already capped at maxBytes, one rewrite of the kept half. Directory growth is
|
|
98
165
|
// bounded separately by the sampled sweepTelemetryDir below (BUG-C21-10).
|
|
166
|
+
// C90-19: the rewrite is tmp+rename — the pre-fix in-place writeFileSync on the
|
|
167
|
+
// live file could expose a torn file or lose an interleaved append.
|
|
99
168
|
function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
100
169
|
let size = 0;
|
|
101
170
|
try {
|
|
@@ -116,7 +185,17 @@ function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
|
116
185
|
keep.unshift(lines[i]);
|
|
117
186
|
kept += lineBytes;
|
|
118
187
|
}
|
|
119
|
-
|
|
188
|
+
const tmpPath = `${filePath}.rot-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
189
|
+
try {
|
|
190
|
+
fs.writeFileSync(tmpPath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
|
|
191
|
+
fs.renameSync(tmpPath, filePath);
|
|
192
|
+
} catch (renameError) {
|
|
193
|
+
try { fs.rmSync(tmpPath, { force: true }); } catch { /* best effort */ }
|
|
194
|
+
if (renameError?.code !== 'EXDEV') throw renameError;
|
|
195
|
+
// EXDEV: tmp and destination on different mounts — same copy-over
|
|
196
|
+
// fallback token-utils writeJson carries.
|
|
197
|
+
fs.writeFileSync(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
|
|
198
|
+
}
|
|
120
199
|
} catch {
|
|
121
200
|
// Rotation failed; drop this row rather than grow past the cap.
|
|
122
201
|
}
|
|
@@ -230,12 +309,18 @@ export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
|
|
|
230
309
|
fs.mkdirSync(dir, { recursive: true });
|
|
231
310
|
const filePath = path.join(dir, `${safeName(sessionId)}.jsonl`);
|
|
232
311
|
const line = `${JSON.stringify(row)}\n`;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
312
|
+
// C90-19: rotation and the append run under the session-file lock — an
|
|
313
|
+
// unlocked append landing between the rotator's readFileSync and its
|
|
314
|
+
// rename was the lost-row window. Lock budget expiry drops the row
|
|
315
|
+
// (telemetry is advisory, never a blocker).
|
|
316
|
+
return withTelemetryFileLock(filePath, () => {
|
|
317
|
+
rotateIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), capBytes(options.maxBytes));
|
|
318
|
+
fs.appendFileSync(filePath, line, 'utf8');
|
|
319
|
+
// Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
|
|
320
|
+
// never alter the row this call just appended.
|
|
321
|
+
try { maybeSweepTelemetryDir(dir); } catch (error) { noteSweepFailure(error); }
|
|
322
|
+
return true;
|
|
323
|
+
}) === true;
|
|
239
324
|
} catch {
|
|
240
325
|
// Advisory: an unwritable or corrupt telemetry target must never alter a
|
|
241
326
|
// hook's verdict, output, or exit status.
|
|
@@ -61,6 +61,23 @@ async function checkFileEvidence(projectRoot, entry) {
|
|
|
61
61
|
const abs = resolveLocator(projectRoot, entry.locator);
|
|
62
62
|
if (abs == null) return { state: UNKNOWN, detail: 'locator-unverifiable' };
|
|
63
63
|
|
|
64
|
+
// Symlink defense (SPEC §14): a locator that resolves inside projectRoot
|
|
65
|
+
// but lands on a symlink pointing outside must never be followed — the
|
|
66
|
+
// realpath containment check makes escapes unverifiable → 'unknown'.
|
|
67
|
+
try {
|
|
68
|
+
const lst = await fsp.lstat(abs);
|
|
69
|
+
if (lst.isSymbolicLink()) {
|
|
70
|
+
const root = path.resolve(projectRoot);
|
|
71
|
+
const real = await fsp.realpath(abs).catch(() => null);
|
|
72
|
+
if (real == null || (real !== root && !real.startsWith(root + path.sep))) {
|
|
73
|
+
return { state: UNKNOWN, detail: 'locator-unverifiable' };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// lstat failure → fall through to the normal stat path, which maps
|
|
78
|
+
// ENOENT to 'file-missing' and other errors to 'file-unverified'.
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
const fp = entry.fingerprint;
|
|
65
82
|
if (typeof fp === 'string' && fp.startsWith('sha1:')) {
|
|
66
83
|
const actual = await sha1Fingerprint(abs);
|
|
@@ -249,7 +249,9 @@ function sanitizeValue(value, ctx, depth) {
|
|
|
249
249
|
let kept = 0;
|
|
250
250
|
for (const [k, v] of Object.entries(value)) {
|
|
251
251
|
if (kept >= MAX_OBJECT_KEYS) break;
|
|
252
|
-
|
|
252
|
+
// TASK-C91-015 (W3-OB1): the denylist is case-insensitive — 'Prompt'
|
|
253
|
+
// and 'AUTHORIZATION' leak the same data as their lowercase forms.
|
|
254
|
+
if (DENIED_PAYLOAD_FIELDS.has(k.toLowerCase())) continue;
|
|
253
255
|
const key = sanitizeKey(k, ctx);
|
|
254
256
|
if (key === null) continue;
|
|
255
257
|
const sv = sanitizeValue(v, ctx, depth + 1);
|
|
@@ -473,6 +475,10 @@ const MAX_POLICY_VERSION_CHARS = 128;
|
|
|
473
475
|
const SAMPLING_BUCKETS = 10000;
|
|
474
476
|
const SAMPLING_SEED = 0;
|
|
475
477
|
let samplingConfigInvalidSeen = false;
|
|
478
|
+
// TASK-C91-015 (W3-OB2): the CONFIG_INVALID report latch is process-level —
|
|
479
|
+
// emitOne re-creates its per-call `state`, so a `state.configInvalidReported`
|
|
480
|
+
// flag re-reported the aggregate on every emit in the same process.
|
|
481
|
+
let samplingConfigInvalidReported = false;
|
|
476
482
|
|
|
477
483
|
function resolveSampling(config = null) {
|
|
478
484
|
const node = isPlainObject(config) ? config.observability : undefined;
|
|
@@ -659,24 +665,46 @@ async function withTimeout(promise, ms) {
|
|
|
659
665
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
660
666
|
}
|
|
661
667
|
|
|
668
|
+
// Marks a readState result synthesized from a FAILED read (corrupt file,
|
|
669
|
+
// transient EACCES/IO_TIMEOUT, non-object payload) — in-memory only, never
|
|
670
|
+
// persisted (parity with segments/internal.js STATE_READ_FAILED).
|
|
671
|
+
const STATE_READ_FAILED = Symbol('ukit.observability.stateReadFailed');
|
|
672
|
+
|
|
662
673
|
async function readState(root, timeoutMs) {
|
|
663
674
|
try {
|
|
664
675
|
const raw = await withTimeout(fs.promises.readFile(path.join(root, STATE_FILE), 'utf8'), timeoutMs);
|
|
665
676
|
const parsed = JSON.parse(raw);
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
677
|
+
// TASK-C91-015 (W3-OB3): a torn/corrupt state file must fail CLOSED —
|
|
678
|
+
// defaulting to `disabled:false` would keep the total-size gate open
|
|
679
|
+
// through corruption. Only ENOENT means "no state yet" (open); every
|
|
680
|
+
// other failure and every non-object payload is disabled:true until a
|
|
681
|
+
// clean write re-enables the store. The synthesized state is MARKED so
|
|
682
|
+
// it is never persisted back as durable disabled:true.
|
|
683
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
684
|
+
return { ...STATE_DEFAULTS, disabled: true, [STATE_READ_FAILED]: true };
|
|
685
|
+
}
|
|
686
|
+
return { ...STATE_DEFAULTS, ...parsed };
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (err && err.code === 'ENOENT') return { ...STATE_DEFAULTS };
|
|
689
|
+
return { ...STATE_DEFAULTS, disabled: true, [STATE_READ_FAILED]: true };
|
|
669
690
|
}
|
|
670
691
|
}
|
|
671
692
|
|
|
672
693
|
async function writeState(root, state, timeoutMs) {
|
|
694
|
+
// TASK-C91-015 (W3-OB3): atomic publish — tmp sibling + rename, same
|
|
695
|
+
// convention as segments/internal.js writeState, so a crash mid-write
|
|
696
|
+
// leaves the previous _state.json (or none), never a torn file. A state
|
|
697
|
+
// synthesized from a failed read is never published — persisting it
|
|
698
|
+
// would turn a transient I/O error into permanent disabled:true.
|
|
699
|
+
if (state && state[STATE_READ_FAILED]) return false;
|
|
700
|
+
const statePath = path.join(root, STATE_FILE);
|
|
701
|
+
const tmpPath = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
673
702
|
try {
|
|
674
|
-
await withTimeout(
|
|
675
|
-
|
|
676
|
-
timeoutMs,
|
|
677
|
-
);
|
|
703
|
+
await withTimeout(fs.promises.writeFile(tmpPath, JSON.stringify(state, null, 2)), timeoutMs);
|
|
704
|
+
await withTimeout(fs.promises.rename(tmpPath, statePath), timeoutMs);
|
|
678
705
|
return true;
|
|
679
706
|
} catch {
|
|
707
|
+
try { await fs.promises.unlink(tmpPath); } catch { /* best-effort */ }
|
|
680
708
|
return false;
|
|
681
709
|
}
|
|
682
710
|
}
|
|
@@ -876,8 +904,8 @@ async function emitOne({ projectRoot, config, record, deps, deadlineMs }) {
|
|
|
876
904
|
}
|
|
877
905
|
return fail('sampled-out');
|
|
878
906
|
}
|
|
879
|
-
if (samplingConfigInvalidSeen && !
|
|
880
|
-
|
|
907
|
+
if (samplingConfigInvalidSeen && !samplingConfigInvalidReported) {
|
|
908
|
+
samplingConfigInvalidReported = true;
|
|
881
909
|
const aggregate = fillEnvelope({
|
|
882
910
|
semantic_name: 'telemetry.dropped',
|
|
883
911
|
payload: { reason_code: 'CONFIG_INVALID', dropped_count: 1 },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs/promises';
|
|
3
4
|
import fsSync from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -331,6 +332,7 @@ function buildRecoveryFileName({
|
|
|
331
332
|
summary = '',
|
|
332
333
|
profile = 'generic',
|
|
333
334
|
exitCode = null,
|
|
335
|
+
contentHash = '',
|
|
334
336
|
} = {}) {
|
|
335
337
|
const slug = sanitizeFileComponent(String(command).split(/\s+/).slice(0, 3).join('-'), {
|
|
336
338
|
fallback: sanitizeFileComponent(profile, { fallback: 'output', maxLength: 16 }),
|
|
@@ -341,6 +343,10 @@ function buildRecoveryFileName({
|
|
|
341
343
|
summary: String(summary ?? '').trim(),
|
|
342
344
|
profile: String(profile ?? 'generic').trim(),
|
|
343
345
|
exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
|
|
346
|
+
// C90-05: bind the name to the bytes it stores. The older inputs let two
|
|
347
|
+
// compressions of the same command compressing to the same summary (the
|
|
348
|
+
// normal retry loop) collide at the tee dir and overwrite each other.
|
|
349
|
+
contentHash: String(contentHash ?? ''),
|
|
344
350
|
}).split(':').at(-1)?.replace(/[^a-z0-9_-]/gi, '-').slice(0, 16) || 'recovery';
|
|
345
351
|
return `${slug}-${fingerprint}.log`;
|
|
346
352
|
}
|
|
@@ -527,6 +533,11 @@ async function persistRawOutput(projectRoot, {
|
|
|
527
533
|
summary,
|
|
528
534
|
profile,
|
|
529
535
|
exitCode,
|
|
536
|
+
contentHash: crypto.createHash('sha256')
|
|
537
|
+
.update(String(stdout ?? ''))
|
|
538
|
+
.update('\0')
|
|
539
|
+
.update(String(stderr ?? ''))
|
|
540
|
+
.digest('hex'),
|
|
530
541
|
});
|
|
531
542
|
const absolutePath = path.join(teeCacheDir, fileName);
|
|
532
543
|
const rawOutputText = buildRawOutputText({
|
|
@@ -153,7 +153,7 @@ async function main() {
|
|
|
153
153
|
? hookPayload.session_id.trim()
|
|
154
154
|
: undefined,
|
|
155
155
|
};
|
|
156
|
-
const state = await loadFreshRouteState(projectRoot);
|
|
156
|
+
const state = await loadFreshRouteState(projectRoot, sessionConfig.sessionId);
|
|
157
157
|
const compressEnabled = shouldCompress(config);
|
|
158
158
|
const header = '=== PROJECT CONTEXT (post-compaction) ===';
|
|
159
159
|
const footer = '=== END ===';
|
|
@@ -375,13 +375,34 @@ async function loadRuntimeConfig(projectRoot) {
|
|
|
375
375
|
return deepMerge(defaultRuntimeConfig(), raw && typeof raw === 'object' ? raw : {});
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
-
|
|
378
|
+
// W3-RI1: route state is session-scoped — its top-level `sessionId` is the
|
|
379
|
+
// owner stamp both writers (skill-router.sh and route-task.mjs) persist.
|
|
380
|
+
// Comparing it before reinjecting matters: session A's compaction must never
|
|
381
|
+
// adopt session B's lane/plans as its own. Fail conservative (planner note):
|
|
382
|
+
// a state whose stored sessionId is absent or different is not reinjected for
|
|
383
|
+
// a sessioned caller; a sessionless payload (legacy/unknown harness) compares
|
|
384
|
+
// against nothing and is left to the same equality — ownerless↔ownerless only.
|
|
385
|
+
async function loadFreshRouteState(projectRoot, sessionId) {
|
|
379
386
|
const statePath = path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json');
|
|
380
387
|
const state = await readJson(statePath, {});
|
|
381
388
|
if (typeof state?.ts === 'number' && (Date.now() - state.ts) > STATE_TTL_MS) {
|
|
382
389
|
return {};
|
|
383
390
|
}
|
|
384
|
-
|
|
391
|
+
if (!state || typeof state !== 'object') return {};
|
|
392
|
+
const ownerId = typeof state.sessionId === 'string' && state.sessionId.trim()
|
|
393
|
+
? state.sessionId.trim()
|
|
394
|
+
: null;
|
|
395
|
+
if (ownerId !== (sessionId ?? null)) {
|
|
396
|
+
try {
|
|
397
|
+
process.stderr.write(
|
|
398
|
+
`[UKit] reinject-context: skipped shared route state owned by a different session (owner=${ownerId ?? 'none'}).\n`,
|
|
399
|
+
);
|
|
400
|
+
} catch {
|
|
401
|
+
// diagnostics are best-effort
|
|
402
|
+
}
|
|
403
|
+
return {};
|
|
404
|
+
}
|
|
405
|
+
return state;
|
|
385
406
|
}
|
|
386
407
|
|
|
387
408
|
function unique(values) {
|
|
@@ -352,6 +352,37 @@ async function drainRunJournal(target) {
|
|
|
352
352
|
}
|
|
353
353
|
return records;
|
|
354
354
|
}
|
|
355
|
+
// W3-RR1: the journal's drain→rm window is one locked section under the SAME
|
|
356
|
+
// journal-local lock appendRunJournal serializes on — a checkpoint appended
|
|
357
|
+
// between the drain and the rm was silently deleted. `update` runs while the
|
|
358
|
+
// record lock is already held; `applied: true` means it landed the target
|
|
359
|
+
// write, so the journal can be consumed. When the journal lock itself is
|
|
360
|
+
// contended the update still runs (the record lock serializes this section),
|
|
361
|
+
// but the journal is kept: an in-flight append has not been observed and the
|
|
362
|
+
// next holder re-merges idempotently. Lock order is record→journal only;
|
|
363
|
+
// appenders never hold the journal while waiting on the record lock, so this
|
|
364
|
+
// cannot deadlock.
|
|
365
|
+
async function runJournalUpdate(target, update) {
|
|
366
|
+
const journalPath = runJournalPathFor(target);
|
|
367
|
+
const lockedOutcome = await withAsyncLock(
|
|
368
|
+
journalPath,
|
|
369
|
+
{ deadlineMs: RUN_JOURNAL_LOCK_BUDGET_MS },
|
|
370
|
+
async () => {
|
|
371
|
+
const result = await update();
|
|
372
|
+
if (result?.applied === true) {
|
|
373
|
+
try {
|
|
374
|
+
await fs.rm(journalPath, { force: true });
|
|
375
|
+
} catch {
|
|
376
|
+
// a leftover journal re-applies idempotently on the next lock
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return result;
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
if (lockedOutcome?.ok === true) return lockedOutcome.value;
|
|
383
|
+
return update();
|
|
384
|
+
}
|
|
385
|
+
|
|
355
386
|
|
|
356
387
|
function recordTimestamp(record) {
|
|
357
388
|
return Number.isFinite(record?.updatedAt) ? record.updatedAt : 0;
|
|
@@ -397,17 +428,17 @@ export async function writeResumableRun(projectRoot, record, {
|
|
|
397
428
|
// Reconcile first: journaled snapshots from earlier busy writes are
|
|
398
429
|
// candidates alongside the incoming record; the newest updatedAt wins and
|
|
399
430
|
// lands in ONE atomic write. The journal is removed only after the write
|
|
400
|
-
// lands, so a crash mid-section leaves the records re-appliable
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
return
|
|
431
|
+
// lands, so a crash mid-section leaves the records re-appliable — and the
|
|
432
|
+
// drain+rm runs under the journal lock (W3-RR1) so an append racing the
|
|
433
|
+
// rm can never lose a checkpoint.
|
|
434
|
+
const result = await runJournalUpdate(target, async () => {
|
|
435
|
+
const journaled = await drainRunJournal(target);
|
|
436
|
+
const candidates = [...journaled, stamped];
|
|
437
|
+
const winner = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
|
|
438
|
+
await writeJsonAtomic(target, winner);
|
|
439
|
+
return { applied: true, value: winner };
|
|
440
|
+
});
|
|
441
|
+
return result.value;
|
|
411
442
|
});
|
|
412
443
|
|
|
413
444
|
if (outcome?.ok === true) {
|
|
@@ -606,27 +637,26 @@ export async function invalidateResumableRun(projectRoot, taskId, codes, {
|
|
|
606
637
|
|
|
607
638
|
const outcome = await withAsyncLock(target, { signal, deadlineMs }, async () => {
|
|
608
639
|
// Reconcile the journal first so invalidation applies to the newest known
|
|
609
|
-
// state, not a superseded on-disk record.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
parsed
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
await
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
return { ok: true, invalidated };
|
|
640
|
+
// state, not a superseded on-disk record. The drain+rm runs under the
|
|
641
|
+
// journal lock (W3-RR1) — same convention as the write path.
|
|
642
|
+
const result = await runJournalUpdate(target, async () => {
|
|
643
|
+
const journaled = await drainRunJournal(target);
|
|
644
|
+
let parsed;
|
|
645
|
+
try {
|
|
646
|
+
parsed = JSON.parse(await fs.readFile(target, 'utf8'));
|
|
647
|
+
} catch (error) {
|
|
648
|
+
if (error?.code !== 'ENOENT') return { applied: false, value: { ok: false, reason: 'invalid' } };
|
|
649
|
+
parsed = null;
|
|
650
|
+
}
|
|
651
|
+
const candidates = [...journaled, ...(parsed ? [parsed] : [])];
|
|
652
|
+
if (candidates.length === 0) return { applied: false, value: { ok: false, reason: 'absent' } };
|
|
653
|
+
const base = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
|
|
654
|
+
const { record, invalidated } = applyInvalidationCodes(base, codeList);
|
|
655
|
+
record.updatedAt = Date.now();
|
|
656
|
+
await writeJsonAtomic(target, record);
|
|
657
|
+
return { applied: true, value: { ok: true, invalidated } };
|
|
658
|
+
});
|
|
659
|
+
return result.value;
|
|
630
660
|
});
|
|
631
661
|
|
|
632
662
|
if (outcome?.ok === true) return outcome.value;
|
|
@@ -283,10 +283,18 @@ export function compactContextBlock(
|
|
|
283
283
|
forceFirstCount: (header ? 1 : 0) + normalizedAnchorLines.length,
|
|
284
284
|
},
|
|
285
285
|
).join('\n').trim();
|
|
286
|
-
|
|
286
|
+
// 'forced-anchors' means every anchor is present — verify it. When the block
|
|
287
|
+
// budget cannot hold them all, the mode must report the shortfall instead of
|
|
288
|
+
// claiming an anchor-complete block that silently dropped the tail anchors
|
|
289
|
+
// (C92-H-03: the dropped tail used to be memory-bridge + ask-before-drop).
|
|
290
|
+
validationMode = findMissingAnchors(text, normalizedAnchorLines).length === 0
|
|
291
|
+
? 'forced-anchors'
|
|
292
|
+
: 'anchor-shortfall';
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
|
|
296
|
+
const missingAnchors = findMissingAnchors(text, normalizedAnchorLines);
|
|
297
|
+
|
|
290
298
|
return {
|
|
291
299
|
text,
|
|
292
300
|
tokensBefore: estimateTokenCount(beforeText),
|
|
@@ -294,6 +302,7 @@ export function compactContextBlock(
|
|
|
294
302
|
savedTokens: Math.max(0, estimateTokenCount(beforeText) - estimateTokenCount(text)),
|
|
295
303
|
validationMode,
|
|
296
304
|
anchorCount: normalizedAnchorLines.length,
|
|
305
|
+
missingAnchors,
|
|
297
306
|
};
|
|
298
307
|
}
|
|
299
308
|
|
|
@@ -361,11 +370,13 @@ function buildCompactedContextLines(
|
|
|
361
370
|
forceFirstCount = 0,
|
|
362
371
|
} = {},
|
|
363
372
|
) {
|
|
373
|
+
const hardTokenCap = Math.max(maxTokens, Math.ceil(maxTokens * 2));
|
|
364
374
|
const sourceLines = Array.isArray(lines) ? lines : [];
|
|
365
375
|
const selected = [];
|
|
366
376
|
const seen = new Set();
|
|
367
377
|
let usedTokens = 0;
|
|
368
378
|
let nonEmptyCount = 0;
|
|
379
|
+
let forcedRemaining = forceFirstCount;
|
|
369
380
|
|
|
370
381
|
for (const line of sourceLines) {
|
|
371
382
|
const compressed = compressLine(line);
|
|
@@ -379,20 +390,37 @@ function buildCompactedContextLines(
|
|
|
379
390
|
}
|
|
380
391
|
|
|
381
392
|
const tokens = estimateTokenCount(compressed);
|
|
382
|
-
|
|
383
|
-
|
|
393
|
+
// Forced lines (header + anchors) are the first `forceFirstCount` distinct
|
|
394
|
+
// non-empty source lines: they bypass the line cap and the soft token budget —
|
|
395
|
+
// dropping a forced anchor would silently break the anchor contract this
|
|
396
|
+
// function exists to enforce. The quota is consumed on the candidate itself,
|
|
397
|
+
// whether or not it fits, so a dropped forced anchor never slides its slot
|
|
398
|
+
// onto a following content line.
|
|
399
|
+
const forceLine = forcedRemaining > 0;
|
|
400
|
+
if (forceLine) {
|
|
401
|
+
forcedRemaining -= 1;
|
|
402
|
+
}
|
|
384
403
|
const wouldExceedLines = nonEmptyCount >= maxLines;
|
|
385
404
|
|
|
386
|
-
if (!forceLine &&
|
|
405
|
+
if ((!forceLine && wouldExceedLines) || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
|
|
387
406
|
break;
|
|
388
407
|
}
|
|
389
|
-
|
|
390
|
-
|
|
408
|
+
|
|
409
|
+
const selectedLine = forceLine && (usedTokens + tokens) > hardTokenCap
|
|
410
|
+
? truncateToTokenBudget(compressed, hardTokenCap - usedTokens)
|
|
411
|
+
: compressed;
|
|
412
|
+
const selectedTokens = estimateTokenCount(selectedLine);
|
|
413
|
+
|
|
414
|
+
// A forced line whose remaining slice of hardTokenCap is empty used to break
|
|
415
|
+
// the loop, dropping every anchor after it. Keep going so later anchors still
|
|
416
|
+
// get their chance — the caller reports whichever anchors never landed.
|
|
417
|
+
if (!selectedLine) {
|
|
418
|
+
continue;
|
|
391
419
|
}
|
|
392
420
|
|
|
393
|
-
selected.push(
|
|
421
|
+
selected.push(selectedLine);
|
|
394
422
|
seen.add(dedupeKey);
|
|
395
|
-
usedTokens +=
|
|
423
|
+
usedTokens += selectedTokens;
|
|
396
424
|
nonEmptyCount += 1;
|
|
397
425
|
}
|
|
398
426
|
|
|
@@ -411,6 +439,13 @@ function findMissingAnchors(text, anchorLines) {
|
|
|
411
439
|
});
|
|
412
440
|
}
|
|
413
441
|
|
|
442
|
+
function truncateToTokenBudget(line, maxTokens) {
|
|
443
|
+
if (maxTokens <= 0) return '';
|
|
444
|
+
const maxChars = Math.max(1, (maxTokens * 4) - 4);
|
|
445
|
+
const value = String(line ?? '');
|
|
446
|
+
return value.length <= maxChars ? value : `${value.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
|
|
447
|
+
}
|
|
448
|
+
|
|
414
449
|
function normalizePromptCacheEntry(entry) {
|
|
415
450
|
if (!entry || typeof entry !== 'object') {
|
|
416
451
|
return null;
|
|
@@ -595,10 +630,18 @@ export async function recordCompaction(projectRoot, entry, { maxEntries = DEFAUL
|
|
|
595
630
|
}
|
|
596
631
|
|
|
597
632
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
633
|
+
// C90-06: lock the read-modify-write like the src twin
|
|
634
|
+
// (src/core/compact/index.js recordCompaction → appendCompactHistory) —
|
|
635
|
+
// concurrent hook processes each prepended to a stale snapshot and
|
|
636
|
+
// last-write-wins silently dropped one entry. The same lock protocol the
|
|
637
|
+
// prompt-cache RMW above uses; fail-closed (a skipped write is journaled,
|
|
638
|
+
// matching the history file's advisory-cache posture).
|
|
639
|
+
return withFileLock(runtimePaths.compactHistoryPath, async () => {
|
|
640
|
+
const history = normalizeCompactHistoryDocument(await readJson(runtimePaths.compactHistoryPath, null), { maxEntries });
|
|
641
|
+
const nextDocument = normalizeCompactHistoryDocument({
|
|
642
|
+
entries: [normalizedEntry, ...history.entries],
|
|
643
|
+
}, { maxEntries });
|
|
644
|
+
await writeJson(runtimePaths.compactHistoryPath, nextDocument);
|
|
645
|
+
return normalizedEntry;
|
|
646
|
+
});
|
|
604
647
|
}
|