@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.
@@ -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
+ }
@@ -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
- return JSON.parse(readFileSync(swarmPath, 'utf8'));
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);