@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
package/src/core/fileOps.js
CHANGED
|
@@ -99,6 +99,67 @@ export function resolveProjectRelativePath(projectRoot, relPath) {
|
|
|
99
99
|
return absolute;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* resolveProjectPathReal(projectRoot, relPath) → resolved absolute path or null.
|
|
104
|
+
*
|
|
105
|
+
* Same lexical gate as resolveProjectRelativePath, then a post-resolution
|
|
106
|
+
* containment check: realpath BOTH the project root and the candidate so a
|
|
107
|
+
* project-local symlink — or a path resolving through a symlinked directory —
|
|
108
|
+
* that points outside the root is refused. The lexical check alone lets
|
|
109
|
+
* fs.readFile silently follow the link (C92-F-03 follow-up / dirty-set guard).
|
|
110
|
+
*
|
|
111
|
+
* A candidate whose leaf does not exist is still resolved: realpath walks to
|
|
112
|
+
* the nearest existing ancestor and the unresolved tail is re-attached
|
|
113
|
+
* lexically (ENOENT/ENOTDIR only — a missing leaf under a symlinked parent is
|
|
114
|
+
* still caught). Any other resolution failure (ELOOP, EACCES, …) is a refusal,
|
|
115
|
+
* never a pass. Returns the RESOLVED path so callers read exactly what was
|
|
116
|
+
* containment-checked.
|
|
117
|
+
*/
|
|
118
|
+
export async function resolveProjectPathReal(projectRoot, relPath) {
|
|
119
|
+
const absolute = resolveProjectRelativePath(projectRoot, relPath);
|
|
120
|
+
if (!absolute) return null;
|
|
121
|
+
|
|
122
|
+
let realRoot;
|
|
123
|
+
try {
|
|
124
|
+
realRoot = await fs.realpath(path.resolve(projectRoot));
|
|
125
|
+
} catch {
|
|
126
|
+
return null; // cannot pin the root — nothing is provably inside it
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let real;
|
|
130
|
+
try {
|
|
131
|
+
real = await fs.realpath(absolute);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const parts = [];
|
|
137
|
+
let cursor = absolute;
|
|
138
|
+
let resolvedAncestor = null;
|
|
139
|
+
while (true) {
|
|
140
|
+
const parent = path.dirname(cursor);
|
|
141
|
+
parts.unshift(path.basename(cursor));
|
|
142
|
+
if (parent === cursor) return null; // walked past the fs root — refuse
|
|
143
|
+
cursor = parent;
|
|
144
|
+
try {
|
|
145
|
+
resolvedAncestor = await fs.realpath(cursor);
|
|
146
|
+
break;
|
|
147
|
+
} catch (inner) {
|
|
148
|
+
if (inner?.code !== 'ENOENT' && inner?.code !== 'ENOTDIR') {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
real = path.join(resolvedAncestor, ...parts);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const relative = path.relative(realRoot, real);
|
|
157
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return real;
|
|
161
|
+
}
|
|
162
|
+
|
|
102
163
|
export async function ensureDir(dirPath) {
|
|
103
164
|
await fs.mkdir(dirPath, { recursive: true });
|
|
104
165
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const DEFAULT_CONFIG = {
|
|
2
2
|
archiveAfterDays: 30,
|
|
3
3
|
maxSessionsKept: 20,
|
|
4
|
+
maxPatternCandidates: 50,
|
|
4
5
|
conflictResolution: 'latest_wins',
|
|
5
6
|
redactPatterns: [
|
|
6
7
|
/sk-[a-z0-9_-]+/gi,
|
|
@@ -151,12 +152,61 @@ function normalizeConfig(config = {}) {
|
|
|
151
152
|
maxSessionsKept: Number.isInteger(mergedConfig.maxSessionsKept) && mergedConfig.maxSessionsKept > 0
|
|
152
153
|
? mergedConfig.maxSessionsKept
|
|
153
154
|
: DEFAULT_CONFIG.maxSessionsKept,
|
|
155
|
+
maxPatternCandidates: Number.isInteger(mergedConfig.maxPatternCandidates) && mergedConfig.maxPatternCandidates > 0
|
|
156
|
+
? mergedConfig.maxPatternCandidates
|
|
157
|
+
: DEFAULT_CONFIG.maxPatternCandidates,
|
|
154
158
|
redactPatterns: Array.isArray(mergedConfig.redactPatterns)
|
|
155
159
|
? mergedConfig.redactPatterns
|
|
156
160
|
: DEFAULT_CONFIG.redactPatterns,
|
|
157
161
|
};
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
// W3-F6: patternCandidates grew without bound — resolved entries were never
|
|
165
|
+
// evicted. Resolved entries older than archiveAfterDays drop first; the
|
|
166
|
+
// surviving list is then capped at maxPatternCandidates, evicting oldest
|
|
167
|
+
// detectedAt first and preferring to evict resolved over still-pending
|
|
168
|
+
// (a pending entry is an open decision; resolved ones are history).
|
|
169
|
+
const PATTERN_CANDIDATE_RESOLVED = new Set(['approved', 'rejected']);
|
|
170
|
+
|
|
171
|
+
function candidateDetectedAt(entry) {
|
|
172
|
+
const detectedAt = Number(entry?.detectedAt ?? 0);
|
|
173
|
+
return Number.isFinite(detectedAt) ? detectedAt : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function prunePatternCandidates(projectMemory, config) {
|
|
177
|
+
const candidates = Array.isArray(projectMemory.patternCandidates)
|
|
178
|
+
? projectMemory.patternCandidates
|
|
179
|
+
: [];
|
|
180
|
+
if (candidates.length === 0) return;
|
|
181
|
+
|
|
182
|
+
const now = config.now ?? Date.now();
|
|
183
|
+
const maxAgeMs = config.archiveAfterDays * 24 * 60 * 60 * 1000;
|
|
184
|
+
const cap = config.maxPatternCandidates;
|
|
185
|
+
|
|
186
|
+
// Age eviction: only resolved candidates — pending ones never expire unseen.
|
|
187
|
+
const fresh = candidates.filter((entry) => {
|
|
188
|
+
if (!PATTERN_CANDIDATE_RESOLVED.has(entry?.status)) return true;
|
|
189
|
+
const detectedAt = candidateDetectedAt(entry);
|
|
190
|
+
return detectedAt <= 0 || now - detectedAt <= maxAgeMs;
|
|
191
|
+
});
|
|
192
|
+
if (fresh.length <= cap) {
|
|
193
|
+
projectMemory.patternCandidates = fresh;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Cap eviction: oldest resolved first, then oldest pending — the newest
|
|
198
|
+
// cap-sized survivors keep their original relative order.
|
|
199
|
+
const isResolved = (entry) => PATTERN_CANDIDATE_RESOLVED.has(entry?.status);
|
|
200
|
+
const resolved = fresh.filter(isResolved).sort((a, b) => candidateDetectedAt(a) - candidateDetectedAt(b));
|
|
201
|
+
const pending = fresh.filter((entry) => !isResolved(entry)).sort((a, b) => candidateDetectedAt(a) - candidateDetectedAt(b));
|
|
202
|
+
const dropCount = fresh.length - cap;
|
|
203
|
+
const dropped = new Set([
|
|
204
|
+
...resolved.slice(0, dropCount),
|
|
205
|
+
...pending.slice(0, Math.max(0, dropCount - resolved.length)),
|
|
206
|
+
]);
|
|
207
|
+
projectMemory.patternCandidates = fresh.filter((entry) => !dropped.has(entry));
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
export function runHygiene(projectMemory, config = {}) {
|
|
161
211
|
const mergedConfig = normalizeConfig(config);
|
|
162
212
|
const nextMemory = redactValue(clone(projectMemory), mergedConfig.redactPatterns);
|
|
@@ -164,7 +214,7 @@ export function runHygiene(projectMemory, config = {}) {
|
|
|
164
214
|
nextMemory.conventions = uniqueStrings(nextMemory.conventions);
|
|
165
215
|
nextMemory.techStack = uniqueStrings(nextMemory.techStack);
|
|
166
216
|
nextMemory.activeRules = uniqueStrings(nextMemory.activeRules);
|
|
167
|
-
|
|
217
|
+
prunePatternCandidates(nextMemory, mergedConfig);
|
|
168
218
|
const conflicts = resolveDecisionConflicts(nextMemory, mergedConfig.conflictResolution);
|
|
169
219
|
const archivedSessions = archiveSessions(nextMemory, mergedConfig);
|
|
170
220
|
|
|
@@ -145,14 +145,17 @@ export async function needsMigration(projectRoot) {
|
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
147
|
* runMigration(projectRoot, { dryRun?, homeDir? } = {})
|
|
148
|
-
* → { migrated, skipped, plan, markerPath, backupDir }
|
|
148
|
+
* → { migrated, skipped, plan, markerPath, backupDir, corruptFiles, corrupt }
|
|
149
149
|
* No-op when the marker exists or no legacy files exist. dryRun returns the
|
|
150
150
|
* full per-record plan ({id, source, action, reason}) and writes nothing.
|
|
151
|
+
* `corruptFiles`/`corrupt` surface unreadable legacy sources; the marker is
|
|
152
|
+
* written only when corruptFiles === 0 — otherwise needsMigration stays true
|
|
153
|
+
* and the corrupt remainder is never silently abandoned.
|
|
151
154
|
*/
|
|
152
155
|
export async function runMigration(projectRoot, { dryRun = false, homeDir } = {}) {
|
|
153
156
|
const paths = buildRuntimePaths(projectRoot);
|
|
154
157
|
const markerPath = path.join(paths.memoryV2Dir, MARKER_NAME);
|
|
155
|
-
const empty = { migrated: 0, skipped: 0, plan: [], markerPath, backupDir: null };
|
|
158
|
+
const empty = { migrated: 0, skipped: 0, plan: [], markerPath, backupDir: null, corruptFiles: 0, corrupt: [] };
|
|
156
159
|
|
|
157
160
|
if (await pathExists(markerPath)) return empty;
|
|
158
161
|
|
|
@@ -185,25 +188,32 @@ export async function runMigration(projectRoot, { dryRun = false, homeDir } = {}
|
|
|
185
188
|
resolveProjectId: (legacyId) => resolvedIds.get(legacyId) ?? null,
|
|
186
189
|
});
|
|
187
190
|
|
|
191
|
+
const corruptFiles = sources.corruptFiles;
|
|
192
|
+
const corrupt = [...sources.corrupt];
|
|
188
193
|
if (dryRun) {
|
|
189
|
-
return { migrated: records.length, skipped, plan, markerPath, backupDir: null };
|
|
194
|
+
return { migrated: records.length, skipped, plan, markerPath, backupDir: null, corruptFiles, corrupt };
|
|
190
195
|
}
|
|
191
196
|
|
|
192
|
-
// Backup-first
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
197
|
+
// Backup-first only when there are records to merge — a corrupt-only rerun
|
|
198
|
+
// would otherwise pile up empty v1-backup dirs on every lazy retry.
|
|
199
|
+
let backupDir = null;
|
|
200
|
+
if (records.length > 0) {
|
|
201
|
+
backupDir = path.join(paths.memoryRoot, `v1-backup-${Date.now()}`);
|
|
202
|
+
const entries = await fs.readdir(paths.memoryRoot, { withFileTypes: true });
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
if (entry.name === 'v2' || entry.name.startsWith('v1-backup-')) continue;
|
|
205
|
+
const src = path.join(paths.memoryRoot, entry.name);
|
|
206
|
+
const dest = path.join(backupDir, entry.name);
|
|
207
|
+
if (entry.isDirectory()) {
|
|
208
|
+
await copyDirRecursive(src, dest);
|
|
209
|
+
} else if (entry.isFile()) {
|
|
210
|
+
await fs.mkdir(backupDir, { recursive: true });
|
|
211
|
+
await fs.copyFile(src, dest);
|
|
212
|
+
}
|
|
204
213
|
}
|
|
205
214
|
}
|
|
206
215
|
|
|
216
|
+
|
|
207
217
|
// Merge into the existing store through mutateMemory's restore op — the
|
|
208
218
|
// single guarded write entry. Tombstone precedence lives there, so a
|
|
209
219
|
// purged record can never resurrect via re-migration; source_fingerprint
|
|
@@ -228,11 +238,21 @@ export async function runMigration(projectRoot, { dryRun = false, homeDir } = {}
|
|
|
228
238
|
skipped,
|
|
229
239
|
byType: records.reduce((acc, r) => ({ ...acc, [r.type]: (acc[r.type] ?? 0) + 1 }), {}),
|
|
230
240
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
// C90-02: the marker is written ONLY when no corrupt legacy file remains.
|
|
242
|
+
// Writing it while a file is unreadable would flip needsMigration to false
|
|
243
|
+
// and silently abandon that file's data forever.
|
|
244
|
+
if (corruptFiles === 0) {
|
|
245
|
+
await writeJson(markerPath, {
|
|
246
|
+
migratedAt: Date.now(),
|
|
247
|
+
counts,
|
|
248
|
+
backupDir,
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
console.warn(
|
|
252
|
+
`[UKit] migration skipped ${corruptFiles} corrupt legacy file(s): ${corrupt.join(', ')}`
|
|
253
|
+
+ ' — marker NOT written; repair or remove them and migrate again.',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
236
256
|
|
|
237
|
-
return { migrated, skipped, plan, markerPath, backupDir };
|
|
257
|
+
return { migrated, skipped, plan, markerPath, backupDir, corruptFiles, corrupt };
|
|
238
258
|
}
|
package/src/core/memory/store.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
5
|
-
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
5
|
+
import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
|
|
6
6
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
7
7
|
import { runHygiene } from './hygiene.js';
|
|
8
8
|
import { redactWritePayload } from './writeGuard.js';
|
|
@@ -534,6 +534,7 @@ async function persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projec
|
|
|
534
534
|
const hygieneConfig = {
|
|
535
535
|
archiveAfterDays: memoryConfig.archiveAfterDays,
|
|
536
536
|
maxSessionsKept: memoryConfig.maxSessions,
|
|
537
|
+
maxPatternCandidates: memoryConfig.maxPatternCandidates,
|
|
537
538
|
...(memoryConfig.redactSecrets === false ? { redactPatterns: [] } : {}),
|
|
538
539
|
};
|
|
539
540
|
|
|
@@ -543,12 +544,43 @@ async function persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projec
|
|
|
543
544
|
return hygienicMemory;
|
|
544
545
|
}
|
|
545
546
|
|
|
547
|
+
// W2-B1: the whole read → mutate → persist chain on <id>.json (plus the
|
|
548
|
+
// <id>.archive.json append inside persist) runs under withFileLock — the
|
|
549
|
+
// same serialization mutateRecordStore gives the v2 doc. Without it two
|
|
550
|
+
// writers (learn + approve, parallel hooks) read the same snapshot and the
|
|
551
|
+
// last write silently eats the other's mutation.
|
|
552
|
+
//
|
|
553
|
+
// `mutate(memory)` returns:
|
|
554
|
+
// - { result } → persist, resolve with result;
|
|
555
|
+
// - { skip: true, result } → skip the write, resolve with result;
|
|
556
|
+
// - falsy / no `result` → persist, resolve with the persisted (hygienic) memory.
|
|
557
|
+
// A lock-wait timeout THROWS (mutateRecordStore convention): returning the
|
|
558
|
+
// computed entry as if persisted would tell callers a dropped write landed.
|
|
559
|
+
async function mutateProjectMemory(projectRoot, runtimePaths, projectId, mutate) {
|
|
560
|
+
const filePath = projectMemoryPath(runtimePaths, projectId);
|
|
561
|
+
const outcome = await withFileLock(filePath, async () => {
|
|
562
|
+
const existing = await readMemoryJson(filePath);
|
|
563
|
+
const memory = { id: projectId, conventions: [], patternCandidates: [], ...existing };
|
|
564
|
+
const mutation = await mutate(memory);
|
|
565
|
+
const skip = mutation?.skip === true;
|
|
566
|
+
const explicitResult = mutation != null && typeof mutation === 'object' && 'result' in mutation;
|
|
567
|
+
const result = explicitResult ? mutation.result : mutation;
|
|
568
|
+
if (skip) return result;
|
|
569
|
+
const persisted = await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
|
|
570
|
+
return explicitResult ? result : persisted;
|
|
571
|
+
});
|
|
572
|
+
if (outcome === undefined) {
|
|
573
|
+
throw new Error(`mutateProjectMemory: lock wait expired for ${filePath} — mutation skipped`);
|
|
574
|
+
}
|
|
575
|
+
return outcome;
|
|
576
|
+
}
|
|
577
|
+
|
|
546
578
|
export async function runProjectHygiene(projectRoot, projectId) {
|
|
547
579
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
548
|
-
|
|
549
|
-
return persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
|
|
580
|
+
return mutateProjectMemory(projectRoot, runtimePaths, projectId, () => ({}));
|
|
550
581
|
}
|
|
551
582
|
|
|
583
|
+
|
|
552
584
|
function patternCandidateFromRecord(record) {
|
|
553
585
|
const isLearningCandidate = record.provenance === 'learning-candidate';
|
|
554
586
|
return {
|
|
@@ -678,45 +710,46 @@ export async function proposePatternCandidate(projectRoot, projectId, candidate,
|
|
|
678
710
|
const entry = await proposePatternCandidateV2(projectRoot, projectId, candidate, { homeDir });
|
|
679
711
|
// Mirror into the legacy project file (hygiene + conventions) so the
|
|
680
712
|
// untouched-on-disk legacy format stays read-compatible.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
713
|
+
await mutateProjectMemory(projectRoot, runtimePaths, projectId, (memory) => {
|
|
714
|
+
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|
|
715
|
+
if (patternCandidates.some((existing) => existing.id === entry.id)) {
|
|
716
|
+
return { skip: true, result: entry };
|
|
717
|
+
}
|
|
684
718
|
memory.patternCandidates = [...patternCandidates, entry];
|
|
685
|
-
|
|
686
|
-
}
|
|
719
|
+
return { result: entry };
|
|
720
|
+
});
|
|
687
721
|
return entry;
|
|
688
722
|
}
|
|
689
723
|
|
|
690
|
-
const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
|
|
691
|
-
|
|
692
724
|
const normalizedText = normalizePatternText(candidate?.text);
|
|
693
725
|
if (!normalizedText) {
|
|
694
726
|
throw new Error('proposePatternCandidate requires candidate.text');
|
|
695
727
|
}
|
|
696
728
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
729
|
+
return mutateProjectMemory(projectRoot, runtimePaths, projectId, (memory) => {
|
|
730
|
+
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|
|
731
|
+
const existingPending = patternCandidates.find(
|
|
732
|
+
(entry) => entry.status === 'pending'
|
|
733
|
+
&& (normalizePatternText(entry.text) === normalizedText
|
|
734
|
+
|| (candidate?.signature && entry.signature === candidate.signature)),
|
|
735
|
+
);
|
|
736
|
+
if (existingPending) {
|
|
737
|
+
return { skip: true, result: existingPending };
|
|
738
|
+
}
|
|
706
739
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
740
|
+
const entry = {
|
|
741
|
+
id: `pc_${crypto.randomBytes(6).toString('hex')}`,
|
|
742
|
+
text: candidate.text,
|
|
743
|
+
category: candidate?.category ?? null,
|
|
744
|
+
detectedFrom: candidate?.detectedFrom ?? null,
|
|
745
|
+
detectedAt: Date.now(),
|
|
746
|
+
status: 'pending',
|
|
747
|
+
};
|
|
748
|
+
if (candidate?.signature) entry.signature = candidate.signature;
|
|
716
749
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
750
|
+
memory.patternCandidates = [...patternCandidates, entry];
|
|
751
|
+
return { result: entry };
|
|
752
|
+
});
|
|
720
753
|
}
|
|
721
754
|
|
|
722
755
|
export async function listPendingPatternCandidates(projectRoot, projectId, { homeDir } = {}) {
|
|
@@ -739,46 +772,48 @@ export async function resolvePatternCandidate(projectRoot, projectId, candidateI
|
|
|
739
772
|
if (await v2Active(projectRoot, { homeDir })) {
|
|
740
773
|
const resolved = await resolvePatternCandidateV2(projectRoot, projectId, candidateId, decision, { homeDir });
|
|
741
774
|
// Mirror the resolution into the legacy project file.
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
775
|
+
await mutateProjectMemory(projectRoot, runtimePaths, projectId, (memory) => {
|
|
776
|
+
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|
|
777
|
+
const target = patternCandidates.find((entry) => entry.id === candidateId);
|
|
778
|
+
if (target) {
|
|
779
|
+
target.status = resolved.status;
|
|
780
|
+
if (decision === 'approve') {
|
|
781
|
+
const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
|
|
782
|
+
if (!conventions.includes(target.text)) {
|
|
783
|
+
memory.conventions = [...conventions, target.text];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return {};
|
|
787
|
+
}
|
|
747
788
|
if (decision === 'approve') {
|
|
748
789
|
const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
|
|
749
|
-
if (!conventions.includes(
|
|
750
|
-
memory.conventions = [...conventions,
|
|
790
|
+
if (!conventions.includes(resolved.text)) {
|
|
791
|
+
memory.conventions = [...conventions, resolved.text];
|
|
792
|
+
return {};
|
|
751
793
|
}
|
|
752
794
|
}
|
|
753
|
-
|
|
754
|
-
}
|
|
755
|
-
const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
|
|
756
|
-
if (!conventions.includes(resolved.text)) {
|
|
757
|
-
memory.conventions = [...conventions, resolved.text];
|
|
758
|
-
await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
795
|
+
return { skip: true };
|
|
796
|
+
});
|
|
761
797
|
return resolved;
|
|
762
798
|
}
|
|
763
799
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
}
|
|
800
|
+
return mutateProjectMemory(projectRoot, runtimePaths, projectId, (memory) => {
|
|
801
|
+
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|
|
802
|
+
const target = patternCandidates.find((entry) => entry.id === candidateId);
|
|
803
|
+
if (!target) {
|
|
804
|
+
throw new Error(`Pattern candidate not found: ${candidateId}`);
|
|
805
|
+
}
|
|
771
806
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
807
|
+
target.status = decision === 'approve' ? 'approved' : 'rejected';
|
|
808
|
+
if (decision === 'approve') {
|
|
809
|
+
const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
|
|
810
|
+
if (!conventions.includes(target.text)) {
|
|
811
|
+
memory.conventions = [...conventions, target.text];
|
|
812
|
+
}
|
|
777
813
|
}
|
|
778
|
-
}
|
|
779
814
|
|
|
780
|
-
|
|
781
|
-
|
|
815
|
+
return { result: target };
|
|
816
|
+
});
|
|
782
817
|
}
|
|
783
818
|
|
|
784
819
|
export async function countMemoryItems(projectRoot, { homeDir } = {}) {
|
package/src/core/metadata.js
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { ensureDir, writeJson, readJsonIfExists, withFileLock } from './fileOps.js';
|
|
3
3
|
|
|
4
|
+
// FR-015 (W2-C2): install.json is the project's install ledger — a corrupt
|
|
5
|
+
// file must never read as "not installed" (data loss: reinstall would wipe the
|
|
6
|
+
// tracked-file manifest and orphan managed files) and never surface as a bare
|
|
7
|
+
// SyntaxError. Every read site wraps parse failures in a typed error; real fs
|
|
8
|
+
// failures (EACCES, EISDIR, …) keep their own codes.
|
|
9
|
+
export class InstallMetadataError extends Error {
|
|
10
|
+
constructor(message, code = 'install_metadata_corrupt', { cause } = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'InstallMetadataError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
if (cause !== undefined) this.cause = cause;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `readJsonIfExists` + typed parse failures: absent → null (first install),
|
|
20
|
+
* corrupt JSON → InstallMetadataError naming the file. Non-parse errors keep
|
|
21
|
+
* their fs codes so callers can still distinguish EACCES/EIO from corruption.
|
|
22
|
+
*/
|
|
23
|
+
export async function readInstallMetadata(installMetaPath) {
|
|
24
|
+
try {
|
|
25
|
+
return await readJsonIfExists(installMetaPath);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof SyntaxError) {
|
|
28
|
+
throw new InstallMetadataError(
|
|
29
|
+
`Install metadata is corrupt: ${installMetaPath} is not valid JSON (${error.message}). `
|
|
30
|
+
+ 'Remove it to reinstall cleanly, or restore it from a backup — UKit will not overwrite it.',
|
|
31
|
+
'install_metadata_corrupt',
|
|
32
|
+
{ cause: error },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
4
39
|
function normalizeTrackedRelativePath(relativePath) {
|
|
5
40
|
if (typeof relativePath !== 'string') {
|
|
6
41
|
return null;
|
|
@@ -43,7 +78,7 @@ export async function writeInstallMetadata({
|
|
|
43
78
|
// uninstall). The read→merge→write runs under withFileLock (C79-12):
|
|
44
79
|
// concurrent installs used to read the same pre-state and last-writer-wins
|
|
45
80
|
// the merge, silently losing tracked-file entries.
|
|
46
|
-
const previousData = await
|
|
81
|
+
const previousData = await readInstallMetadata(installMetaPath);
|
|
47
82
|
const previousFiles = previousData?.tool === 'ukit' && Array.isArray(previousData?.files)
|
|
48
83
|
? previousData.files
|
|
49
84
|
: [];
|
|
@@ -147,7 +182,7 @@ export async function removeTrackedPathsFromMetadata({
|
|
|
147
182
|
}
|
|
148
183
|
|
|
149
184
|
const removeResult = await withFileLock(installMetaPath, async () => {
|
|
150
|
-
const metadata = await
|
|
185
|
+
const metadata = await readInstallMetadata(installMetaPath);
|
|
151
186
|
if (!metadata || metadata.tool !== 'ukit' || !Array.isArray(metadata.files)) {
|
|
152
187
|
return 'noop';
|
|
153
188
|
}
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
// → adaptContextItems
|
|
18
18
|
//
|
|
19
19
|
// ingestStoredTelemetry({ projectRoot, recorder, deadlineMs?, sources?, now? })
|
|
20
|
-
// → Promise<{ ok, status:'complete'|'partial'|'degraded',
|
|
20
|
+
// → Promise<{ ok:true, status:'complete'|'partial'|'degraded',
|
|
21
21
|
// coverage: { <source>: { read, emitted, rejected, skipped, reason? } },
|
|
22
|
-
// last_cursors }
|
|
22
|
+
// last_cursors }
|
|
23
|
+
// | { ok:false, reason:'invalid_args'|'ingest_lock_timeout' }>
|
|
23
24
|
//
|
|
24
25
|
// Counters: `read` rows consumed by the adapter lane; `emitted` records
|
|
25
26
|
// accepted (or queued-at-full-queue) by recorder.emit; `rejected` covers
|
|
@@ -45,6 +46,7 @@ import { adaptDecisionReceipts } from './decisionAdapter.js';
|
|
|
45
46
|
import { adaptContextItems } from './contextAdapter.js';
|
|
46
47
|
import { createAdapterContext, isPlainObject } from './common.js';
|
|
47
48
|
import { listLedgerFiles } from '../../../diagnostics/ledgerFiles.js';
|
|
49
|
+
import { withFileLock } from '../../fileOps.js';
|
|
48
50
|
|
|
49
51
|
const STORAGE_REL = path.join('.ukit', 'storage');
|
|
50
52
|
const CACHE_REL = path.join(STORAGE_REL, 'cache');
|
|
@@ -362,6 +364,32 @@ export async function ingestStoredTelemetry({
|
|
|
362
364
|
return { ok: false, reason: 'invalid_args' };
|
|
363
365
|
}
|
|
364
366
|
|
|
367
|
+
// C90-10: the whole loadCursors → emit → saveCursors transaction runs
|
|
368
|
+
// under the per-cursor-file withFileLock. Two overlapping collectors used
|
|
369
|
+
// to read the same cursor snapshot and double-emit every row, then
|
|
370
|
+
// last-writer-wins the cursor file. An expired lock wait fails closed:
|
|
371
|
+
// the collect is dropped (journaled by fileOps), never run unlocked.
|
|
372
|
+
const cursorFile = path.join(projectRoot, CURSOR_REL);
|
|
373
|
+
let result;
|
|
374
|
+
try {
|
|
375
|
+
result = await withFileLock(cursorFile, () =>
|
|
376
|
+
ingestOnce({ projectRoot, recorder, deadlineMs, sources, now, limitLedgers }));
|
|
377
|
+
} catch (error) {
|
|
378
|
+
// Lock protocol itself failed (e.g. EACCES making the lock dir) —
|
|
379
|
+
// typed failure, never an unlocked run.
|
|
380
|
+
return { ok: false, reason: error?.code ?? 'ingest_lock_error' };
|
|
381
|
+
}
|
|
382
|
+
return result === undefined ? { ok: false, reason: 'ingest_lock_timeout' } : result;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function ingestOnce({
|
|
386
|
+
projectRoot,
|
|
387
|
+
recorder,
|
|
388
|
+
deadlineMs,
|
|
389
|
+
sources,
|
|
390
|
+
now,
|
|
391
|
+
limitLedgers,
|
|
392
|
+
}) {
|
|
365
393
|
const nowFn = typeof now === 'function' ? now : () => Date.now();
|
|
366
394
|
const start = nowFn();
|
|
367
395
|
const deadline = Number.isFinite(deadlineMs) ? start + deadlineMs : null;
|
|
@@ -47,18 +47,33 @@ export function resolveStage(config = null) {
|
|
|
47
47
|
const SAMPLING_RATE_KEYS = new Set(['low', 'normal', 'high', 'critical']);
|
|
48
48
|
const MAX_POLICY_VERSION_CHARS = 128;
|
|
49
49
|
|
|
50
|
-
// Process-wide
|
|
51
|
-
// module-level
|
|
50
|
+
// Process-wide latches: a recorder boot is a process, so "once per boot" is
|
|
51
|
+
// module-level — the same granularity resolveStage operates at. The REPORT
|
|
52
|
+
// latch is separate from the SEEN latch (TASK-C91-015, W3-OB2): consumers
|
|
53
|
+
// like sessionBoot.js create a fresh recorder per emit, so a per-recorder
|
|
54
|
+
// `config_invalid_reported` flag re-reported the aggregate every time.
|
|
52
55
|
let samplingConfigInvalidSeen = false;
|
|
56
|
+
let samplingConfigInvalidReportedFlag = false;
|
|
53
57
|
|
|
54
58
|
export function samplingConfigInvalid() {
|
|
55
59
|
return samplingConfigInvalidSeen;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
/** True when the CONFIG_INVALID aggregate was already queued in this process. */
|
|
63
|
+
export function samplingConfigInvalidReported() {
|
|
64
|
+
return samplingConfigInvalidReportedFlag;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Mark the CONFIG_INVALID aggregate as emitted — call before queueing it. */
|
|
68
|
+
export function markSamplingConfigInvalidReported() {
|
|
69
|
+
samplingConfigInvalidReportedFlag = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Test/secondary-consumer hook: the flags are boot-scoped observations, not
|
|
73
|
+
// stateful policy — clearing them re-arms the one-shot report.
|
|
60
74
|
export function resetSamplingConfigInvalid() {
|
|
61
75
|
samplingConfigInvalidSeen = false;
|
|
76
|
+
samplingConfigInvalidReportedFlag = false;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
export function resolveSampling(config = null) {
|
|
@@ -279,7 +279,9 @@ export function registerCrashCapture({ root, writerId, bootId, now } = {}) {
|
|
|
279
279
|
|
|
280
280
|
function writeRecord(record, name) {
|
|
281
281
|
const target = path.join(dir, name);
|
|
282
|
-
|
|
282
|
+
// C90-15 / TASK-C91-015: per-call entropy — pid alone lets a stale
|
|
283
|
+
// `.tmp-<pid>` or a second write to the same target collide mid-publish.
|
|
284
|
+
const tmpPath = `${target}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
283
285
|
try {
|
|
284
286
|
fs.writeFileSync(tmpPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
285
287
|
fs.renameSync(tmpPath, target);
|