@ngockhoale/ukit 2.6.7 → 2.6.8

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 (44) hide show
  1. package/manifests/documentation.yaml +77 -6
  2. package/manifests/hostCapabilities.yaml +49 -0
  3. package/manifests/instructionRules.yaml +7 -0
  4. package/package.json +1 -1
  5. package/scripts/bench/goldTasks.json +38 -0
  6. package/scripts/bench/runGold.mjs +220 -0
  7. package/scripts/release/verify-release.mjs +6 -0
  8. package/src/cli/commands/code.js +182 -0
  9. package/src/cli/commands/doctor.js +35 -3
  10. package/src/cli/commands/indexTools.js +102 -1
  11. package/src/cli/commands/memory.js +137 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/codeintel/compiler.js +316 -0
  14. package/src/core/codeintel/diagnostics.js +114 -0
  15. package/src/core/codeintel/freshness.js +295 -0
  16. package/src/core/codeintel/impact.js +251 -0
  17. package/src/core/codeintel/invalidation.js +150 -0
  18. package/src/core/codeintel/manifest.js +176 -0
  19. package/src/core/codeintel/packet.js +146 -0
  20. package/src/core/codeintel/providers.js +201 -0
  21. package/src/core/codeintel/retriever.js +372 -0
  22. package/src/core/codeintel/router.js +149 -0
  23. package/src/core/codeintel/semanticProvider.js +235 -0
  24. package/src/core/docContracts.js +723 -0
  25. package/src/core/memory/migrate.js +324 -0
  26. package/src/core/memory/records.js +172 -0
  27. package/src/core/memory/retrieval.js +161 -11
  28. package/src/core/memory/store.js +398 -0
  29. package/src/core/memory/storeV2.js +171 -0
  30. package/src/core/memory/storeV2Loader.js +22 -0
  31. package/src/core/runtimeConfig.js +125 -0
  32. package/src/core/runtimePaths.js +3 -0
  33. package/src/index/taskRouting.js +39 -0
  34. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  35. package/templates/AGENTS.md +46 -99
  36. package/templates/CLAUDE.md +46 -99
  37. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  38. package/templates/docs/BUGFIX.md +2 -19
  39. package/templates/docs/BUG_INDEX.md +43 -0
  40. package/templates/docs/BUG_METRICS.md +1 -5
  41. package/templates/docs/BUG_TEMPLATE.md +1 -11
  42. package/templates/docs/UKIT_INTERNALS.md +4 -0
  43. package/templates/instructions/core.md +46 -99
  44. package/templates/ukit/storage/config.json +30 -0
@@ -0,0 +1,324 @@
1
+ // Memory v1 → v2 migration — SPEC §4.
2
+ // Idempotent: marker v2/migrated-from-v1.json; re-run is a no-op.
3
+ // Backup-first: legacy memory dir copied to v1-backup-<ts>/ before writing.
4
+ // Legacy files are never mutated.
5
+
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { buildRuntimePaths } from '../runtimePaths.js';
10
+ import { readJsonIfExists, writeJson } from '../fileOps.js';
11
+ import { loadRuntimeConfig } from '../runtimeConfig.js';
12
+ import { createRecord } from './records.js';
13
+ import { saveRecords } from './storeV2.js';
14
+
15
+ const MARKER_NAME = 'migrated-from-v1.json';
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+
18
+ async function pathExists(targetPath) {
19
+ try {
20
+ await fs.access(targetPath);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ async function readJsonTolerant(filePath) {
28
+ try {
29
+ return await readJsonIfExists(filePath);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function listJsonFiles(dirPath) {
36
+ let entries = [];
37
+ try {
38
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
39
+ } catch {
40
+ return [];
41
+ }
42
+ return entries
43
+ .filter((e) => e.isFile() && e.name.endsWith('.json'))
44
+ .map((e) => e.name)
45
+ .sort();
46
+ }
47
+
48
+ async function copyDirRecursive(srcDir, destDir) {
49
+ await fs.mkdir(destDir, { recursive: true });
50
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
51
+ for (const entry of entries) {
52
+ const src = path.join(srcDir, entry.name);
53
+ const dest = path.join(destDir, entry.name);
54
+ if (entry.isDirectory()) {
55
+ await copyDirRecursive(src, dest);
56
+ } else if (entry.isFile()) {
57
+ await fs.copyFile(src, dest);
58
+ }
59
+ }
60
+ }
61
+
62
+ function fingerprint(type, text) {
63
+ const normalized = String(text).toLowerCase().replace(/\s+/g, ' ').trim();
64
+ return crypto.createHash('sha1').update(`v1:${type}:${normalized}`).digest('hex');
65
+ }
66
+
67
+ const skippedCounter = { count: 0 };
68
+
69
+ function makeRecord({ type, scope, text, confidence, provenance, projectId = null, validUntil = null, createdAt = null, meta = {}, status = null }) {
70
+ try {
71
+ const record = createRecord({
72
+ type,
73
+ scope,
74
+ text,
75
+ provenance,
76
+ sourceFingerprint: fingerprint(type, text),
77
+ confidence,
78
+ createdBy: 'migration',
79
+ projectId,
80
+ validUntil,
81
+ meta,
82
+ });
83
+ if (createdAt != null && Number.isFinite(createdAt)) record.created_at = createdAt;
84
+ if (status) record.status = status;
85
+ return record;
86
+ } catch {
87
+ skippedCounter.count += 1;
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function migrateUserMemory(user, records) {
93
+ if (!user || typeof user !== 'object') return;
94
+ for (const rule of Array.isArray(user.rules) ? user.rules : []) {
95
+ if (typeof rule !== 'string' || !rule) continue;
96
+ const rec = makeRecord({
97
+ type: 'project_rule', scope: 'user', text: rule,
98
+ confidence: 1.0, provenance: 'legacy:user-rules',
99
+ createdAt: user.updatedAt,
100
+ });
101
+ if (rec) records.push(rec);
102
+ }
103
+ const prefs = user.preferences && typeof user.preferences === 'object' ? user.preferences : {};
104
+ for (const [key, value] of Object.entries(prefs)) {
105
+ const text = `${key} = ${typeof value === 'string' ? value : JSON.stringify(value)}`;
106
+ const rec = makeRecord({
107
+ type: 'derived_fact', scope: 'user', text,
108
+ confidence: 1.0, provenance: 'legacy:user-preferences',
109
+ createdAt: user.updatedAt,
110
+ });
111
+ if (rec) records.push(rec);
112
+ }
113
+ }
114
+
115
+ function migrateProjectMemory(project, projectId, records) {
116
+ if (!project || typeof project !== 'object') return;
117
+ const createdAt = project.updatedAt;
118
+
119
+ for (const text of Array.isArray(project.conventions) ? project.conventions : []) {
120
+ if (typeof text !== 'string' || !text) continue;
121
+ const rec = makeRecord({
122
+ type: 'project_rule', scope: 'repo', text,
123
+ confidence: 0.9, provenance: 'legacy:conventions',
124
+ projectId, createdAt,
125
+ });
126
+ if (rec) records.push(rec);
127
+ }
128
+
129
+ for (const text of Array.isArray(project.activeRules) ? project.activeRules : []) {
130
+ if (typeof text !== 'string' || !text) continue;
131
+ const rec = makeRecord({
132
+ type: 'project_rule', scope: 'repo', text,
133
+ confidence: 1.0, provenance: 'legacy:activeRules',
134
+ projectId, createdAt,
135
+ });
136
+ if (rec) records.push(rec);
137
+ }
138
+
139
+ for (const decision of Array.isArray(project.decisions) ? project.decisions : []) {
140
+ if (!decision || typeof decision !== 'object') continue;
141
+ const text = decision.why ? `${decision.what} — ${decision.why}` : String(decision.what ?? '');
142
+ if (!text) continue;
143
+ const rec = makeRecord({
144
+ type: 'derived_fact', scope: 'repo', text,
145
+ confidence: 0.9, provenance: 'legacy:decisions',
146
+ projectId, createdAt: decision.when ?? createdAt,
147
+ });
148
+ if (rec) records.push(rec);
149
+ }
150
+
151
+ const profileEntries = [
152
+ ...(typeof project.architecture === 'string' && project.architecture ? [project.architecture] : []),
153
+ ...(Array.isArray(project.techStack) ? project.techStack.filter((t) => typeof t === 'string' && t) : []),
154
+ ];
155
+ for (const text of profileEntries) {
156
+ const rec = makeRecord({
157
+ type: 'derived_fact', scope: 'repo', text,
158
+ confidence: 0.8, provenance: 'legacy:project-profile',
159
+ projectId, createdAt,
160
+ });
161
+ if (rec) records.push(rec);
162
+ }
163
+
164
+ for (const pc of Array.isArray(project.patternCandidates) ? project.patternCandidates : []) {
165
+ if (!pc || typeof pc !== 'object' || typeof pc.text !== 'string' || !pc.text) continue;
166
+ const legacyStatus = pc.status ?? 'pending';
167
+ const meta = { ...pc, legacyStatus };
168
+ const base = {
169
+ text: pc.text,
170
+ scope: 'task', confidence: 0.4, provenance: 'legacy:pattern-candidate',
171
+ projectId, createdAt: pc.detectedAt ?? createdAt, meta,
172
+ };
173
+ let rec = null;
174
+ if (legacyStatus === 'approved') {
175
+ rec = makeRecord({ ...base, type: 'project_rule', scope: 'repo', confidence: 0.9 });
176
+ } else if (legacyStatus === 'rejected') {
177
+ rec = makeRecord({ ...base, type: 'derived_fact', status: 'archived' });
178
+ } else {
179
+ rec = makeRecord({ ...base, type: 'derived_fact' });
180
+ }
181
+ if (rec) records.push(rec);
182
+ }
183
+ }
184
+
185
+ function sessionText(session) {
186
+ return [
187
+ session.taskDescription,
188
+ session.outcome ? `outcome: ${session.outcome}` : null,
189
+ ...(Array.isArray(session.keyActions) ? session.keyActions : []),
190
+ ].filter(Boolean).join(' — ');
191
+ }
192
+
193
+ function migrateSession(session, episodeTtlMs, records) {
194
+ if (!session || typeof session !== 'object') return;
195
+ const text = sessionText(session);
196
+ if (!text) return;
197
+ const createdAt = session.startedAt ?? session.endedAt ?? null;
198
+ const validUntil = createdAt != null ? createdAt + episodeTtlMs : null;
199
+ const rec = makeRecord({
200
+ type: 'episode', scope: 'session', text,
201
+ confidence: 0.7, provenance: 'legacy:session',
202
+ projectId: typeof session.projectId === 'string' ? session.projectId : null,
203
+ createdAt, validUntil,
204
+ meta: { legacyId: session.id ?? null },
205
+ });
206
+ if (rec) records.push(rec);
207
+ }
208
+
209
+ async function collectLegacySources(paths) {
210
+ const sources = { user: null, projects: [], sessions: [] };
211
+
212
+ const user = await readJsonTolerant(paths.userMemoryPath);
213
+ if (user) sources.user = user;
214
+
215
+ for (const name of await listJsonFiles(paths.projectsDir)) {
216
+ const filePath = path.join(paths.projectsDir, name);
217
+ const content = await readJsonTolerant(filePath);
218
+ if (!content) continue;
219
+ if (name.endsWith('.archive.json')) {
220
+ for (const s of Array.isArray(content.sessions) ? content.sessions : []) {
221
+ sources.sessions.push(s);
222
+ }
223
+ continue;
224
+ }
225
+ sources.projects.push({ id: content.id ?? name.replace(/\.json$/, ''), content });
226
+ }
227
+
228
+ for (const name of await listJsonFiles(paths.sessionsDir)) {
229
+ const filePath = path.join(paths.sessionsDir, name);
230
+ const content = await readJsonTolerant(filePath);
231
+ if (!content) continue;
232
+ if (name.endsWith('.archive.json') && Array.isArray(content.sessions)) {
233
+ sources.sessions.push(...content.sessions);
234
+ } else {
235
+ sources.sessions.push(content);
236
+ }
237
+ }
238
+
239
+ return sources;
240
+ }
241
+
242
+ function hasLegacyContent(sources) {
243
+ return Boolean(sources.user) || sources.projects.length > 0 || sources.sessions.length > 0;
244
+ }
245
+
246
+ async function buildRecords(projectRoot, paths, sources) {
247
+ const config = await loadRuntimeConfig(projectRoot);
248
+ const ttlDays = Number.isFinite(config?.memoryV2?.episodeTtlDays) && config.memoryV2.episodeTtlDays > 0
249
+ ? config.memoryV2.episodeTtlDays
250
+ : 90;
251
+ const episodeTtlMs = ttlDays * DAY_MS;
252
+
253
+ const records = [];
254
+ if (sources.user) migrateUserMemory(sources.user, records);
255
+ for (const { id, content } of sources.projects) migrateProjectMemory(content, id, records);
256
+ for (const session of sources.sessions) migrateSession(session, episodeTtlMs, records);
257
+ return records;
258
+ }
259
+
260
+ /**
261
+ * needsMigration(projectRoot) → boolean
262
+ * True when the marker is absent AND legacy memory files exist.
263
+ */
264
+ export async function needsMigration(projectRoot) {
265
+ const paths = buildRuntimePaths(projectRoot);
266
+ const markerPath = path.join(paths.memoryV2Dir, MARKER_NAME);
267
+ if (await pathExists(markerPath)) return false;
268
+ const sources = await collectLegacySources(paths);
269
+ return hasLegacyContent(sources);
270
+ }
271
+
272
+ /**
273
+ * runMigration(projectRoot, { dryRun? } = {})
274
+ * → { migrated, skipped, markerPath, backupDir }
275
+ * No-op when the marker exists or no legacy files exist. dryRun reports
276
+ * would-be counts without writing records/marker/backup.
277
+ */
278
+ export async function runMigration(projectRoot, { dryRun = false } = {}) {
279
+ const paths = buildRuntimePaths(projectRoot);
280
+ const markerPath = path.join(paths.memoryV2Dir, MARKER_NAME);
281
+ const empty = { migrated: 0, skipped: 0, markerPath, backupDir: null };
282
+
283
+ if (await pathExists(markerPath)) return empty;
284
+
285
+ const sources = await collectLegacySources(paths);
286
+ if (!hasLegacyContent(sources)) return empty;
287
+
288
+ skippedCounter.count = 0;
289
+ const records = await buildRecords(projectRoot, paths, sources);
290
+ const skipped = skippedCounter.count;
291
+ if (dryRun) {
292
+ return { migrated: records.length, skipped, markerPath, backupDir: null };
293
+ }
294
+
295
+ // Backup-first: copy legacy memory dir (excluding v2 + prior backups) before writing.
296
+ const backupDir = path.join(paths.memoryRoot, `v1-backup-${Date.now()}`);
297
+ const entries = await fs.readdir(paths.memoryRoot, { withFileTypes: true });
298
+ for (const entry of entries) {
299
+ if (entry.name === 'v2' || entry.name.startsWith('v1-backup-')) continue;
300
+ const src = path.join(paths.memoryRoot, entry.name);
301
+ const dest = path.join(backupDir, entry.name);
302
+ if (entry.isDirectory()) {
303
+ await copyDirRecursive(src, dest);
304
+ } else if (entry.isFile()) {
305
+ await fs.mkdir(backupDir, { recursive: true });
306
+ await fs.copyFile(src, dest);
307
+ }
308
+ }
309
+
310
+ await saveRecords(projectRoot, records);
311
+
312
+ const counts = {
313
+ total: records.length,
314
+ skipped,
315
+ byType: records.reduce((acc, r) => ({ ...acc, [r.type]: (acc[r.type] ?? 0) + 1 }), {}),
316
+ };
317
+ await writeJson(markerPath, {
318
+ migratedAt: Date.now(),
319
+ counts,
320
+ backupDir,
321
+ });
322
+
323
+ return { migrated: records.length, skipped, markerPath, backupDir };
324
+ }
@@ -0,0 +1,172 @@
1
+ // Memory v2 record schema — SPEC §2.
2
+ // Schema constants, validation, normalization, creation, usability check.
3
+
4
+ import crypto from 'node:crypto';
5
+
6
+ export const RECORD_TYPES = Object.freeze([
7
+ 'project_rule',
8
+ 'derived_fact',
9
+ 'episode',
10
+ 'procedure',
11
+ ]);
12
+
13
+ export const RECORD_SCOPES = Object.freeze([
14
+ 'repo',
15
+ 'branch',
16
+ 'task',
17
+ 'session',
18
+ 'user',
19
+ ]);
20
+
21
+ export const RECORD_STATUSES = Object.freeze([
22
+ 'active',
23
+ 'stale',
24
+ 'contradicted',
25
+ 'archived',
26
+ ]);
27
+
28
+ function newId() {
29
+ return `mem_${crypto.randomBytes(6).toString('hex')}`;
30
+ }
31
+
32
+ /**
33
+ * validateRecord(record) → { ok: boolean, errors: string[] }
34
+ */
35
+ export function validateRecord(record) {
36
+ const errors = [];
37
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
38
+ return { ok: false, errors: ['record is not an object'] };
39
+ }
40
+ if (typeof record.id !== 'string' || !/^mem_[0-9a-f]{12}$/.test(record.id)) {
41
+ errors.push('id must match mem_<12 hex>');
42
+ }
43
+ if (!RECORD_TYPES.includes(record.type)) {
44
+ errors.push(`type must be one of ${RECORD_TYPES.join('|')}`);
45
+ }
46
+ if (!RECORD_SCOPES.includes(record.scope)) {
47
+ errors.push(`scope must be one of ${RECORD_SCOPES.join('|')}`);
48
+ }
49
+ if (typeof record.text !== 'string' || record.text.length === 0) {
50
+ errors.push('text must be a non-empty string');
51
+ }
52
+ if (record.provenance != null && typeof record.provenance !== 'string') {
53
+ errors.push('provenance must be a string');
54
+ }
55
+ if (record.source_fingerprint != null && typeof record.source_fingerprint !== 'string') {
56
+ errors.push('source_fingerprint must be string | null');
57
+ }
58
+ if (typeof record.confidence !== 'number' || Number.isNaN(record.confidence)
59
+ || record.confidence < 0 || record.confidence > 1) {
60
+ errors.push('confidence must be a number 0..1');
61
+ }
62
+ if (typeof record.created_by !== 'string' || record.created_by.length === 0) {
63
+ errors.push('created_by must be a non-empty string');
64
+ }
65
+ if (typeof record.created_at !== 'number' || !Number.isFinite(record.created_at)) {
66
+ errors.push('created_at must be epoch ms');
67
+ }
68
+ if (record.valid_until != null
69
+ && (typeof record.valid_until !== 'number' || !Number.isFinite(record.valid_until))) {
70
+ errors.push('valid_until must be epoch ms | null');
71
+ }
72
+ if (!RECORD_STATUSES.includes(record.status)) {
73
+ errors.push(`status must be one of ${RECORD_STATUSES.join('|')}`);
74
+ }
75
+ if (record.project_id != null && typeof record.project_id !== 'string') {
76
+ errors.push('project_id must be string | null');
77
+ }
78
+ if (record.meta == null || typeof record.meta !== 'object' || Array.isArray(record.meta)) {
79
+ errors.push('meta must be an object');
80
+ }
81
+ return { ok: errors.length === 0, errors };
82
+ }
83
+
84
+ /**
85
+ * createRecord(input) → record — validates + fills defaults. Throws on invalid input.
86
+ */
87
+ export function createRecord({
88
+ type,
89
+ scope,
90
+ text,
91
+ provenance = null,
92
+ sourceFingerprint = null,
93
+ confidence = 1.0,
94
+ createdBy,
95
+ projectId = null,
96
+ validUntil = null,
97
+ meta = {},
98
+ } = {}) {
99
+ const record = {
100
+ id: newId(),
101
+ type,
102
+ scope,
103
+ text,
104
+ provenance,
105
+ source_fingerprint: sourceFingerprint,
106
+ confidence,
107
+ created_by: createdBy,
108
+ created_at: Date.now(),
109
+ valid_until: validUntil,
110
+ status: 'active',
111
+ project_id: projectId,
112
+ meta,
113
+ };
114
+ const { ok, errors } = validateRecord(record);
115
+ if (!ok) {
116
+ throw new Error(`createRecord: invalid record — ${errors.join('; ')}`);
117
+ }
118
+ return record;
119
+ }
120
+
121
+ /**
122
+ * normalizeRecord(raw) → record | null — best-effort coerce; null when unrecoverable.
123
+ */
124
+ export function normalizeRecord(raw) {
125
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
126
+
127
+ const type = RECORD_TYPES.includes(raw.type) ? raw.type : null;
128
+ const text = typeof raw.text === 'string' && raw.text.length > 0 ? raw.text : null;
129
+ if (!type || !text) return null;
130
+
131
+ const scope = RECORD_SCOPES.includes(raw.scope) ? raw.scope : 'repo';
132
+ const status = RECORD_STATUSES.includes(raw.status) ? raw.status : 'active';
133
+
134
+ let confidence = typeof raw.confidence === 'number' && !Number.isNaN(raw.confidence)
135
+ ? raw.confidence : 1.0;
136
+ confidence = Math.min(1, Math.max(0, confidence));
137
+
138
+ const createdAt = typeof raw.created_at === 'number' && Number.isFinite(raw.created_at)
139
+ ? raw.created_at : Date.now();
140
+ const validUntil = typeof raw.valid_until === 'number' && Number.isFinite(raw.valid_until)
141
+ ? raw.valid_until : null;
142
+
143
+ const record = {
144
+ id: typeof raw.id === 'string' && /^mem_[0-9a-f]{12}$/.test(raw.id) ? raw.id : newId(),
145
+ type,
146
+ scope,
147
+ text,
148
+ provenance: typeof raw.provenance === 'string' ? raw.provenance : null,
149
+ source_fingerprint: typeof raw.source_fingerprint === 'string' ? raw.source_fingerprint : null,
150
+ confidence,
151
+ created_by: typeof raw.created_by === 'string' && raw.created_by.length > 0
152
+ ? raw.created_by : 'system',
153
+ created_at: createdAt,
154
+ valid_until: validUntil,
155
+ status,
156
+ project_id: typeof raw.project_id === 'string' ? raw.project_id : null,
157
+ meta: raw.meta && typeof raw.meta === 'object' && !Array.isArray(raw.meta) ? raw.meta : {},
158
+ };
159
+
160
+ const { ok } = validateRecord(record);
161
+ return ok ? record : null;
162
+ }
163
+
164
+ /**
165
+ * isRecordUsable(record, now = Date.now()) → boolean
166
+ */
167
+ export function isRecordUsable(record, now = Date.now()) {
168
+ if (!record || typeof record !== 'object') return false;
169
+ if (record.status !== 'active') return false;
170
+ if (record.valid_until != null && record.valid_until <= now) return false;
171
+ return true;
172
+ }