@ngockhoale/ukit 2.7.13 → 3.0.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.
- package/CHANGELOG.md +13 -0
- package/manifests/documentation.yaml +11 -0
- package/manifests/platform.full.yaml +182 -0
- package/manifests/platform.user.yaml +53 -0
- package/package.json +3 -1
- package/src/cli/commands/diff.js +4 -2
- package/src/cli/commands/doctor.js +22 -1
- package/src/cli/commands/install.js +10 -0
- package/src/cli/commands/memory.js +142 -3
- package/src/cli/commands/playbook.js +53 -0
- package/src/cli/index.js +7 -0
- package/src/core/memory/recordStore.js +81 -0
- package/src/core/memory/storeV2.js +16 -52
- package/src/core/memory/userMemory.js +111 -0
- package/src/core/paths.js +1 -0
- package/src/core/runInstallPipeline.js +96 -3
- package/src/core/runtimeConfig.js +170 -5
- package/src/core/userPaths.js +21 -0
- package/src/core/userPlaybooks.js +185 -0
- package/src/index/taskRouting.js +422 -21
- package/src/index/verificationPlan.js +17 -0
- package/src/manifest/validateManifest.js +19 -0
- package/templates/.claude/config/providers.md +1 -3
- package/templates/.claude/skills/principle-attack-the-premise/SKILL.md +16 -0
- package/templates/.claude/skills/principle-boundary-discipline/SKILL.md +16 -0
- package/templates/.claude/skills/principle-encode-lessons-in-structure/SKILL.md +16 -0
- package/templates/.claude/skills/principle-fix-root-causes/SKILL.md +18 -0
- package/templates/.claude/skills/principle-foundational-thinking/SKILL.md +17 -0
- package/templates/.claude/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/templates/.claude/skills/principle-laziness-protocol/SKILL.md +17 -0
- package/templates/.claude/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +16 -0
- package/templates/.claude/skills/principle-minimize-reader-load/SKILL.md +17 -0
- package/templates/.claude/skills/principle-model-the-domain/SKILL.md +16 -0
- package/templates/.claude/skills/principle-never-block-on-the-human/SKILL.md +16 -0
- package/templates/.claude/skills/principle-prove-it-works/SKILL.md +18 -0
- package/templates/.claude/skills/principle-sequence-verifiable-units/SKILL.md +16 -0
- package/templates/.claude/skills/principle-subtract-before-you-add/SKILL.md +16 -0
- package/templates/.claude/skills/principle-test-behavior-not-implementation/SKILL.md +18 -0
- package/templates/.claude/ukit/index/route-task.mjs +652 -28
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +238 -9
- package/templates/ukit/README.md +31 -0
- package/templates/ukit/storage/config.json +10 -0
- package/templates/user/README.md +21 -0
- package/templates/user/playbooks/bug-fix.md +18 -0
- package/templates/user/playbooks/issue-implementation.md +14 -0
- 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
|
|
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.
|
|
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
|
+
}
|