@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
@@ -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: copy legacy memory dir (excluding v2 + prior backups) before writing.
193
- const backupDir = path.join(paths.memoryRoot, `v1-backup-${Date.now()}`);
194
- const entries = await fs.readdir(paths.memoryRoot, { withFileTypes: true });
195
- for (const entry of entries) {
196
- if (entry.name === 'v2' || entry.name.startsWith('v1-backup-')) continue;
197
- const src = path.join(paths.memoryRoot, entry.name);
198
- const dest = path.join(backupDir, entry.name);
199
- if (entry.isDirectory()) {
200
- await copyDirRecursive(src, dest);
201
- } else if (entry.isFile()) {
202
- await fs.mkdir(backupDir, { recursive: true });
203
- await fs.copyFile(src, dest);
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
- await writeJson(markerPath, {
232
- migratedAt: Date.now(),
233
- counts,
234
- backupDir,
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
  }
@@ -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
- const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
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
- const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
682
- const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
683
- if (!patternCandidates.some((existing) => existing.id === entry.id)) {
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
- await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
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
- const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
698
- const existingPending = patternCandidates.find(
699
- (entry) => entry.status === 'pending'
700
- && (normalizePatternText(entry.text) === normalizedText
701
- || (candidate?.signature && entry.signature === candidate.signature)),
702
- );
703
- if (existingPending) {
704
- return existingPending;
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
- const entry = {
708
- id: `pc_${crypto.randomBytes(6).toString('hex')}`,
709
- text: candidate.text,
710
- category: candidate?.category ?? null,
711
- detectedFrom: candidate?.detectedFrom ?? null,
712
- detectedAt: Date.now(),
713
- status: 'pending',
714
- };
715
- if (candidate?.signature) entry.signature = candidate.signature;
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
- memory.patternCandidates = [...patternCandidates, entry];
718
- await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
719
- return entry;
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
- const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
743
- const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
744
- const target = patternCandidates.find((entry) => entry.id === candidateId);
745
- if (target) {
746
- target.status = resolved.status;
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(target.text)) {
750
- memory.conventions = [...conventions, target.text];
790
+ if (!conventions.includes(resolved.text)) {
791
+ memory.conventions = [...conventions, resolved.text];
792
+ return {};
751
793
  }
752
794
  }
753
- await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
754
- } else if (decision === 'approve') {
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
- const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
765
-
766
- const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
767
- const target = patternCandidates.find((entry) => entry.id === candidateId);
768
- if (!target) {
769
- throw new Error(`Pattern candidate not found: ${candidateId}`);
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
- target.status = decision === 'approve' ? 'approved' : 'rejected';
773
- if (decision === 'approve') {
774
- const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
775
- if (!conventions.includes(target.text)) {
776
- memory.conventions = [...conventions, target.text];
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
- await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
781
- return target;
815
+ return { result: target };
816
+ });
782
817
  }
783
818
 
784
819
  export async function countMemoryItems(projectRoot, { homeDir } = {}) {
@@ -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 readJsonIfExists(installMetaPath);
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 readJsonIfExists(installMetaPath);
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 latch: a recorder boot is a process, so "once per boot" is a
51
- // module-level one-shot — the same granularity resolveStage operates at.
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
- // Test/secondary-consumer hook: the flag is a boot-scoped observation, not
59
- // stateful policy — clearing it re-arms the one-shot report.
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
- const tmpPath = `${target}.tmp-${process.pid}`;
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);