@ijfw/memory-server 1.3.0 → 1.4.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 (64) hide show
  1. package/fixtures/team/book.json +47 -0
  2. package/fixtures/team/business.json +47 -0
  3. package/fixtures/team/content.json +47 -0
  4. package/fixtures/team/design.json +47 -0
  5. package/fixtures/team/mixed.json +59 -0
  6. package/fixtures/team/research.json +47 -0
  7. package/fixtures/team/software.json +47 -0
  8. package/package.json +1 -9
  9. package/src/active-extension-writer.js +116 -0
  10. package/src/blackboard.js +360 -0
  11. package/src/cli-run.js +91 -0
  12. package/src/codex-agents.js +177 -0
  13. package/src/compute/extract.js +3 -0
  14. package/src/compute/fts5.js +4 -4
  15. package/src/compute/graph-lock.js +0 -2
  16. package/src/compute/migrations/003-tier-semantic.js +3 -3
  17. package/src/compute/runner.js +44 -15
  18. package/src/compute/schema.sql +1 -1
  19. package/src/cross-orchestrator-cli.js +974 -13
  20. package/src/cross-orchestrator.js +9 -1
  21. package/src/dashboard-client.html +144 -1
  22. package/src/dashboard-server.js +75 -2
  23. package/src/design-intelligence.js +721 -0
  24. package/src/dispatch/colon-syntax.js +31 -3
  25. package/src/dispatch/domain-manifest.js +251 -0
  26. package/src/dispatch/extension.js +404 -0
  27. package/src/dispatch/override.js +221 -0
  28. package/src/dispatch-planner.js +1 -0
  29. package/src/dream/runner.mjs +3 -3
  30. package/src/extension-installer.js +1230 -0
  31. package/src/extension-manifest-schema.js +301 -0
  32. package/src/extension-signer.js +740 -0
  33. package/src/gate-result-formatter.js +95 -0
  34. package/src/gate-result-schema.js +274 -0
  35. package/src/gate-result.js +195 -0
  36. package/src/intent-router.js +2 -0
  37. package/src/lib/npm-view.js +1 -0
  38. package/src/memory/fts5.js +3 -3
  39. package/src/memory/migrations/002-tier-semantic.js +2 -2
  40. package/src/memory/staleness.js +1 -1
  41. package/src/memory/tier-promotion.js +6 -6
  42. package/src/memory/tokenize.js +1 -1
  43. package/src/memory-feedback.js +188 -0
  44. package/src/override-manifest-schema.js +146 -0
  45. package/src/override-resolver.js +699 -0
  46. package/src/override-use-registry.js +307 -0
  47. package/src/overrides/presets/academic.md +101 -0
  48. package/src/overrides/presets/book.md +87 -0
  49. package/src/overrides/presets/campaign.md +95 -0
  50. package/src/overrides/presets/screenplay.md +99 -0
  51. package/src/recovery/checkpoint.js +191 -0
  52. package/src/redactor.js +2 -0
  53. package/src/runtime-mediator.js +178 -0
  54. package/src/sandbox.js +17 -3
  55. package/src/server.js +94 -2
  56. package/src/swarm/dispatch-prompt.js +154 -0
  57. package/src/swarm/planner.js +399 -0
  58. package/src/swarm/review.js +136 -0
  59. package/src/swarm/worktree.js +239 -0
  60. package/src/team/generator.js +119 -0
  61. package/src/team/schemas.js +341 -0
  62. package/src/trident/dispatch.js +47 -0
  63. package/src/update-check.js +1 -1
  64. package/src/vectors.js +7 -8
@@ -0,0 +1,404 @@
1
+ /**
2
+ * dispatch/extension.js
3
+ *
4
+ * IJFW v1.4.0 / W3/t16 — extension colon-namespace dispatch handler.
5
+ *
6
+ * Routes `extension:<command>` from colon-syntax dispatch and
7
+ * `ijfw extension <command>` from the CLI to the W2 extension-installer
8
+ * primitives.
9
+ *
10
+ * Commands:
11
+ * add <source> [scope] — install (npm name | local path | https:// git url)
12
+ * list — aggregate extensions across project+org+user scopes
13
+ * remove <name> [scope] — uninstall + cleanup
14
+ * audit — registry + per-extension permission summary
15
+ * deploy-lazy — (W6/S12) walk ~/.ijfw/extensions-{org,user}/ and
16
+ * deploy each registered extension's skills to the
17
+ * current project's platform dirs. Fired by the
18
+ * session-start hook so org/user-scoped extensions
19
+ * become available in every project session.
20
+ */
21
+
22
+ import {
23
+ installExtension,
24
+ uninstallExtension,
25
+ listExtensions,
26
+ } from '../extension-installer.js';
27
+ import {
28
+ generatePublisherKeypair,
29
+ addTrustedPublisher,
30
+ removeTrustedPublisher,
31
+ readTrustedPublishers,
32
+ } from '../extension-signer.js';
33
+ import {
34
+ deployExtensionSkillsToPlatforms,
35
+ deployExtensionToAgentsMd,
36
+ } from '../../../installer/src/install-helpers.js';
37
+ import { promises as fs } from 'node:fs';
38
+ import path from 'node:path';
39
+ import os from 'node:os';
40
+
41
+ const VALID_SCOPES = new Set(['project', 'org', 'user']);
42
+
43
+ function parseScope(rawScope, fallback = 'project') {
44
+ return VALID_SCOPES.has(rawScope) ? rawScope : fallback;
45
+ }
46
+
47
+ /**
48
+ * Parse `<source> [scope]` allowing the source to contain whitespace when
49
+ * wrapped in single or double quotes (paths with spaces, etc.).
50
+ *
51
+ * Rules:
52
+ * - If args starts with `"` or `'`, source is the body between matching
53
+ * quotes; whatever follows the close quote is candidate scope.
54
+ * - Otherwise: if the LAST whitespace-separated token matches the scope
55
+ * enum (project|org|user), source is the greedy join of everything
56
+ * before it. Else source is the whole trimmed args (no scope).
57
+ *
58
+ * Returns { source, scope } where scope is the parsed raw token (caller
59
+ * still runs it through parseScope to coerce to default).
60
+ */
61
+ function parseSourceAndScope(args) {
62
+ const raw = String(args || '');
63
+ const trimmed = raw.replace(/^\s+/, '');
64
+ if (!trimmed) return { source: '', scope: undefined };
65
+
66
+ const first = trimmed[0];
67
+ if (first === '"' || first === "'") {
68
+ const close = trimmed.indexOf(first, 1);
69
+ if (close > 0) {
70
+ const source = trimmed.slice(1, close);
71
+ const rest = trimmed.slice(close + 1).trim();
72
+ const scope = rest.split(/\s+/).filter(Boolean)[0];
73
+ return { source, scope };
74
+ }
75
+ // unmatched quote — fall through to non-quoted parse using the raw text
76
+ }
77
+
78
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
79
+ if (tokens.length === 0) return { source: '', scope: undefined };
80
+ const last = tokens[tokens.length - 1];
81
+ // W6.2/R5-M-01: only strip a trailing scope token when the input is exactly
82
+ // 2 tokens (`<source> <scope>`). Paths with internal whitespace must be
83
+ // quoted (handled above); a single token is always pure source. This is
84
+ // narrower than W6.1's looksLikePath heuristic, which broke the common
85
+ // `ijfw extension add ./local-pkg user` case. The original Gemini-med
86
+ // case (`/my/project` → source/scope mishandle) is now covered by the
87
+ // single-token path falling through to "no scope".
88
+ if (tokens.length === 2 && VALID_SCOPES.has(last)) {
89
+ return { source: tokens[0], scope: last };
90
+ }
91
+ return { source: tokens.join(' '), scope: undefined };
92
+ }
93
+
94
+ /**
95
+ * Strip recognised flag tokens from an args string and return both the
96
+ * cleaned args and a flags object. Flags are positional-agnostic.
97
+ *
98
+ * --allow-unsigned -> opts.allowUnsigned = true
99
+ * --accept-untrusted -> opts.acceptUntrusted = true
100
+ * --activate -> opts.activate = true
101
+ */
102
+ function extractAddFlags(args) {
103
+ const tokens = String(args || '').split(/\s+/).filter(Boolean);
104
+ const flags = { allowUnsigned: false, acceptUntrusted: false, activate: false };
105
+ const keep = [];
106
+ for (const t of tokens) {
107
+ if (t === '--allow-unsigned') { flags.allowUnsigned = true; continue; }
108
+ if (t === '--accept-untrusted') { flags.acceptUntrusted = true; continue; }
109
+ if (t === '--activate') { flags.activate = true; continue; }
110
+ keep.push(t);
111
+ }
112
+ return { args: keep.join(' '), flags };
113
+ }
114
+
115
+ async function cmdAdd({ args, projectRoot }) {
116
+ const { args: stripped, flags } = extractAddFlags(args);
117
+ const { source, scope: rawScope } = parseSourceAndScope(stripped);
118
+ if (!source) return { ok: false, command: 'add', error: 'missing source (npm name, path, or https:// git url)' };
119
+ const scope = parseScope(rawScope);
120
+ try {
121
+ const r = await installExtension(source, {
122
+ scope,
123
+ projectRoot,
124
+ allowUnsigned: flags.allowUnsigned,
125
+ acceptUntrusted: flags.acceptUntrusted,
126
+ activate: flags.activate,
127
+ });
128
+ return { ok: !!r.ok, command: 'add', result: r };
129
+ } catch (err) {
130
+ return { ok: false, command: 'add', error: err.message };
131
+ }
132
+ }
133
+
134
+ async function cmdKeygen({ args }) {
135
+ const authorName = String(args || '').trim();
136
+ if (!authorName) return { ok: false, command: 'keygen', error: 'missing author name' };
137
+ try {
138
+ const kp = await generatePublisherKeypair(authorName);
139
+ return {
140
+ ok: true,
141
+ command: 'keygen',
142
+ result: {
143
+ keyId: kp.keyId,
144
+ publicKey: kp.publicKey,
145
+ dir: kp.dir,
146
+ // private key is on disk at <dir>/private.pem; never echo to log.
147
+ },
148
+ };
149
+ } catch (err) {
150
+ return { ok: false, command: 'keygen', error: err.message };
151
+ }
152
+ }
153
+
154
+ async function cmdTrust({ args }) {
155
+ // Args shape: "<keyId> <publicKeyPemMultiline>"
156
+ // The PEM body almost certainly contains spaces and newlines — accept
157
+ // everything after the first whitespace as the public key.
158
+ const raw = String(args || '');
159
+ const idx = raw.search(/\s/);
160
+ if (idx < 0) return { ok: false, command: 'trust', error: 'usage: trust <keyId> <publicKeyPem>' };
161
+ const keyId = raw.slice(0, idx).trim();
162
+ const publicKey = raw.slice(idx + 1).trim();
163
+ if (!keyId || !publicKey) return { ok: false, command: 'trust', error: 'usage: trust <keyId> <publicKeyPem>' };
164
+ try {
165
+ const r = await addTrustedPublisher(keyId, publicKey);
166
+ return { ok: !!r.ok, command: 'trust', result: r };
167
+ } catch (err) {
168
+ return { ok: false, command: 'trust', error: err.message };
169
+ }
170
+ }
171
+
172
+ async function cmdUntrust({ args }) {
173
+ const keyId = String(args || '').trim();
174
+ if (!keyId) return { ok: false, command: 'untrust', error: 'missing keyId' };
175
+ try {
176
+ const r = await removeTrustedPublisher(keyId);
177
+ return { ok: !!r.ok, command: 'untrust', result: { removed: r.removed } };
178
+ } catch (err) {
179
+ return { ok: false, command: 'untrust', error: err.message };
180
+ }
181
+ }
182
+
183
+ async function cmdTrusted() {
184
+ try {
185
+ const store = await readTrustedPublishers();
186
+ const publishers = Object.entries(store.publishers || {}).map(([keyId, v]) => ({
187
+ keyId,
188
+ name: v.name ?? null,
189
+ added_at: v.added_at ?? null,
190
+ }));
191
+ return { ok: true, command: 'trusted', result: { publishers, count: publishers.length } };
192
+ } catch (err) {
193
+ return { ok: false, command: 'trusted', error: err.message };
194
+ }
195
+ }
196
+
197
+ async function cmdRemove({ args, projectRoot }) {
198
+ const { source: name, scope: rawScope } = parseSourceAndScope(args);
199
+ if (!name) return { ok: false, command: 'remove', error: 'missing extension name' };
200
+ const scope = parseScope(rawScope);
201
+ try {
202
+ const r = await uninstallExtension(name, { scope, projectRoot });
203
+ return { ok: !!r.ok, command: 'remove', result: r };
204
+ } catch (err) {
205
+ return { ok: false, command: 'remove', error: err.message };
206
+ }
207
+ }
208
+
209
+ async function cmdList({ projectRoot }) {
210
+ try {
211
+ const r = await listExtensions(projectRoot);
212
+ const extensions = Array.isArray(r) ? r : (r?.extensions ?? []);
213
+ return { ok: true, command: 'list', result: { extensions, count: extensions.length } };
214
+ } catch (err) {
215
+ return { ok: false, command: 'list', error: err.message };
216
+ }
217
+ }
218
+
219
+ async function cmdAudit({ projectRoot }) {
220
+ try {
221
+ const r = await listExtensions(projectRoot);
222
+ const extensions = Array.isArray(r) ? r : (r?.extensions ?? []);
223
+ const summary = extensions.map(e => ({
224
+ name: e.name,
225
+ version: e.version,
226
+ scope: e.scope,
227
+ status: e.status ?? 'active',
228
+ last_trident_verdict: e.last_trident_verdict ?? null,
229
+ // listExtensions now returns `permissions` and `description` directly
230
+ // on each entry (W6B-1). Reading them at the top level keeps the
231
+ // dispatch independent of the registry's internal manifest shape.
232
+ permissions: e.permissions ?? null,
233
+ description: e.description ?? null,
234
+ }));
235
+ return { ok: true, command: 'audit', result: { summary, count: summary.length } };
236
+ } catch (err) {
237
+ return { ok: false, command: 'audit', error: err.message };
238
+ }
239
+ }
240
+
241
+ /**
242
+ * cmdDeployLazy — W6/S12.
243
+ *
244
+ * Org/user-scoped extensions install to ~/.ijfw/extensions-{org,user}/<name>/
245
+ * but the platform skill dirs are project-local. So the bundled `installExtension`
246
+ * only deploys to platforms for project-scope installs. For org/user scopes,
247
+ * the skill files become available in any given project by way of THIS function,
248
+ * fired by the session-start hook.
249
+ *
250
+ * Walks both scope dirs, reads each extension's manifest.json, and calls the
251
+ * existing platform-deploy helper to copy skills into the current project's
252
+ * platform skill dirs + inject the AGENTS.md fence. Idempotent: re-running is
253
+ * safe (deploy helpers are atomic + AGENTS.md inject is fenced).
254
+ *
255
+ * Errors per-extension are captured in `failed[]` and do NOT abort the rest.
256
+ */
257
+ // W6.1/C4-M-01: validate extension dir name at the readdir boundary.
258
+ // readdir limits entries to single-segment names (no traversal possible)
259
+ // but a hand-placed dir like `Weird Name With Spaces/` would still flow
260
+ // through to deployExtensionSkillsToPlatforms which would create
261
+ // `ext-Weird Name With Spaces` dirs across every platform.
262
+ // eslint-disable-next-line security/detect-unsafe-regex -- anchored, bounded npm name shape; no nested ambiguous repetition
263
+ const EXTENSION_NAME_PATTERN = /^(@[a-z0-9-]+\/)?[a-z][a-z0-9-]*$/;
264
+
265
+ // W6.2/R5-H-02: scoped extensions live at `<root>/@scope/pkg/` (two-level).
266
+ // Flat extensions live at `<root>/pkg/` (one-level). Enumerate both shapes
267
+ // and yield canonical `[name, extDir]` pairs.
268
+ async function* enumerateExtensions(root, scope, skipped) {
269
+ let entries;
270
+ try {
271
+ entries = await fs.readdir(root, { withFileTypes: true });
272
+ } catch (err) {
273
+ if (err.code === 'ENOENT') return;
274
+ throw err;
275
+ }
276
+ for (const entry of entries) {
277
+ // Non-directory entries are skipped at the top level (incl. symlinks).
278
+ if (!entry.isDirectory()) {
279
+ skipped.push({ scope, name: entry.name, reason: 'not-a-directory' });
280
+ continue;
281
+ }
282
+ if (entry.name.startsWith('@')) {
283
+ // Scoped: recurse one level. Combined name is `@scope/pkg`.
284
+ const scopedRoot = path.join(root, entry.name);
285
+ let inner;
286
+ try {
287
+ inner = await fs.readdir(scopedRoot, { withFileTypes: true });
288
+ } catch {
289
+ skipped.push({ scope, name: entry.name, reason: 'scoped-readdir-failed' });
290
+ continue;
291
+ }
292
+ for (const sub of inner) {
293
+ if (!sub.isDirectory()) {
294
+ skipped.push({ scope, name: `${entry.name}/${sub.name}`, reason: 'not-a-directory' });
295
+ continue;
296
+ }
297
+ const combined = `${entry.name}/${sub.name}`;
298
+ if (!EXTENSION_NAME_PATTERN.test(combined)) {
299
+ skipped.push({ scope, name: combined, reason: 'invalid-extension-name' });
300
+ continue;
301
+ }
302
+ yield [combined, path.join(scopedRoot, sub.name)];
303
+ }
304
+ continue;
305
+ }
306
+ if (!EXTENSION_NAME_PATTERN.test(entry.name)) {
307
+ skipped.push({ scope, name: entry.name, reason: 'invalid-extension-name' });
308
+ continue;
309
+ }
310
+ yield [entry.name, path.join(root, entry.name)];
311
+ }
312
+ }
313
+
314
+ async function cmdDeployLazy({ projectRoot }) {
315
+ const result = { ok: true, command: 'deploy-lazy', result: { deployed: [], failed: [], skipped: [] } };
316
+ const scopeRoots = [
317
+ { scope: 'org', root: path.join(os.homedir(), '.ijfw', 'extensions-org') },
318
+ { scope: 'user', root: path.join(os.homedir(), '.ijfw', 'extensions-user') },
319
+ ];
320
+
321
+ for (const { scope, root } of scopeRoots) {
322
+ try {
323
+ for await (const [name, extDir] of enumerateExtensions(root, scope, result.result.skipped)) {
324
+ await deployOneExtension({ scope, name, extDir, projectRoot, result });
325
+ }
326
+ } catch (err) {
327
+ result.result.failed.push({ scope, name: null, error: `readdir ${root}: ${err.message}` });
328
+ }
329
+ }
330
+ return result;
331
+ }
332
+
333
+ async function deployOneExtension({ scope, name, extDir, projectRoot, result }) {
334
+ const manifestPath = path.join(extDir, 'manifest.json');
335
+ let manifest;
336
+ try {
337
+ const raw = await fs.readFile(manifestPath, 'utf8');
338
+ manifest = JSON.parse(raw);
339
+ } catch (err) {
340
+ result.result.failed.push({ scope, name, error: `manifest read: ${err.message}` });
341
+ return;
342
+ }
343
+ const skills = Array.isArray(manifest.skills) ? manifest.skills : [];
344
+ try {
345
+ // W6.1/R4-H-02: pass the org/user-scope source dir explicitly so the
346
+ // helper reads from `~/.ijfw/extensions-{org,user}/<name>/skills`
347
+ // (or `~/.ijfw/extensions-{org,user}/@scope/pkg/skills` for scoped
348
+ // extensions per W6.2/R5-H-02) instead of the default project path.
349
+ const sourceDir = path.join(extDir, 'skills');
350
+ const dep = await deployExtensionSkillsToPlatforms(name, skills, projectRoot, { sourceDir });
351
+ await deployExtensionToAgentsMd(name, skills, projectRoot);
352
+ result.result.deployed.push({ scope, name, version: manifest.version, deployed: dep.deployed?.length ?? 0 });
353
+ } catch (err) {
354
+ result.result.failed.push({ scope, name, error: `deploy: ${err.message}` });
355
+ }
356
+ }
357
+
358
+ async function cmdActivate({ args, projectRoot }) {
359
+ const name = args && args.trim();
360
+ if (!name) return { ok: false, command: 'activate', error: 'missing extension name; usage: activate <name>' };
361
+ try {
362
+ const { findInstalledManifest, writeActiveExtension } = await import('../active-extension-writer.js');
363
+ const lookup = await findInstalledManifest(name, projectRoot);
364
+ if (!lookup.ok) return { ok: false, command: 'activate', error: lookup.error };
365
+ const result = await writeActiveExtension(lookup.manifest, lookup.scope);
366
+ if (!result.ok) return { ok: false, command: 'activate', error: result.error };
367
+ return { ok: true, command: 'activate', result: { name, scope: lookup.scope, path: result.path } };
368
+ } catch (err) {
369
+ return { ok: false, command: 'activate', error: err.message };
370
+ }
371
+ }
372
+
373
+ async function cmdDeactivate() {
374
+ try {
375
+ const { clearActiveExtension } = await import('../active-extension-writer.js');
376
+ const r = await clearActiveExtension();
377
+ return { ok: r.ok, command: 'deactivate', result: { removed: r.removed } };
378
+ } catch (err) {
379
+ return { ok: false, command: 'deactivate', error: err.message };
380
+ }
381
+ }
382
+
383
+ export async function extensionDispatch({ command, args = '', projectRoot }) {
384
+ const ctx = { command, args: String(args || ''), projectRoot: String(projectRoot || process.cwd()) };
385
+ switch (command) {
386
+ case 'add': return cmdAdd(ctx);
387
+ case 'list': return cmdList(ctx);
388
+ case 'remove': return cmdRemove(ctx);
389
+ case 'audit': return cmdAudit(ctx);
390
+ case 'deploy-lazy': return cmdDeployLazy(ctx);
391
+ case 'keygen': return cmdKeygen(ctx);
392
+ case 'trust': return cmdTrust(ctx);
393
+ case 'untrust': return cmdUntrust(ctx);
394
+ case 'trusted': return cmdTrusted(ctx);
395
+ case 'activate': return cmdActivate(ctx);
396
+ case 'deactivate': return cmdDeactivate(ctx);
397
+ default:
398
+ return {
399
+ ok: false,
400
+ command,
401
+ error: `unknown extension command: ${command}. Supported: add | list | remove | audit | deploy-lazy | keygen | trust | untrust | trusted | activate | deactivate`,
402
+ };
403
+ }
404
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * dispatch/override.js
3
+ *
4
+ * IJFW v1.4.0 / W3/t16 — override colon-namespace dispatch handler.
5
+ *
6
+ * Routes `override:<command>` invocations from colon-syntax dispatch and
7
+ * `ijfw override <command>` invocations from the CLI to the W1
8
+ * override-resolver primitives.
9
+ *
10
+ * Commands:
11
+ * add <preset> [scope] — record + redeploy
12
+ * list — read active-overrides.json for project
13
+ * audit — summarise active overrides + extends chains
14
+ * promote <preset> — copy project-scope override to user scope
15
+ * remove <preset> — remove active override + redeploy base
16
+ * deploy <skill> — force redeploy of one skill
17
+ */
18
+
19
+ import {
20
+ recordActiveOverride,
21
+ removeActiveOverride,
22
+ deployResolvedSkill,
23
+ resolveSkill,
24
+ resolveOverridePaths,
25
+ loadOverrideFile,
26
+ } from '../override-resolver.js';
27
+ import { promises as fs } from 'node:fs';
28
+ import path from 'node:path';
29
+ import os from 'node:os';
30
+ import { OVERRIDE_SCOPES, BUILTIN_PRESETS, PRESET_NAME_PATTERN } from '../override-manifest-schema.js';
31
+
32
+ // All built-in presets target ijfw-critique in v1.4.0. We hardcode the
33
+ // affected-skills list for the add/remove paths; if a preset later targets
34
+ // additional skills, expand this map.
35
+ const PRESET_TARGET_SKILLS = {
36
+ book: ['ijfw-critique'],
37
+ campaign: ['ijfw-critique'],
38
+ academic: ['ijfw-critique'],
39
+ screenplay: ['ijfw-critique'],
40
+ };
41
+
42
+ /**
43
+ * Validate preset NAME shape at the dispatch boundary. Strings like
44
+ * `book; rm -rf $HOME` would otherwise round-trip into receipts and
45
+ * prelude suggestions.
46
+ */
47
+ function validatePresetName(preset) {
48
+ if (typeof preset !== 'string' || !PRESET_NAME_PATTERN.test(preset)) {
49
+ return 'invalid preset name';
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Confirm a preset name refers to a real preset file. Accepts built-ins,
56
+ * `~/.ijfw/overrides/presets/<preset>.md`, and `~/.ijfw/user-overrides/<preset>`.
57
+ */
58
+ async function presetExists(preset) {
59
+ if (BUILTIN_PRESETS.includes(preset)) return true;
60
+ const home = os.homedir();
61
+ const candidates = [
62
+ path.join(home, '.ijfw', 'overrides', 'presets', `${preset}.md`),
63
+ path.join(home, '.ijfw', 'user-overrides', preset),
64
+ ];
65
+ for (const p of candidates) {
66
+ try {
67
+ await fs.stat(p);
68
+ return true;
69
+ } catch {
70
+ /* try next */
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+
76
+ function nowIso() {
77
+ return new Date().toISOString();
78
+ }
79
+
80
+ async function readActiveOverrides(projectRoot) {
81
+ const file = path.join(os.homedir(), '.ijfw/state/active-overrides.json');
82
+ try {
83
+ const raw = await fs.readFile(file, 'utf8');
84
+ const json = JSON.parse(raw);
85
+ return json?.projects?.[projectRoot]?.active_overrides ?? [];
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ async function cmdAdd({ args, projectRoot }) {
92
+ const [preset, rawScope] = args.split(/\s+/).filter(Boolean);
93
+ if (!preset) return { ok: false, command: 'add', error: 'missing preset name' };
94
+ const shapeErr = validatePresetName(preset);
95
+ if (shapeErr) return { ok: false, command: 'add', error: shapeErr };
96
+ if (!(await presetExists(preset))) {
97
+ return { ok: false, command: 'add', error: `unknown preset: ${preset}` };
98
+ }
99
+ const scope = OVERRIDE_SCOPES.includes(rawScope) ? rawScope : 'project';
100
+ const override = { preset, scope, applied_at: nowIso() };
101
+ try {
102
+ await recordActiveOverride(projectRoot, override);
103
+ } catch (err) {
104
+ return { ok: false, command: 'add', error: `recordActiveOverride failed: ${err.message}` };
105
+ }
106
+ const affected = PRESET_TARGET_SKILLS[preset] ?? ['ijfw-critique'];
107
+ const deploys = [];
108
+ for (const skill of affected) {
109
+ try {
110
+ deploys.push(await deployResolvedSkill(skill, projectRoot, {}));
111
+ } catch (err) {
112
+ deploys.push({ skill, deployed: [], failed: [{ platform: 'all', error: err.message }] });
113
+ }
114
+ }
115
+ return { ok: true, command: 'add', result: { preset, scope, affected_skills: affected, deploys } };
116
+ }
117
+
118
+ async function cmdRemove({ args, projectRoot }) {
119
+ const [preset] = args.split(/\s+/).filter(Boolean);
120
+ if (!preset) return { ok: false, command: 'remove', error: 'missing preset name' };
121
+ const shapeErr = validatePresetName(preset);
122
+ if (shapeErr) return { ok: false, command: 'remove', error: shapeErr };
123
+ try {
124
+ await removeActiveOverride(projectRoot, preset);
125
+ } catch (err) {
126
+ return { ok: false, command: 'remove', error: `removeActiveOverride failed: ${err.message}` };
127
+ }
128
+ const affected = PRESET_TARGET_SKILLS[preset] ?? ['ijfw-critique'];
129
+ const deploys = [];
130
+ for (const skill of affected) {
131
+ try {
132
+ deploys.push(await deployResolvedSkill(skill, projectRoot, {}));
133
+ } catch (err) {
134
+ deploys.push({ skill, deployed: [], failed: [{ platform: 'all', error: err.message }] });
135
+ }
136
+ }
137
+ return { ok: true, command: 'remove', result: { preset, affected_skills: affected, deploys } };
138
+ }
139
+
140
+ async function cmdList({ projectRoot }) {
141
+ const active = await readActiveOverrides(projectRoot);
142
+ return { ok: true, command: 'list', result: { active, count: active.length } };
143
+ }
144
+
145
+ async function cmdAudit({ projectRoot }) {
146
+ const active = await readActiveOverrides(projectRoot);
147
+ const items = [];
148
+ for (const entry of active) {
149
+ const affected = PRESET_TARGET_SKILLS[entry.preset] ?? ['ijfw-critique'];
150
+ for (const skill of affected) {
151
+ const paths = resolveOverridePaths(skill, projectRoot);
152
+ let sections = 0;
153
+ let extendsChain = [];
154
+ for (const p of paths) {
155
+ const file = await loadOverrideFile(p).catch(() => null);
156
+ if (!file) continue;
157
+ if (Array.isArray(file.manifest?.extends)) extendsChain = file.manifest.extends;
158
+ const matches = file.body?.match(/<!--\s*ijfw-override:/g);
159
+ sections += matches ? matches.length : 0;
160
+ }
161
+ items.push({ preset: entry.preset, scope: entry.scope, skill, sections, extends: extendsChain });
162
+ }
163
+ }
164
+ return { ok: true, command: 'audit', result: { items } };
165
+ }
166
+
167
+ async function cmdPromote({ args, projectRoot }) {
168
+ const [preset] = args.split(/\s+/).filter(Boolean);
169
+ if (!preset) return { ok: false, command: 'promote', error: 'missing preset name' };
170
+ const shapeErr = validatePresetName(preset);
171
+ if (shapeErr) return { ok: false, command: 'promote', error: shapeErr };
172
+ if (!(await presetExists(preset))) {
173
+ return { ok: false, command: 'promote', error: `unknown preset: ${preset}` };
174
+ }
175
+ const affected = PRESET_TARGET_SKILLS[preset] ?? ['ijfw-critique'];
176
+ const promoted = [];
177
+ for (const skill of affected) {
178
+ const src = path.join(projectRoot, '.ijfw/skill-overrides', skill, 'override.md');
179
+ const dst = path.join(os.homedir(), '.ijfw/user-overrides', skill, 'override.md');
180
+ try {
181
+ const data = await fs.readFile(src, 'utf8');
182
+ await fs.mkdir(path.dirname(dst), { recursive: true });
183
+ await fs.writeFile(dst + '.tmp', data, 'utf8');
184
+ await fs.rename(dst + '.tmp', dst);
185
+ promoted.push({ skill, src, dst });
186
+ } catch (err) {
187
+ promoted.push({ skill, src, dst, error: err.message });
188
+ }
189
+ }
190
+ return { ok: true, command: 'promote', result: { preset, promoted } };
191
+ }
192
+
193
+ async function cmdDeploy({ args, projectRoot }) {
194
+ const [skill] = args.split(/\s+/).filter(Boolean);
195
+ if (!skill) return { ok: false, command: 'deploy', error: 'missing skill name' };
196
+ try {
197
+ const merged = await resolveSkill(skill, projectRoot);
198
+ const r = await deployResolvedSkill(skill, projectRoot, {});
199
+ return { ok: true, command: 'deploy', result: { skill, body_length: merged.length, ...r } };
200
+ } catch (err) {
201
+ return { ok: false, command: 'deploy', error: err.message };
202
+ }
203
+ }
204
+
205
+ export async function overrideDispatch({ command, args = '', projectRoot }) {
206
+ const ctx = { command, args: String(args || ''), projectRoot: String(projectRoot || process.cwd()) };
207
+ switch (command) {
208
+ case 'add': return cmdAdd(ctx);
209
+ case 'remove': return cmdRemove(ctx);
210
+ case 'list': return cmdList(ctx);
211
+ case 'audit': return cmdAudit(ctx);
212
+ case 'promote': return cmdPromote(ctx);
213
+ case 'deploy': return cmdDeploy(ctx);
214
+ default:
215
+ return {
216
+ ok: false,
217
+ command,
218
+ error: `unknown override command: ${command}. Supported: add | list | audit | promote | remove | deploy`,
219
+ };
220
+ }
221
+ }
@@ -6,6 +6,7 @@
6
6
  //
7
7
  // Pure + synchronous. ESM. Zero deps. Filesystem only touched by caller.
8
8
 
9
+ // eslint-disable-next-line security/detect-unsafe-regex -- plan markdown is bounded human-authored text; pattern is line-anchored and token-sized.
9
10
  const WAVE_HEADER = /^###\s+Wave\s+([0-9]+[A-Z])(?:-([A-Za-z0-9_+]+))?\b/;
10
11
  // Bullet sub-wave form: `- **11A-mcp**: description`. Parsed as a child of
11
12
  // the most recently seen Wave header.
@@ -106,7 +106,7 @@ log(`start: host=${opts.host}, reason=${opts.reason}, project=${opts.projectRoot
106
106
  // ---------------------------------------------------------------------------
107
107
  //
108
108
  // D1 (Wave 2 Agent E) lands `mcp-server/src/memory/tier-promotion.js`
109
- // with the deterministic promotion rules from D-PILLAR-SPEC.md §1. We
109
+ // with the deterministic promotion rules from D-PILLAR-SPEC.md section 1. We
110
110
  // import-by-URL so a missing module is a soft skip rather than a top-
111
111
  // level ESM resolve failure.
112
112
  //
@@ -210,7 +210,7 @@ async function runTierPromotion() {
210
210
  }
211
211
  // GA real fix-wave F4: Working->Procedural was missing from the
212
212
  // dream-cycle dispatch. The function exists in tier-promotion.js
213
- // (per D-PILLAR-SPEC §1) but the runner only fired We + Es,
213
+ // (per D-PILLAR-SPEC section 1) but the runner only fired We + Es,
214
214
  // leaving the Procedural tier orphaned. Wire it here.
215
215
  //
216
216
  // Source signal per spec: TaskUpdate completed events with duration
@@ -297,7 +297,7 @@ async function runTierPromotion() {
297
297
  // F4 helper: discover TaskUpdate completed events for Procedural promotion.
298
298
  // ---------------------------------------------------------------------------
299
299
  //
300
- // D-PILLAR-SPEC §1 Working->Procedural promotion fires from TaskUpdate
300
+ // D-PILLAR-SPEC section 1 Working->Procedural promotion fires from TaskUpdate
301
301
  // completed events with duration >= 5min and matching git commit window.
302
302
  // The alpha runner does not yet have a dedicated TaskUpdate event source
303
303
  // in the working memory ledger (events arrive via observation bodies but