@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.
Files changed (68) hide show
  1. package/README.md +67 -0
  2. package/fixtures/team/book.json +47 -0
  3. package/fixtures/team/business.json +47 -0
  4. package/fixtures/team/content.json +47 -0
  5. package/fixtures/team/design.json +47 -0
  6. package/fixtures/team/mixed.json +59 -0
  7. package/fixtures/team/research.json +47 -0
  8. package/fixtures/team/software.json +47 -0
  9. package/package.json +1 -9
  10. package/src/.registry-meta-key.pem +3 -0
  11. package/src/active-extension-writer.js +142 -0
  12. package/src/blackboard.js +360 -0
  13. package/src/cli-run.js +91 -0
  14. package/src/codex-agents.js +177 -0
  15. package/src/compute/extract.js +3 -0
  16. package/src/compute/fts5.js +4 -4
  17. package/src/compute/graph-lock.js +0 -2
  18. package/src/compute/migrations/003-tier-semantic.js +3 -3
  19. package/src/compute/runner.js +44 -15
  20. package/src/compute/schema.sql +1 -1
  21. package/src/cross-orchestrator-cli.js +974 -13
  22. package/src/cross-orchestrator.js +9 -1
  23. package/src/dashboard-client.html +353 -1
  24. package/src/dashboard-server.js +318 -2
  25. package/src/design-intelligence.js +721 -0
  26. package/src/dispatch/colon-syntax.js +31 -3
  27. package/src/dispatch/domain-manifest.js +251 -0
  28. package/src/dispatch/extension.js +637 -0
  29. package/src/dispatch/override.js +221 -0
  30. package/src/dispatch-planner.js +1 -0
  31. package/src/dream/runner.mjs +3 -3
  32. package/src/extension-installer.js +1269 -0
  33. package/src/extension-manifest-schema.js +301 -0
  34. package/src/extension-permission-check.mjs +79 -0
  35. package/src/extension-registry.js +619 -0
  36. package/src/extension-signer.js +905 -0
  37. package/src/gate-result-formatter.js +95 -0
  38. package/src/gate-result-schema.js +274 -0
  39. package/src/gate-result.js +195 -0
  40. package/src/intent-router.js +2 -0
  41. package/src/lib/npm-view.js +1 -0
  42. package/src/memory/fts5.js +3 -3
  43. package/src/memory/migrations/002-tier-semantic.js +2 -2
  44. package/src/memory/staleness.js +1 -1
  45. package/src/memory/tier-promotion.js +6 -6
  46. package/src/memory/tokenize.js +1 -1
  47. package/src/memory-feedback.js +372 -0
  48. package/src/override-manifest-schema.js +146 -0
  49. package/src/override-resolver.js +699 -0
  50. package/src/override-use-registry.js +307 -0
  51. package/src/overrides/presets/academic.md +101 -0
  52. package/src/overrides/presets/book.md +87 -0
  53. package/src/overrides/presets/campaign.md +95 -0
  54. package/src/overrides/presets/screenplay.md +99 -0
  55. package/src/recovery/checkpoint.js +191 -0
  56. package/src/redactor.js +2 -0
  57. package/src/runtime-mediator.js +207 -0
  58. package/src/sandbox.js +17 -3
  59. package/src/server.js +94 -2
  60. package/src/swarm/dispatch-prompt.js +154 -0
  61. package/src/swarm/planner.js +399 -0
  62. package/src/swarm/review.js +136 -0
  63. package/src/swarm/worktree.js +239 -0
  64. package/src/team/generator.js +119 -0
  65. package/src/team/schemas.js +341 -0
  66. package/src/trident/dispatch.js +47 -0
  67. package/src/update-check.js +1 -1
  68. 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 };