@ngockhoale/ukit 3.4.1 → 3.4.3
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
|
@@ -310,15 +310,30 @@ export function createSupervisor(opts = {}) {
|
|
|
310
310
|
op.seq += 1;
|
|
311
311
|
const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, artifactRefs);
|
|
312
312
|
await append(runtimeDir, event);
|
|
313
|
-
await writeOperationState(runtimeDir, op.id,
|
|
314
|
-
state: to,
|
|
315
|
-
fencingEpoch,
|
|
316
|
-
ownerEpoch,
|
|
317
|
-
});
|
|
313
|
+
await writeOperationState(runtimeDir, op.id, stateRecord(op, to));
|
|
318
314
|
return event;
|
|
319
315
|
});
|
|
320
316
|
}
|
|
321
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Full-overwrite state-cache record (C92-E-03). recovery.js fences restart
|
|
320
|
+
* reconciliation on `pid`/`processStartTime`/`spawned`/`exitStatus` — every
|
|
321
|
+
* write must re-carry them or a later transition silently drops the launch
|
|
322
|
+
* metadata (writeOperationState overwrites wholesale). `undefined` fields
|
|
323
|
+
* are dropped by JSON.stringify, so pre-spawn records omit pid honestly.
|
|
324
|
+
*/
|
|
325
|
+
function stateRecord(op, to) {
|
|
326
|
+
return {
|
|
327
|
+
state: to,
|
|
328
|
+
fencingEpoch,
|
|
329
|
+
ownerEpoch,
|
|
330
|
+
pid: op.pgid ?? undefined,
|
|
331
|
+
processStartTime: op.processStartTime ?? undefined,
|
|
332
|
+
spawned: op.proc != null,
|
|
333
|
+
exitStatus: Number.isInteger(op.exitInfo?.code) ? op.exitInfo.code : undefined,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
322
337
|
function emitObservation(op, cls) {
|
|
323
338
|
if (op.observedClasses.has(cls)) return;
|
|
324
339
|
op.observedClasses.add(cls);
|
|
@@ -340,6 +355,14 @@ export function createSupervisor(opts = {}) {
|
|
|
340
355
|
if (op.exited || signal === 0) {
|
|
341
356
|
return;
|
|
342
357
|
}
|
|
358
|
+
// C92-E-01: target only a real owned process group. `-null` evaluates to
|
|
359
|
+
// -0, which process.kill accepts as group 0 — OUR OWN process group. A
|
|
360
|
+
// pre-spawn cancel or close() hits exactly that window (pgid set at
|
|
361
|
+
// spawn+assignment only), so a missing/invalid pgid must skip, never
|
|
362
|
+
// signal.
|
|
363
|
+
if (!Number.isInteger(op.pgid) || op.pgid <= 0) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
343
366
|
try {
|
|
344
367
|
killImpl(signal, -op.pgid);
|
|
345
368
|
} catch {
|
|
@@ -368,11 +391,7 @@ export function createSupervisor(opts = {}) {
|
|
|
368
391
|
reason: op.cancelReason ?? 'killed',
|
|
369
392
|
}, op.artifactRefs);
|
|
370
393
|
await append(runtimeDir, event);
|
|
371
|
-
await writeOperationState(runtimeDir, op.id,
|
|
372
|
-
state: 'cancelled',
|
|
373
|
-
fencingEpoch,
|
|
374
|
-
ownerEpoch,
|
|
375
|
-
});
|
|
394
|
+
await writeOperationState(runtimeDir, op.id, stateRecord(op, 'cancelled'));
|
|
376
395
|
closeOpSpans(op, 'failed', {
|
|
377
396
|
errorCode: reasonCodeForFailure({ cancelReason: op.cancelReason ?? 'cancel_requested' }),
|
|
378
397
|
exitCode: op.exitInfo?.code,
|
|
@@ -484,7 +503,7 @@ export function createSupervisor(opts = {}) {
|
|
|
484
503
|
op.seq += 1;
|
|
485
504
|
const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, op.artifactRefs);
|
|
486
505
|
await append(runtimeDir, event);
|
|
487
|
-
await writeOperationState(runtimeDir, op.id,
|
|
506
|
+
await writeOperationState(runtimeDir, op.id, stateRecord(op, to));
|
|
488
507
|
// DF2-FR07: terminal transition committed — close tool + execution
|
|
489
508
|
// spans with the same outcome. Cancelled is a failed run with a
|
|
490
509
|
// USER_CANCELLED/TOOL_TIMEOUT code, never a silent open span.
|
|
@@ -576,6 +595,7 @@ export function createSupervisor(opts = {}) {
|
|
|
576
595
|
chainError: null,
|
|
577
596
|
proc: null,
|
|
578
597
|
pgid: null,
|
|
598
|
+
processStartTime: null,
|
|
579
599
|
exited: false,
|
|
580
600
|
finalized: false,
|
|
581
601
|
cancelRequested: false,
|
|
@@ -593,11 +613,24 @@ export function createSupervisor(opts = {}) {
|
|
|
593
613
|
op.telemetryCtx = ctx;
|
|
594
614
|
operations.set(op.id, op);
|
|
595
615
|
|
|
616
|
+
// C90-08: the 'starting' append is the durable start record — if the
|
|
617
|
+
// journal chain failed, the enqueue's catch has already swallowed the
|
|
618
|
+
// throw into op.chainError, so it must be checked HERE, before spawn
|
|
619
|
+
// releases a side effect with no proof-of-start on disk.
|
|
596
620
|
await transition(op, 'starting', {
|
|
597
621
|
attempt: spec.attempt,
|
|
598
622
|
sideEffectClass: spec.sideEffectClass,
|
|
599
623
|
...(hostLane ? { host: hostLane.name } : {}),
|
|
600
624
|
});
|
|
625
|
+
if (op.chainError) {
|
|
626
|
+
const startErr = op.chainError;
|
|
627
|
+
operations.delete(op.id);
|
|
628
|
+
endExecutionSpan(ctx, 'failed', {
|
|
629
|
+
operation: spec.operationId,
|
|
630
|
+
error_code: reasonCodeForFailure({ code: startErr?.code }),
|
|
631
|
+
});
|
|
632
|
+
throw startErr;
|
|
633
|
+
}
|
|
601
634
|
|
|
602
635
|
let proc;
|
|
603
636
|
// DF2-FR07: the owned child process IS this run's tool invocation —
|
|
@@ -621,8 +654,17 @@ export function createSupervisor(opts = {}) {
|
|
|
621
654
|
cwd: spec.cwd,
|
|
622
655
|
});
|
|
623
656
|
} catch (err) {
|
|
624
|
-
|
|
657
|
+
// C92-E-02: spawn threw while 'starting' → land terminal. The
|
|
658
|
+
// target edge is derived from op.state — never from
|
|
659
|
+
// cancelRequested, which close() sets on ANY non-terminal op:
|
|
660
|
+
// 'starting → cancelled' is illegal and silently strands the op.
|
|
661
|
+
// A cancel-claimed op is already 'cancel_pending' (performCancel
|
|
662
|
+
// and close() both take that edge first) → 'cancelled' is legal;
|
|
663
|
+
// anything else routes the throw through 'starting → failed'.
|
|
664
|
+
const to = op.state === 'cancel_pending' ? 'cancelled' : 'failed';
|
|
665
|
+
await transition(op, to, {
|
|
625
666
|
error: { code: 'spawn_failed', message: String(err?.message ?? err) },
|
|
667
|
+
...(to === 'cancelled' ? { reason: op.cancelReason ?? 'killed' } : {}),
|
|
626
668
|
});
|
|
627
669
|
if (telemetry) {
|
|
628
670
|
const code = reasonCodeForFailure({ code: 'spawn_failed', errno: err?.code });
|
|
@@ -642,6 +684,7 @@ export function createSupervisor(opts = {}) {
|
|
|
642
684
|
|
|
643
685
|
op.proc = proc;
|
|
644
686
|
op.pgid = proc.pid;
|
|
687
|
+
op.processStartTime = nowMs();
|
|
645
688
|
op.lastOutputAt = nowMs();
|
|
646
689
|
|
|
647
690
|
// C80-23: attach exit/error listeners BEFORE any other work — a fast-
|
|
@@ -669,7 +712,16 @@ export function createSupervisor(opts = {}) {
|
|
|
669
712
|
}
|
|
670
713
|
}
|
|
671
714
|
|
|
672
|
-
|
|
715
|
+
// C92-E-01: a cancel that landed before spawn (killGroup saw pgid
|
|
716
|
+
// null and skipped) must not leave the just-spawned child running —
|
|
717
|
+
// reap its group now that ownership is proven, and never commit the
|
|
718
|
+
// 'running' edge (cancel_pending → running is not a legal edge; the
|
|
719
|
+
// op resolves terminal via exit/backstop as 'cancelled').
|
|
720
|
+
if (op.cancelRequested) {
|
|
721
|
+
killGroup(op, SIGKILL);
|
|
722
|
+
} else {
|
|
723
|
+
await transition(op, 'running', { pid: proc.pid ?? null });
|
|
724
|
+
}
|
|
673
725
|
if (!op.exited) scheduleTick(op);
|
|
674
726
|
|
|
675
727
|
return {
|
|
@@ -752,6 +804,10 @@ export function createSupervisor(opts = {}) {
|
|
|
752
804
|
/**
|
|
753
805
|
* Reap in-flight owned children and drain journals with a bounded wait
|
|
754
806
|
* on the REAL clock — the injected clock may never advance on its own.
|
|
807
|
+
* Every close-kill claims the op via the legal `cancel_pending` edge
|
|
808
|
+
* (R4.5 C92-E-02): without it a 'starting'/'running' op would need an
|
|
809
|
+
* illegal direct `→ cancelled` edge on exit, which `transition`/
|
|
810
|
+
* `finalizeExit` reject and strand the op non-terminal.
|
|
755
811
|
*/
|
|
756
812
|
async close() {
|
|
757
813
|
closed = true;
|
|
@@ -760,6 +816,8 @@ export function createSupervisor(opts = {}) {
|
|
|
760
816
|
op.timers.clear();
|
|
761
817
|
if (!op.exited && !isTerminal(op.state)) {
|
|
762
818
|
op.cancelRequested = true;
|
|
819
|
+
op.cancelReason ??= 'supervisor_close';
|
|
820
|
+
transition(op, 'cancel_pending', { reason: op.cancelReason });
|
|
763
821
|
killGroup(op, SIGKILL);
|
|
764
822
|
}
|
|
765
823
|
}
|
package/src/core/applyPlan.js
CHANGED
|
@@ -203,8 +203,18 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
203
203
|
}
|
|
204
204
|
throw writeError;
|
|
205
205
|
}
|
|
206
|
+
// W2-C8: the content write already committed (atomic rename / wx create), so a
|
|
207
|
+
// chmod failure here must NOT abort the install — the same error-tolerance
|
|
208
|
+
// policy as write-fallback warns. The file exists with correct content at
|
|
209
|
+
// default mode; skipping the entry would misreport a committed write.
|
|
206
210
|
if (typeof entry.mode === 'number') {
|
|
207
|
-
|
|
211
|
+
try {
|
|
212
|
+
await fs.chmod(entry.targetPath, entry.mode);
|
|
213
|
+
} catch (chmodError) {
|
|
214
|
+
console.warn(
|
|
215
|
+
`[UKit] Warning: chmod ${entry.mode.toString(8)} failed for ${entry.targetPath} — ${chmodError?.code ?? chmodError?.message ?? chmodError}. File content was written; adjust permissions manually if needed.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
208
218
|
}
|
|
209
219
|
writes.push({
|
|
210
220
|
id: entry.id,
|
|
@@ -11,11 +11,13 @@ import { runDiagnostics } from './diagnostics.js';
|
|
|
11
11
|
import { findAnalogies } from './analogy.js';
|
|
12
12
|
import { summarizeFile } from './summaries.js';
|
|
13
13
|
import { createEdge } from './providers.js';
|
|
14
|
+
import { resolveProjectPathReal } from '../fileOps.js';
|
|
14
15
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
15
16
|
|
|
16
17
|
// Context Compiler v1 (SPEC §5–§8) — the peek→expand→read detail ladder.
|
|
17
18
|
//
|
|
18
|
-
// Pipeline: routeTask (unless mode forced) → guardLevel (
|
|
19
|
+
// Pipeline: routeTask (unless mode forced) → guardLevel (freshness guard —
|
|
20
|
+
// reports the real repair level, repairs nothing itself)
|
|
19
21
|
// → retrieve → budget-fitted evidence selection (lowest-confidence first out,
|
|
20
22
|
// drops recorded in `omitted`) → validatePacket.
|
|
21
23
|
//
|
|
@@ -89,8 +91,15 @@ function fitToBudget({ evidence, anchors, relations }, budgetTokens, dropped) {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
async function readExcerpt(rootDir, relPath) {
|
|
94
|
+
// C92-F-03 (+symlink follow-up): containment guard identical to
|
|
95
|
+
// summaries.js readHead — absolute paths, `..` escapes, and entries that
|
|
96
|
+
// resolve outside the realpath'd root through a symlink must never reach
|
|
97
|
+
// fs.readFile. The resolved path is opened so the read hits exactly what
|
|
98
|
+
// was containment-checked.
|
|
99
|
+
const abs = await resolveProjectPathReal(rootDir, relPath);
|
|
100
|
+
if (!abs) return null;
|
|
92
101
|
try {
|
|
93
|
-
const content = await fs.readFile(
|
|
102
|
+
const content = await fs.readFile(abs, 'utf8');
|
|
94
103
|
const lines = content.split('\n').slice(0, EXCERPT_MAX_LINES);
|
|
95
104
|
return lines.join('\n').slice(0, EXCERPT_MAX_CHARS);
|
|
96
105
|
} catch {
|
|
@@ -145,10 +154,14 @@ export async function compileContext(projectRoot, task, options = {}) {
|
|
|
145
154
|
// `read` step of the ladder: L2 excerpts when routed depth >= 2 or --read.
|
|
146
155
|
const wantRead = opts.read === true || routed.depth >= 2;
|
|
147
156
|
|
|
148
|
-
//
|
|
157
|
+
// Freshness guard — never throws; staleness is reported, never repaired
|
|
158
|
+
// here. C92-F-02: maxLevel 'L4' so recommendedLevel is the REAL repair level
|
|
159
|
+
// (same call-site contract as indexTools.js doctor) — the old 'L0' clamp made
|
|
160
|
+
// clampLevel rewrite an L4 verdict to 'L0', yielding the impossible
|
|
161
|
+
// {stale:true, level:'L0'} pair and a "repair level L0" next_action.
|
|
149
162
|
let guard = null;
|
|
150
163
|
try {
|
|
151
|
-
guard = await guardLevel(rootDir, { maxLevel: '
|
|
164
|
+
guard = await guardLevel(rootDir, { maxLevel: 'L4' });
|
|
152
165
|
} catch {
|
|
153
166
|
guard = null;
|
|
154
167
|
}
|
|
@@ -169,7 +182,7 @@ export async function compileContext(projectRoot, task, options = {}) {
|
|
|
169
182
|
stale,
|
|
170
183
|
};
|
|
171
184
|
if (stale) {
|
|
172
|
-
packet.next_actions.push(`ukit index refresh — snapshot stale (repair level ${guard
|
|
185
|
+
packet.next_actions.push(`ukit index refresh — snapshot stale (repair level ${guard.recommendedLevel})`);
|
|
173
186
|
}
|
|
174
187
|
|
|
175
188
|
// `none` mode: valid packet, empty evidence, reason recorded — never fabricate.
|
|
@@ -313,16 +326,40 @@ export async function compileContext(projectRoot, task, options = {}) {
|
|
|
313
326
|
}
|
|
314
327
|
}
|
|
315
328
|
|
|
316
|
-
//
|
|
329
|
+
// Selection bookkeeping: `dropped` collects every packet-visible omission —
|
|
330
|
+
// read-lane refusals (out-of-tree paths), budget drops, and lane-suppressed
|
|
331
|
+
// anchors/relations. It lands verbatim in `packet.omitted` below.
|
|
332
|
+
const dropped = [];
|
|
333
|
+
|
|
334
|
+
// L2 read lane: attach excerpts to the top candidates only — reads are
|
|
335
|
+
// budget-gated so a candidate that cannot survive fitToBudget is never
|
|
336
|
+
// opened (C92-F-11). `reserved` mirrors the evidence budget: the item's own
|
|
337
|
+
// token cost when it can be carried bare, plus the excerpt once read. A
|
|
338
|
+
// dropped candidate's cost does NOT accumulate, matching fit()'s kept-only
|
|
339
|
+
// accounting. Read bound = (items that fit bare, in order) + one overflow
|
|
340
|
+
// probe — never the full candidate list.
|
|
317
341
|
if (wantRead && level !== 'L0') {
|
|
342
|
+
let reserved = 0;
|
|
318
343
|
for (const item of candidates) {
|
|
319
|
-
|
|
344
|
+
// Defence in depth behind the retriever (C92-F-03): every escaping
|
|
345
|
+
// path is refused AND accounted for, even when the budget is spent —
|
|
346
|
+
// post-realpath, so symlinked evidence entries cannot slip through.
|
|
347
|
+
if (item.path && !(await resolveProjectPathReal(rootDir, item.path))) {
|
|
348
|
+
dropped.push({ what: item.path, why: 'out-of-tree' });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const bare = estimateTokens(item);
|
|
352
|
+
if (reserved + bare > requested) continue; // cannot fit even bare — fit drops it
|
|
353
|
+
if (!item.path) {
|
|
354
|
+
reserved += bare;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
320
357
|
const excerpt = await readExcerpt(rootDir, item.path);
|
|
321
358
|
if (excerpt) item.excerpt = excerpt;
|
|
359
|
+
reserved += estimateTokens(item);
|
|
322
360
|
}
|
|
323
361
|
}
|
|
324
362
|
|
|
325
|
-
const dropped = [];
|
|
326
363
|
const expandLanes = level !== 'L0';
|
|
327
364
|
const fitted = fitToBudget(
|
|
328
365
|
{
|
|
@@ -341,6 +378,12 @@ export async function compileContext(projectRoot, task, options = {}) {
|
|
|
341
378
|
}
|
|
342
379
|
}
|
|
343
380
|
if (!expandLanes) {
|
|
381
|
+
// C92-F-07: peek suppresses BOTH lanes — anchors get the same `omitted`
|
|
382
|
+
// bookkeeping relations already had, so a peek packet is distinguishable
|
|
383
|
+
// from "the retriever found no anchors".
|
|
384
|
+
for (const anchor of result.anchors ?? []) {
|
|
385
|
+
dropped.push({ what: anchor?.path ?? 'anchor', why: 'budget' });
|
|
386
|
+
}
|
|
344
387
|
for (const rel of result.relations ?? []) {
|
|
345
388
|
dropped.push({ what: `${rel.from}→${rel.to}`, why: 'budget' });
|
|
346
389
|
}
|
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
2
3
|
import fs from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { resolveProjectPathReal } from '../fileOps.js';
|
|
4
6
|
|
|
5
7
|
// Post-edit diagnostics (SPEC §7 — CI-206). Cheap checks over dirty paths:
|
|
6
8
|
// `node --check` for .js/.mjs/.cjs, `JSON.parse` for .json. Everything else is
|
|
7
9
|
// skipped silently. Never throws: missing files, spawn failures and timeouts
|
|
8
10
|
// degrade to a `severity:'warning'` entry or are skipped — the caller always
|
|
9
11
|
// gets a (possibly empty) normalized diagnostics list.
|
|
12
|
+
//
|
|
13
|
+
// Dirty-set entries are untrusted input (dirty.json can be tampered with):
|
|
14
|
+
// any entry that escapes the project root ('../x', absolute, or a symlink /
|
|
15
|
+
// symlinked directory resolving outside the realpath'd root) is refused with
|
|
16
|
+
// the resolveProjectPathReal guard, and the refusal is reported
|
|
17
|
+
// {source:'guard'} rather than silently dropped.
|
|
18
|
+
//
|
|
19
|
+
// C92-F-06: `timeoutMs` is a TOTAL deadline for the run, not a per-file budget.
|
|
20
|
+
// Checks run on async execFile (spawnSync blocked the event loop for the whole
|
|
21
|
+
// run, so no caller-side timer could fire). A file whose check loses the
|
|
22
|
+
// deadline race is reported {source:'deadline'}, as is a checkable file the
|
|
23
|
+
// deadline never let start — a silently short run is the same class of bug as
|
|
24
|
+
// the old clamp.
|
|
10
25
|
|
|
11
26
|
const SYNTAX_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
|
|
12
27
|
const MAX_DIAGNOSTICS = 50;
|
|
28
|
+
const TIMED_OUT = Symbol('timed-out');
|
|
29
|
+
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
13
31
|
|
|
14
32
|
function parseCheckLine(stderr) {
|
|
15
33
|
const lines = String(stderr ?? '')
|
|
@@ -27,10 +45,10 @@ function parseCheckLine(stderr) {
|
|
|
27
45
|
};
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
async function checkJson(
|
|
48
|
+
async function checkJson(absPath, relPath) {
|
|
31
49
|
let content;
|
|
32
50
|
try {
|
|
33
|
-
content = await fs.readFile(
|
|
51
|
+
content = await fs.readFile(absPath, 'utf8');
|
|
34
52
|
} catch {
|
|
35
53
|
return null; // missing file → skip
|
|
36
54
|
}
|
|
@@ -50,66 +68,139 @@ async function checkJson(rootDir, relPath) {
|
|
|
50
68
|
}
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
async function checkSyntax(
|
|
54
|
-
const absPath = path.join(rootDir, relPath);
|
|
71
|
+
async function checkSyntax(absPath, relPath, timeoutMs, exec) {
|
|
55
72
|
try {
|
|
56
73
|
await fs.access(absPath);
|
|
57
74
|
} catch {
|
|
58
75
|
return null; // missing file → skip
|
|
59
76
|
}
|
|
60
|
-
let result;
|
|
61
77
|
try {
|
|
62
|
-
|
|
78
|
+
await exec(process.execPath, ['--check', absPath], {
|
|
63
79
|
timeout: timeoutMs,
|
|
64
80
|
encoding: 'utf8',
|
|
65
81
|
maxBuffer: 1024 * 1024,
|
|
66
82
|
});
|
|
83
|
+
return null; // exit 0 → clean
|
|
67
84
|
} catch (error) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
if (!error || typeof error !== 'object') {
|
|
86
|
+
return { file: relPath, severity: 'warning', message: 'spawn failed: no result', source: 'syntax' };
|
|
87
|
+
}
|
|
88
|
+
// maxBuffer overflow is a spawn failure, not a timeout — execFile kills
|
|
89
|
+
// with SIGTERM for both, so check the buffer code first.
|
|
90
|
+
const hitBuffer = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error.code === 'ENOBUFS';
|
|
91
|
+
const timedOut = !hitBuffer && (error.killed === true || error.signal === 'SIGTERM' || error.code === 'ETIMEDOUT');
|
|
92
|
+
if (timedOut) {
|
|
93
|
+
return { file: relPath, severity: 'warning', message: `syntax check timed out after ${timeoutMs}ms`, source: 'syntax' };
|
|
94
|
+
}
|
|
95
|
+
if (typeof error.code !== 'number') {
|
|
96
|
+
// spawn failure (ENOENT, EACCES, …) — no syntax verdict.
|
|
97
|
+
return { file: relPath, severity: 'warning', message: `spawn failed: ${error.message ?? String(error)}`, source: 'syntax' };
|
|
98
|
+
}
|
|
99
|
+
const { message, line } = parseCheckLine(error.stderr || error.stdout);
|
|
100
|
+
const diag = { file: relPath, severity: 'error', message, source: 'syntax' };
|
|
101
|
+
if (line !== null) diag.line = line;
|
|
102
|
+
return diag;
|
|
79
103
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function delay(ms) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const t = setTimeout(resolve, ms);
|
|
109
|
+
// Never pin the process if the race resolves first.
|
|
110
|
+
t.unref?.();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkable(relPath) {
|
|
115
|
+
const ext = path.posix.extname(relPath.replace(/\\/g, '/')).toLowerCase();
|
|
116
|
+
return ext === '.json' || SYNTAX_EXTENSIONS.has(ext) ? ext : null;
|
|
85
117
|
}
|
|
86
118
|
|
|
87
119
|
/**
|
|
88
|
-
* runDiagnostics(projectRoot, relPaths, { timeoutMs?, maxFiles? })
|
|
89
|
-
* → [{ file, severity:'error'|'warning', message, line?,
|
|
120
|
+
* runDiagnostics(projectRoot, relPaths, { timeoutMs?, maxFiles?, _exec? })
|
|
121
|
+
* → [{ file, severity:'error'|'warning', message, line?,
|
|
122
|
+
* source:'syntax'|'json'|'deadline'|'guard' }]
|
|
90
123
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
124
|
+
* `timeoutMs` bounds the WHOLE run as a total deadline (C92-F-06), never
|
|
125
|
+
* per-file — worst case is timeoutMs plus one in-flight check, not
|
|
126
|
+
* maxFiles × timeoutMs. Files the deadline never let start are reported
|
|
127
|
+
* {source:'deadline'} so a truncated run is visible, and a check that loses
|
|
128
|
+
* its deadline race reports the same. Deterministic order (input order
|
|
129
|
+
* preserved); capped at `maxFiles` inputs and MAX_DIAGNOSTICS entries.
|
|
130
|
+
* `_exec` is a test seam: promisified execFile shape — (file, args, opts) →
|
|
131
|
+
* Promise<{stdout, stderr}> — and must honor opts.timeout.
|
|
93
132
|
*/
|
|
94
133
|
export async function runDiagnostics(projectRoot, relPaths, {
|
|
95
134
|
timeoutMs = 8000,
|
|
96
135
|
maxFiles = 50,
|
|
97
|
-
|
|
136
|
+
_exec = execFileAsync,
|
|
98
137
|
} = {}) {
|
|
99
138
|
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
100
139
|
const diagnostics = [];
|
|
101
140
|
const inputs = (Array.isArray(relPaths) ? relPaths : [])
|
|
102
141
|
.filter((p) => typeof p === 'string' && p.length > 0)
|
|
103
142
|
.slice(0, Math.max(0, maxFiles));
|
|
143
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
104
144
|
|
|
105
145
|
for (const relPath of inputs) {
|
|
106
146
|
if (diagnostics.length >= MAX_DIAGNOSTICS) break;
|
|
107
|
-
const ext =
|
|
108
|
-
|
|
147
|
+
const ext = checkable(relPath);
|
|
148
|
+
if (!ext) continue; // unchecked extensions stay silent — no entry at all
|
|
149
|
+
// Resolve containment post-realpath: a dirty entry that is a symlink (or
|
|
150
|
+
// resolves through a symlinked directory) to outside content passes the
|
|
151
|
+
// lexical check yet reads out-of-tree bytes — refuse it here.
|
|
152
|
+
const resolved = await resolveProjectPathReal(rootDir, relPath);
|
|
153
|
+
if (!resolved) {
|
|
154
|
+
// Tampered dirty.json entry ('../x.json', '/abs', symlink escape) —
|
|
155
|
+
// never read outside the project root, and report the refusal so a
|
|
156
|
+
// hostile/dirty entry is visible rather than silently dropped.
|
|
157
|
+
diagnostics.push({
|
|
158
|
+
file: relPath,
|
|
159
|
+
severity: 'warning',
|
|
160
|
+
message: 'refused: path escapes project root',
|
|
161
|
+
source: 'guard',
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const remaining = deadline - Date.now();
|
|
166
|
+
if (remaining <= 0) {
|
|
167
|
+
diagnostics.push({
|
|
168
|
+
file: relPath,
|
|
169
|
+
severity: 'warning',
|
|
170
|
+
message: 'skipped: total diagnostics budget exhausted',
|
|
171
|
+
source: 'deadline',
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
let check;
|
|
109
176
|
if (ext === '.json') {
|
|
110
|
-
|
|
111
|
-
} else
|
|
112
|
-
|
|
177
|
+
check = checkJson(resolved, relPath);
|
|
178
|
+
} else {
|
|
179
|
+
// The slice of the total budget left for this file — the last file in
|
|
180
|
+
// a nearly-spent budget cannot overrun the deadline on its own.
|
|
181
|
+
check = checkSyntax(resolved, relPath, remaining, _exec);
|
|
182
|
+
}
|
|
183
|
+
check.catch(() => {}); // late rejection after a lost race must never be unhandled
|
|
184
|
+
let diag;
|
|
185
|
+
try {
|
|
186
|
+
diag = await Promise.race([check, delay(remaining).then(() => TIMED_OUT)]);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// Contract: never throws — an internal check failure degrades to a
|
|
189
|
+
// warning entry rather than propagating.
|
|
190
|
+
diag = {
|
|
191
|
+
file: relPath,
|
|
192
|
+
severity: 'warning',
|
|
193
|
+
message: `check failed: ${error?.message ?? String(error)}`,
|
|
194
|
+
source: ext === '.json' ? 'json' : 'syntax',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (diag === TIMED_OUT) {
|
|
198
|
+
diag = {
|
|
199
|
+
file: relPath,
|
|
200
|
+
severity: 'warning',
|
|
201
|
+
message: 'check cut off at total diagnostics deadline',
|
|
202
|
+
source: 'deadline',
|
|
203
|
+
};
|
|
113
204
|
}
|
|
114
205
|
if (diag) diagnostics.push(diag);
|
|
115
206
|
}
|
|
@@ -6,7 +6,10 @@ import { getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/
|
|
|
6
6
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
7
7
|
|
|
8
8
|
const GIT_TIMEOUT_MS = 3000;
|
|
9
|
-
|
|
9
|
+
// In-flight fs.stat bound for the non-git manifest overlay: coverage is the
|
|
10
|
+
// whole manifest (every indexed path contributes to identity), but stat calls
|
|
11
|
+
// run in bounded batches so in-flight I/O never scales with tree size.
|
|
12
|
+
const MANIFEST_STAT_BATCH = 64;
|
|
10
13
|
const LEVELS = ['L0', 'L1', 'L2', 'L3', 'L4'];
|
|
11
14
|
const SNAPSHOT_FILE = 'freshness.json';
|
|
12
15
|
const DIRTY_FILE = 'dirty.json';
|
|
@@ -113,9 +116,13 @@ async function overlayEntriesGit(rootDir, statusOutput, extraPaths = []) {
|
|
|
113
116
|
return entries;
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
// Non-git fallback: hash LIVE mtimes for index-covered
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
+
// Non-git fallback: hash LIVE mtimes for every index-covered path in
|
|
120
|
+
// files.json. Stored mtimeMs from files.json is stale by definition — only a
|
|
121
|
+
// live fs.stat detects post-index edits, and there is no cheaper signal
|
|
122
|
+
// (directory mtimes do not propagate on content writes), so coverage cannot
|
|
123
|
+
// be capped without losing tail edits. Work is bounded by the manifest itself
|
|
124
|
+
// — an index-built artifact — and stat batches are capped at
|
|
125
|
+
// MANIFEST_STAT_BATCH in-flight calls.
|
|
119
126
|
// Incremental mode: only when dirty-set tracking is active (dirty.json exists,
|
|
120
127
|
// not saturated) AND a prior snapshot carries overlayEntries — reuse stored
|
|
121
128
|
// entries for paths proven unchanged by the dirty set; live-stat dirty,
|
|
@@ -131,7 +138,7 @@ async function overlayEntriesManifest(rootDir, { incremental, snapshot, dirty }
|
|
|
131
138
|
}
|
|
132
139
|
const manifestPaths = [];
|
|
133
140
|
const seen = new Set();
|
|
134
|
-
for (const item of items
|
|
141
|
+
for (const item of items) {
|
|
135
142
|
const rel = typeof item?.filePath === 'string' ? item.filePath : null;
|
|
136
143
|
if (!rel || seen.has(rel)) continue;
|
|
137
144
|
seen.add(rel);
|
|
@@ -145,14 +152,20 @@ async function overlayEntriesManifest(rootDir, { incremental, snapshot, dirty }
|
|
|
145
152
|
const canReuse = incremental === true && stored !== null && dirtyPaths !== null;
|
|
146
153
|
|
|
147
154
|
const entries = {};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
// Bounded-batch live stat: every manifest path contributes to the hash.
|
|
156
|
+
for (let i = 0; i < manifestPaths.length; i += MANIFEST_STAT_BATCH) {
|
|
157
|
+
const batch = manifestPaths.slice(i, i + MANIFEST_STAT_BATCH);
|
|
158
|
+
const values = await Promise.all(batch.map(async (rel) => {
|
|
159
|
+
// Reuse only when dirty tracking proves the path unchanged and a stored
|
|
160
|
+
// entry exists; otherwise stat live (never trust files.json mtimeMs).
|
|
161
|
+
if (canReuse && !dirtyPaths.has(rel) && typeof stored[rel] === 'string') {
|
|
162
|
+
return stored[rel];
|
|
163
|
+
}
|
|
164
|
+
return statEntry(rootDir, rel);
|
|
165
|
+
}));
|
|
166
|
+
for (let j = 0; j < batch.length; j += 1) {
|
|
167
|
+
entries[batch[j]] = values[j];
|
|
154
168
|
}
|
|
155
|
-
entries[rel] = await statEntry(rootDir, rel);
|
|
156
169
|
}
|
|
157
170
|
// Dirty paths outside the manifest still affect identity (stat live);
|
|
158
171
|
// index-internal artifacts are skipped.
|
|
@@ -94,7 +94,10 @@ export async function notifyEdit(projectRoot, relPaths) {
|
|
|
94
94
|
const current = await readDirtyData(projectRoot);
|
|
95
95
|
const merged = new Set(current.paths);
|
|
96
96
|
for (const p of incoming) merged.add(p);
|
|
97
|
-
|
|
97
|
+
// `saturated` is latched: the truncated tail was already discarded, so a
|
|
98
|
+
// merge that lands back under DIRTY_MAX_PATHS is still incomplete. The
|
|
99
|
+
// marker clears only when a full rebuild rewrites the record.
|
|
100
|
+
const saturated = current.saturated || merged.size > DIRTY_MAX_PATHS;
|
|
98
101
|
const paths = [...merged].sort().slice(0, DIRTY_MAX_PATHS);
|
|
99
102
|
await writeDirtyData(projectRoot, paths, saturated);
|
|
100
103
|
return paths;
|
|
@@ -141,14 +144,19 @@ export async function readDirty(projectRoot) {
|
|
|
141
144
|
/**
|
|
142
145
|
* Return the accumulated dirty set and clear the file (index-refresh lane).
|
|
143
146
|
* `saturated` is surfaced so callers can treat a saturated set as full
|
|
144
|
-
* invalidation — a saturated drain with few/no paths is NOT a clean state
|
|
147
|
+
* invalidation — a saturated drain with few/no paths is NOT a clean state,
|
|
148
|
+
* and the marker stays latched on disk until a full rebuild clears it.
|
|
145
149
|
* @returns {Promise<{ paths: string[], saturated: boolean }>}
|
|
146
150
|
*/
|
|
147
151
|
export async function drainDirty(projectRoot) {
|
|
148
152
|
const { paths, saturated } = await readDirtyData(projectRoot);
|
|
149
153
|
if (paths.length === 0 && !saturated) return { paths: [], saturated: false };
|
|
150
154
|
try {
|
|
151
|
-
|
|
155
|
+
// Preserve the saturated marker on disk: the consumer (`readDirtySet` in
|
|
156
|
+
// freshness.js) must keep seeing "this set is incomplete" — laundered to
|
|
157
|
+
// saturated:false it would justify reusing entries for dropped paths. Only
|
|
158
|
+
// a full rebuild rewrites the record as clean.
|
|
159
|
+
await writeDirtyData(projectRoot, [], saturated);
|
|
152
160
|
} catch {
|
|
153
161
|
// never throw — drained set is still returned
|
|
154
162
|
}
|