@ngockhoale/ukit 2.7.13 → 2.8.0

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 (46) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/manifests/documentation.yaml +11 -0
  3. package/manifests/platform.full.yaml +182 -0
  4. package/manifests/platform.user.yaml +53 -0
  5. package/package.json +3 -1
  6. package/src/cli/commands/diff.js +4 -2
  7. package/src/cli/commands/doctor.js +22 -1
  8. package/src/cli/commands/install.js +10 -0
  9. package/src/cli/commands/memory.js +142 -3
  10. package/src/cli/commands/playbook.js +53 -0
  11. package/src/cli/index.js +7 -0
  12. package/src/core/memory/recordStore.js +81 -0
  13. package/src/core/memory/storeV2.js +16 -52
  14. package/src/core/memory/userMemory.js +111 -0
  15. package/src/core/paths.js +1 -0
  16. package/src/core/runInstallPipeline.js +96 -3
  17. package/src/core/runtimeConfig.js +170 -5
  18. package/src/core/userPaths.js +21 -0
  19. package/src/core/userPlaybooks.js +185 -0
  20. package/src/index/taskRouting.js +422 -21
  21. package/src/index/verificationPlan.js +17 -0
  22. package/src/manifest/validateManifest.js +19 -0
  23. package/templates/.claude/config/providers.md +1 -3
  24. package/templates/.claude/skills/principle-attack-the-premise/SKILL.md +16 -0
  25. package/templates/.claude/skills/principle-boundary-discipline/SKILL.md +16 -0
  26. package/templates/.claude/skills/principle-encode-lessons-in-structure/SKILL.md +16 -0
  27. package/templates/.claude/skills/principle-fix-root-causes/SKILL.md +18 -0
  28. package/templates/.claude/skills/principle-foundational-thinking/SKILL.md +17 -0
  29. package/templates/.claude/skills/principle-guard-the-context-window/SKILL.md +16 -0
  30. package/templates/.claude/skills/principle-laziness-protocol/SKILL.md +17 -0
  31. package/templates/.claude/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +16 -0
  32. package/templates/.claude/skills/principle-minimize-reader-load/SKILL.md +17 -0
  33. package/templates/.claude/skills/principle-model-the-domain/SKILL.md +16 -0
  34. package/templates/.claude/skills/principle-never-block-on-the-human/SKILL.md +16 -0
  35. package/templates/.claude/skills/principle-prove-it-works/SKILL.md +18 -0
  36. package/templates/.claude/skills/principle-sequence-verifiable-units/SKILL.md +16 -0
  37. package/templates/.claude/skills/principle-subtract-before-you-add/SKILL.md +16 -0
  38. package/templates/.claude/skills/principle-test-behavior-not-implementation/SKILL.md +18 -0
  39. package/templates/.claude/ukit/index/route-task.mjs +652 -28
  40. package/templates/.claude/ukit/runtime/execution-ledger.mjs +238 -9
  41. package/templates/ukit/README.md +31 -0
  42. package/templates/ukit/storage/config.json +10 -0
  43. package/templates/user/README.md +21 -0
  44. package/templates/user/playbooks/bug-fix.md +18 -0
  45. package/templates/user/playbooks/issue-implementation.md +14 -0
  46. package/templates/user/storage/config.json +16 -0
@@ -0,0 +1,81 @@
1
+ // Record-document store core — SPEC §3 / FR-007.
2
+ // Path-agnostic read/write of the v2 records document
3
+ // ({ schemaVersion: 2, records: [...] }) so both the project store
4
+ // (storeV2.js) and the user store (userMemory.js) share one implementation.
5
+ // Writes go through fileOps.writeJson (tmp+rename).
6
+
7
+ import { readJsonIfExists, writeJson } from '../fileOps.js';
8
+ import { normalizeRecord } from './records.js';
9
+
10
+ const SCHEMA_VERSION = 2;
11
+
12
+ export const PATCHABLE_FIELDS = ['type', 'scope', 'text', 'provenance', 'confidence',
13
+ 'valid_until', 'status', 'project_id', 'meta', 'source_fingerprint'];
14
+
15
+ export function isRawRecord(input) {
16
+ return input && typeof input === 'object' && typeof input.id === 'string'
17
+ && typeof input.status === 'string';
18
+ }
19
+
20
+ /**
21
+ * readRecordStore(recordsPath) → { records, invalidSkipped }
22
+ * Tolerant: missing file → {records:[]}; unparseable → {records:[], invalidSkipped:1};
23
+ * per-record normalizeRecord skip.
24
+ */
25
+ export async function readRecordStore(recordsPath) {
26
+ let doc;
27
+ try {
28
+ doc = await readJsonIfExists(recordsPath);
29
+ } catch {
30
+ return { records: [], invalidSkipped: 1 };
31
+ }
32
+ if (!doc) return { records: [], invalidSkipped: 0 };
33
+
34
+ const rawRecords = Array.isArray(doc.records) ? doc.records : [];
35
+ const records = [];
36
+ let invalidSkipped = Array.isArray(doc.records) ? 0 : 1;
37
+ for (const raw of rawRecords) {
38
+ const normalized = normalizeRecord(raw);
39
+ if (normalized) records.push(normalized);
40
+ else invalidSkipped += 1;
41
+ }
42
+ return { records, invalidSkipped };
43
+ }
44
+
45
+ /**
46
+ * writeRecordStore(recordsPath, records) → void
47
+ * Atomic write; drops unrecoverable entries.
48
+ */
49
+ export async function writeRecordStore(recordsPath, records) {
50
+ const normalized = (Array.isArray(records) ? records : [])
51
+ .map((raw) => normalizeRecord(raw))
52
+ .filter(Boolean);
53
+ await writeJson(recordsPath, {
54
+ schemaVersion: SCHEMA_VERSION,
55
+ records: normalized,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * applyRecordPatch(records, id, patch) → { record, records } | null
61
+ * Patches PATCHABLE_FIELDS on the record with `id`, re-normalizes, and returns
62
+ * the updated list (caller persists). Throws when the patch is unrecoverable.
63
+ */
64
+ export function applyRecordPatch(records, id, patch = {}) {
65
+ const index = records.findIndex((r) => r.id === id);
66
+ if (index === -1) return null;
67
+
68
+ const next = { ...records[index] };
69
+ for (const field of PATCHABLE_FIELDS) {
70
+ if (Object.prototype.hasOwnProperty.call(patch, field)) {
71
+ next[field] = patch[field];
72
+ }
73
+ }
74
+ const normalized = normalizeRecord(next);
75
+ if (!normalized) {
76
+ throw new Error(`applyRecordPatch: patch produces an invalid record (id ${id})`);
77
+ }
78
+ const updated = [...records];
79
+ updated[index] = normalized;
80
+ return { record: normalized, records: updated };
81
+ }
@@ -4,14 +4,19 @@
4
4
  // Read ops call ensureMigrated() first so every consumer triggers the lazy
5
5
  // v1→v2 migration (SPEC §4); migrate.js is lazy-imported to break the
6
6
  // storeV2↔migrate import cycle (runMigration writes via saveRecords).
7
+ // The record-document read/write core lives in recordStore.js (FR-007) so the
8
+ // user-level store (userMemory.js) shares it without the migration flow.
7
9
 
8
10
  import fs from 'node:fs/promises';
9
11
  import { buildRuntimePaths } from '../runtimePaths.js';
10
- import { readJsonIfExists, writeJson } from '../fileOps.js';
11
12
  import { loadRuntimeConfig } from '../runtimeConfig.js';
12
13
  import { createRecord, normalizeRecord } from './records.js';
13
-
14
- const SCHEMA_VERSION = 2;
14
+ import {
15
+ readRecordStore,
16
+ writeRecordStore,
17
+ applyRecordPatch,
18
+ isRawRecord,
19
+ } from './recordStore.js';
15
20
 
16
21
  // Per-process memoization for ensureMigrated — one-shot check per projectRoot.
17
22
  const migratedRoots = new Set();
@@ -43,11 +48,6 @@ async function ensureMigrated(projectRoot) {
43
48
  }
44
49
  }
45
50
 
46
- function isRawRecord(input) {
47
- return input && typeof input === 'object' && typeof input.id === 'string'
48
- && typeof input.status === 'string';
49
- }
50
-
51
51
  /**
52
52
  * loadRecords(projectRoot) → record[] — tolerant: invalid entries skipped.
53
53
  */
@@ -59,23 +59,7 @@ export async function loadRecords(projectRoot) {
59
59
 
60
60
  async function readStore(projectRoot) {
61
61
  const paths = buildRuntimePaths(projectRoot);
62
- let doc;
63
- try {
64
- doc = await readJsonIfExists(paths.memoryV2RecordsPath);
65
- } catch {
66
- return { records: [], invalidSkipped: 1 };
67
- }
68
- if (!doc) return { records: [], invalidSkipped: 0 };
69
-
70
- const rawRecords = Array.isArray(doc.records) ? doc.records : [];
71
- const records = [];
72
- let invalidSkipped = Array.isArray(doc.records) ? 0 : 1;
73
- for (const raw of rawRecords) {
74
- const normalized = normalizeRecord(raw);
75
- if (normalized) records.push(normalized);
76
- else invalidSkipped += 1;
77
- }
78
- return { records, invalidSkipped };
62
+ return readRecordStore(paths.memoryV2RecordsPath);
79
63
  }
80
64
 
81
65
  /**
@@ -83,13 +67,7 @@ async function readStore(projectRoot) {
83
67
  */
84
68
  export async function saveRecords(projectRoot, records) {
85
69
  const paths = buildRuntimePaths(projectRoot);
86
- const normalized = (Array.isArray(records) ? records : [])
87
- .map((raw) => normalizeRecord(raw))
88
- .filter(Boolean);
89
- await writeJson(paths.memoryV2RecordsPath, {
90
- schemaVersion: SCHEMA_VERSION,
91
- records: normalized,
92
- });
70
+ await writeRecordStore(paths.memoryV2RecordsPath, records);
93
71
  }
94
72
 
95
73
  /**
@@ -108,31 +86,17 @@ export async function addRecord(projectRoot, recordInput) {
108
86
  return record;
109
87
  }
110
88
 
111
- const PATCHABLE_FIELDS = ['type', 'scope', 'text', 'provenance', 'confidence',
112
- 'valid_until', 'status', 'project_id', 'meta', 'source_fingerprint'];
113
-
114
89
  /**
115
90
  * updateRecord(projectRoot, id, patch) → record | null
116
91
  */
117
92
  export async function updateRecord(projectRoot, id, patch = {}) {
118
93
  await ensureMigrated(projectRoot);
119
- const { records } = await readStore(projectRoot);
120
- const index = records.findIndex((r) => r.id === id);
121
- if (index === -1) return null;
122
-
123
- const next = { ...records[index] };
124
- for (const field of PATCHABLE_FIELDS) {
125
- if (Object.prototype.hasOwnProperty.call(patch, field)) {
126
- next[field] = patch[field];
127
- }
128
- }
129
- const normalized = normalizeRecord(next);
130
- if (!normalized) {
131
- throw new Error(`updateRecord: patch produces an invalid record (id ${id})`);
132
- }
133
- records[index] = normalized;
134
- await saveRecords(projectRoot, records);
135
- return normalized;
94
+ const paths = buildRuntimePaths(projectRoot);
95
+ const { records } = await readRecordStore(paths.memoryV2RecordsPath);
96
+ const result = applyRecordPatch(records, id, patch);
97
+ if (!result) return null;
98
+ await writeRecordStore(paths.memoryV2RecordsPath, result.records);
99
+ return result.record;
136
100
  }
137
101
 
138
102
  /**
@@ -0,0 +1,111 @@
1
+ // User-scope memory store — SPEC FR-008.
2
+ // Canonical user-level memory API at ~/.ukit/storage/memory/v2/records.json,
3
+ // shared across projects. Same v2 record document as the project store but
4
+ // with NO v1→v2 migration (the user layer starts at v2). All functions take
5
+ // `{homeDir}={}` and resolve paths via buildUserPaths.
6
+
7
+ import { buildUserPaths } from '../userPaths.js';
8
+ import { createRecord, normalizeRecord } from './records.js';
9
+ import {
10
+ readRecordStore,
11
+ writeRecordStore,
12
+ applyRecordPatch,
13
+ isRawRecord,
14
+ } from './recordStore.js';
15
+ import { loadRecords } from './storeV2.js';
16
+
17
+ function userRecordsPath(homeDir) {
18
+ return buildUserPaths({ homeDir }).memoryV2RecordsPath;
19
+ }
20
+
21
+ /**
22
+ * loadUserRecords({homeDir}={}) → record[] — tolerant: invalid entries skipped.
23
+ */
24
+ export async function loadUserRecords({ homeDir } = {}) {
25
+ const { records } = await readRecordStore(userRecordsPath(homeDir));
26
+ return records;
27
+ }
28
+
29
+ /**
30
+ * saveUserRecords(records, {homeDir}={}) → void — atomic write.
31
+ */
32
+ export async function saveUserRecords(records, { homeDir } = {}) {
33
+ await writeRecordStore(userRecordsPath(homeDir), records);
34
+ }
35
+
36
+ /**
37
+ * addUserRecord(recordInput, {homeDir}={}) → record
38
+ * Accepts either createRecord-style input or a full raw record (normalized).
39
+ */
40
+ export async function addUserRecord(recordInput, { homeDir } = {}) {
41
+ const record = isRawRecord(recordInput)
42
+ ? normalizeRecord(recordInput)
43
+ : createRecord(recordInput);
44
+ if (!record) {
45
+ throw new Error('addUserRecord: input could not be normalized into a valid record');
46
+ }
47
+ const recordsPath = userRecordsPath(homeDir);
48
+ const { records } = await readRecordStore(recordsPath);
49
+ await writeRecordStore(recordsPath, [...records, record]);
50
+ return record;
51
+ }
52
+
53
+ /**
54
+ * updateUserRecord(id, patch, {homeDir}={}) → record | null
55
+ */
56
+ export async function updateUserRecord(id, patch = {}, { homeDir } = {}) {
57
+ const recordsPath = userRecordsPath(homeDir);
58
+ const { records } = await readRecordStore(recordsPath);
59
+ const result = applyRecordPatch(records, id, patch);
60
+ if (!result) return null;
61
+ await writeRecordStore(recordsPath, result.records);
62
+ return result.record;
63
+ }
64
+
65
+ /**
66
+ * getUserRecord(id, {homeDir}={}) → record | null
67
+ */
68
+ export async function getUserRecord(id, { homeDir } = {}) {
69
+ const { records } = await readRecordStore(userRecordsPath(homeDir));
70
+ return records.find((r) => r.id === id) ?? null;
71
+ }
72
+
73
+ /**
74
+ * queryUserRecords({ type?, scope?, status?, projectId? }, {homeDir}={}) → record[]
75
+ */
76
+ export async function queryUserRecords({ type, scope, status, projectId } = {}, { homeDir } = {}) {
77
+ const records = await loadUserRecords({ homeDir });
78
+ return records.filter((r) => (type == null || r.type === type)
79
+ && (scope == null || r.scope === scope)
80
+ && (status == null || r.status === status)
81
+ && (projectId == null || r.project_id === projectId));
82
+ }
83
+
84
+ /**
85
+ * userMemoryStats({homeDir}={}) → { total, byType, byStatus, invalidSkipped }
86
+ */
87
+ export async function userMemoryStats({ homeDir } = {}) {
88
+ const { records, invalidSkipped } = await readRecordStore(userRecordsPath(homeDir));
89
+ const byType = {};
90
+ const byStatus = {};
91
+ for (const r of records) {
92
+ byType[r.type] = (byType[r.type] ?? 0) + 1;
93
+ byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
94
+ }
95
+ return { total: records.length, byType, byStatus, invalidSkipped };
96
+ }
97
+
98
+ /**
99
+ * loadMergedRecords(projectRoot, {homeDir}={}) → record[]
100
+ * Project records ++ user records; the user record wins on id collision.
101
+ */
102
+ export async function loadMergedRecords(projectRoot, { homeDir } = {}) {
103
+ const [projectRecords, userRecords] = await Promise.all([
104
+ loadRecords(projectRoot),
105
+ loadUserRecords({ homeDir }),
106
+ ]);
107
+ const merged = new Map();
108
+ for (const r of projectRecords) merged.set(r.id, r);
109
+ for (const r of userRecords) merged.set(r.id, r);
110
+ return [...merged.values()];
111
+ }
package/src/core/paths.js CHANGED
@@ -5,6 +5,7 @@ export function buildPathConfig({ packageRoot, projectRoot }) {
5
5
  packageRoot,
6
6
  projectRoot,
7
7
  manifestPath: path.join(packageRoot, 'manifests', 'platform.full.yaml'),
8
+ userManifestPath: path.join(packageRoot, 'manifests', 'platform.user.yaml'),
8
9
  templatesRoot: path.join(packageRoot, 'templates'),
9
10
  ukitRoot: path.join(projectRoot, '.claude', 'ukit', '.ukit'),
10
11
  installMetaPath: path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json'),
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { loadManifest } from '../manifest/loadManifest.js';
4
5
  import { detectStack } from '../stack/detectStack.js';
@@ -14,9 +15,10 @@ import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js
14
15
  import { ensureGitignore } from './ensureGitignore.js';
15
16
  import { repairBrokenHooks } from './repairBrokenHooks.js';
16
17
  import { applyGatewayResilienceEnv } from './gatewayResilienceEnv.js';
17
- import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
18
+ import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath, writeJson } from './fileOps.js';
18
19
  import { findOpencodeArtifacts, opencodeSteerMessage } from './opencodeSteer.js';
19
20
  import { loadRuntimeConfig } from './runtimeConfig.js';
21
+ import { buildUserPaths } from './userPaths.js';
20
22
 
21
23
  const AUTO_PRUNE_OBSOLETE_PREFIXES = [
22
24
  '.claude/skills/',
@@ -247,6 +249,74 @@ async function pruneObsoleteManagedPaths({
247
249
 
248
250
  return removedCount;
249
251
  }
252
+ // SPEC FR-004 — user-layer seeding pass. Runs after the project plan/diff (and
253
+ // after the project apply on real installs): seeds ~/.ukit/ from
254
+ // manifests/platform.user.yaml. Every user item is `mergeStrategy: skip`, so
255
+ // existing user files are never touched — install only creates what is missing.
256
+ // The pass is advisory: any failure is caught, warned, and reported via
257
+ // `userLayer.error` — it must never fail the project install.
258
+ // User-layer writes are deliberately NOT fed to writeInstallMetadata or
259
+ // buildManagedRelativePathSet — those are project-scoped; the user layer has its
260
+ // own marker at ~/.ukit/install.json.
261
+ async function runUserLayerPass({
262
+ pathConfig,
263
+ homeDir,
264
+ stackContext,
265
+ variables,
266
+ selectedAdapterItemIds,
267
+ packageVersion,
268
+ dryRun,
269
+ }) {
270
+ const userLayer = { seeded: 0, kept: 0, skipped: 0, error: null, userRoot: null };
271
+ let userRows = [];
272
+ try {
273
+ // buildUserPaths lives inside the try so a bad homeDir can never fail the
274
+ // project install — the whole pass is advisory.
275
+ const userPaths = buildUserPaths({ homeDir });
276
+ userLayer.userRoot = userPaths.userRoot;
277
+ // pathConfig.userManifestPath is set by buildPathConfig; fall back to the
278
+ // package layout (templatesRoot's sibling manifests/) for callers that build
279
+ // pathConfig by hand (tests, embedded runners).
280
+ const userManifestPath = pathConfig.userManifestPath
281
+ ?? path.join(pathConfig.templatesRoot, '..', 'manifests', 'platform.user.yaml');
282
+ const userManifest = await loadManifest(userManifestPath);
283
+ const userPlan = await buildInstallPlan({
284
+ manifest: userManifest,
285
+ stackContext,
286
+ templatesRoot: pathConfig.templatesRoot,
287
+ variables,
288
+ projectRoot: userPaths.userRoot,
289
+ selectedAdapterItemIds,
290
+ });
291
+ const userDiff = await diffInstallPlan(userPlan);
292
+ const userSummary = summarizeDiff(userDiff);
293
+ userRows = toDiffRows(userDiff).map((row) => ({ ...row, layer: 'user' }));
294
+
295
+ if (!dryRun) {
296
+ const { skippedByAction } = await applyDiffResults(userDiff, {
297
+ backupRoot: path.join(userPaths.userRoot, 'backups'),
298
+ projectRoot: userPaths.userRoot,
299
+ });
300
+ userLayer.seeded = userSummary.create - (skippedByAction?.create ?? 0);
301
+ userLayer.kept = userSummary.skip + userSummary.unchanged;
302
+ userLayer.skipped = (skippedByAction?.create ?? 0) + (skippedByAction?.update ?? 0);
303
+ // UKit-managed marker (not user data): records that the pass ran.
304
+ await writeJson(userPaths.installMetaPath, {
305
+ tool: 'ukit',
306
+ version: packageVersion,
307
+ seededAt: new Date().toISOString(),
308
+ });
309
+ } else {
310
+ userLayer.seeded = userSummary.create;
311
+ userLayer.kept = userSummary.skip + userSummary.unchanged;
312
+ }
313
+ } catch (error) {
314
+ userLayer.error = error?.message ?? String(error);
315
+ console.warn(`[UKit] Warning: user-layer seeding skipped — ${userLayer.error}`);
316
+ }
317
+ return { userLayer, userRows };
318
+ }
319
+
250
320
 
251
321
  export async function runInstallPipeline({
252
322
  packageVersion,
@@ -255,6 +325,7 @@ export async function runInstallPipeline({
255
325
  selectedAdapterItemIds,
256
326
  retainedManagedRelativePaths = [],
257
327
  withCodegraph = false,
328
+ homeDir = os.homedir(),
258
329
  }) {
259
330
  // Check for an existing install before making any changes.
260
331
  // cleanupLegacyPaths() is only safe to run on reinstalls: on a fresh install
@@ -404,28 +475,50 @@ export async function runInstallPipeline({
404
475
  retainedManagedRelativePaths,
405
476
  });
406
477
 
478
+ const { userLayer, userRows } = await runUserLayerPass({
479
+ pathConfig,
480
+ homeDir,
481
+ stackContext,
482
+ variables,
483
+ selectedAdapterItemIds,
484
+ packageVersion,
485
+ dryRun: false,
486
+ });
487
+
407
488
  return {
408
489
  manifest,
409
490
  stackContext,
410
491
  projectContext,
411
492
  providerContext,
412
493
  summary,
413
- rows: toDiffRows(diffResults),
494
+ rows: [...toDiffRows(diffResults), ...userRows],
414
495
  writes,
415
496
  hookRepair,
416
497
  gatewayResilience,
498
+ userLayer,
417
499
  };
418
500
  }
419
501
 
502
+ const { userLayer, userRows } = await runUserLayerPass({
503
+ pathConfig,
504
+ homeDir,
505
+ stackContext,
506
+ variables,
507
+ selectedAdapterItemIds,
508
+ packageVersion,
509
+ dryRun: true,
510
+ });
511
+
420
512
  return {
421
513
  manifest,
422
514
  stackContext,
423
515
  projectContext,
424
516
  providerContext,
425
517
  summary: plannedSummary,
426
- rows: toDiffRows(diffResults),
518
+ rows: [...toDiffRows(diffResults), ...userRows],
427
519
  writes: [],
428
520
  hookRepair: { removals: [], files: [] },
429
521
  gatewayResilience: { customGateway: false, reason: 'dry-run' },
522
+ userLayer,
430
523
  };
431
524
  }