@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
|
@@ -53,6 +53,7 @@ from pptx import Presentation
|
|
|
53
53
|
# Constants
|
|
54
54
|
THUMBNAIL_WIDTH = 300 # Fixed thumbnail width in pixels
|
|
55
55
|
CONVERSION_DPI = 100 # DPI for PDF to image conversion
|
|
56
|
+
MIN_COLS = 3 # Minimum number of columns
|
|
56
57
|
MAX_COLS = 6 # Maximum number of columns
|
|
57
58
|
DEFAULT_COLS = 5 # Default number of columns
|
|
58
59
|
JPEG_QUALITY = 95 # JPEG compression quality
|
|
@@ -89,7 +90,11 @@ def main():
|
|
|
89
90
|
|
|
90
91
|
args = parser.parse_args()
|
|
91
92
|
|
|
92
|
-
# Validate columns
|
|
93
|
+
# Validate columns — C90-13: reject below-range values as a clean option
|
|
94
|
+
# error at argv time so the range(…, cols*(cols+1)) ValueError path in
|
|
95
|
+
# create_grids() is unreachable.
|
|
96
|
+
if args.cols < MIN_COLS:
|
|
97
|
+
parser.error(f"--cols must be between {MIN_COLS} and {MAX_COLS} (got {args.cols})")
|
|
93
98
|
cols = min(args.cols, MAX_COLS)
|
|
94
99
|
if args.cols > MAX_COLS:
|
|
95
100
|
print(f"Warning: Columns limited to {MAX_COLS} (requested {args.cols})")
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { withFileLock } from '../../runtime/token-utils.mjs';
|
|
6
|
+
import { withAsyncLock } from '../../runtime/async-lock.mjs';
|
|
6
7
|
|
|
7
8
|
// One wall-clock ceiling for the WHOLE discovery phase: both `git ls-files`
|
|
8
9
|
// enumerations AND the non-git fallback walk share this single budget. Every
|
|
@@ -28,6 +29,23 @@ export class IndexDiscoveryTimeoutError extends Error {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Fail-closed lock timeout (C92-B-01): a contended index-build lock that stays
|
|
34
|
+
* held past INDEX_BUILD_LOCK_WAIT_MS means another builder is still publishing
|
|
35
|
+
* (or a dead holder's lock could not be reclaimed). Building unlocked would
|
|
36
|
+
* interleave tmp+rename writes into a torn artifact set, so the caller gets a
|
|
37
|
+
* typed failure instead of a race.
|
|
38
|
+
*/
|
|
39
|
+
export class IndexBuildLockTimeoutError extends Error {
|
|
40
|
+
constructor({ waitedMs, lockPath }) {
|
|
41
|
+
super(`Index build lock ${lockPath} was still held after ${waitedMs}ms; refusing to publish unlocked.`);
|
|
42
|
+
this.name = 'IndexBuildLockTimeoutError';
|
|
43
|
+
this.code = 'INDEX_BUILD_LOCK_TIMEOUT';
|
|
44
|
+
this.waitedMs = waitedMs;
|
|
45
|
+
this.lockPath = lockPath;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
// Upper bound on in-flight filesystem stat calls during discovery: git
|
|
32
50
|
// candidate stats and the non-git fallback walk share this fixed pool.
|
|
33
51
|
// The previous `Promise.all(candidates.map(stat))` produced one unbounded
|
|
@@ -211,7 +229,56 @@ const VIETNAMESE_TOKEN_ALIASES = new Map([
|
|
|
211
229
|
// snapshots from a different version instead of trusting unknown fields.
|
|
212
230
|
export const DISCOVERY_SNAPSHOT_VERSION = 1;
|
|
213
231
|
|
|
232
|
+
export const INDEX_BUILD_LOCK_NAME = 'index-build';
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* How long a builder waits for a build already in flight before failing
|
|
236
|
+
* closed. Must comfortably cover a worst-case build of a large repo — the
|
|
237
|
+
* wait replaces what used to be a silent interleaved publish, so an overly
|
|
238
|
+
* short budget would trade torn artifacts for spurious timeouts.
|
|
239
|
+
*/
|
|
240
|
+
export const INDEX_BUILD_LOCK_WAIT_MS = 60_000;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* C92-B-01: every buildCodeIndex entry point publishes under the shared
|
|
244
|
+
* `index-build` lock (the same path ensureIndexFresh locks). Per-artifact
|
|
245
|
+
* tmp+rename was never the problem — two builders with ASYMMETRIC write sets
|
|
246
|
+
* interleaved their renames, so a reader could consume meta.json from build A
|
|
247
|
+
* and files.json from build B. The lock lives inside the builder because the
|
|
248
|
+
* callers (refresh-index.mjs, build-index.mjs, git hooks) were all unlocked;
|
|
249
|
+
* ensureIndexFresh drives buildCodeIndexInner directly while it already
|
|
250
|
+
* holds the lock, so its outer hold cannot self-deadlock.
|
|
251
|
+
* Fail-closed: a lock that cannot be acquired inside the wait budget throws
|
|
252
|
+
* IndexBuildLockTimeoutError instead of publishing unlocked.
|
|
253
|
+
*/
|
|
214
254
|
export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
|
|
255
|
+
// A signal aborted before we even look at the lock must classify exactly
|
|
256
|
+
// like a discovery cancellation: callers rely on the typed
|
|
257
|
+
// IndexDiscoveryTimeoutError(aborted) and on no artifact directory ever
|
|
258
|
+
// appearing for a build that never started.
|
|
259
|
+
if (signal?.aborted) {
|
|
260
|
+
throw new IndexDiscoveryTimeoutError({ deadlineMs, phase: 'build', elapsedMs: 0, aborted: true });
|
|
261
|
+
}
|
|
262
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
263
|
+
const lockBase = path.join(getIndexDir(absoluteRoot), INDEX_BUILD_LOCK_NAME);
|
|
264
|
+
const outcome = await withAsyncLock(
|
|
265
|
+
lockBase,
|
|
266
|
+
{ signal, deadlineMs: INDEX_BUILD_LOCK_WAIT_MS },
|
|
267
|
+
() => buildCodeIndexInner({ rootDir: absoluteRoot, changedFiles, discoverySnapshot, signal, deadlineMs }),
|
|
268
|
+
);
|
|
269
|
+
if (outcome?.ok === true) {
|
|
270
|
+
return outcome.value;
|
|
271
|
+
}
|
|
272
|
+
if (outcome?.reason === 'aborted') {
|
|
273
|
+
throw new IndexCancelledError({ phase: 'build-lock' });
|
|
274
|
+
}
|
|
275
|
+
throw new IndexBuildLockTimeoutError({
|
|
276
|
+
waitedMs: outcome?.waitedMs ?? INDEX_BUILD_LOCK_WAIT_MS,
|
|
277
|
+
lockPath: `${lockBase}.lock`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function buildCodeIndexInner({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
|
|
215
282
|
const absoluteRoot = path.resolve(rootDir);
|
|
216
283
|
const indexDir = getIndexDir(absoluteRoot);
|
|
217
284
|
|
|
@@ -222,11 +289,22 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
|
|
|
222
289
|
// wins over the optimization, and incremental output must stay equivalent to
|
|
223
290
|
// a clean full rebuild.
|
|
224
291
|
const changedHintPlan = normalizeChangedFileHints(changedFiles);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
292
|
+
let incrementalFallbackReason = changedHintPlan && !changedHintPlan.ok
|
|
293
|
+
? 'unsafe-changed-file-hints'
|
|
294
|
+
: null;
|
|
295
|
+
if (changedHintPlan?.ok) {
|
|
296
|
+
const incremental = await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal });
|
|
297
|
+
if (incremental?.rejected) {
|
|
298
|
+
// C92-B-02: a rejected merge is no longer silent. The reason lands on the
|
|
299
|
+
// build summary, and corruption-class rejections are also warned so the
|
|
300
|
+
// CLI callers that ignore the summary still surface it.
|
|
301
|
+
incrementalFallbackReason = incremental.detail
|
|
302
|
+
? `${incremental.rejected} (${incremental.detail})`
|
|
303
|
+
: incremental.rejected;
|
|
304
|
+
if (incremental.detail) {
|
|
305
|
+
console.warn(`[index] incremental refresh rejected: meta.artifactItemCounts ${incremental.rejected} (${incremental.detail}) — falling back to full rebuild`);
|
|
306
|
+
}
|
|
307
|
+
} else if (incremental) {
|
|
230
308
|
return incremental;
|
|
231
309
|
}
|
|
232
310
|
}
|
|
@@ -538,6 +616,11 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
|
|
|
538
616
|
reusedAnalogs: canReuseAnalogs,
|
|
539
617
|
preservedRuntimeCaches,
|
|
540
618
|
mode: 'full',
|
|
619
|
+
// C92-B-02: when changed-file hints were offered but the incremental merge
|
|
620
|
+
// refused them, this names the rejection (e.g.
|
|
621
|
+
// 'artifact-item-counts-mismatch (files.json: recorded 4, actual 5)') so
|
|
622
|
+
// the fallback is diagnosable instead of silently returning 'full' forever.
|
|
623
|
+
incrementalFallbackReason,
|
|
541
624
|
parsedFiles: filesToParse.slice(),
|
|
542
625
|
removedFiles: [],
|
|
543
626
|
};
|
|
@@ -658,19 +741,10 @@ function isValidCoveredItems(items, fileRecords, keyFn) {
|
|
|
658
741
|
});
|
|
659
742
|
}
|
|
660
743
|
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// match the array actually loaded — a mismatch (or a pre-count meta) falls back
|
|
666
|
-
// to full discovery.
|
|
667
|
-
function artifactItemCountsMatch(metaArtifact, counts) {
|
|
668
|
-
const recorded = metaArtifact?.artifactItemCounts;
|
|
669
|
-
if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded)) {
|
|
670
|
-
return false;
|
|
671
|
-
}
|
|
672
|
-
return Object.entries(counts).every(([name, count]) => Number(recorded[name]) === count);
|
|
673
|
-
}
|
|
744
|
+
// C92-B-02: every rejection from tryIncrementalIndexUpdate carries a reason
|
|
745
|
+
// (and, for integrity failures, a detail string) instead of a bare null, so a
|
|
746
|
+
// torn or corrupted artifact set lands on the build summary as
|
|
747
|
+
// incrementalFallbackReason rather than silently forcing 'full' forever.
|
|
674
748
|
|
|
675
749
|
// Stat every hinted path and classify it as an upsert, a removal, or — for
|
|
676
750
|
// extensions discovery never indexes — a no-op. Returns null when a hint
|
|
@@ -802,7 +876,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
|
|
|
802
876
|
[INDEX_ARTIFACTS.calls]: callsArtifact?.items?.length ?? -1,
|
|
803
877
|
[INDEX_ARTIFACTS.relations]: relationsArtifact?.items?.length ?? -1,
|
|
804
878
|
};
|
|
805
|
-
const
|
|
879
|
+
const previousArtifactsSchemaAndShapeUsable = [
|
|
806
880
|
metaArtifact,
|
|
807
881
|
filesArtifact,
|
|
808
882
|
symbolsArtifact,
|
|
@@ -817,16 +891,29 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
|
|
|
817
891
|
&& isValidCoveredItems(importsArtifact.items, filesArtifact.items, (item) => item.from)
|
|
818
892
|
&& isValidCoveredItems(callsArtifact.items, filesArtifact.items, (item) => item.filePath)
|
|
819
893
|
&& isValidCoveredItems(relationsArtifact.items, filesArtifact.items, (item) => item.filePath)
|
|
820
|
-
&& Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles)
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
894
|
+
&& Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles);
|
|
895
|
+
if (!previousArtifactsSchemaAndShapeUsable) {
|
|
896
|
+
return { rejected: 'previous-artifacts-unusable' };
|
|
897
|
+
}
|
|
898
|
+
// C92-B-02: the count gate used to fold silently into the same null as every
|
|
899
|
+
// other unusability, so a torn publish permanently disabled incremental
|
|
900
|
+
// refresh with no diagnostic. Name the artifacts that disagree with meta.
|
|
901
|
+
const recordedCounts = metaArtifact?.artifactItemCounts;
|
|
902
|
+
const countsRecorded = recordedCounts && typeof recordedCounts === 'object' && !Array.isArray(recordedCounts);
|
|
903
|
+
const countMismatchDetail = !countsRecorded
|
|
904
|
+
? 'artifactItemCounts missing from meta.json'
|
|
905
|
+
: Object.entries(artifactItemCounts)
|
|
906
|
+
.filter(([name, actual]) => Number(recordedCounts[name]) !== actual)
|
|
907
|
+
.map(([name, actual]) => `${name}: recorded ${recordedCounts[name] ?? 'missing'}, actual ${actual}`)
|
|
908
|
+
.join(', ');
|
|
909
|
+
if (!countsRecorded || countMismatchDetail) {
|
|
910
|
+
return { rejected: 'artifact-item-counts-mismatch', detail: countMismatchDetail };
|
|
824
911
|
}
|
|
825
912
|
|
|
826
913
|
const previousRecordsByPath = new Map(filesArtifact.items.map((item) => [item.filePath, item]));
|
|
827
914
|
const hintUpdates = await resolveChangedHintUpdates(absoluteRoot, changedPaths, previousRecordsByPath);
|
|
828
915
|
if (!hintUpdates) {
|
|
829
|
-
return
|
|
916
|
+
return { rejected: 'changed-hints-unresolvable' };
|
|
830
917
|
}
|
|
831
918
|
|
|
832
919
|
// Merge the hint outcome into one sorted record set — the same shape and
|
|
@@ -895,7 +982,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
|
|
|
895
982
|
}
|
|
896
983
|
}, signal, 'stat-style'));
|
|
897
984
|
if (styleEntries.some((entry) => entry?.statError)) {
|
|
898
|
-
return
|
|
985
|
+
return { rejected: 'style-stat-unresolvable' };
|
|
899
986
|
}
|
|
900
987
|
const styleFilePaths = styleEntries.filter(Boolean).map((entry) => normalizeRelative(absoluteRoot, entry.absolutePath));
|
|
901
988
|
|
|
@@ -1209,7 +1296,7 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
|
|
|
1209
1296
|
const directScores = new Map();
|
|
1210
1297
|
for (const file of safeItems(files)) {
|
|
1211
1298
|
const filePath = file.filePath;
|
|
1212
|
-
if (isLikelyTestFilePath(filePath) && !includeLikelyTestFiles) continue;
|
|
1299
|
+
if (typeof filePath !== 'string' || !filePath || (isLikelyTestFilePath(filePath) && !includeLikelyTestFiles)) continue;
|
|
1213
1300
|
const searchDescriptor = searchDescriptorsByFile.get(filePath) ?? createFileSearchDescriptor({ filePath, symbols: [] });
|
|
1214
1301
|
const { score, reasons } = scoreDirectFileMatch({
|
|
1215
1302
|
queryDescriptor,
|
|
@@ -1378,8 +1465,24 @@ function ensureArtifact(artifact) {
|
|
|
1378
1465
|
};
|
|
1379
1466
|
}
|
|
1380
1467
|
|
|
1468
|
+
// C92-B-03 consumer symmetry: an artifact written under a different
|
|
1469
|
+
// INDEX_SCHEMA_VERSION (older OR newer — a package downgrade leaves newer
|
|
1470
|
+
// artifacts on disk) is treated as absent, exactly like getFileOutline's
|
|
1471
|
+
// schema gate on symbols.json. Without it, queryCodeIndex scores the
|
|
1472
|
+
// mismatched set and looks healthy while outline: silently disappears.
|
|
1473
|
+
function safeSchemaArtifact(artifact) {
|
|
1474
|
+
const normalized = ensureArtifact(artifact);
|
|
1475
|
+
return normalized.schemaVersion === INDEX_SCHEMA_VERSION ? normalized : { items: [] };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// The cache keys matched against this escaped value are produced by
|
|
1479
|
+
// JSON.stringify, which escapes control characters (\n, \b, \u00XX, lone
|
|
1480
|
+
// surrogates) on top of backslash/quote. Hand-escaping only \\ and \" means a
|
|
1481
|
+
// rootDir containing control characters never matches its own cache keys, so
|
|
1482
|
+
// clearIndexArtifactCache leaves stale results behind. Reuse JSON.stringify
|
|
1483
|
+
// itself and strip the surrounding quotes to stay in lockstep.
|
|
1381
1484
|
function escapeJsonString(value) {
|
|
1382
|
-
return String(value)
|
|
1485
|
+
return JSON.stringify(String(value)).slice(1, -1);
|
|
1383
1486
|
}
|
|
1384
1487
|
|
|
1385
1488
|
// ── Context Resolver ──
|
|
@@ -2939,8 +3042,10 @@ async function loadQuerySearchBundle(rootDir) {
|
|
|
2939
3042
|
readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols),
|
|
2940
3043
|
])
|
|
2941
3044
|
.then(([files, symbols]) => {
|
|
2942
|
-
|
|
2943
|
-
|
|
3045
|
+
// C92-B-03: same schema contract getFileOutline applies — a mismatched
|
|
3046
|
+
// artifact reads as empty instead of feeding ranking.
|
|
3047
|
+
const safeFiles = safeSchemaArtifact(files);
|
|
3048
|
+
const safeSymbols = safeSchemaArtifact(symbols);
|
|
2944
3049
|
const symbolMap = groupBy(safeItems(safeSymbols), (item) => item.filePath);
|
|
2945
3050
|
return {
|
|
2946
3051
|
files: safeFiles,
|
|
@@ -2972,11 +3077,11 @@ async function loadQuerySupportBundle(rootDir) {
|
|
|
2972
3077
|
readArtifact(absoluteRoot, INDEX_ARTIFACTS.archetypes),
|
|
2973
3078
|
]))
|
|
2974
3079
|
.then(async ([files, imports, testsMap, hotspots, archetypes]) => {
|
|
2975
|
-
const safeFiles =
|
|
2976
|
-
const safeImports =
|
|
2977
|
-
const safeTestsMap =
|
|
2978
|
-
const safeHotspots =
|
|
2979
|
-
const safeArchetypes =
|
|
3080
|
+
const safeFiles = safeSchemaArtifact(files);
|
|
3081
|
+
const safeImports = safeSchemaArtifact(imports);
|
|
3082
|
+
const safeTestsMap = safeSchemaArtifact(testsMap);
|
|
3083
|
+
const safeHotspots = safeSchemaArtifact(hotspots);
|
|
3084
|
+
const safeArchetypes = safeSchemaArtifact(archetypes);
|
|
2980
3085
|
const indexedFileSet = new Set(safeItems(safeFiles).map((item) => item.filePath));
|
|
2981
3086
|
const importAliasContext = importsNeedAliasContext(safeItems(safeImports))
|
|
2982
3087
|
? await loadImportAliasContext({ rootDir: absoluteRoot })
|
|
@@ -3399,7 +3504,7 @@ function buildResolvedImportGraphs(rootDir, imports, indexedFileSet, importAlias
|
|
|
3399
3504
|
const importersByTarget = new Map();
|
|
3400
3505
|
|
|
3401
3506
|
for (const edge of imports) {
|
|
3402
|
-
if (
|
|
3507
|
+
if (typeof edge?.from !== 'string' || typeof edge?.to !== 'string' || !edge.from || !edge.to) continue;
|
|
3403
3508
|
|
|
3404
3509
|
const target = resolveImportSpecifier({
|
|
3405
3510
|
rootDir,
|
|
@@ -3525,9 +3630,13 @@ function resolveImportSpecifier({
|
|
|
3525
3630
|
indexedFileSet,
|
|
3526
3631
|
aliasContext,
|
|
3527
3632
|
}) {
|
|
3528
|
-
if (!specifier
|
|
3633
|
+
if (typeof specifier !== 'string' || !specifier.trim()) return null;
|
|
3529
3634
|
|
|
3530
3635
|
if (specifier.startsWith('.')) {
|
|
3636
|
+
// A relative specifier cannot be resolved without the importing file, so a
|
|
3637
|
+
// malformed (non-string) edge.from must skip the edge instead of throwing
|
|
3638
|
+
// inside path.posix.dirname.
|
|
3639
|
+
if (typeof fromFilePath !== 'string') return null;
|
|
3531
3640
|
const fromDir = path.posix.dirname(fromFilePath);
|
|
3532
3641
|
const base = path.posix.normalize(path.posix.join(fromDir, specifier));
|
|
3533
3642
|
return resolveCandidateBase(base, indexedFileSet);
|
|
@@ -4495,7 +4604,12 @@ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, e
|
|
|
4495
4604
|
|| Number.isNaN(maxAgeMs)
|
|
4496
4605
|
|| maxAgeMs < 0
|
|
4497
4606
|
|| (now - effectiveGeneratedAtMs) >= maxAgeMs
|
|
4498
|
-
|| !metaArtifact?.sourceFingerprint
|
|
4607
|
+
|| !metaArtifact?.sourceFingerprint
|
|
4608
|
+
// C92-B-03: an artifact set written under a different INDEX_SCHEMA_VERSION
|
|
4609
|
+
// — older OR newer — is not fresh. `!==` covers the package-downgrade
|
|
4610
|
+
// direction too; serving the old schema made `outline:` silently vanish
|
|
4611
|
+
// while ranking kept answering.
|
|
4612
|
+
|| metaArtifact?.schemaVersion !== INDEX_SCHEMA_VERSION;
|
|
4499
4613
|
|
|
4500
4614
|
// Legacy boolean callers (query-index, triage, verify-context) stop here and
|
|
4501
4615
|
// let buildCodeIndex do its own discovery when a rebuild is needed.
|
|
@@ -4578,7 +4692,10 @@ export async function ensureIndexFresh({
|
|
|
4578
4692
|
return lastRefreshMs;
|
|
4579
4693
|
}
|
|
4580
4694
|
|
|
4581
|
-
|
|
4695
|
+
// Shares the builder's lock: buildCodeIndex acquires `<base>.lock` for
|
|
4696
|
+
// base `index-build`, so this must name the same base — and drives the
|
|
4697
|
+
// unwrapped inner builder since this critical section already holds it.
|
|
4698
|
+
const lockPath = `${getIndexDir(absoluteRoot)}/${INDEX_BUILD_LOCK_NAME}`;
|
|
4582
4699
|
const result = await withFileLock(lockPath, async () => {
|
|
4583
4700
|
const lockedRefreshMs = await getIndexArtifactGeneratedAt({ rootDir: absoluteRoot });
|
|
4584
4701
|
const lockedStale = lockedRefreshMs === null
|
|
@@ -4592,7 +4709,7 @@ export async function ensureIndexFresh({
|
|
|
4592
4709
|
if (!lockedStale) {
|
|
4593
4710
|
return { refreshed: false, generatedAtMs: lockedRefreshMs };
|
|
4594
4711
|
}
|
|
4595
|
-
const summary = await
|
|
4712
|
+
const summary = await buildCodeIndexInner({ rootDir: absoluteRoot, ...discoveryOptions, discoverySnapshot });
|
|
4596
4713
|
return { refreshed: true, summary, generatedAtMs: summary?.generatedAtMs ?? Date.now() };
|
|
4597
4714
|
});
|
|
4598
4715
|
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
// Parity is locked by tests/consistency/registryParity.test.js.
|
|
18
18
|
|
|
19
19
|
import fs from 'node:fs/promises';
|
|
20
|
-
import fsSync from 'node:fs';
|
|
21
20
|
import os from 'node:os';
|
|
22
21
|
import path from 'node:path';
|
|
23
22
|
import { fileURLToPath } from 'node:url';
|
|
@@ -77,12 +76,14 @@ const AUTONOMOUS_TRIGGER_RE = /\b(finish (?:this|the) backlog|bounded (?:long )?
|
|
|
77
76
|
const RELEASE_TRIGGER_RE = /\b(publish|ship it|release|bump (?:the )?version|deploy|cut a release|tag (?:the )?release)\b/i;
|
|
78
77
|
const HANDOFF_CYCLE_TRIGGER_RE = /\b(handoff|multi[- ]?task cycle|task pipeline|execute the plan|worktree)\b/i;
|
|
79
78
|
|
|
80
|
-
// session-pickup discriminator needs the resumable-record check.
|
|
81
|
-
|
|
79
|
+
// session-pickup discriminator needs the resumable-record check. ASYNC scan
|
|
80
|
+
// (W3-PB2): the resolve path runs inside hook processes where a sync
|
|
81
|
+
// readdirSync parks the event loop and can starve the hook self-deadline.
|
|
82
|
+
async function hasResumableRecord(projectRoot) {
|
|
82
83
|
if (!projectRoot) return false;
|
|
83
84
|
try {
|
|
84
85
|
const runsDir = path.join(projectRoot, '.ukit', 'storage', 'runs');
|
|
85
|
-
const entries =
|
|
86
|
+
const entries = await fs.readdir(runsDir, { withFileTypes: true });
|
|
86
87
|
return entries.some((entry) => entry.isFile() && entry.name.endsWith('.json'));
|
|
87
88
|
} catch {
|
|
88
89
|
return false;
|
|
@@ -288,7 +289,7 @@ function matchFirst(text, regexes) {
|
|
|
288
289
|
return -1;
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
function buildRouteSignals({
|
|
292
|
+
async function buildRouteSignals({
|
|
292
293
|
signalText,
|
|
293
294
|
executionMode,
|
|
294
295
|
escalationTriggers,
|
|
@@ -357,7 +358,7 @@ function buildRouteSignals({
|
|
|
357
358
|
testHarness: TEST_HARNESS_RE.test(signalText),
|
|
358
359
|
measurable: /\d/.test(signalText) || MEASURABLE_RE.test(signalText),
|
|
359
360
|
dataMove: DATA_MOVE_RE.test(signalText),
|
|
360
|
-
hasResumableRecord: hasResumableRecord(projectRoot),
|
|
361
|
+
hasResumableRecord: await hasResumableRecord(projectRoot),
|
|
361
362
|
// BL-026 review fix: ask to convert/author a skill — beats defect verbs in
|
|
362
363
|
// the same sentence ("make this repeated fix flow a skill").
|
|
363
364
|
skillAsk: SKILL_ASK_RE.test(signalText),
|
|
@@ -375,14 +376,17 @@ const MEASURABLE_RE = /\b(latency|baseline|benchmark|profile|cpu|memory|heap|tak
|
|
|
375
376
|
// REQUIRED — a registry file without them is malformed (FR-007), where the
|
|
376
377
|
// workflow-policy loader tolerates filename-stem ids.
|
|
377
378
|
function parsePlaybookMeta(raw, filePath) {
|
|
378
|
-
|
|
379
|
+
// W3-PB1: normalize CRLF before fence matching — a Windows-authored
|
|
380
|
+
// frontmatter block is still frontmatter, not malformed.
|
|
381
|
+
const text = String(raw).replace(/\r\n/g, '\n');
|
|
382
|
+
if (!text.startsWith('---\n')) {
|
|
379
383
|
return null;
|
|
380
384
|
}
|
|
381
|
-
const fenceIndex =
|
|
385
|
+
const fenceIndex = text.indexOf('\n---', 3);
|
|
382
386
|
if (fenceIndex === -1) {
|
|
383
387
|
return null; // unterminated frontmatter
|
|
384
388
|
}
|
|
385
|
-
const metaText =
|
|
389
|
+
const metaText = text.slice(4, fenceIndex);
|
|
386
390
|
const meta = {};
|
|
387
391
|
for (const line of metaText.split('\n')) {
|
|
388
392
|
const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
@@ -407,7 +411,7 @@ function parsePlaybookMeta(raw, filePath) {
|
|
|
407
411
|
if (lanes.length === 0) {
|
|
408
412
|
return null;
|
|
409
413
|
}
|
|
410
|
-
const body =
|
|
414
|
+
const body = text.slice(fenceIndex + 4).replace(/^\n/, '');
|
|
411
415
|
if (body.trim() === '') {
|
|
412
416
|
return null; // empty body → not a playbook
|
|
413
417
|
}
|
|
@@ -552,7 +556,7 @@ export async function resolvePlaybookId({
|
|
|
552
556
|
...(Array.isArray(escalationTriggers) ? escalationTriggers : []),
|
|
553
557
|
...deriveEscalationTriggers(floor),
|
|
554
558
|
])];
|
|
555
|
-
const signals = buildRouteSignals({
|
|
559
|
+
const signals = await buildRouteSignals({
|
|
556
560
|
signalText,
|
|
557
561
|
executionMode,
|
|
558
562
|
escalationTriggers: mergedTriggers,
|
|
@@ -74,15 +74,27 @@ async function readJsonl(filePath) {
|
|
|
74
74
|
.filter(Boolean);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// C92-D-03: a pre-edit manifest row is never rewritten once a post-edit pass
|
|
78
|
+
// consumes it, so "the last row with a rollbackPath and no afterHash" keeps
|
|
79
|
+
// matching every later edit of the same path — the delta gate then diffs
|
|
80
|
+
// against a two-generations-old blob and its BLOCKED remediation points at a
|
|
81
|
+
// restore target that discards landed work. Manifest ORDER is the consumption
|
|
82
|
+
// marker instead: scan newest → oldest and decide from the FIRST row seen for
|
|
83
|
+
// this path. If that row is a post-edit audit row (or carries no usable
|
|
84
|
+
// rollbackPath), the backup for it was already consumed — there is no baseline
|
|
85
|
+
// for this edit, and no older row may be resurrected.
|
|
77
86
|
async function findLatestBackup(projectRoot, relativePath) {
|
|
78
87
|
const backupsRoot = path.join(projectRoot, '.ukit', 'storage', 'backups');
|
|
79
88
|
for (const manifestPath of await listManifestPaths(backupsRoot)) {
|
|
80
89
|
const entries = await readJsonl(manifestPath);
|
|
81
90
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
82
91
|
const entry = entries[index];
|
|
83
|
-
if (entry.file
|
|
84
|
-
|
|
92
|
+
if (entry.file !== relativePath) continue;
|
|
93
|
+
const isPostEditRow = entry.event === 'post-edit' || Boolean(entry.afterHash);
|
|
94
|
+
if (isPostEditRow || !entry.rollbackPath) {
|
|
95
|
+
return { consumed: true, manifestPath };
|
|
85
96
|
}
|
|
97
|
+
return { entry, manifestPath };
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
100
|
return null;
|
|
@@ -171,11 +183,20 @@ export async function verifyPostEdit({ projectRoot = process.cwd(), payload = {}
|
|
|
171
183
|
if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'missing-after', file: resolved.relative };
|
|
172
184
|
|
|
173
185
|
const latest = await findLatestBackup(projectRoot, resolved.relative);
|
|
174
|
-
|
|
186
|
+
// Consumed (or absent) baseline → there is nothing truthful to diff against.
|
|
187
|
+
// Skipping is deliberate: a fabricated delta is strictly worse than no gate.
|
|
188
|
+
if (!latest || latest.consumed) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
|
|
175
189
|
|
|
176
190
|
const rollbackPath = path.resolve(projectRoot, latest.entry.rollbackPath);
|
|
177
|
-
const beforeBuffer = await fs.readFile(rollbackPath);
|
|
191
|
+
const beforeBuffer = await fs.readFile(rollbackPath).catch(() => null);
|
|
192
|
+
if (!beforeBuffer) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
|
|
178
193
|
const beforeProfile = analyzeTextBuffer(beforeBuffer);
|
|
194
|
+
// Self-validating baseline: when the producer recorded beforeHash, the blob
|
|
195
|
+
// must hash to exactly that value. A mismatch means the blob is stale or
|
|
196
|
+
// corrupt — treat it as missing rather than diff against wrong content.
|
|
197
|
+
if (latest.entry.beforeHash && beforeProfile.sha256 !== latest.entry.beforeHash) {
|
|
198
|
+
return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
|
|
199
|
+
}
|
|
179
200
|
const afterProfile = await analyzeTextFile(resolved.absolute);
|
|
180
201
|
const risk = classifySafePatchRisk(resolved.relative, afterProfile, config);
|
|
181
202
|
const delta = beforeProfile.utf8Valid && afterProfile.utf8Valid
|
|
@@ -64,6 +64,10 @@ async function backupIfNeeded({ projectRoot, payload }) {
|
|
|
64
64
|
await fs.copyFile(resolved.absolute, rollbackPath);
|
|
65
65
|
const manifestPath = path.join(backupRoot, 'manifest.jsonl');
|
|
66
66
|
const entry = {
|
|
67
|
+
// C92-D-03: explicit row typing — post-edit-verify consumes a baseline by
|
|
68
|
+
// manifest order, so readers must be able to tell a pre-edit backup row
|
|
69
|
+
// from a post-edit audit row without inferring it from field presence.
|
|
70
|
+
event: 'pre-edit',
|
|
67
71
|
file: resolved.relative,
|
|
68
72
|
beforeHash: profile.sha256,
|
|
69
73
|
profile: publicTextProfile(profile),
|
|
@@ -123,12 +123,16 @@ function excludeNodeModulesFromGit(root) {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// The `.claude` copy must never clobber a git-TRACKED
|
|
127
|
-
// `.claude/commands/ukit/
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// worktree's tracked file with
|
|
126
|
+
// The `.claude` copy must never clobber a file that is git-TRACKED **in the
|
|
127
|
+
// worktree being populated** (today exactly `.claude/commands/ukit/
|
|
128
|
+
// handoff-fullstack.md` — owned by TASK-005). A fresh worktree already has
|
|
129
|
+
// that file checked out by `git worktree add`; if the gitignored dev mirror
|
|
130
|
+
// in mainRoot has locally drifted from the committed copy, an unconditional
|
|
131
|
+
// recursive copy would silently overwrite the worktree's tracked file with
|
|
132
|
+
// main's unreviewed local state. W3-02: the probe must run against the
|
|
133
|
+
// DESTINATION worktree's index (`git -C <wtPath> ls-files`), not the main
|
|
134
|
+
// tree's — a file tracked only on the worktree's branch is invisible to the
|
|
135
|
+
// main index and would be clobbered.
|
|
132
136
|
function trackedClaudeFiles(root) {
|
|
133
137
|
try {
|
|
134
138
|
const res = gitProbe(['ls-files', '--', '.claude'], root);
|
|
@@ -183,10 +187,11 @@ if (!fs.existsSync(srcClaude)) {
|
|
|
183
187
|
} else {
|
|
184
188
|
// Always (re)copy: a fresh worktree may already carry a partial, git-tracked
|
|
185
189
|
// .claude/ (e.g. commands), which must not make us skip the hooks/scripts.
|
|
186
|
-
// Never overwrite a
|
|
187
|
-
// handoff-fullstack.md
|
|
188
|
-
// checked out for it alone.
|
|
189
|
-
|
|
190
|
+
// Never overwrite a file tracked in the WORKTREE's index (e.g.
|
|
191
|
+
// handoff-fullstack.md, or a file committed only on the worktree's branch)
|
|
192
|
+
// — leave whatever `git worktree add` already checked out for it alone.
|
|
193
|
+
// W3-02: probe wtPath, not mainRoot — each linked worktree has its own index.
|
|
194
|
+
const trackedInClaude = trackedClaudeFiles(wtPath);
|
|
190
195
|
fs.cpSync(srcClaude, dstClaude, {
|
|
191
196
|
recursive: true,
|
|
192
197
|
filter: (src) => {
|
|
@@ -24,7 +24,7 @@ const rootDir = getRootDir(args);
|
|
|
24
24
|
const limitArg = readFlagValue(args, '--limit');
|
|
25
25
|
const limit = Number.parseInt(limitArg ?? '5', 10);
|
|
26
26
|
const showOutline = !args.includes('--no-outline');
|
|
27
|
-
const query = collectPositionalArgs(args, ['--
|
|
27
|
+
const query = collectPositionalArgs(args, ['--no-outline']);
|
|
28
28
|
|
|
29
29
|
if (!query) {
|
|
30
30
|
console.error('Usage: node .claude/ukit/index/query-index.mjs "<error|symbol|path>"');
|
|
@@ -167,23 +167,30 @@ function getRootDir(argv) {
|
|
|
167
167
|
|
|
168
168
|
function readFlagValue(argv, flag) {
|
|
169
169
|
const exact = argv.indexOf(flag);
|
|
170
|
-
if (exact >= 0 && argv[exact + 1]
|
|
170
|
+
if (exact >= 0 && argv[exact + 1] && !argv[exact + 1].startsWith('--')) {
|
|
171
|
+
return argv[exact + 1];
|
|
172
|
+
}
|
|
171
173
|
const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
|
|
172
174
|
return withEquals ? withEquals.slice(flag.length + 1) : null;
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
function collectPositionalArgs(argv,
|
|
177
|
+
function collectPositionalArgs(argv, booleanFlags = []) {
|
|
176
178
|
return argv
|
|
177
|
-
.filter((arg, index) => !isFlagOrValue(argv, index,
|
|
179
|
+
.filter((arg, index) => !isFlagOrValue(argv, index, booleanFlags))
|
|
178
180
|
.join(' ')
|
|
179
181
|
.trim();
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
function isFlagOrValue(argv, index,
|
|
184
|
+
function isFlagOrValue(argv, index, booleanFlags = []) {
|
|
183
185
|
const arg = argv[index];
|
|
184
186
|
if (!arg.startsWith('--')) {
|
|
185
187
|
const prev = argv[index - 1];
|
|
186
|
-
|
|
188
|
+
// A bare token following a `--flag` is that flag's value — the previous
|
|
189
|
+
// parser let unknown flags (e.g. `--foo bar`) leak `bar` into the query.
|
|
190
|
+
// Boolean flags (`--no-outline`) and `--flag=value` forms consume nothing.
|
|
191
|
+
if (!prev || !prev.startsWith('--')) return false;
|
|
192
|
+
if (booleanFlags.includes(prev) || prev.includes('=')) return false;
|
|
193
|
+
return true;
|
|
187
194
|
}
|
|
188
195
|
return true;
|
|
189
196
|
}
|