@ijfw/memory-server 1.4.3 → 1.4.4
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/package.json +1 -1
- package/src/cross-orchestrator.js +164 -2
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +12 -1
- package/src/dashboard-server.js +79 -0
- package/src/dispatch/extension.js +3 -1
- package/src/dispatch/wave-cli.js +128 -0
- package/src/orchestrator/review.js +101 -0
- package/src/orchestrator/status-protocol.js +168 -0
- package/src/orchestrator/verification-gate.js +97 -0
- package/src/orchestrator/wave-state.js +255 -0
- package/src/swarm-config.js +32 -8
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wave-state.js — Atomic STATE.md read/write for orchestrator wave tracking.
|
|
3
|
+
*
|
|
4
|
+
* STATE.md lives at <projectRoot>/.ijfw/wave-<waveId>/STATE.md.
|
|
5
|
+
* Format: YAML frontmatter (---delimited) + markdown body.
|
|
6
|
+
* Writes are atomic: withFsLock + write-to-tmp + rename.
|
|
7
|
+
*
|
|
8
|
+
* Landed in W10-A0 (v1.4.4 prelude). checkpointWave is a stub;
|
|
9
|
+
* N4 (W10-A2) will flesh out the blackboard→STATE rollup logic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdir, readFile, writeFile, rename, appendFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { withFsLock } from '../fs-lock.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Internal YAML helpers — flat subset only (string/number/boolean/string[])
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a YAML frontmatter block (lines between the two `---` delimiters).
|
|
22
|
+
* Supports: scalar string/number/boolean values, arrays of strings (block style).
|
|
23
|
+
* Rejects nested maps with a clear error.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} block Lines between the two `---` markers (no delimiters)
|
|
26
|
+
* @returns {object}
|
|
27
|
+
*/
|
|
28
|
+
function parseYaml(block) {
|
|
29
|
+
const result = {};
|
|
30
|
+
const lines = block.split('\n');
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (i < lines.length) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) { i++; continue; }
|
|
35
|
+
|
|
36
|
+
const colonIdx = line.indexOf(':');
|
|
37
|
+
if (colonIdx === -1) { i++; continue; }
|
|
38
|
+
|
|
39
|
+
const key = line.slice(0, colonIdx).trim();
|
|
40
|
+
const rest = line.slice(colonIdx + 1).trim();
|
|
41
|
+
|
|
42
|
+
if (!key) { i++; continue; }
|
|
43
|
+
|
|
44
|
+
// Detect nested map: next non-empty lines are indented key: value pairs
|
|
45
|
+
if (rest === '') {
|
|
46
|
+
// Could be array or nested map — peek ahead
|
|
47
|
+
const nextLines = [];
|
|
48
|
+
let j = i + 1;
|
|
49
|
+
while (j < lines.length && lines[j].trim() !== '' && !lines[j].match(/^\S.*:/)) {
|
|
50
|
+
nextLines.push(lines[j]);
|
|
51
|
+
j++;
|
|
52
|
+
}
|
|
53
|
+
if (nextLines.length > 0 && nextLines[0].trimStart().startsWith('- ')) {
|
|
54
|
+
// Block sequence
|
|
55
|
+
result[key] = nextLines.map((l) => l.replace(/^\s*-\s?/, ''));
|
|
56
|
+
i = j;
|
|
57
|
+
continue;
|
|
58
|
+
} else if (nextLines.length > 0) {
|
|
59
|
+
throw new Error(`wave-state: nested YAML maps are not supported (key: "${key}")`);
|
|
60
|
+
}
|
|
61
|
+
result[key] = null;
|
|
62
|
+
i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Inline array: [a, b, c]
|
|
67
|
+
if (rest.startsWith('[')) {
|
|
68
|
+
const inner = rest.replace(/^\[/, '').replace(/\]$/, '');
|
|
69
|
+
result[key] = inner ? inner.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')) : [];
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Scalar
|
|
75
|
+
if (rest === 'true') { result[key] = true; }
|
|
76
|
+
else if (rest === 'false') { result[key] = false; }
|
|
77
|
+
else if (rest === 'null' || rest === '~') { result[key] = null; }
|
|
78
|
+
else if (!Number.isNaN(Number(rest)) && rest !== '') { result[key] = Number(rest); }
|
|
79
|
+
else { result[key] = rest.replace(/^['"]|['"]$/g, ''); }
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Emit a YAML frontmatter block for flat string/number/boolean/string[] values.
|
|
87
|
+
* @param {object} obj
|
|
88
|
+
* @returns {string} (no leading/trailing `---`)
|
|
89
|
+
*/
|
|
90
|
+
function emitYaml(obj) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
93
|
+
if (val === null || val === undefined) {
|
|
94
|
+
lines.push(`${key}: null`);
|
|
95
|
+
} else if (Array.isArray(val)) {
|
|
96
|
+
if (val.length === 0) {
|
|
97
|
+
lines.push(`${key}: []`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(`${key}:`);
|
|
100
|
+
for (const item of val) lines.push(` - ${item}`);
|
|
101
|
+
}
|
|
102
|
+
} else if (typeof val === 'boolean') {
|
|
103
|
+
lines.push(`${key}: ${val}`);
|
|
104
|
+
} else if (typeof val === 'number') {
|
|
105
|
+
lines.push(`${key}: ${val}`);
|
|
106
|
+
} else if (typeof val === 'object') {
|
|
107
|
+
throw new Error(`wave-state: nested YAML objects are not supported (key: "${key}")`);
|
|
108
|
+
} else {
|
|
109
|
+
lines.push(`${key}: ${val}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Path helpers
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function wavePaths(waveId, projectRoot) {
|
|
120
|
+
const dir = join(projectRoot, '.ijfw', `wave-${waveId}`);
|
|
121
|
+
return {
|
|
122
|
+
dir,
|
|
123
|
+
state: join(dir, 'STATE.md'),
|
|
124
|
+
summary: join(dir, 'SUMMARY.md'),
|
|
125
|
+
lock: join(dir, '.STATE.md.lock'),
|
|
126
|
+
summaryLock: join(dir, '.SUMMARY.md.lock'),
|
|
127
|
+
tmp: join(dir, '.STATE.md.tmp'),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Public API
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read a wave's STATE.md and return parsed { frontmatter, body, raw }.
|
|
137
|
+
* Returns null if the wave directory or file doesn't exist.
|
|
138
|
+
* Throws on malformed frontmatter.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} waveId e.g. "W10-A0"
|
|
141
|
+
* @param {string} projectRoot absolute path to project root
|
|
142
|
+
* @returns {Promise<{frontmatter: object, body: string, raw: string} | null>}
|
|
143
|
+
*/
|
|
144
|
+
export async function readWaveState(waveId, projectRoot) {
|
|
145
|
+
const { state } = wavePaths(waveId, projectRoot);
|
|
146
|
+
let raw;
|
|
147
|
+
try {
|
|
148
|
+
raw = await readFile(state, 'utf8');
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code === 'ENOENT') return null;
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Parse frontmatter
|
|
155
|
+
if (!raw.startsWith('---')) {
|
|
156
|
+
throw new Error(`wave-state: STATE.md for "${waveId}" is missing YAML frontmatter`);
|
|
157
|
+
}
|
|
158
|
+
const secondDelim = raw.indexOf('\n---', 3);
|
|
159
|
+
if (secondDelim === -1) {
|
|
160
|
+
throw new Error(`wave-state: STATE.md for "${waveId}" has unclosed YAML frontmatter`);
|
|
161
|
+
}
|
|
162
|
+
const fmBlock = raw.slice(4, secondDelim); // skip "---\n"
|
|
163
|
+
const body = raw.slice(secondDelim + 4).replace(/^\n+/, ''); // skip "\n---\n\n"
|
|
164
|
+
|
|
165
|
+
const frontmatter = parseYaml(fmBlock);
|
|
166
|
+
return { frontmatter, body, raw };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Atomically write a wave's STATE.md using withFsLock + tmp+rename.
|
|
171
|
+
* Auto-creates .ijfw/wave-<waveId>/ if missing.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} waveId
|
|
174
|
+
* @param {{frontmatter: object, body: string}} state
|
|
175
|
+
* @param {string} projectRoot
|
|
176
|
+
* @returns {Promise<void>}
|
|
177
|
+
*/
|
|
178
|
+
export async function writeWaveState(waveId, state, projectRoot) {
|
|
179
|
+
const { dir, state: statePath, lock, tmp } = wavePaths(waveId, projectRoot);
|
|
180
|
+
const payload = `---\n${emitYaml(state.frontmatter)}\n---\n\n${state.body}`;
|
|
181
|
+
|
|
182
|
+
await withFsLock(lock, async () => {
|
|
183
|
+
await mkdir(dir, { recursive: true });
|
|
184
|
+
await writeFile(tmp, payload, 'utf8');
|
|
185
|
+
await rename(tmp, statePath);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Append a delta entry to a wave's SUMMARY.md — markdown append-only log.
|
|
191
|
+
* r13-M-03 (post-Trident r13 fix): minimum-viable implementation closing the
|
|
192
|
+
* handoff §N4 promise. Full blackboard→STATE rollup remains future work for
|
|
193
|
+
* v1.5.0 (would mean reading blackboard.js claims/findings and summarising).
|
|
194
|
+
*
|
|
195
|
+
* Delta shape (caller chooses what to record):
|
|
196
|
+
* { agent_id?, task_id?, commits?: string[], tests_delta?: string,
|
|
197
|
+
* contracts_touched?: string[], surprises?: string }
|
|
198
|
+
*
|
|
199
|
+
* Atomic via withFsLock + appendFile. Each delta is rendered as a markdown
|
|
200
|
+
* H3 section dated by ISO timestamp; subsequent entries append below.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} waveId
|
|
203
|
+
* @param {object} delta
|
|
204
|
+
* @param {string} projectRoot
|
|
205
|
+
* @returns {Promise<void>}
|
|
206
|
+
*/
|
|
207
|
+
export async function appendSummary(waveId, delta, projectRoot) {
|
|
208
|
+
const { dir, summary, summaryLock } = wavePaths(waveId, projectRoot);
|
|
209
|
+
const ts = new Date().toISOString();
|
|
210
|
+
const lines = [`### ${ts}`];
|
|
211
|
+
if (delta.agent_id) lines.push(`- **agent:** ${delta.agent_id}`);
|
|
212
|
+
if (delta.task_id) lines.push(`- **task:** ${delta.task_id}`);
|
|
213
|
+
if (Array.isArray(delta.commits) && delta.commits.length) {
|
|
214
|
+
lines.push(`- **commits:** ${delta.commits.join(', ')}`);
|
|
215
|
+
}
|
|
216
|
+
if (delta.tests_delta) lines.push(`- **tests:** ${delta.tests_delta}`);
|
|
217
|
+
if (Array.isArray(delta.contracts_touched) && delta.contracts_touched.length) {
|
|
218
|
+
lines.push(`- **contracts:** ${delta.contracts_touched.join(', ')}`);
|
|
219
|
+
}
|
|
220
|
+
if (delta.surprises) lines.push(`- **surprises:** ${delta.surprises}`);
|
|
221
|
+
const payload = lines.join('\n') + '\n\n';
|
|
222
|
+
|
|
223
|
+
await withFsLock(summaryLock, async () => {
|
|
224
|
+
await mkdir(dir, { recursive: true });
|
|
225
|
+
await appendFile(summary, payload, 'utf8');
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Stub checkpoint — full blackboard→STATE rollup remains v1.5.0 work.
|
|
231
|
+
* Seeds an empty state if missing; updates only frontmatter.checkpoint_at.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} waveId
|
|
234
|
+
* @param {string} projectRoot
|
|
235
|
+
* @returns {Promise<{frontmatter: object, body: string}>}
|
|
236
|
+
*/
|
|
237
|
+
export async function checkpointWave(waveId, projectRoot) {
|
|
238
|
+
const now = new Date().toISOString();
|
|
239
|
+
const existing = await readWaveState(waveId, projectRoot);
|
|
240
|
+
|
|
241
|
+
const next = existing
|
|
242
|
+
? { frontmatter: { ...existing.frontmatter, checkpoint_at: now }, body: existing.body }
|
|
243
|
+
: {
|
|
244
|
+
frontmatter: {
|
|
245
|
+
wave_id: waveId,
|
|
246
|
+
status: 'in_progress',
|
|
247
|
+
created_at: now,
|
|
248
|
+
checkpoint_at: now,
|
|
249
|
+
},
|
|
250
|
+
body: '',
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
await writeWaveState(waveId, next, projectRoot);
|
|
254
|
+
return next;
|
|
255
|
+
}
|
package/src/swarm-config.js
CHANGED
|
@@ -12,6 +12,9 @@ import { join } from 'node:path';
|
|
|
12
12
|
export const SCHEMA = {
|
|
13
13
|
project_type: 'string',
|
|
14
14
|
specialists: [{ id: 'string', role: 'string', agent_type: 'string' }],
|
|
15
|
+
// v1.4.4 N10: auditor roster for phase-e-auto
|
|
16
|
+
auditors: ['string'], // roster IDs to use for Cross-Audit Phase; default ['codex','gemini','claude']
|
|
17
|
+
auditor_count: 'number', // number of lenses; default 3
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
const BASE = [
|
|
@@ -22,13 +25,20 @@ const BASE = [
|
|
|
22
25
|
const TESTS_SPECIALIST = { id: 'tests', role: 'Test coverage', agent_type: 'pr-test-analyzer' };
|
|
23
26
|
const TYPES_SPECIALIST = { id: 'types', role: 'Type invariants', agent_type: 'type-design-analyzer' };
|
|
24
27
|
|
|
28
|
+
// v1.4.4 N6 — 5 specialist agents addressing v1.4.3 build pain points
|
|
29
|
+
const DOC_VERIFIER = { id: 'doc-verifier', role: 'Doc accuracy', agent_type: 'ijfw-doc-verifier' };
|
|
30
|
+
const PATTERN_MAPPER = { id: 'pattern-mapper', role: 'Onboarding patterns', agent_type: 'ijfw-pattern-mapper' };
|
|
31
|
+
const SECURITY_AUDITOR = { id: 'security-auditor', role: 'Security mitigations', agent_type: 'ijfw-security-auditor' };
|
|
32
|
+
const INTEGRATION_CHECKER = { id: 'integration-checker', role: 'E2E flow verification', agent_type: 'ijfw-integration-checker' };
|
|
33
|
+
const NYQUIST_AUDITOR = { id: 'nyquist-auditor', role: 'Coverage gaps', agent_type: 'ijfw-nyquist-auditor' };
|
|
34
|
+
|
|
25
35
|
export const DEFAULT_SPECIALISTS = {
|
|
26
|
-
node: [...BASE, TESTS_SPECIALIST],
|
|
27
|
-
python: [...BASE, TESTS_SPECIALIST],
|
|
28
|
-
typed: [...BASE, TESTS_SPECIALIST, TYPES_SPECIALIST],
|
|
29
|
-
go: [...BASE],
|
|
30
|
-
rust: [...BASE],
|
|
31
|
-
other: [...BASE],
|
|
36
|
+
node: [...BASE, TESTS_SPECIALIST, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
37
|
+
python: [...BASE, TESTS_SPECIALIST, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
38
|
+
typed: [...BASE, TESTS_SPECIALIST, TYPES_SPECIALIST, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
39
|
+
go: [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
40
|
+
rust: [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
41
|
+
other: [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR],
|
|
32
42
|
};
|
|
33
43
|
|
|
34
44
|
// Detects project type from filesystem signals in projectDir.
|
|
@@ -51,10 +61,22 @@ export function detectProjectType(projectDir) {
|
|
|
51
61
|
return 'other';
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
// Default auditor IDs for Cross-Audit Phase (N10).
|
|
65
|
+
export const DEFAULT_AUDITORS = ['codex', 'gemini', 'claude'];
|
|
66
|
+
export const DEFAULT_AUDITOR_COUNT = 3;
|
|
67
|
+
|
|
68
|
+
// Merge v1.4.4 N10 defaults into a raw config object (non-destructive).
|
|
69
|
+
function applySwarmDefaults(config) {
|
|
70
|
+
const out = { ...config };
|
|
71
|
+
if (!Array.isArray(out.auditors)) out.auditors = [...DEFAULT_AUDITORS];
|
|
72
|
+
if (typeof out.auditor_count !== 'number' || out.auditor_count < 1) out.auditor_count = DEFAULT_AUDITOR_COUNT;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
// Returns a fresh default config object for the given project type.
|
|
55
77
|
function buildDefault(projectType) {
|
|
56
78
|
const specialists = DEFAULT_SPECIALISTS[projectType] ?? DEFAULT_SPECIALISTS.other;
|
|
57
|
-
return { project_type: projectType, specialists: specialists.map(s => ({ ...s })) };
|
|
79
|
+
return applySwarmDefaults({ project_type: projectType, specialists: specialists.map(s => ({ ...s })) });
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
// Reads .ijfw/swarm.json if present, otherwise detects type, generates
|
|
@@ -63,7 +85,9 @@ export function loadSwarmConfig(projectDir) {
|
|
|
63
85
|
const swarmPath = join(projectDir, '.ijfw', 'swarm.json');
|
|
64
86
|
|
|
65
87
|
if (existsSync(swarmPath)) {
|
|
66
|
-
|
|
88
|
+
const raw = JSON.parse(readFileSync(swarmPath, 'utf8'));
|
|
89
|
+
// Merge defaults for v1.4.4 N10 fields so older swarm.json files work.
|
|
90
|
+
return applySwarmDefaults(raw);
|
|
67
91
|
}
|
|
68
92
|
|
|
69
93
|
const projectType = detectProjectType(projectDir);
|