@ngockhoale/ukit 2.7.12 → 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 +13 -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
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { buildRuntimePaths } from './runtimePaths.js';
5
+ import { buildUserPaths } from './userPaths.js';
5
6
  import { loadShippedCompactBudget } from './compact/contextBudget.js';
6
7
  import { buildConfigContracts } from './executionContracts.js';
7
8
 
@@ -18,6 +19,67 @@ const VALID_AGENTS = new Set(['claude-code', 'codex', 'antigravity', 'omp']);
18
19
  // 'unattended' is the only mode implemented this cycle; interactive/safe-auto are reserved.
19
20
  export const VALID_PERMISSION_MODES = new Set(['interactive', 'safe-auto', 'unattended']);
20
21
 
22
+ // --- modelRoles (SPEC-model-roles, v3 mechanic #4) -------------------------------------
23
+ // Role labels name WHAT the model is for, not which tier it is. Values are cost-tier
24
+ // names ('lite'|'code'|'smart'|'vision') or 'inherit-parent' — never raw provider model
25
+ // IDs; the host adapter resolves tier→actual model. Arrays = panels (one agent per
26
+ // entry; list length sets the panel count). omp's .omp/config.yml modelRoles resolves
27
+ // @smol/@default/@slow to the same tiers (smol↔lite, default↔code, slow↔smart).
28
+ export const MODEL_ROLE_TIERS = new Set(['lite', 'code', 'smart', 'vision']);
29
+ export const MODEL_ROLE_INHERIT = 'inherit-parent';
30
+ export const DEFAULT_MODEL_ROLES = Object.freeze({
31
+ code: 'code',
32
+ judgment: 'smart',
33
+ 'review-panel': ['smart', 'code', 'lite'],
34
+ 'fast-worker': 'lite',
35
+ vision: 'vision',
36
+ });
37
+
38
+ function isValidModelRoleValue(value) {
39
+ if (typeof value === 'string') {
40
+ return MODEL_ROLE_TIERS.has(value) || value === MODEL_ROLE_INHERIT;
41
+ }
42
+ if (Array.isArray(value)) {
43
+ return value.length > 0
44
+ && value.every((entry) => MODEL_ROLE_TIERS.has(entry) || entry === MODEL_ROLE_INHERIT);
45
+ }
46
+ return false;
47
+ }
48
+
49
+ // Resolves the effective role→tier map. Invalid entries warn + fall back to the
50
+ // default for that role — never block, never throw. Unknown role names are dropped
51
+ // with a warning so a stale config cannot invent roles the skills don't speak.
52
+ export function resolveModelRoles(config = null) {
53
+ const warnings = [];
54
+ const resolved = {
55
+ code: DEFAULT_MODEL_ROLES.code,
56
+ judgment: DEFAULT_MODEL_ROLES.judgment,
57
+ 'review-panel': [...DEFAULT_MODEL_ROLES['review-panel']],
58
+ 'fast-worker': DEFAULT_MODEL_ROLES['fast-worker'],
59
+ vision: DEFAULT_MODEL_ROLES.vision,
60
+ };
61
+ const configured = config?.modelRoles;
62
+ if (configured === undefined || configured === null) {
63
+ return { roles: resolved, warnings };
64
+ }
65
+ if (!isPlainObject(configured)) {
66
+ warnings.push('modelRoles must be an object; using defaults.');
67
+ return { roles: resolved, warnings };
68
+ }
69
+ for (const [role, value] of Object.entries(configured)) {
70
+ if (!(role in DEFAULT_MODEL_ROLES)) {
71
+ warnings.push(`modelRoles.${role} is not a known role; ignored.`);
72
+ continue;
73
+ }
74
+ if (!isValidModelRoleValue(value)) {
75
+ warnings.push(`modelRoles.${role} must be a tier name, 'inherit-parent', or a non-empty array of those; using default.`);
76
+ continue;
77
+ }
78
+ resolved[role] = Array.isArray(value) ? [...value] : value;
79
+ }
80
+ return { roles: resolved, warnings };
81
+ }
82
+
21
83
  const BLOCKED_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
22
84
 
23
85
  function isPlainObject(value) {
@@ -47,6 +109,62 @@ function mergeObjects(base, override) {
47
109
  return merged;
48
110
  }
49
111
 
112
+ // User-level layer (SPEC §5 FR-005): ~/.ukit/storage/config.json merges between the
113
+ // built-in defaults and the project config — project wins every key. `version` and
114
+ // `agent` are project-managed: a stale user file must not pin a project to an old
115
+ // version or a removed agent, so they are stripped (warn once per process per key).
116
+ const PROJECT_MANAGED_USER_KEYS = new Set(['version', 'agent']);
117
+ const warnedManagedUserKeys = new Set();
118
+
119
+ function stripProjectManagedKeys(userRaw) {
120
+ if (!isPlainObject(userRaw)) {
121
+ return userRaw;
122
+ }
123
+ const stripped = { ...userRaw };
124
+ for (const key of PROJECT_MANAGED_USER_KEYS) {
125
+ if (!(key in stripped)) {
126
+ continue;
127
+ }
128
+ delete stripped[key];
129
+ if (!warnedManagedUserKeys.has(key)) {
130
+ warnedManagedUserKeys.add(key);
131
+ console.warn(`[UKit] Ignoring project-managed key "${key}" in user-level config (~/.ukit/storage/config.json).`);
132
+ }
133
+ }
134
+ return stripped;
135
+ }
136
+
137
+ async function readUserConfig(homeDir) {
138
+ const userPaths = buildUserPaths(homeDir === undefined ? {} : { homeDir });
139
+ try {
140
+ const raw = await fs.readFile(userPaths.configPath, 'utf8');
141
+ return { exists: true, rawConfig: JSON.parse(raw), parseError: null };
142
+ } catch (error) {
143
+ if (error?.code === 'ENOENT') {
144
+ return { exists: false, rawConfig: null, parseError: null };
145
+ }
146
+ // parseError also covers non-ENOENT read failures (e.g. EACCES) — the user
147
+ // layer is ignored either way.
148
+ return { exists: true, rawConfig: null, parseError: error?.message ?? String(error) };
149
+ }
150
+ }
151
+
152
+ // Raw merged view (pre-validation) for consumers that only need key values — the
153
+ // router reads routing.* through this without pulling warn/validate semantics into
154
+ // the hot path. Returns {} when neither layer exists or both are unreadable.
155
+ export async function readMergedRuntimeConfig(projectRoot, { homeDir } = {}) {
156
+ const runtimePaths = buildRuntimePaths(projectRoot);
157
+ let projectRaw = null;
158
+ try {
159
+ projectRaw = JSON.parse(await fs.readFile(runtimePaths.configPath, 'utf8'));
160
+ } catch {
161
+ projectRaw = null;
162
+ }
163
+ const user = await readUserConfig(homeDir);
164
+ const userLayer = user.parseError ? null : stripProjectManagedKeys(user.rawConfig);
165
+ return mergeObjects(userLayer ?? {}, projectRaw ?? {});
166
+ }
167
+
50
168
  function pushBooleanError(errors, value, label) {
51
169
  if (typeof value !== 'boolean') {
52
170
  errors.push(`${label} must be boolean.`);
@@ -137,6 +255,20 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
137
255
  enabled: true,
138
256
  defaultModel: 'claude-sonnet-5',
139
257
  },
258
+ // M01.1 stage keys (docs/pstack/MIGRATION_ROLLBACK named-keys table). Every stage
259
+ // key treats absence as "off"; stages promote off -> shadow -> canary -> default.
260
+ routing: {
261
+ routeSchema: { stage: 'off' },
262
+ },
263
+ // SPEC-model-roles: role→tier map injected into delegating-lane route output.
264
+ // Values are tier names or 'inherit-parent' — never raw provider model IDs.
265
+ modelRoles: {
266
+ code: 'code',
267
+ judgment: 'smart',
268
+ 'review-panel': ['smart', 'code', 'lite'],
269
+ 'fast-worker': 'lite',
270
+ vision: 'vision',
271
+ },
140
272
  orchestration: {
141
273
  enabled: true,
142
274
  orchestratorModel: 'claude-sonnet-5',
@@ -396,6 +528,34 @@ export function validateRuntimeConfig(config) {
396
528
  }
397
529
  }
398
530
 
531
+ // routing.* stage keys are optional-present and additive-namespaced: absent → valid
532
+ // (pre-M01.1 configs); present → each known stage key must hold a reserved stage.
533
+ // Unknown siblings under routing.* stay valid so later milestones can add stages
534
+ // without breaking older validators.
535
+ if (config.routing !== undefined) {
536
+ if (!isPlainObject(config.routing)) {
537
+ errors.push('routing must be an object.');
538
+ } else {
539
+ const VALID_ROUTE_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
540
+ if (config.routing.routeSchema !== undefined) {
541
+ if (!isPlainObject(config.routing.routeSchema)) {
542
+ errors.push('routing.routeSchema must be an object.');
543
+ } else if (config.routing.routeSchema.stage !== undefined
544
+ && !VALID_ROUTE_STAGES.has(config.routing.routeSchema.stage)) {
545
+ errors.push(`routing.routeSchema.stage must be one of: ${[...VALID_ROUTE_STAGES].join(', ')}.`);
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ // modelRoles is optional-present and additive-namespaced (SPEC-model-roles): absent →
552
+ // valid (pre-v3 configs); present → must be an object. Entry-level issues (unknown
553
+ // role, bad value) never error here — resolveModelRoles warns + falls back per entry
554
+ // so a bad role value can never block the whole config.
555
+ if (config.modelRoles !== undefined && !isPlainObject(config.modelRoles)) {
556
+ errors.push('modelRoles must be an object.');
557
+ }
558
+
399
559
  if (!isPlainObject(config.orchestration)) {
400
560
  errors.push('orchestration must be an object.');
401
561
  } else {
@@ -687,7 +847,7 @@ function normalizeLegacyOpencodeEntries(config) {
687
847
  return config;
688
848
  }
689
849
 
690
- export async function inspectRuntimeConfig(projectRoot) {
850
+ export async function inspectRuntimeConfig(projectRoot, { homeDir } = {}) {
691
851
  const runtimePaths = buildRuntimePaths(projectRoot);
692
852
  let exists = false;
693
853
  let rawConfig = null;
@@ -704,7 +864,11 @@ export async function inspectRuntimeConfig(projectRoot) {
704
864
  }
705
865
  }
706
866
 
707
- const config = normalizeLegacyOpencodeEntries(buildDefaultRuntimeConfig(rawConfig ?? {}));
867
+ const user = await readUserConfig(homeDir);
868
+ const userLayer = user.parseError ? null : stripProjectManagedKeys(user.rawConfig);
869
+ const mergedRaw = mergeObjects(userLayer ?? {}, rawConfig ?? {});
870
+
871
+ const config = normalizeLegacyOpencodeEntries(buildDefaultRuntimeConfig(mergedRaw));
708
872
  const validation = validateRuntimeConfig(config);
709
873
  const errors = [
710
874
  ...(parseError ? [`config.json parse error: ${parseError}`] : []),
@@ -718,12 +882,13 @@ export async function inspectRuntimeConfig(projectRoot) {
718
882
  parseError,
719
883
  valid: exists && !parseError && validation.valid,
720
884
  errors,
885
+ user: { exists: user.exists, parseError: user.parseError },
721
886
  };
722
887
  }
723
888
 
724
- export async function loadRuntimeConfig(projectRoot) {
725
- const inspection = await inspectRuntimeConfig(projectRoot);
726
- if (inspection.exists && !inspection.valid) {
889
+ export async function loadRuntimeConfig(projectRoot, { homeDir } = {}) {
890
+ const inspection = await inspectRuntimeConfig(projectRoot, { homeDir });
891
+ if (inspection.errors.length > 0) {
727
892
  console.warn(`[UKit] Invalid UKit runtime config: ${inspection.errors.join('; ')}`);
728
893
  return buildDefaultRuntimeConfig();
729
894
  }
@@ -0,0 +1,21 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export function buildUserPaths({ homeDir = os.homedir() } = {}) {
5
+ const userRoot = path.join(homeDir, '.ukit');
6
+ const storageRoot = path.join(userRoot, 'storage');
7
+ const memoryRoot = path.join(storageRoot, 'memory');
8
+ const memoryV2Dir = path.join(memoryRoot, 'v2');
9
+
10
+ return {
11
+ homeDir,
12
+ userRoot,
13
+ storageRoot,
14
+ configPath: path.join(storageRoot, 'config.json'),
15
+ playbooksDir: path.join(userRoot, 'playbooks'),
16
+ memoryRoot,
17
+ memoryV2Dir,
18
+ memoryV2RecordsPath: path.join(memoryV2Dir, 'records.json'),
19
+ installMetaPath: path.join(userRoot, 'install.json'),
20
+ };
21
+ }
@@ -0,0 +1,185 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { buildUserPaths } from './userPaths.js';
5
+ // Cycle note: taskRouting.js (TASK-007) imports resolvePlaybook from this module,
6
+ // making this a cyclic import. Safe because WORKFLOW_POLICIES /
7
+ // WORKFLOW_POLICY_BY_MODE are only dereferenced inside function bodies at call
8
+ // time — never at module top-level — so the live binding is always initialized.
9
+ import { WORKFLOW_POLICIES, WORKFLOW_POLICY_BY_MODE } from '../index/taskRouting.js';
10
+
11
+ // Playbook file format (SPEC FR-009): `playbooks/<id>.md` with optional
12
+ // frontmatter `---\nid: <id>\nlanes: [a, b]\n---` followed by the markdown body.
13
+ // Missing `id` → filename stem; missing `lanes` → []. A file whose body is empty
14
+ // after frontmatter, or that cannot be read/parsed, is skipped so resolution
15
+ // falls through to the next precedence level (builtin < user < project).
16
+
17
+ function parseFrontmatter(raw) {
18
+ if (!raw.startsWith('---\n')) {
19
+ return { meta: {}, body: raw };
20
+ }
21
+ // Search from 3: in `---\n---` the closing fence's leading \n is the same
22
+ // newline that terminates the opening fence.
23
+ const fenceIndex = raw.indexOf('\n---', 3);
24
+ if (fenceIndex === -1) {
25
+ return null; // unterminated frontmatter → malformed file
26
+ }
27
+ const after = raw.slice(fenceIndex + 4);
28
+ if (after.length > 0 && !after.startsWith('\n')) {
29
+ return null; // `---x` is not a closing fence
30
+ }
31
+ const meta = {};
32
+ for (const line of raw.slice(4, fenceIndex).split('\n')) {
33
+ const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
34
+ if (match) {
35
+ meta[match[1]] = match[2].trim();
36
+ }
37
+ }
38
+ return { meta, body: after.replace(/^\n/, '') };
39
+ }
40
+
41
+ function parseLanes(value) {
42
+ if (value == null || value === '') {
43
+ return [];
44
+ }
45
+ const inner = value.startsWith('[') && value.endsWith(']')
46
+ ? value.slice(1, -1)
47
+ : value;
48
+ return inner
49
+ .split(',')
50
+ .map((lane) => lane.trim().replace(/^['"]|['"]$/g, ''))
51
+ .filter(Boolean);
52
+ }
53
+
54
+ // Returns {id, lanes, body, sourcePath} or null when the file is malformed or
55
+ // carries no usable body.
56
+ function parsePlaybookFile(raw, filePath) {
57
+ const parsed = parseFrontmatter(raw);
58
+ if (parsed === null) {
59
+ return null;
60
+ }
61
+ const body = parsed.body.trim();
62
+ if (body === '') {
63
+ return null;
64
+ }
65
+ const stem = path.basename(filePath, '.md');
66
+ return {
67
+ id: typeof parsed.meta.id === 'string' && parsed.meta.id !== ''
68
+ ? parsed.meta.id
69
+ : stem,
70
+ lanes: parseLanes(parsed.meta.lanes),
71
+ body,
72
+ sourcePath: filePath,
73
+ };
74
+ }
75
+
76
+ async function readPlaybookFile(filePath) {
77
+ let raw;
78
+ try {
79
+ raw = await fs.readFile(filePath, 'utf8');
80
+ } catch {
81
+ return null; // missing or unreadable → not a playbook at this level
82
+ }
83
+ const entry = parsePlaybookFile(raw, filePath);
84
+ if (entry === null) {
85
+ console.warn(`[UKit] Warning: skipping malformed playbook ${filePath}`);
86
+ }
87
+ return entry;
88
+ }
89
+
90
+ export async function loadPlaybooks(dir) {
91
+ let entries;
92
+ try {
93
+ entries = await fs.readdir(dir, { withFileTypes: true });
94
+ } catch (error) {
95
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
96
+ console.warn(`[UKit] Warning: cannot read playbook dir ${dir} — ${error.message}`);
97
+ }
98
+ return new Map();
99
+ }
100
+ const playbooks = new Map();
101
+ for (const entry of entries) {
102
+ if (!entry.isFile() || !entry.name.endsWith('.md')) {
103
+ continue;
104
+ }
105
+ const filePath = path.join(dir, entry.name);
106
+ const playbook = await readPlaybookFile(filePath);
107
+ if (playbook !== null) {
108
+ playbooks.set(playbook.id, playbook);
109
+ }
110
+ }
111
+ return playbooks;
112
+ }
113
+
114
+ function isSafePolicyName(policyName) {
115
+ return (
116
+ typeof policyName === 'string'
117
+ && policyName !== ''
118
+ && !policyName.includes('/')
119
+ && !policyName.includes('\\')
120
+ && !policyName.includes('..')
121
+ );
122
+ }
123
+
124
+ export async function resolvePlaybook(
125
+ policyName,
126
+ { projectRoot, homeDir, builtins = WORKFLOW_POLICIES } = {},
127
+ ) {
128
+ if (!isSafePolicyName(policyName)) {
129
+ return null;
130
+ }
131
+ const candidates = [];
132
+ if (projectRoot) {
133
+ candidates.push({
134
+ dir: path.join(projectRoot, '.ukit', 'playbooks'),
135
+ source: 'project',
136
+ });
137
+ }
138
+ candidates.push({
139
+ dir: buildUserPaths({ homeDir }).playbooksDir,
140
+ source: 'user',
141
+ });
142
+ for (const { dir, source } of candidates) {
143
+ const playbook = await readPlaybookFile(path.join(dir, `${policyName}.md`));
144
+ if (playbook !== null) {
145
+ return { id: playbook.id, body: playbook.body, source };
146
+ }
147
+ }
148
+ const builtinBody = builtins[policyName];
149
+ if (typeof builtinBody === 'string' && builtinBody !== '') {
150
+ return { id: policyName, body: builtinBody, source: 'builtin' };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ export async function listPlaybooks(
156
+ { projectRoot, homeDir, builtins = WORKFLOW_POLICIES } = {},
157
+ ) {
158
+ // Builtin lanes are derived from the shipped lane→policy map (inverse of
159
+ // WORKFLOW_POLICY_BY_MODE); injected builtins not in that map list no lanes.
160
+ const builtinLanes = {};
161
+ for (const [lane, policy] of Object.entries(WORKFLOW_POLICY_BY_MODE)) {
162
+ (builtinLanes[policy] ??= []).push(lane);
163
+ }
164
+ const merged = new Map();
165
+ for (const id of Object.keys(builtins)) {
166
+ merged.set(id, { id, source: 'builtin', lanes: builtinLanes[id] ?? [] });
167
+ }
168
+ const userDir = buildUserPaths({ homeDir }).playbooksDir;
169
+ for (const [dir, source] of [
170
+ [userDir, 'user'],
171
+ [projectRoot ? path.join(projectRoot, '.ukit', 'playbooks') : null, 'project'],
172
+ ]) {
173
+ if (dir === null) {
174
+ continue;
175
+ }
176
+ for (const playbook of (await loadPlaybooks(dir)).values()) {
177
+ merged.set(playbook.id, {
178
+ id: playbook.id,
179
+ source,
180
+ lanes: playbook.lanes,
181
+ });
182
+ }
183
+ }
184
+ return [...merged.values()].sort((a, b) => a.id.localeCompare(b.id));
185
+ }