@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
package/src/memory/tokenize.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|