@ijfw/memory-server 1.3.0 → 1.4.1
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/README.md +67 -0
- package/fixtures/team/book.json +47 -0
- package/fixtures/team/business.json +47 -0
- package/fixtures/team/content.json +47 -0
- package/fixtures/team/design.json +47 -0
- package/fixtures/team/mixed.json +59 -0
- package/fixtures/team/research.json +47 -0
- package/fixtures/team/software.json +47 -0
- package/package.json +1 -9
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +142 -0
- package/src/blackboard.js +360 -0
- package/src/cli-run.js +91 -0
- package/src/codex-agents.js +177 -0
- package/src/compute/extract.js +3 -0
- package/src/compute/fts5.js +4 -4
- package/src/compute/graph-lock.js +0 -2
- package/src/compute/migrations/003-tier-semantic.js +3 -3
- package/src/compute/runner.js +44 -15
- package/src/compute/schema.sql +1 -1
- package/src/cross-orchestrator-cli.js +974 -13
- package/src/cross-orchestrator.js +9 -1
- package/src/dashboard-client.html +353 -1
- package/src/dashboard-server.js +318 -2
- package/src/design-intelligence.js +721 -0
- package/src/dispatch/colon-syntax.js +31 -3
- package/src/dispatch/domain-manifest.js +251 -0
- package/src/dispatch/extension.js +637 -0
- package/src/dispatch/override.js +221 -0
- package/src/dispatch-planner.js +1 -0
- package/src/dream/runner.mjs +3 -3
- package/src/extension-installer.js +1269 -0
- package/src/extension-manifest-schema.js +301 -0
- package/src/extension-permission-check.mjs +79 -0
- package/src/extension-registry.js +619 -0
- package/src/extension-signer.js +905 -0
- package/src/gate-result-formatter.js +95 -0
- package/src/gate-result-schema.js +274 -0
- package/src/gate-result.js +195 -0
- package/src/intent-router.js +2 -0
- package/src/lib/npm-view.js +1 -0
- package/src/memory/fts5.js +3 -3
- package/src/memory/migrations/002-tier-semantic.js +2 -2
- package/src/memory/staleness.js +1 -1
- package/src/memory/tier-promotion.js +6 -6
- package/src/memory/tokenize.js +1 -1
- package/src/memory-feedback.js +372 -0
- package/src/override-manifest-schema.js +146 -0
- package/src/override-resolver.js +699 -0
- package/src/override-use-registry.js +307 -0
- package/src/overrides/presets/academic.md +101 -0
- package/src/overrides/presets/book.md +87 -0
- package/src/overrides/presets/campaign.md +95 -0
- package/src/overrides/presets/screenplay.md +99 -0
- package/src/recovery/checkpoint.js +191 -0
- package/src/redactor.js +2 -0
- package/src/runtime-mediator.js +207 -0
- package/src/sandbox.js +17 -3
- package/src/server.js +94 -2
- package/src/swarm/dispatch-prompt.js +154 -0
- package/src/swarm/planner.js +399 -0
- package/src/swarm/review.js +136 -0
- package/src/swarm/worktree.js +239 -0
- package/src/team/generator.js +119 -0
- package/src/team/schemas.js +341 -0
- package/src/trident/dispatch.js +47 -0
- package/src/update-check.js +1 -1
- package/src/vectors.js +7 -8
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* override-resolver.js
|
|
3
|
+
*
|
|
4
|
+
* IJFW v1.4.0 Wave 1 / t6 — Deployment-Time Override Resolver
|
|
5
|
+
*
|
|
6
|
+
* Resolves base SKILL.md + 4-tier override chain (base presets -> user -> org
|
|
7
|
+
* -> project, last-write-wins per section) into a merged skill body, and
|
|
8
|
+
* deploys that merged body into every present platform skill dir under
|
|
9
|
+
* projectRoot.
|
|
10
|
+
*
|
|
11
|
+
* Resolution is deployment-time. No runtime interception. Platform agents
|
|
12
|
+
* read SKILL.md from their own dir at use time and have no idea overrides
|
|
13
|
+
* happened.
|
|
14
|
+
*
|
|
15
|
+
* Section-fenced merge format:
|
|
16
|
+
* override file body:
|
|
17
|
+
* <!-- ijfw-override: rubric -->
|
|
18
|
+
* ... override content ...
|
|
19
|
+
* <!-- ijfw-override-end -->
|
|
20
|
+
*
|
|
21
|
+
* base skill body:
|
|
22
|
+
* <!-- ijfw-override-target: rubric -->
|
|
23
|
+
* ... original content (replaced) ...
|
|
24
|
+
* <!-- ijfw-override-target-end -->
|
|
25
|
+
*
|
|
26
|
+
* If a section has no matching target in the base body, the override section
|
|
27
|
+
* is skipped with a console.warn — non-fatal so a single stale override does
|
|
28
|
+
* not break deploy.
|
|
29
|
+
*
|
|
30
|
+
* Zero new prod deps. Built-in Node only.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs/promises';
|
|
34
|
+
import { statSync } from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { randomBytes } from 'node:crypto';
|
|
39
|
+
|
|
40
|
+
// Absolute path to this module's directory (mcp-server/src/), used to locate
|
|
41
|
+
// the bundled built-in preset files shipped alongside the resolver.
|
|
42
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const BUNDLED_PRESETS_DIR = path.join(MODULE_DIR, 'overrides', 'presets');
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
BUILTIN_PRESETS,
|
|
47
|
+
MAX_EXTENDS_DEPTH,
|
|
48
|
+
SKILL_NAME_PATTERN,
|
|
49
|
+
PRESET_NAME_PATTERN,
|
|
50
|
+
OVERRIDE_SCOPES,
|
|
51
|
+
validateOverrideManifest,
|
|
52
|
+
detectCircularExtends,
|
|
53
|
+
} from './override-manifest-schema.js';
|
|
54
|
+
import {
|
|
55
|
+
recordOverrideUse,
|
|
56
|
+
removeOverrideUse,
|
|
57
|
+
} from './override-use-registry.js';
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Platform discovery
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Return the set of platform skill dirs that currently exist under
|
|
65
|
+
* projectRoot. Used by deployResolvedSkill to know which platforms to write
|
|
66
|
+
* the merged body into.
|
|
67
|
+
*
|
|
68
|
+
* TODO(W2b/t11): replace this with an exported helper from
|
|
69
|
+
* installer/src/install-helpers.js once that module exposes a canonical
|
|
70
|
+
* platform-list getter. Until then this on-disk probe is the contract.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} projectRoot
|
|
73
|
+
* @returns {string[]} absolute paths to existing platform skill dirs
|
|
74
|
+
*/
|
|
75
|
+
export function getPlatformSkillDirs(projectRoot) {
|
|
76
|
+
const candidates = [
|
|
77
|
+
'claude/skills',
|
|
78
|
+
'codex/skills',
|
|
79
|
+
'gemini/extensions/ijfw/skills',
|
|
80
|
+
'cursor/skills',
|
|
81
|
+
'windsurf/skills',
|
|
82
|
+
'copilot/skills',
|
|
83
|
+
'hermes/skills',
|
|
84
|
+
'wayland/skills',
|
|
85
|
+
'shared/skills',
|
|
86
|
+
'universal/skills',
|
|
87
|
+
];
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const rel of candidates) {
|
|
90
|
+
const abs = path.join(projectRoot, rel);
|
|
91
|
+
try {
|
|
92
|
+
const st = statSync(abs);
|
|
93
|
+
if (st && st.isDirectory()) out.push(abs);
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore — dir doesn't exist
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Input validation
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Guard skill identifiers against path traversal and unexpected characters.
|
|
107
|
+
*
|
|
108
|
+
* `skill` flows directly into path.join for both base body reads under
|
|
109
|
+
* shared/skills/<skill>/SKILL.md and per-platform deploy targets. An attacker
|
|
110
|
+
* (or buggy dispatch arg) passing "../../../etc/passwd" would escape the
|
|
111
|
+
* shared/skills/ boundary. Reject anything that doesn't match the same
|
|
112
|
+
* kebab-case pattern the override manifest schema enforces.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} skill
|
|
115
|
+
* @param {string} fnName caller name for the error message
|
|
116
|
+
*/
|
|
117
|
+
function assertValidSkillName(skill, fnName) {
|
|
118
|
+
if (typeof skill !== 'string' || !SKILL_NAME_PATTERN.test(skill)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${fnName}: invalid skill name ${JSON.stringify(skill)} — must match ${SKILL_NAME_PATTERN}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Path resolution
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Ordered override file paths for a skill. Caller filters out non-existent
|
|
131
|
+
* ones. Order is base presets -> user -> org -> project; resolveSkill applies
|
|
132
|
+
* them in that order so project wins.
|
|
133
|
+
*
|
|
134
|
+
* NOTE: base preset paths are resolved INSIDE resolveSkill from the
|
|
135
|
+
* manifests' `extends:` fields. resolveOverridePaths returns the
|
|
136
|
+
* user/org/project trio plus a placeholder slot for base presets (which the
|
|
137
|
+
* caller ignores).
|
|
138
|
+
*
|
|
139
|
+
* @param {string} skill
|
|
140
|
+
* @param {string} projectRoot
|
|
141
|
+
* @returns {Array<string|null>} 4 paths in tier order (base-preset slot is null)
|
|
142
|
+
*/
|
|
143
|
+
export function resolveOverridePaths(skill, projectRoot) {
|
|
144
|
+
const home = os.homedir();
|
|
145
|
+
return [
|
|
146
|
+
null, // base preset paths are computed dynamically by resolveSkill
|
|
147
|
+
path.join(home, '.ijfw', 'user-overrides', skill, 'override.md'),
|
|
148
|
+
path.join(home, '.ijfw', 'org-overrides', skill, 'override.md'),
|
|
149
|
+
path.join(projectRoot, '.ijfw', 'skill-overrides', skill, 'override.md'),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Guard a preset name before any path construction. Strings from
|
|
155
|
+
* active-overrides.json / extends chains / dispatch input must pass here.
|
|
156
|
+
* Throws if the name doesn't match the kebab-case pattern.
|
|
157
|
+
*/
|
|
158
|
+
function assertValidPresetName(preset, fnName) {
|
|
159
|
+
if (typeof preset !== 'string' || !PRESET_NAME_PATTERN.test(preset)) {
|
|
160
|
+
throw new TypeError(
|
|
161
|
+
`${fnName}: invalid preset name ${JSON.stringify(preset)} — must match ${PRESET_NAME_PATTERN}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function presetOverridePath(preset) {
|
|
167
|
+
// Primary location: per-user copy under ~/.ijfw/overrides/presets/ . The
|
|
168
|
+
// installer copies built-in presets here on install; users may edit them
|
|
169
|
+
// or drop their own custom presets alongside.
|
|
170
|
+
// W6.4/C7-H-01 defense-in-depth: reject traversal at the path-builder.
|
|
171
|
+
assertValidPresetName(preset, 'presetOverridePath');
|
|
172
|
+
return path.join(os.homedir(), '.ijfw', 'overrides', 'presets', `${preset}.md`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function bundledPresetPath(preset) {
|
|
176
|
+
// Fallback: the built-in presets ship inside the npm package at
|
|
177
|
+
// mcp-server/src/overrides/presets/. On a fresh install where nothing has
|
|
178
|
+
// copied them to ~/.ijfw/overrides/presets/ yet, the resolver still needs
|
|
179
|
+
// them so `ijfw override add book` works the first time without
|
|
180
|
+
// bootstrapping. Per-user files always win when present.
|
|
181
|
+
assertValidPresetName(preset, 'bundledPresetPath');
|
|
182
|
+
return path.join(BUNDLED_PRESETS_DIR, `${preset}.md`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Load a preset override file, trying the per-user path first and falling
|
|
187
|
+
* back to the bundled copy. Returns null if neither exists. Throws if a
|
|
188
|
+
* located file is structurally invalid.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} preset
|
|
191
|
+
* @returns {Promise<{manifest: object, body: string} | null>}
|
|
192
|
+
*/
|
|
193
|
+
async function loadPresetByName(preset) {
|
|
194
|
+
const homed = await loadOverrideFile(presetOverridePath(preset));
|
|
195
|
+
if (homed) return homed;
|
|
196
|
+
return loadOverrideFile(bundledPresetPath(preset));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// YAML frontmatter parsing (minimal)
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
204
|
+
|
|
205
|
+
function parseFrontmatter(raw) {
|
|
206
|
+
const m = raw.match(FRONTMATTER_RE);
|
|
207
|
+
if (!m) return { manifest: {}, body: raw };
|
|
208
|
+
const head = m[1];
|
|
209
|
+
const body = raw.slice(m[0].length);
|
|
210
|
+
const manifest = {};
|
|
211
|
+
for (const lineRaw of head.split(/\r?\n/)) {
|
|
212
|
+
const line = lineRaw.trim();
|
|
213
|
+
if (!line || line.startsWith('#')) continue;
|
|
214
|
+
const colon = line.indexOf(':');
|
|
215
|
+
if (colon === -1) continue;
|
|
216
|
+
const key = line.slice(0, colon).trim();
|
|
217
|
+
let value = line.slice(colon + 1).trim();
|
|
218
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
219
|
+
value = value
|
|
220
|
+
.slice(1, -1)
|
|
221
|
+
.split(',')
|
|
222
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
} else {
|
|
225
|
+
value = value.replace(/^["']|["']$/g, '');
|
|
226
|
+
}
|
|
227
|
+
manifest[key] = value;
|
|
228
|
+
}
|
|
229
|
+
return { manifest, body };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// File loading
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Read + parse + validate one override file.
|
|
238
|
+
* Returns null if the file does not exist (ENOENT).
|
|
239
|
+
* Throws if the manifest is structurally invalid — callers can decide whether
|
|
240
|
+
* to swallow.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} filePath
|
|
243
|
+
* @returns {Promise<{manifest: object, body: string} | null>}
|
|
244
|
+
*/
|
|
245
|
+
export async function loadOverrideFile(filePath) {
|
|
246
|
+
let raw;
|
|
247
|
+
try {
|
|
248
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (err && err.code === 'ENOENT') return null;
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
const { manifest, body } = parseFrontmatter(raw);
|
|
254
|
+
const { valid, errors } = validateOverrideManifest(manifest);
|
|
255
|
+
if (!valid) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Invalid override manifest at ${filePath}: ${errors.join('; ')}`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return { manifest, body };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Section merge
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
const SECTION_BLOCK_RE = /<!--\s*ijfw-override:\s*([a-z][a-z0-9-]*)\s*-->([\s\S]*?)<!--\s*ijfw-override-end\s*-->/g;
|
|
268
|
+
|
|
269
|
+
function targetRegex(section) {
|
|
270
|
+
// Match the corresponding target block in the base body. section name is
|
|
271
|
+
// already constrained by SECTION_BLOCK_RE to [a-z0-9-]+ so no special
|
|
272
|
+
// chars, but escape defensively anyway.
|
|
273
|
+
const safe = section.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
274
|
+
return new RegExp(
|
|
275
|
+
`<!--\\s*ijfw-override-target:\\s*${safe}\\s*-->[\\s\\S]*?<!--\\s*ijfw-override-target-end\\s*-->`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Apply an override file's section blocks onto a base skill body. Returns a
|
|
281
|
+
* new string. Missing targets emit a console.warn and are skipped.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} baseSkillBody
|
|
284
|
+
* @param {{manifest: object, body: string}} overrideFile
|
|
285
|
+
* @returns {string}
|
|
286
|
+
*/
|
|
287
|
+
export function applyOverride(baseSkillBody, overrideFile) {
|
|
288
|
+
if (!overrideFile) return baseSkillBody;
|
|
289
|
+
let out = baseSkillBody;
|
|
290
|
+
const body = overrideFile.body || '';
|
|
291
|
+
SECTION_BLOCK_RE.lastIndex = 0;
|
|
292
|
+
let m;
|
|
293
|
+
while ((m = SECTION_BLOCK_RE.exec(body)) !== null) {
|
|
294
|
+
const section = m[1];
|
|
295
|
+
const inner = m[2];
|
|
296
|
+
const tre = targetRegex(section);
|
|
297
|
+
if (!tre.test(out)) {
|
|
298
|
+
console.warn(
|
|
299
|
+
`[ijfw override-resolver] override section "${section}" has no matching <!-- ijfw-override-target: ${section} --> ... <!-- ijfw-override-target-end --> in base body — skipping (manifest: ${JSON.stringify(overrideFile.manifest)})`
|
|
300
|
+
);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Replace the target block with a fresh wrapped section so the next tier
|
|
304
|
+
// can also override it.
|
|
305
|
+
const replacement = `<!-- ijfw-override-target: ${section} -->${inner}<!-- ijfw-override-target-end -->`;
|
|
306
|
+
out = out.replace(tre, () => replacement);
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Full resolution
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Read base SKILL.md -> walk tier chain (base presets -> user -> org ->
|
|
317
|
+
* project) -> return merged body string. Missing base skill returns ''
|
|
318
|
+
* (graceful — keeps deploy from crashing on a typo'd skill name).
|
|
319
|
+
*
|
|
320
|
+
* ## Active-overrides wiring (S6)
|
|
321
|
+
*
|
|
322
|
+
* `ijfw override add <preset>` records the chosen preset in
|
|
323
|
+
* `~/.ijfw/state/active-overrides.json` for the current project but does NOT
|
|
324
|
+
* write an `extends: [<preset>]` line into any override file. resolveSkill
|
|
325
|
+
* therefore consults that state file on every resolution and treats the
|
|
326
|
+
* recorded presets as an IMPLICIT extends chain — programmatically
|
|
327
|
+
* equivalent to the user having written `extends: [book, academic, ...]` in
|
|
328
|
+
* a project-tier override.
|
|
329
|
+
*
|
|
330
|
+
* Algorithm:
|
|
331
|
+
* 1. Read active-overrides for projectRoot. Extract the preset list.
|
|
332
|
+
* 2. Append any presets explicitly named via `extends:` in user/org/project
|
|
333
|
+
* override files (preserving the existing project-first ordering).
|
|
334
|
+
* 3. Recursively load every preset (and the presets they extend) under the
|
|
335
|
+
* same MAX_EXTENDS_DEPTH and cycle guards.
|
|
336
|
+
* 4. Apply order: deepest-first preset DFS -> user -> org -> project.
|
|
337
|
+
*
|
|
338
|
+
* The implicit and explicit lists share the same downstream pipeline, so a
|
|
339
|
+
* preset that appears via both routes is only loaded/applied once.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} skill
|
|
342
|
+
* @param {string} projectRoot
|
|
343
|
+
* @returns {Promise<string>}
|
|
344
|
+
*/
|
|
345
|
+
export async function resolveSkill(skill, projectRoot) {
|
|
346
|
+
assertValidSkillName(skill, 'resolveSkill');
|
|
347
|
+
const basePath = path.join(projectRoot, 'shared', 'skills', skill, 'SKILL.md');
|
|
348
|
+
let baseBody = '';
|
|
349
|
+
try {
|
|
350
|
+
baseBody = await fs.readFile(basePath, 'utf8');
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (!err || err.code !== 'ENOENT') throw err;
|
|
353
|
+
return '';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const [, userPath, orgPath, projectPath] = resolveOverridePaths(skill, projectRoot);
|
|
357
|
+
|
|
358
|
+
// Load the three non-preset tiers first so we know which presets are
|
|
359
|
+
// referenced via `extends:`.
|
|
360
|
+
const userFile = await loadOverrideFile(userPath);
|
|
361
|
+
const orgFile = await loadOverrideFile(orgPath);
|
|
362
|
+
const projectFile = await loadOverrideFile(projectPath);
|
|
363
|
+
|
|
364
|
+
// Collect referenced presets in project-first order so the project's
|
|
365
|
+
// extends list wins on ordering ambiguity. We still apply ALL referenced
|
|
366
|
+
// presets before any user/org/project overrides so later tiers can override
|
|
367
|
+
// preset content.
|
|
368
|
+
const presetOrder = [];
|
|
369
|
+
|
|
370
|
+
// S6: implicit extends from active-overrides state. This is what
|
|
371
|
+
// `ijfw override add book` records; consulting it here is what makes the
|
|
372
|
+
// command actually take effect at deploy time.
|
|
373
|
+
const activePresets = await readActiveOverridesForProject(projectRoot);
|
|
374
|
+
for (const p of activePresets) {
|
|
375
|
+
if (typeof p === 'string' && !presetOrder.includes(p)) presetOrder.push(p);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const f of [projectFile, orgFile, userFile]) {
|
|
379
|
+
if (!f || !f.manifest) continue;
|
|
380
|
+
const ext = f.manifest.extends;
|
|
381
|
+
if (!ext) continue;
|
|
382
|
+
const list = Array.isArray(ext) ? ext : [ext];
|
|
383
|
+
for (const p of list) {
|
|
384
|
+
if (typeof p === 'string' && !presetOrder.includes(p)) presetOrder.push(p);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Build preset graph and load every preset (and any preset they extend).
|
|
389
|
+
// presetGraph is a Map<presetName, {extends: string[]}> so it satisfies
|
|
390
|
+
// detectCircularExtends's .get() contract.
|
|
391
|
+
const presetGraph = new Map();
|
|
392
|
+
const loadedPresets = new Map();
|
|
393
|
+
|
|
394
|
+
async function loadPresetRecursive(preset, depth) {
|
|
395
|
+
if (depth > MAX_EXTENDS_DEPTH) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`[ijfw override-resolver] extends chain exceeded MAX_EXTENDS_DEPTH=${MAX_EXTENDS_DEPTH} at "${preset}"`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (loadedPresets.has(preset)) return;
|
|
401
|
+
// S7: try ~/.ijfw/overrides/presets/<preset>.md first, then fall back to
|
|
402
|
+
// the bundled copy at mcp-server/src/overrides/presets/<preset>.md so
|
|
403
|
+
// fresh installs (no per-user copy yet) still find the 4 built-ins.
|
|
404
|
+
const pf = await loadPresetByName(preset);
|
|
405
|
+
loadedPresets.set(preset, pf); // may be null
|
|
406
|
+
const parents = [];
|
|
407
|
+
if (pf && pf.manifest && pf.manifest.extends) {
|
|
408
|
+
const ext = pf.manifest.extends;
|
|
409
|
+
const list = Array.isArray(ext) ? ext : [ext];
|
|
410
|
+
for (const p of list) if (typeof p === 'string') parents.push(p);
|
|
411
|
+
}
|
|
412
|
+
presetGraph.set(preset, { extends: parents });
|
|
413
|
+
for (const p of parents) {
|
|
414
|
+
await loadPresetRecursive(p, depth + 1);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
for (const p of presetOrder) {
|
|
419
|
+
if (!presetGraph.has(p)) presetGraph.set(p, { extends: [] });
|
|
420
|
+
await loadPresetRecursive(p, 1);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Cycle check.
|
|
424
|
+
for (const start of presetGraph.keys()) {
|
|
425
|
+
const { circular, chain } = detectCircularExtends(presetGraph, start);
|
|
426
|
+
if (circular) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`[ijfw override-resolver] circular extends detected: ${chain.join(' -> ')}`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Apply order: deepest-extends preset first -> ... -> shallow presets ->
|
|
434
|
+
// user -> org -> project. Use a post-order DFS so a preset's parents are
|
|
435
|
+
// applied before the preset itself.
|
|
436
|
+
const applyOrder = [];
|
|
437
|
+
const visited = new Set();
|
|
438
|
+
function dfs(p) {
|
|
439
|
+
if (visited.has(p)) return;
|
|
440
|
+
visited.add(p);
|
|
441
|
+
const node = presetGraph.get(p);
|
|
442
|
+
for (const parent of (node && node.extends) || []) dfs(parent);
|
|
443
|
+
applyOrder.push(p);
|
|
444
|
+
}
|
|
445
|
+
for (const p of presetOrder) dfs(p);
|
|
446
|
+
|
|
447
|
+
let merged = baseBody;
|
|
448
|
+
for (const preset of applyOrder) {
|
|
449
|
+
const pf = loadedPresets.get(preset);
|
|
450
|
+
if (pf) merged = applyOverride(merged, pf);
|
|
451
|
+
}
|
|
452
|
+
if (userFile) merged = applyOverride(merged, userFile);
|
|
453
|
+
if (orgFile) merged = applyOverride(merged, orgFile);
|
|
454
|
+
if (projectFile) merged = applyOverride(merged, projectFile);
|
|
455
|
+
|
|
456
|
+
return merged;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// Deployment
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
async function atomicWrite(targetPath, contents) {
|
|
464
|
+
const dir = path.dirname(targetPath);
|
|
465
|
+
await fs.mkdir(dir, { recursive: true });
|
|
466
|
+
// Unique suffix per writer: two parallel deploys of the same skill would
|
|
467
|
+
// otherwise collide on a shared `${targetPath}.tmp` and one would clobber
|
|
468
|
+
// the other mid-write before the rename. pid + 4 bytes of randomness keeps
|
|
469
|
+
// the suffix unique across threads and processes.
|
|
470
|
+
const tmp = `${targetPath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
471
|
+
await fs.writeFile(tmp, contents, 'utf8');
|
|
472
|
+
await fs.rename(tmp, targetPath);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Resolve `skill` and write the merged body to every present platform skill
|
|
477
|
+
* dir under projectRoot. Atomic per platform (tmp + rename). Failures on one
|
|
478
|
+
* platform are recorded in `failed[]` but do not abort the others.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} skill
|
|
481
|
+
* @param {string} projectRoot
|
|
482
|
+
* @param {object} [opts] reserved — currently unused (W2b will wire dry-run,
|
|
483
|
+
* explicit platform list, etc.)
|
|
484
|
+
* @returns {Promise<{deployed: Array<{platform: string, path: string}>, failed: Array<{platform: string, path: string, error: string}>}>}
|
|
485
|
+
*/
|
|
486
|
+
export async function deployResolvedSkill(skill, projectRoot, _opts = {}) {
|
|
487
|
+
assertValidSkillName(skill, 'deployResolvedSkill');
|
|
488
|
+
const merged = await resolveSkill(skill, projectRoot);
|
|
489
|
+
const platformDirs = getPlatformSkillDirs(projectRoot);
|
|
490
|
+
const deployed = [];
|
|
491
|
+
const failed = [];
|
|
492
|
+
|
|
493
|
+
for (const platformDir of platformDirs) {
|
|
494
|
+
const target = path.join(platformDir, skill, 'SKILL.md');
|
|
495
|
+
try {
|
|
496
|
+
await atomicWrite(target, merged);
|
|
497
|
+
deployed.push({ platform: platformDir, path: target });
|
|
498
|
+
} catch (err) {
|
|
499
|
+
failed.push({
|
|
500
|
+
platform: platformDir,
|
|
501
|
+
path: target,
|
|
502
|
+
error: err && err.message ? err.message : String(err),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { deployed, failed };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Active overrides state file
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
function activeOverridesPath() {
|
|
515
|
+
return path.join(os.homedir(), '.ijfw', 'state', 'active-overrides.json');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function readActiveOverrides() {
|
|
519
|
+
const p = activeOverridesPath();
|
|
520
|
+
try {
|
|
521
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
522
|
+
const parsed = JSON.parse(raw);
|
|
523
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.projects) {
|
|
524
|
+
return { projects: {} };
|
|
525
|
+
}
|
|
526
|
+
return parsed;
|
|
527
|
+
} catch (err) {
|
|
528
|
+
if (err && err.code === 'ENOENT') return { projects: {} };
|
|
529
|
+
if (err instanceof SyntaxError) return { projects: {} };
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* S6 wiring helper. Read the active-overrides state file and return the
|
|
536
|
+
* ordered list of preset names recorded for `projectRoot`. Order is the
|
|
537
|
+
* insertion order in active_overrides[] (which is the order the user ran
|
|
538
|
+
* `ijfw override add ...`). Resolver-visible failures are swallowed and
|
|
539
|
+
* mapped to []: a missing/corrupt state file must never block deploy.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} projectRoot
|
|
542
|
+
* @returns {Promise<string[]>}
|
|
543
|
+
*/
|
|
544
|
+
async function readActiveOverridesForProject(projectRoot) {
|
|
545
|
+
let state;
|
|
546
|
+
try {
|
|
547
|
+
state = await readActiveOverrides();
|
|
548
|
+
} catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
const proj = state && state.projects && state.projects[projectRoot];
|
|
552
|
+
if (!proj || !Array.isArray(proj.active_overrides)) return [];
|
|
553
|
+
const out = [];
|
|
554
|
+
for (const entry of proj.active_overrides) {
|
|
555
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
556
|
+
const preset = entry.preset;
|
|
557
|
+
// W6.4/C7-H-01: state file is user-editable (same threat model as
|
|
558
|
+
// R6-H-01's home-scope manifests). Reject any preset name that doesn't
|
|
559
|
+
// match the kebab-case pattern before it reaches presetOverridePath().
|
|
560
|
+
// A handwritten `"preset": "../../../evil/pwn"` would otherwise resolve
|
|
561
|
+
// outside ~/.ijfw/overrides/presets/ and inject arbitrary .md content
|
|
562
|
+
// into every deployed SKILL.md.
|
|
563
|
+
if (typeof preset !== 'string' || !PRESET_NAME_PATTERN.test(preset)) continue;
|
|
564
|
+
if (out.includes(preset)) continue;
|
|
565
|
+
out.push(preset);
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function writeActiveOverrides(state) {
|
|
571
|
+
const p = activeOverridesPath();
|
|
572
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
573
|
+
// Same collision concern as atomicWrite above — two concurrent
|
|
574
|
+
// recordActiveOverride calls could clobber each other's tmp file.
|
|
575
|
+
const tmp = `${p}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
576
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
577
|
+
await fs.rename(tmp, p);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Record an active override for a project. Override shape:
|
|
582
|
+
* { preset: string, scope: 'base'|'user'|'org'|'project', applied_at?: string }
|
|
583
|
+
* If an entry with the same preset+scope already exists, its applied_at is
|
|
584
|
+
* updated.
|
|
585
|
+
*
|
|
586
|
+
* @param {string} projectRoot
|
|
587
|
+
* @param {{preset: string, scope: string, applied_at?: string}} override
|
|
588
|
+
*/
|
|
589
|
+
export async function recordActiveOverride(projectRoot, override) {
|
|
590
|
+
if (!override || typeof override !== 'object') {
|
|
591
|
+
throw new Error('recordActiveOverride: override must be an object');
|
|
592
|
+
}
|
|
593
|
+
if (!override.preset || !override.scope) {
|
|
594
|
+
throw new Error('recordActiveOverride: override must have preset and scope');
|
|
595
|
+
}
|
|
596
|
+
// W6.4/C7-H-01-N1: validate shape at the writer too so the state file can
|
|
597
|
+
// never persist a bad entry. Pairs with readActiveOverridesForProject's
|
|
598
|
+
// read-side filter — defense in depth across producer + consumer.
|
|
599
|
+
if (!PRESET_NAME_PATTERN.test(override.preset)) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`recordActiveOverride: invalid preset name ${JSON.stringify(override.preset)} — must match ${PRESET_NAME_PATTERN}`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (!OVERRIDE_SCOPES.includes(override.scope)) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`recordActiveOverride: invalid scope ${JSON.stringify(override.scope)} — must be one of ${OVERRIDE_SCOPES.join('|')}`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
const state = await readActiveOverrides();
|
|
610
|
+
const proj = state.projects[projectRoot] || { active_overrides: [] };
|
|
611
|
+
if (!Array.isArray(proj.active_overrides)) proj.active_overrides = [];
|
|
612
|
+
const appliedAt = override.applied_at || new Date().toISOString();
|
|
613
|
+
const existingIdx = proj.active_overrides.findIndex(
|
|
614
|
+
(o) => o && o.preset === override.preset && o.scope === override.scope
|
|
615
|
+
);
|
|
616
|
+
if (existingIdx >= 0) {
|
|
617
|
+
proj.active_overrides[existingIdx] = {
|
|
618
|
+
...proj.active_overrides[existingIdx],
|
|
619
|
+
...override,
|
|
620
|
+
applied_at: appliedAt,
|
|
621
|
+
};
|
|
622
|
+
} else {
|
|
623
|
+
proj.active_overrides.push({
|
|
624
|
+
preset: override.preset,
|
|
625
|
+
scope: override.scope,
|
|
626
|
+
applied_at: appliedAt,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
state.projects[projectRoot] = proj;
|
|
630
|
+
await writeActiveOverrides(state);
|
|
631
|
+
|
|
632
|
+
// t14: mirror into the cross-project override-use registry so the prelude
|
|
633
|
+
// can suggest promote-to-user-defaults when the same set lights up across
|
|
634
|
+
// N+ projects. Lazy-import project-type-detector to dodge the cold-scan
|
|
635
|
+
// module weight when the resolver is only ever called for a single skill.
|
|
636
|
+
try {
|
|
637
|
+
let projectType = 'unknown';
|
|
638
|
+
try {
|
|
639
|
+
const detector = await import('./project-type-detector.js');
|
|
640
|
+
const r = await detector.detect(projectRoot);
|
|
641
|
+
if (r && typeof r.primary_type === 'string') projectType = r.primary_type;
|
|
642
|
+
else if (r && typeof r.type === 'string') projectType = r.type;
|
|
643
|
+
} catch {
|
|
644
|
+
// detect() may throw on cold-scan stalls or missing dirs; the registry
|
|
645
|
+
// accepts 'unknown' and we can backfill later.
|
|
646
|
+
}
|
|
647
|
+
await recordOverrideUse(projectRoot, override.preset, override.scope, projectType);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
// A registry failure must NEVER fail the resolver write. Log to stderr so
|
|
650
|
+
// the dashboard's log tail surfaces it without breaking the deploy flow.
|
|
651
|
+
console.warn(
|
|
652
|
+
`[ijfw override-resolver] override-use-registry record failed (non-fatal): ${err && err.message ? err.message : err}`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Remove all active-override entries for a project whose preset matches.
|
|
659
|
+
* Idempotent — missing entry is a no-op.
|
|
660
|
+
*
|
|
661
|
+
* @param {string} projectRoot
|
|
662
|
+
* @param {string} preset
|
|
663
|
+
*/
|
|
664
|
+
export async function removeActiveOverride(projectRoot, preset) {
|
|
665
|
+
const state = await readActiveOverrides();
|
|
666
|
+
const proj = state.projects[projectRoot];
|
|
667
|
+
if (!proj || !Array.isArray(proj.active_overrides)) {
|
|
668
|
+
// Still try the cross-project registry — it may have stale entries even
|
|
669
|
+
// when the per-project state file is missing.
|
|
670
|
+
try {
|
|
671
|
+
await removeOverrideUse(projectRoot, preset);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
console.warn(
|
|
674
|
+
`[ijfw override-resolver] override-use-registry remove failed (non-fatal): ${err && err.message ? err.message : err}`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
proj.active_overrides = proj.active_overrides.filter(
|
|
680
|
+
(o) => !(o && o.preset === preset)
|
|
681
|
+
);
|
|
682
|
+
state.projects[projectRoot] = proj;
|
|
683
|
+
await writeActiveOverrides(state);
|
|
684
|
+
|
|
685
|
+
// t14: keep the cross-project registry in sync.
|
|
686
|
+
try {
|
|
687
|
+
await removeOverrideUse(projectRoot, preset);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
console.warn(
|
|
690
|
+
`[ijfw override-resolver] override-use-registry remove failed (non-fatal): ${err && err.message ? err.message : err}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
// Re-exports for caller convenience
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
|
|
699
|
+
export { BUILTIN_PRESETS, MAX_EXTENDS_DEPTH };
|