@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
@@ -1,6 +1,6 @@
1
1
  // IJFW v1.3.0 -- shared tokenization + Jaccard similarity for tier-promotion.
2
2
  //
3
- // Source authority: .planning/1.3.0/D-PILLAR-SPEC.md §1 (Episodic ->
3
+ // Source authority: .planning/1.3.0/D-PILLAR-SPEC.md section 1 (Episodic ->
4
4
  // Semantic supersession trigger B uses token-set Jaccard > 0.7).
5
5
  //
6
6
  // Zero-deps, deterministic. Lowercases, strips non-word chars, drops
@@ -0,0 +1,372 @@
1
+ /**
2
+ * memory-feedback.js
3
+ *
4
+ * IJFW v1.4.0 W7/B3 -- Memory Feedback Auto-Routing
5
+ *
6
+ * Reads .ijfw/memory/gate-receipts/ under a project root, detects repeated
7
+ * FAIL/FLAG patterns on the same affected_artifacts[].type, and returns
8
+ * one-liner markdown suggestion strings for surface in ijfw_memory_prelude.
9
+ *
10
+ * All entry points are best-effort: any error returns empty output without
11
+ * throwing. No PII leakage: suggestion text contains only artifact TYPE and
12
+ * counts, never IDs or full receipt content.
13
+ */
14
+
15
+ import { readdir, readFile, lstat } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+
18
+ const RECEIPTS_SUBPATH = join('.ijfw', 'memory', 'gate-receipts');
19
+ const MAX_FILE_BYTES = 64 * 1024;
20
+ const FAIL_VERDICTS = new Set(['FAIL', 'FLAG']);
21
+
22
+ /**
23
+ * readRecentReceipts(projectRoot, limit)
24
+ *
25
+ * Reads .ijfw/memory/gate-receipts/*.json under projectRoot.
26
+ * Sorts by mtime descending, takes the first `limit` entries.
27
+ * Parses each JSON safely; skips malformed or structurally invalid files.
28
+ * Returns an array of parsed gate-result objects.
29
+ *
30
+ * @param {string} projectRoot
31
+ * @param {number} [limit=50]
32
+ * @returns {Promise<object[]>}
33
+ */
34
+ export async function readRecentReceipts(projectRoot, limit = 50) {
35
+ const receiptsDir = join(projectRoot, RECEIPTS_SUBPATH);
36
+
37
+ let entries;
38
+ try {
39
+ entries = await readdir(receiptsDir);
40
+ } catch {
41
+ return [];
42
+ }
43
+
44
+ const jsonFiles = entries.filter((e) => e.endsWith('.json'));
45
+ if (jsonFiles.length === 0) return [];
46
+
47
+ const withMtime = [];
48
+ for (const name of jsonFiles) {
49
+ const filePath = join(receiptsDir, name);
50
+ try {
51
+ // W7.1/B3-H-01 + B3-M-01: lstat (not stat) so symlinks are detected,
52
+ // pre-check size BEFORE readFile so a multi-GB attacker file cannot
53
+ // OOM the prelude on read.
54
+ const info = await lstat(filePath);
55
+ if (info.isSymbolicLink()) continue; // reject symlinks
56
+ if (!info.isFile()) continue; // only regular files
57
+ if (info.size > MAX_FILE_BYTES) continue; // size cap pre-read
58
+ withMtime.push({ filePath, mtime: info.mtimeMs });
59
+ } catch {
60
+ // unreadable entry -- skip
61
+ }
62
+ }
63
+
64
+ withMtime.sort((a, b) => b.mtime - a.mtime);
65
+ const candidates = withMtime.slice(0, limit);
66
+
67
+ const results = [];
68
+ for (const { filePath } of candidates) {
69
+ try {
70
+ const raw = await readFile(filePath, { encoding: 'utf8', flag: 'r' });
71
+ // Already size-bounded by pre-check above; this is belt-and-braces.
72
+ const bounded = raw.length > MAX_FILE_BYTES ? raw.slice(0, MAX_FILE_BYTES) : raw;
73
+ const parsed = JSON.parse(bounded);
74
+ if (
75
+ parsed &&
76
+ typeof parsed === 'object' &&
77
+ !Array.isArray(parsed) &&
78
+ typeof parsed.verdict === 'string' &&
79
+ Array.isArray(parsed.affected_artifacts)
80
+ ) {
81
+ results.push(parsed);
82
+ }
83
+ } catch {
84
+ // malformed JSON or read error -- skip
85
+ }
86
+ }
87
+
88
+ return results;
89
+ }
90
+
91
+ /**
92
+ * detectRepeatedFail(receipts, opts)
93
+ *
94
+ * Examines the last `opts.window` (default 10) receipts for repeated FAIL/FLAG
95
+ * on the same affected_artifacts[].type value.
96
+ *
97
+ * @param {object[]} receipts
98
+ * @param {{ threshold?: number, window?: number }} [opts]
99
+ * @returns {Array<{ kind: string, artifact_type: string, count: number, threshold: number, sample: string[] }>}
100
+ */
101
+ function detectRepeatedFail(receipts, opts = {}) {
102
+ const threshold = typeof opts.threshold === 'number' ? opts.threshold : 3;
103
+ const window = typeof opts.window === 'number' ? opts.window : 10;
104
+
105
+ if (!Array.isArray(receipts) || receipts.length === 0) return [];
106
+
107
+ const windowReceipts = receipts.slice(0, window);
108
+
109
+ const countsByType = new Map();
110
+ const samplesByType = new Map();
111
+
112
+ for (const receipt of windowReceipts) {
113
+ if (!receipt || typeof receipt !== 'object') continue;
114
+ if (!FAIL_VERDICTS.has(receipt.verdict)) continue;
115
+ if (!Array.isArray(receipt.affected_artifacts)) continue;
116
+
117
+ const seenTypes = new Set();
118
+ for (const artifact of receipt.affected_artifacts) {
119
+ if (!artifact || typeof artifact !== 'object') continue;
120
+ if (typeof artifact.type !== 'string' || artifact.type.length === 0) continue;
121
+
122
+ const t = artifact.type;
123
+ if (seenTypes.has(t)) continue;
124
+ seenTypes.add(t);
125
+
126
+ countsByType.set(t, (countsByType.get(t) ?? 0) + 1);
127
+
128
+ if (!samplesByType.has(t)) samplesByType.set(t, []);
129
+ const gateId =
130
+ typeof receipt.gate_id === 'string' ? receipt.gate_id : 'unknown';
131
+ samplesByType.get(t).push(gateId);
132
+ }
133
+ }
134
+
135
+ const patterns = [];
136
+ for (const [artifact_type, count] of countsByType.entries()) {
137
+ if (count >= threshold) {
138
+ patterns.push({
139
+ kind: 'repeated-fail-on-same-artifact',
140
+ artifact_type,
141
+ count,
142
+ threshold,
143
+ sample: samplesByType.get(artifact_type) ?? [],
144
+ });
145
+ }
146
+ }
147
+
148
+ return patterns;
149
+ }
150
+
151
+ /**
152
+ * detectRisingFailRate(receipts, opts)
153
+ *
154
+ * Compares the fail rate in the most recent `window` receipts to the `window`
155
+ * receipts before that. If the rate rose by >= minRise (absolute), emits a
156
+ * rising-fail-rate pattern.
157
+ *
158
+ * @param {object[]} receipts
159
+ * @param {{ window?: number, minRise?: number }} [opts]
160
+ * @returns {Array<{ kind: string, from_rate: number, to_rate: number, window: number, suggestion: string }>}
161
+ */
162
+ export function detectRisingFailRate(receipts, opts = {}) {
163
+ try {
164
+ const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 20;
165
+ const minRise = typeof opts.minRise === 'number' ? opts.minRise : 0.2;
166
+
167
+ if (!Array.isArray(receipts) || receipts.length < 2) return [];
168
+
169
+ const recent = receipts.slice(0, window);
170
+ const prior = receipts.slice(window, window * 2);
171
+
172
+ if (prior.length === 0) return [];
173
+
174
+ const failRate = (arr) => {
175
+ const valid = arr.filter((r) => r && typeof r === 'object' && typeof r.verdict === 'string');
176
+ if (valid.length === 0) return 0;
177
+ return valid.filter((r) => FAIL_VERDICTS.has(r.verdict)).length / valid.length;
178
+ };
179
+
180
+ const fromRate = failRate(prior);
181
+ const toRate = failRate(recent);
182
+
183
+ if (toRate - fromRate < minRise) return [];
184
+
185
+ const fromPct = Math.round(fromRate * 100);
186
+ const toPct = Math.round(toRate * 100);
187
+
188
+ return [{
189
+ kind: 'rising-fail-rate',
190
+ from_rate: fromRate,
191
+ to_rate: toRate,
192
+ window,
193
+ suggestion: `gate fail rate rose from ${fromPct}% to ${toPct}% in the last ${window} receipts — consider rolling back the most recent changes`,
194
+ }];
195
+ } catch {
196
+ return [];
197
+ }
198
+ }
199
+
200
+ /**
201
+ * detectCrossSkillCorrelation(receipts, opts)
202
+ *
203
+ * Looks at the last `window` receipts. If >= minDistinctGates distinct gate_id
204
+ * prefixes (split on first `-` or `:`) have a FAIL/FLAG verdict, emits a
205
+ * cross-skill-correlation pattern.
206
+ *
207
+ * @param {object[]} receipts
208
+ * @param {{ window?: number, minDistinctGates?: number }} [opts]
209
+ * @returns {Array<{ kind: string, distinct_gates: number, window: number, suggestion: string }>}
210
+ */
211
+ export function detectCrossSkillCorrelation(receipts, opts = {}) {
212
+ try {
213
+ const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 10;
214
+ const minDistinctGates = typeof opts.minDistinctGates === 'number' ? opts.minDistinctGates : 3;
215
+
216
+ if (!Array.isArray(receipts) || receipts.length === 0) return [];
217
+
218
+ const windowReceipts = receipts.slice(0, window);
219
+ const prefixes = new Set();
220
+
221
+ for (const receipt of windowReceipts) {
222
+ if (!receipt || typeof receipt !== 'object') continue;
223
+ if (!FAIL_VERDICTS.has(receipt.verdict)) continue;
224
+ if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
225
+
226
+ // Take the prefix before the first `-` or `:`
227
+ const prefix = receipt.gate_id.split(/[-:]/)[0];
228
+ if (prefix) prefixes.add(prefix);
229
+ }
230
+
231
+ if (prefixes.size < minDistinctGates) return [];
232
+
233
+ return [{
234
+ kind: 'cross-skill-correlation',
235
+ distinct_gates: prefixes.size,
236
+ window,
237
+ suggestion: `${prefixes.size} different gates flagged in the last ${window} receipts — review project state, not individual artifacts`,
238
+ }];
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+
244
+ /**
245
+ * detectRegression(receipts, opts)
246
+ *
247
+ * For each unique (gate_id, artifact_type) key in receipts: if the most recent
248
+ * `failWindow` receipts were all FAIL/FLAG but the `passWindow` receipts before
249
+ * that were all PASS, emits a regression pattern.
250
+ *
251
+ * artifact_type is the TYPE field (e.g. 'chapter'), never the ID.
252
+ *
253
+ * @param {object[]} receipts
254
+ * @param {{ passWindow?: number, failWindow?: number }} [opts]
255
+ * @returns {Array<{ kind: string, gate_id: string, artifact_type: string, suggestion: string }>}
256
+ */
257
+ export function detectRegression(receipts, opts = {}) {
258
+ try {
259
+ const passWindow = typeof opts.passWindow === 'number' && opts.passWindow > 0 ? opts.passWindow : 5;
260
+ const failWindow = typeof opts.failWindow === 'number' && opts.failWindow > 0 ? opts.failWindow : 2;
261
+
262
+ if (!Array.isArray(receipts) || receipts.length === 0) return [];
263
+
264
+ // Build per-(gate_id, artifact_type) ordered lists (receipts[0] = most recent).
265
+ // receipts are assumed newest-first (as returned by readRecentReceipts).
266
+ const streams = new Map(); // key -> [receipt, ...]
267
+
268
+ for (const receipt of receipts) {
269
+ if (!receipt || typeof receipt !== 'object') continue;
270
+ if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
271
+ if (!Array.isArray(receipt.affected_artifacts)) continue;
272
+
273
+ const seenTypes = new Set();
274
+ for (const artifact of receipt.affected_artifacts) {
275
+ if (!artifact || typeof artifact !== 'object') continue;
276
+ if (typeof artifact.type !== 'string' || artifact.type.length === 0) continue;
277
+
278
+ const t = artifact.type;
279
+ if (seenTypes.has(t)) continue;
280
+ seenTypes.add(t);
281
+
282
+ const key = `${receipt.gate_id}\x00${t}`;
283
+ if (!streams.has(key)) streams.set(key, []);
284
+ streams.get(key).push(receipt);
285
+ }
286
+ }
287
+
288
+ const patterns = [];
289
+
290
+ for (const [key, stream] of streams.entries()) {
291
+ if (stream.length < failWindow + passWindow) continue;
292
+
293
+ const recentSlice = stream.slice(0, failWindow);
294
+ const priorSlice = stream.slice(failWindow, failWindow + passWindow);
295
+
296
+ const allRecentFail = recentSlice.every((r) => FAIL_VERDICTS.has(r.verdict));
297
+ const allPriorPass = priorSlice.every((r) => r.verdict === 'PASS');
298
+
299
+ if (!allRecentFail || !allPriorPass) continue;
300
+
301
+ const [gate_id, artifact_type] = key.split('\x00');
302
+ patterns.push({
303
+ kind: 'regression',
304
+ gate_id,
305
+ artifact_type,
306
+ suggestion: `gate ${gate_id} on ${artifact_type} was passing last ${passWindow} runs; failing now — likely regression`,
307
+ });
308
+ }
309
+
310
+ return patterns;
311
+ } catch {
312
+ return [];
313
+ }
314
+ }
315
+
316
+ /**
317
+ * detectPatterns(receipts, opts)
318
+ *
319
+ * Dispatcher: runs all four detectors and returns the union in deterministic
320
+ * order: repeated-fail-on-same-artifact, rising-fail-rate, cross-skill-correlation,
321
+ * regression.
322
+ *
323
+ * @param {object[]} receipts
324
+ * @param {{ threshold?: number, window?: number }} [opts]
325
+ * @returns {object[]}
326
+ */
327
+ export function detectPatterns(receipts, opts = {}) {
328
+ if (!Array.isArray(receipts)) return [];
329
+ return [
330
+ ...detectRepeatedFail(receipts, opts),
331
+ ...detectRisingFailRate(receipts, opts),
332
+ ...detectCrossSkillCorrelation(receipts, opts),
333
+ ...detectRegression(receipts, opts),
334
+ ];
335
+ }
336
+
337
+ /**
338
+ * getFeedbackSuggestions(projectRoot, opts)
339
+ *
340
+ * Reads gate receipts, detects patterns, and returns an array of one-liner
341
+ * markdown bullet bodies (caller prepends "- ").
342
+ *
343
+ * Text format: "Pattern detected: <count>/<window> recent gates flagged on
344
+ * <artifact_type> -- consider reviewing <artifact_type> scope"
345
+ *
346
+ * Never throws; returns [] on any error.
347
+ *
348
+ * @param {string} projectRoot
349
+ * @param {{ threshold?: number, window?: number, limit?: number }} [opts]
350
+ * @returns {Promise<string[]>}
351
+ */
352
+ export async function getFeedbackSuggestions(projectRoot, opts = {}) {
353
+ try {
354
+ // W7.1: bound caller-supplied opts to defensible minimums so misconfigured
355
+ // callers cannot disable the feature or pass negative values.
356
+ const limit = Math.max(1, typeof opts.limit === 'number' ? opts.limit : 50);
357
+ const window = Math.max(1, typeof opts.window === 'number' ? opts.window : 10);
358
+ const threshold = Math.max(1, typeof opts.threshold === 'number' ? opts.threshold : 3);
359
+
360
+ const receipts = await readRecentReceipts(projectRoot, limit);
361
+ const patterns = detectPatterns(receipts, { threshold, window });
362
+
363
+ return patterns.map((p) => {
364
+ if (p.kind === 'repeated-fail-on-same-artifact') {
365
+ return `Pattern detected: ${p.count}/${window} recent gates flagged on ${p.artifact_type} -- consider reviewing ${p.artifact_type} scope`;
366
+ }
367
+ return `Pattern detected: ${p.suggestion}`;
368
+ });
369
+ } catch {
370
+ return [];
371
+ }
372
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * override-manifest-schema.js
3
+ *
4
+ * IJFW v1.4.0 Wave 0 / t2 — Override Manifest Schema
5
+ *
6
+ * Overrides are section-fenced markdown patches applied to base IJFW skills
7
+ * at DEPLOYMENT time (never runtime). The resolver merges base + tier chain
8
+ * and writes the result to every platform skill dir.
9
+ *
10
+ * File format (.ijfw/skill-overrides/project/<skill>/override.md):
11
+ * ---
12
+ * extends: [book, academic-style]
13
+ * scope: project
14
+ * skill: ijfw-critique
15
+ * ---
16
+ *
17
+ * <!-- ijfw-override: rubric -->
18
+ * ... section body ...
19
+ * <!-- ijfw-override-end -->
20
+ *
21
+ * 4-tier resolution (last-write-wins, project has final say per R4):
22
+ * 1. base presets ~/.ijfw/overrides/presets/
23
+ * 2. user ~/.ijfw/user-overrides/
24
+ * 3. org ~/.ijfw/org-overrides/
25
+ * 4. project .ijfw/skill-overrides/
26
+ *
27
+ * `extends:` chain depth-limited to 5; circular chains rejected.
28
+ *
29
+ * Hand-rolled validator. Zero new prod deps.
30
+ */
31
+
32
+ export const SCHEMA_VERSION = '1.0';
33
+
34
+ /**
35
+ * Ordered list. Earlier scopes are overridden by later ones (last-write-wins).
36
+ * Project always has final precedence over org > user > base presets.
37
+ */
38
+ export const OVERRIDE_SCOPES = Object.freeze(['base', 'user', 'org', 'project']);
39
+
40
+ export const BUILTIN_PRESETS = Object.freeze([
41
+ 'book',
42
+ 'campaign',
43
+ 'academic',
44
+ 'screenplay',
45
+ ]);
46
+
47
+ export const MAX_EXTENDS_DEPTH = 5;
48
+
49
+ export const SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
50
+ export const PRESET_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
51
+
52
+ /** Section fence markers used by the resolver. */
53
+ export const OVERRIDE_OPEN_FENCE = /<!--\s*ijfw-override:\s*([a-z][a-z0-9-]*)\s*-->/g;
54
+ export const OVERRIDE_CLOSE_FENCE = /<!--\s*ijfw-override-end\s*-->/;
55
+
56
+ function isString(v) {
57
+ return typeof v === 'string';
58
+ }
59
+
60
+ function isNonNullObject(v) {
61
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
62
+ }
63
+
64
+ /**
65
+ * validateOverrideManifest(obj) — validates the YAML frontmatter portion of
66
+ * an override file (parsed into an object). The body (section fences) is
67
+ * validated by the resolver, not here.
68
+ *
69
+ * @param {unknown} obj
70
+ * @returns {{valid: boolean, errors: string[]}}
71
+ */
72
+ export function validateOverrideManifest(obj) {
73
+ const errors = [];
74
+
75
+ if (!isNonNullObject(obj)) {
76
+ return { valid: false, errors: ['root: must be an object'] };
77
+ }
78
+
79
+ // scope (required)
80
+ if (!OVERRIDE_SCOPES.includes(obj.scope)) {
81
+ errors.push(
82
+ `scope: must be one of ${OVERRIDE_SCOPES.join('|')}, got ${JSON.stringify(obj.scope)}`,
83
+ );
84
+ }
85
+
86
+ // skill (required)
87
+ if (!isString(obj.skill) || !SKILL_NAME_PATTERN.test(obj.skill)) {
88
+ errors.push(
89
+ `skill: must be a kebab-case identifier matching ${SKILL_NAME_PATTERN}`,
90
+ );
91
+ }
92
+
93
+ // extends (optional, array of preset names)
94
+ if (obj.extends !== undefined) {
95
+ if (!Array.isArray(obj.extends)) {
96
+ errors.push('extends: must be an array of preset name strings (or omitted)');
97
+ } else {
98
+ obj.extends.forEach((p, i) => {
99
+ if (!isString(p) || !PRESET_NAME_PATTERN.test(p)) {
100
+ errors.push(
101
+ `extends[${i}]: must be a kebab-case preset name, got ${JSON.stringify(p)}`,
102
+ );
103
+ }
104
+ });
105
+ // Self-reference check (cycle detection across files is the resolver's job;
106
+ // this just blocks the trivially obvious case).
107
+ if (isString(obj.skill) && obj.extends.includes(obj.skill)) {
108
+ errors.push(`extends: must not include own skill "${obj.skill}" (circular)`);
109
+ }
110
+ }
111
+ }
112
+
113
+ return { valid: errors.length === 0, errors };
114
+ }
115
+
116
+ /**
117
+ * detectCircularExtends(graph, start, seen) — detects cycles in the extends
118
+ * graph. Resolver passes a Map<presetName, manifest>; we walk recursively.
119
+ *
120
+ * Exported for use by the resolver (t6) and by tests (t18).
121
+ *
122
+ * @param {Map<string, {extends?: string[]}>} graph
123
+ * @param {string} start
124
+ * @param {Set<string>} [seen]
125
+ * @param {number} [depth]
126
+ * @returns {{circular: boolean, chain: string[]}}
127
+ */
128
+ export function detectCircularExtends(graph, start, seen = new Set(), depth = 0) {
129
+ if (depth > MAX_EXTENDS_DEPTH) {
130
+ return { circular: true, chain: [...seen, start, '...(depth-exceeded)'] };
131
+ }
132
+ if (seen.has(start)) {
133
+ return { circular: true, chain: [...seen, start] };
134
+ }
135
+ const m = graph.get(start);
136
+ if (!m || !Array.isArray(m.extends) || m.extends.length === 0) {
137
+ return { circular: false, chain: [...seen, start] };
138
+ }
139
+ const next = new Set(seen);
140
+ next.add(start);
141
+ for (const parent of m.extends) {
142
+ const r = detectCircularExtends(graph, parent, next, depth + 1);
143
+ if (r.circular) return r;
144
+ }
145
+ return { circular: false, chain: [...next, start] };
146
+ }