@ngockhoale/ukit 3.4.0 → 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 +23 -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
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Enumerate → classify → gate → remove only classified-safe entries. Dry-run
|
|
7
7
|
* is the default and mutates nothing; `--apply` runs `git worktree remove`
|
|
8
|
-
* exclusively on entries classified `safe
|
|
9
|
-
*
|
|
8
|
+
* exclusively on entries classified `safe` — stale entries (`prunable` /
|
|
9
|
+
* missing dir) go through `git worktree prune` instead, since `remove` has
|
|
10
|
+
* no tree to detach (older git exits 128 there). The worktree containing this
|
|
11
|
+
* process's cwd/--root is excluded by resolved-path identity, never by
|
|
12
|
+
* porcelain record position. Never a blanket sweep — every skipped entry
|
|
13
|
+
* keeps a named verdict.
|
|
10
14
|
*
|
|
11
15
|
* Classification (in priority order, first match wins):
|
|
12
16
|
* safe — prunable/stale worktree (missing dir or unborn branch) that is
|
|
@@ -15,15 +19,20 @@
|
|
|
15
19
|
* skip-locked — porcelain `locked` flag (a human locked it)
|
|
16
20
|
* skip-detached-head — detached HEAD (nothing merges into anything)
|
|
17
21
|
* skip-dirty — `git -C <wt> status --porcelain` non-empty or
|
|
18
|
-
* unreadable (unknown state is never removable)
|
|
22
|
+
* unreadable (unknown state is never removable);
|
|
23
|
+
* `reason=` on the verdict line names which case it was
|
|
19
24
|
* skip-active — resumable-run records present (.ukit/storage/runs/)
|
|
20
25
|
* OR docs/AI_HANDOFF/RUN.md phase not done/blocked,
|
|
21
26
|
* OR the branch has commits not merged into default
|
|
22
27
|
*
|
|
23
28
|
* CLI: node worktree-sweep.mjs [--apply] [--root <dir>]
|
|
24
29
|
* exit 0 on a successful report/apply (even when nothing is removable);
|
|
25
|
-
* non-zero only on invocation
|
|
26
|
-
* failure
|
|
30
|
+
* non-zero only on invocation or repo-level errors (bad flag, not-a-repo,
|
|
31
|
+
* a git spawn failure/timeout on a root call such as `worktree list`).
|
|
32
|
+
* A failed or timed-out per-worktree call is NOT an invocation error —
|
|
33
|
+
* it degrades that entry to skip-dirty with a `reason=` on its verdict
|
|
34
|
+
* line and the sweep continues (C92-D-02). A failed `worktree remove`
|
|
35
|
+
* is reported per-entry, not fatal.
|
|
27
36
|
*/
|
|
28
37
|
|
|
29
38
|
import fs from 'node:fs';
|
|
@@ -42,13 +51,17 @@ function fail(msg) {
|
|
|
42
51
|
process.exit(1);
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
// `degrade` marks a per-worktree call whose spawn error or timeout must not
|
|
55
|
+
// abort the sweep: the caller gets the errored result back and degrades that
|
|
56
|
+
// entry (C92-D-02 — one worktree on a wedged volume is not an invocation
|
|
57
|
+
// error). Calls without it are repo-level and stay fatal on spawn errors.
|
|
58
|
+
function git(root, args, { degrade = false } = {}) {
|
|
46
59
|
const res = spawnSync('git', ['-C', root, ...args], {
|
|
47
60
|
encoding: 'utf8',
|
|
48
61
|
timeout: GIT_CALL_TIMEOUT_MS,
|
|
49
62
|
killSignal: 'SIGKILL',
|
|
50
63
|
});
|
|
51
|
-
if (res.error) {
|
|
64
|
+
if (res.error && !degrade) {
|
|
52
65
|
fail(`git ${args[0]} failed to run: ${res.error.message ?? res.error}`);
|
|
53
66
|
}
|
|
54
67
|
return res;
|
|
@@ -93,10 +106,29 @@ for (const line of list.stdout.split('\n')) {
|
|
|
93
106
|
}
|
|
94
107
|
if (cur) worktrees.push(cur);
|
|
95
108
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
// Removal candidates are excluded by resolved-path identity, never by list
|
|
110
|
+
// position (W3-06): the old positional `worktrees.shift()` only protected the
|
|
111
|
+
// FIRST porcelain record. Invoked from inside a linked worktree (or with a
|
|
112
|
+
// --root pointing at one), the caller's own worktree stayed in the removal
|
|
113
|
+
// set and `git worktree remove` happily deleted the process's cwd.
|
|
114
|
+
// The main worktree is record 0 — kept for the default-branch fallback but
|
|
115
|
+
// also excluded below by path identity.
|
|
116
|
+
const main = worktrees[0] ?? null;
|
|
117
|
+
const realPath = (p) => {
|
|
118
|
+
try { return fs.realpathSync(p); } catch { return path.resolve(p); }
|
|
119
|
+
};
|
|
120
|
+
const mainPath = main ? realPath(main.path) : null;
|
|
121
|
+
// A worktree is "self" when its root contains this process's cwd or the
|
|
122
|
+
// resolved --root (usually the same path). Containment, not equality — the
|
|
123
|
+
// sweep may be invoked from any subdirectory of the worktree.
|
|
124
|
+
const selfRoots = [root, process.cwd()].map((p) => realPath(p) + path.sep);
|
|
125
|
+
const isSelfWorktree = (wtPath) => {
|
|
126
|
+
const wt = realPath(wtPath) + path.sep;
|
|
127
|
+
return selfRoots.some((self) => self.startsWith(wt));
|
|
128
|
+
};
|
|
129
|
+
const candidates = worktrees.filter(
|
|
130
|
+
(wt) => realPath(wt.path) !== mainPath && !isSelfWorktree(wt.path),
|
|
131
|
+
);
|
|
100
132
|
|
|
101
133
|
// Default branch: prefer remote HEAD, then local main/master, then the main
|
|
102
134
|
// worktree's own checked-out branch.
|
|
@@ -120,11 +152,19 @@ if (defaultBranch) {
|
|
|
120
152
|
}
|
|
121
153
|
|
|
122
154
|
// --- classify ---------------------------------------------------------------
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
155
|
+
// Readability check, not just dirtiness: returns the reason to print whenever
|
|
156
|
+
// the status is non-clean — actual uncommitted work, a non-zero exit, or a
|
|
157
|
+
// spawn error/timeout on that worktree (degraded, never fatal: C92-D-02).
|
|
158
|
+
// Unknown state is never removable.
|
|
159
|
+
function dirtyState(wtPath) {
|
|
160
|
+
const res = git(wtPath, ['status', '--porcelain'], { degrade: true });
|
|
161
|
+
if (res.error) return { dirty: true, reason: `status check failed: ${res.error.message ?? res.error}` };
|
|
162
|
+
if (res.status !== 0) {
|
|
163
|
+
const detail = (res.stderr || '').split('\n')[0].trim();
|
|
164
|
+
return { dirty: true, reason: `status check failed (exit ${res.status})${detail ? `: ${detail}` : ''}` };
|
|
165
|
+
}
|
|
166
|
+
if (res.stdout.trim().length > 0) return { dirty: true, reason: 'uncommitted changes' };
|
|
167
|
+
return { dirty: false };
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
function hasActiveRun(wtPath) {
|
|
@@ -148,27 +188,29 @@ function hasActiveRun(wtPath) {
|
|
|
148
188
|
}
|
|
149
189
|
|
|
150
190
|
function classify(wt) {
|
|
151
|
-
|
|
191
|
+
const verdict = (v, reason) => ({ verdict: v, reason });
|
|
192
|
+
if (wt.locked) return verdict('skip-locked');
|
|
152
193
|
if (wt.prunable || !fs.existsSync(wt.path)) {
|
|
153
194
|
// Stale: the checkout directory is already gone — clean by definition.
|
|
154
|
-
return 'safe';
|
|
195
|
+
return verdict('safe');
|
|
155
196
|
}
|
|
156
|
-
if (wt.detached || !wt.branch) return 'skip-detached-head';
|
|
157
|
-
|
|
158
|
-
if (
|
|
197
|
+
if (wt.detached || !wt.branch) return verdict('skip-detached-head');
|
|
198
|
+
const status = dirtyState(wt.path);
|
|
199
|
+
if (status.dirty) return verdict('skip-dirty', status.reason);
|
|
200
|
+
if (hasActiveRun(wt.path)) return verdict('skip-active');
|
|
159
201
|
const branchExists = git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${wt.branch}`]).status === 0;
|
|
160
|
-
if (!branchExists) return 'safe'; // stale: branch gone/unborn, and clean
|
|
202
|
+
if (!branchExists) return verdict('safe'); // stale: branch gone/unborn, and clean
|
|
161
203
|
if (mergedSet.has(wt.branch)) {
|
|
162
204
|
// `git branch --merged` lists tip-merged branches; zero commits ahead is
|
|
163
205
|
// implied — a merged tip IS zero-ahead. Double-check cheaply.
|
|
164
206
|
const ahead = git(root, ['rev-list', '--count', `${defaultBranch}..${wt.branch}`]);
|
|
165
|
-
if (ahead.status === 0 && Number(ahead.stdout.trim()) === 0) return 'safe';
|
|
207
|
+
if (ahead.status === 0 && Number(ahead.stdout.trim()) === 0) return verdict('safe');
|
|
166
208
|
}
|
|
167
|
-
return 'skip-active'; // branch carries unmerged work — a run may resume it
|
|
209
|
+
return verdict('skip-active'); // branch carries unmerged work — a run may resume it
|
|
168
210
|
}
|
|
169
211
|
|
|
170
212
|
// --- report + apply ---------------------------------------------------------
|
|
171
|
-
const results = candidates.map((wt) => ({ ...wt,
|
|
213
|
+
const results = candidates.map((wt) => ({ ...wt, ...classify(wt) }));
|
|
172
214
|
|
|
173
215
|
const mode = apply ? 'apply' : 'dry-run';
|
|
174
216
|
console.log(`worktree-sweep (${mode}) — root: ${root}`);
|
|
@@ -178,13 +220,29 @@ if (results.length === 0) {
|
|
|
178
220
|
}
|
|
179
221
|
for (const r of results) {
|
|
180
222
|
let line = `${r.path} branch=${r.branch ?? '(none)'} verdict=${r.verdict}`;
|
|
223
|
+
if (r.reason) line += ` reason=${r.reason}`;
|
|
181
224
|
if (apply && r.verdict === 'safe') {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
225
|
+
// W3-01: a stale entry (porcelain `prunable` or missing dir) has no tree
|
|
226
|
+
// left for `git worktree remove`, which fails outright on older git
|
|
227
|
+
// (exit 128, "fatal: '<path>' does not exist"). Drop the admin record via
|
|
228
|
+
// `git worktree prune` instead; `--expire=now` bypasses the default
|
|
229
|
+
// gc.worktreePruneExpire window. Prune skips locked worktrees — but a
|
|
230
|
+
// locked entry never reaches 'safe' anyway.
|
|
231
|
+
const stale = r.prunable || !fs.existsSync(r.path);
|
|
232
|
+
const rm = stale
|
|
233
|
+
? spawnSync('git', ['-C', root, 'worktree', 'prune', '--expire=now'], {
|
|
234
|
+
encoding: 'utf8',
|
|
235
|
+
timeout: GIT_CALL_TIMEOUT_MS,
|
|
236
|
+
killSignal: 'SIGKILL',
|
|
237
|
+
})
|
|
238
|
+
: spawnSync('git', ['-C', root, 'worktree', 'remove', r.path], {
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
timeout: GIT_CALL_TIMEOUT_MS,
|
|
241
|
+
killSignal: 'SIGKILL',
|
|
242
|
+
});
|
|
243
|
+
const stillRegistered = stale
|
|
244
|
+
&& git(root, ['worktree', 'list', '--porcelain']).stdout.includes(`worktree ${r.path}\n`);
|
|
245
|
+
if (rm.status === 0 && !stillRegistered) line += ' removed';
|
|
188
246
|
else line += ` remove-failed: ${(rm.stderr || rm.error?.message || '').trim() || 'unknown error'}`;
|
|
189
247
|
}
|
|
190
248
|
console.log(line);
|
|
@@ -497,13 +497,18 @@ export async function buildCompactThresholds(config = {}) {
|
|
|
497
497
|
const hardCapTokens = negotiated
|
|
498
498
|
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
499
499
|
: shippedHardCap;
|
|
500
|
-
// An explicit operator tokenThreshold is
|
|
501
|
-
//
|
|
502
|
-
//
|
|
500
|
+
// An explicit operator tokenThreshold is honoured but still clamped under the
|
|
501
|
+
// negotiated cap: an advisory soft/hard pair sitting at or above hardCapTokens can
|
|
502
|
+
// never fire, because context-hardcap-gate.sh already refuses tools at the cap
|
|
503
|
+
// (C92-H-01 — on a default install tokenThreshold is the shipped 150k default, not a
|
|
504
|
+
// real operator choice, so this is correcting a number, not overriding intent).
|
|
503
505
|
const softThreshold = explicitSoftThreshold > 0
|
|
504
|
-
? Math.max(1, explicitSoftThreshold)
|
|
506
|
+
? Math.max(1, Math.min(explicitSoftThreshold, hardCapTokens - 1))
|
|
505
507
|
: Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
|
|
506
|
-
const hardThreshold = Math.
|
|
508
|
+
const hardThreshold = Math.min(
|
|
509
|
+
hardCapTokens,
|
|
510
|
+
Math.max(softThreshold + 1, Math.round(softThreshold * 1.6)),
|
|
511
|
+
);
|
|
507
512
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
508
513
|
|
|
509
514
|
return {
|
|
@@ -773,6 +778,9 @@ export function resolveThresholdCompactBudget({
|
|
|
773
778
|
// record's flat fields stay projected at the document's top level so readers that predate
|
|
774
779
|
// this shape (raw JSON readers) still see a coherent state.
|
|
775
780
|
const PRESSURE_SESSIONS_MAX = 8;
|
|
781
|
+
// C90-16: sessionless writers (omp tool events without session_id/transcript)
|
|
782
|
+
// accumulate here — never on the newest sibling's record.
|
|
783
|
+
const SESSIONLESS_PRESSURE_BUCKET = '_sessionless';
|
|
776
784
|
|
|
777
785
|
function normalizeSessionId(value) {
|
|
778
786
|
const id = typeof value === 'string' ? value.trim() : '';
|
|
@@ -793,12 +801,20 @@ function readPressureDocument(raw) {
|
|
|
793
801
|
return raw && typeof raw === 'object' ? { default: raw } : {};
|
|
794
802
|
}
|
|
795
803
|
|
|
796
|
-
function pickPressureSession(sessions, sessionId) {
|
|
804
|
+
function pickPressureSession(sessions, sessionId, { forWrite = false } = {}) {
|
|
797
805
|
if (sessionId) {
|
|
798
806
|
// A session id that has no record yet starts clean — it must not inherit another
|
|
799
807
|
// session's totals, and the other way round nothing here touches that session.
|
|
800
808
|
return { id: sessionId, record: sessions[sessionId] ?? null };
|
|
801
809
|
}
|
|
810
|
+
if (forWrite) {
|
|
811
|
+
// C90-16: a writer without a session id must never accumulate onto the
|
|
812
|
+
// newest sibling's record — cross-session pressure corruption survives a
|
|
813
|
+
// SessionStart reset and wedges that session's hard-cap gate. Sessionless
|
|
814
|
+
// mutations accumulate on a dedicated bucket instead, even when the
|
|
815
|
+
// document holds no records yet.
|
|
816
|
+
return { id: SESSIONLESS_PRESSURE_BUCKET, record: sessions[SESSIONLESS_PRESSURE_BUCKET] ?? null };
|
|
817
|
+
}
|
|
802
818
|
const entries = Object.entries(sessions);
|
|
803
819
|
if (!entries.length) {
|
|
804
820
|
return { id: 'default', record: null };
|
|
@@ -835,11 +851,22 @@ export async function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
835
851
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
836
852
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
837
853
|
if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
854
|
+
// A persisted pair is only honoured while it still sits inside the negotiated cap.
|
|
855
|
+
// The record outlives capacity negotiation: an install that ran before the record
|
|
856
|
+
// existed, or before the model was verified, carries thresholds tuned for the
|
|
857
|
+
// shipped 500k ceiling — restoring them re-pins the advisory ABOVE the live
|
|
858
|
+
// hardCapTokens forever (C92-H-02). Falling through keeps the freshly derived
|
|
859
|
+
// pair, which is also what the rewritten record persists, so the file self-heals.
|
|
860
|
+
if (rawSoftThreshold < thresholds.hardCapTokens) {
|
|
861
|
+
thresholds.softThreshold = rawSoftThreshold;
|
|
862
|
+
thresholds.hardThreshold = Math.min(
|
|
863
|
+
thresholds.hardCapTokens,
|
|
864
|
+
rawHardThreshold > rawSoftThreshold
|
|
865
|
+
? rawHardThreshold
|
|
866
|
+
: Math.max(rawSoftThreshold + 1, Math.round(rawSoftThreshold * 1.6)),
|
|
867
|
+
);
|
|
868
|
+
thresholds.baselineTokens = Math.max(120, Math.min(18_000, Math.round(thresholds.softThreshold * 0.18)));
|
|
869
|
+
}
|
|
843
870
|
}
|
|
844
871
|
const recentPrompts = (Array.isArray(rawState?.recentPrompts) ? rawState.recentPrompts : [])
|
|
845
872
|
.map((entry) => normalizePromptEntry(entry))
|
|
@@ -1185,13 +1212,17 @@ export async function buildThresholdCompactPlan({
|
|
|
1185
1212
|
const compacted = compactContextBlock(rawLines, {
|
|
1186
1213
|
maxTokens: budget.maxTokens,
|
|
1187
1214
|
maxLines: budget.maxLines,
|
|
1215
|
+
// Guardrail anchors are listed FIRST: under hard pressure the block budget may
|
|
1216
|
+
// not fit every anchor, and the lines that protect the user (ask-before-drop)
|
|
1217
|
+
// and the session (memory bridge) are the ones that must never be the tail
|
|
1218
|
+
// casualty (C92-H-03).
|
|
1188
1219
|
anchorLines: [
|
|
1189
1220
|
thresholdLine,
|
|
1221
|
+
askBeforeDropLine,
|
|
1222
|
+
memoryBridgeLine,
|
|
1190
1223
|
routeLine,
|
|
1191
1224
|
previousLine,
|
|
1192
1225
|
outputLine,
|
|
1193
|
-
memoryBridgeLine,
|
|
1194
|
-
askBeforeDropLine,
|
|
1195
1226
|
].filter(Boolean),
|
|
1196
1227
|
maxAnchors: 6,
|
|
1197
1228
|
});
|
|
@@ -1208,6 +1239,7 @@ export async function buildThresholdCompactPlan({
|
|
|
1208
1239
|
tokensBefore: compacted.tokensBefore,
|
|
1209
1240
|
tokensAfter: compacted.tokensAfter,
|
|
1210
1241
|
savedTokens: compacted.savedTokens,
|
|
1242
|
+
missingAnchors: compacted.missingAnchors ?? [],
|
|
1211
1243
|
};
|
|
1212
1244
|
}
|
|
1213
1245
|
|
|
@@ -1232,7 +1264,7 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
1232
1264
|
const scopedConfig = scopeCapacityConfig(config, projectRoot);
|
|
1233
1265
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1234
1266
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1235
|
-
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1267
|
+
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
|
|
1236
1268
|
const current = await buildCompactPressureState(record, scopedConfig);
|
|
1237
1269
|
const next = await mutator(current);
|
|
1238
1270
|
const normalized = await buildCompactPressureState(next, scopedConfig);
|
|
@@ -1247,7 +1279,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
|
|
|
1247
1279
|
const scopedConfig = scopeCapacityConfig(config, projectRoot);
|
|
1248
1280
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1249
1281
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1250
|
-
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1282
|
+
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
|
|
1251
1283
|
const normalized = await buildCompactPressureState(state, scopedConfig);
|
|
1252
1284
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1253
1285
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
@@ -1086,33 +1086,48 @@ async function mergeContinuationSidecar(ledger, target) {
|
|
|
1086
1086
|
// Reads the merged ledger view (main file + existing sidecar) so consecutive ticks
|
|
1087
1087
|
// accumulate even while the main lock stays busy. Returns { ok } — a sidecar write
|
|
1088
1088
|
// failure means the tick is genuinely lost and the caller reports the rejection.
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1089
|
+
// W3-LED1: the RMW runs under a sidecar-local lock — two racing continuations
|
|
1090
|
+
// must both tick, not clobber each other's read-merge-write. The main ledger
|
|
1091
|
+
// lock stays unavailable here (that is why this path runs), so the sidecar
|
|
1092
|
+
// lock is the ONLY lock taken: lock order sidecar-alone can never deadlock
|
|
1093
|
+
// with the main→journal order. A busy sidecar lock fails closed — the tick is
|
|
1094
|
+
// reported rejected rather than written unlocked over a holder's mid-flight
|
|
1095
|
+
// merge — and the caller reports the rejection.
|
|
1096
|
+
async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {}) {
|
|
1097
|
+
const sidecarPath = continuationSidecarPathFor(target);
|
|
1098
|
+
const outcome = await withAsyncLock(
|
|
1099
|
+
sidecarPath,
|
|
1100
|
+
{ signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
|
|
1101
|
+
async () => {
|
|
1102
|
+
try {
|
|
1103
|
+
const base = (await mergeContinuationSidecar(await readJson(target, null), target)) || {};
|
|
1104
|
+
const applied = event.type === 'notified'
|
|
1105
|
+
? { ...base, notified: true, updatedAt: Date.now() }
|
|
1106
|
+
: applyContinuationToLedger(base, event);
|
|
1107
|
+
// The merge gate requires sidecarAt > ledgerAt. A notified tick inherits the
|
|
1108
|
+
// base stamp unchanged, so it must be bumped past the base it was computed
|
|
1109
|
+
// from — otherwise the tick is invisible to every evaluator and the cap's
|
|
1110
|
+
// final notice loops forever while contention persists. The +1 floor also
|
|
1111
|
+
// covers a continuation landing in the same millisecond as the base stamp.
|
|
1112
|
+
const baseAt = Number(base.lastContinuationAt) || 0;
|
|
1113
|
+
const stampedAt = Math.max(Number(applied.lastContinuationAt) || 0, baseAt + 1);
|
|
1114
|
+
await writeJsonAtomic(sidecarPath, {
|
|
1115
|
+
requestKey: event?.requestKey ?? applied.requestKey ?? applied.continuationRequestKey ?? null,
|
|
1116
|
+
promptKey: event?.promptKey ?? applied.promptKey ?? null,
|
|
1117
|
+
continuationCount: Number(applied.continuationCount) || 0,
|
|
1118
|
+
noProgressCount: Number(applied.noProgressCount) || 0,
|
|
1119
|
+
lastProgressDigest: applied.lastProgressDigest ?? null,
|
|
1120
|
+
notified: applied.notified === true,
|
|
1121
|
+
lastContinuationAt: stampedAt,
|
|
1122
|
+
updatedAt: Date.now(),
|
|
1123
|
+
});
|
|
1124
|
+
return { ok: true };
|
|
1125
|
+
} catch {
|
|
1126
|
+
return { ok: false };
|
|
1127
|
+
}
|
|
1128
|
+
},
|
|
1129
|
+
);
|
|
1130
|
+
return outcome.ok === true ? outcome.value : { ok: false };
|
|
1116
1131
|
}
|
|
1117
1132
|
|
|
1118
1133
|
function newEventId() {
|
|
@@ -1998,9 +2013,27 @@ export async function recordLedgerEvent(event, {
|
|
|
1998
2013
|
if (drained.commit) await drained.commit();
|
|
1999
2014
|
// The continuation sidecar was already merged into `current` (and therefore into
|
|
2000
2015
|
// the ledger just written): its ticks are now durable state, so the sidecar is
|
|
2001
|
-
// retired.
|
|
2002
|
-
//
|
|
2003
|
-
|
|
2016
|
+
// retired. W3-LED1: the retire runs under the sidecar-local lock and re-merges
|
|
2017
|
+
// a tick that landed between the ledger read and the write — removing the
|
|
2018
|
+
// sidecar unlocked deleted a durable tick outright. A busy sidecar lock skips
|
|
2019
|
+
// the retire entirely: the file re-merges idempotently on the next read
|
|
2020
|
+
// (merge gate sidecarAt > ledgerAt, notified OR-ed) and the next commit retires it.
|
|
2021
|
+
try {
|
|
2022
|
+
await withAsyncLock(
|
|
2023
|
+
continuationSidecarPathFor(target),
|
|
2024
|
+
{ signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
|
|
2025
|
+
async () => {
|
|
2026
|
+
const base = await readJson(target, null);
|
|
2027
|
+
const remerged = await mergeContinuationSidecar(base, target);
|
|
2028
|
+
if (remerged && remerged !== base) {
|
|
2029
|
+
await writeJsonAtomic(target, remerged);
|
|
2030
|
+
}
|
|
2031
|
+
try { await fs.rm(continuationSidecarPathFor(target), { force: true }); } catch {}
|
|
2032
|
+
},
|
|
2033
|
+
);
|
|
2034
|
+
} catch {
|
|
2035
|
+
// retire is best-effort post-commit; the sidecar re-merges idempotently
|
|
2036
|
+
}
|
|
2004
2037
|
return { committed: true, eventId, value, drained: drained.applied, quarantined: drained.quarantined };
|
|
2005
2038
|
});
|
|
2006
2039
|
if (outcome.ok) {
|
|
@@ -56,21 +56,55 @@ const matchBrace = (s, open) => {
|
|
|
56
56
|
return -1;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
59
|
+
// Index of the closer matching the `{`/`[` at `open` (string-aware, mixed
|
|
60
|
+
// brackets), or -1 when unclosed/mismatched. A strict mismatch also aborts:
|
|
61
|
+
// depth bookkeeping past it would be meaningless.
|
|
62
|
+
const matchEnclosure = (s, open) => {
|
|
63
|
+
const stack = [];
|
|
64
|
+
for (let i = open; i < s.length; i += 1) {
|
|
65
|
+
const c = s[i];
|
|
66
|
+
if (c === '"') {
|
|
67
|
+
const end = scanStringEnd(s, i);
|
|
68
|
+
if (end === -1) return -1;
|
|
69
|
+
i = end;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (c === '{' || c === '[') { stack.push(c); continue; }
|
|
73
|
+
if (c === '}' || c === ']') {
|
|
74
|
+
const top = stack.pop();
|
|
75
|
+
if ((c === '}' && top !== '{') || (c === ']' && top !== '[')) return -1;
|
|
76
|
+
if (stack.length === 0) return i;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// BUG-C90-14 (FR-002): find `"key"` at THIS object's depth inside [lo, hi) —
|
|
83
|
+
// the region bounds are the parent object's braces, so every candidate here is
|
|
84
|
+
// top-level by construction. A same-named key inside a NESTED value (object or
|
|
85
|
+
// array) must be skipped structurally, never matched: the previous flat
|
|
86
|
+
// indexOf scan let `{"nested":{"command":"echo hi"},"command":"rm -rf /"}`
|
|
87
|
+
// salvage "echo hi" instead of the real outer field (fail-open on the deny
|
|
88
|
+
// gates). Returns the index of the key's `:` or -1.
|
|
63
89
|
const findKey = (s, key, lo, hi) => {
|
|
64
90
|
const needle = `"${key}"`;
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
for (let i = lo; i < hi; i += 1) {
|
|
92
|
+
const c = s[i];
|
|
93
|
+
if (c === '"') {
|
|
94
|
+
if (s.startsWith(needle, i)) {
|
|
95
|
+
const colon = skipWs(s, i + needle.length);
|
|
96
|
+
if (colon < hi && s[colon] === ':') return colon;
|
|
97
|
+
}
|
|
98
|
+
const end = scanStringEnd(s, i);
|
|
99
|
+
if (end === -1) return -1; // truncated string — nothing provable beyond
|
|
100
|
+
i = end;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === '{' || c === '[') {
|
|
104
|
+
const close = matchEnclosure(s, i);
|
|
105
|
+
if (close === -1 || close >= hi) return -1;
|
|
106
|
+
i = close;
|
|
72
107
|
}
|
|
73
|
-
pos = s.indexOf(needle, pos + 1);
|
|
74
108
|
}
|
|
75
109
|
return -1;
|
|
76
110
|
};
|
|
@@ -84,7 +118,9 @@ export function salvageField(text, dotPath) {
|
|
|
84
118
|
.filter(Boolean);
|
|
85
119
|
if (!data || data[0] !== '{' || dotted.length === 0 || dotted.length > 4) return INCOMPLETE;
|
|
86
120
|
|
|
87
|
-
|
|
121
|
+
// The scan region is INSIDE the current object: [lo, hi) bounds the braces,
|
|
122
|
+
// so start after the opening `{` (data[0] === '{' is guaranteed above).
|
|
123
|
+
let regionLo = 1;
|
|
88
124
|
let regionHi = data.length;
|
|
89
125
|
for (let k = 0; k < dotted.length; k += 1) {
|
|
90
126
|
const colon = findKey(data, dotted[k], regionLo, regionHi);
|
|
@@ -233,26 +233,61 @@ const matchBrace = (s, open) => {
|
|
|
233
233
|
}
|
|
234
234
|
return -1;
|
|
235
235
|
};
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
236
|
+
// Index of the closer matching the `{`/`[` at `open` (string-aware, mixed
|
|
237
|
+
// brackets), or -1 when unclosed/mismatched. A strict mismatch also aborts:
|
|
238
|
+
// depth bookkeeping past it would be meaningless.
|
|
239
|
+
const matchEnclosure = (s, open) => {
|
|
240
|
+
const stack = [];
|
|
241
|
+
for (let i = open; i < s.length; i += 1) {
|
|
242
|
+
const c = s[i];
|
|
243
|
+
if (c === '"') {
|
|
244
|
+
const end = scanStringEnd(s, i);
|
|
245
|
+
if (end === -1) return -1;
|
|
246
|
+
i = end;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (c === '{' || c === '[') { stack.push(c); continue; }
|
|
250
|
+
if (c === '}' || c === ']') {
|
|
251
|
+
const top = stack.pop();
|
|
252
|
+
if ((c === '}' && top !== '{') || (c === ']' && top !== '[')) return -1;
|
|
253
|
+
if (stack.length === 0) return i;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return -1;
|
|
257
|
+
};
|
|
258
|
+
// BUG-C90-14 (FR-002): find `"key"` at THIS object's depth inside [lo, hi) —
|
|
259
|
+
// the region bounds are the parent object's braces, so every candidate here is
|
|
260
|
+
// top-level by construction. A same-named key inside a NESTED value (object or
|
|
261
|
+
// array) must be skipped structurally, never matched: the previous flat
|
|
262
|
+
// indexOf scan let `{"nested":{"command":"echo hi"},"command":"rm -rf /"}`
|
|
263
|
+
// salvage "echo hi" instead of the real outer field (fail-open on the deny
|
|
264
|
+
// gates). Returns the index of the key's `:` or -1.
|
|
240
265
|
const findKey = (s, key, lo, hi) => {
|
|
241
266
|
const needle = `"${key}"`;
|
|
242
|
-
let
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
267
|
+
for (let i = lo; i < hi; i += 1) {
|
|
268
|
+
const c = s[i];
|
|
269
|
+
if (c === '"') {
|
|
270
|
+
if (s.startsWith(needle, i)) {
|
|
271
|
+
const colon = skipWs(s, i + needle.length);
|
|
272
|
+
if (colon < hi && s[colon] === ':') return colon;
|
|
273
|
+
}
|
|
274
|
+
const end = scanStringEnd(s, i);
|
|
275
|
+
if (end === -1) return -1; // truncated string — nothing provable beyond
|
|
276
|
+
i = end;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (c === '{' || c === '[') {
|
|
280
|
+
const close = matchEnclosure(s, i);
|
|
281
|
+
if (close === -1 || close >= hi) return -1;
|
|
282
|
+
i = close;
|
|
249
283
|
}
|
|
250
|
-
pos = s.indexOf(needle, pos + 1);
|
|
251
284
|
}
|
|
252
285
|
return -1;
|
|
253
286
|
};
|
|
254
287
|
|
|
255
|
-
|
|
288
|
+
// The scan region is INSIDE the current object: [lo, hi) bounds the braces, so
|
|
289
|
+
// start after the opening `{` (data[0] === '{' is guaranteed above).
|
|
290
|
+
let regionLo = 1;
|
|
256
291
|
let regionHi = data.length;
|
|
257
292
|
for (let k = 0; k < dotted.length; k += 1) {
|
|
258
293
|
const colon = findKey(data, dotted[k], regionLo, regionHi);
|