@ijfw/memory-server 1.4.1 → 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,101 @@
1
+ /**
2
+ * review.js — Two-stage per-task review for the IJFW orchestrator.
3
+ *
4
+ * Stage 1: spec-compliance reviewer — confirms the diff faithfully
5
+ * implements every requirement in the task spec.
6
+ * Stage 2: code-quality reviewer — checks correctness, security, and
7
+ * project-convention adherence (runs only after Stage 1 passes).
8
+ *
9
+ * The `dispatch` parameter is injected by the caller so this module is
10
+ * testable without a live Agent tool. Signature:
11
+ * (kind: 'spec-compliance' | 'code-quality', ctx: object)
12
+ * => Promise<{ verdict: 'PASS' | 'FAIL', findings: string[] }>
13
+ *
14
+ * Landed in W10-A2 (v1.4.4 — N3).
15
+ */
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Maximum re-review iterations before escalation. */
22
+ export const REVIEW_MAX_ITERATIONS = 3;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Returns true when re-review should be triggered.
30
+ *
31
+ * @param {'PASS'|'FAIL'} prevVerdict
32
+ * @param {number} iteration 1-based iteration count (1 = first attempt)
33
+ * @returns {boolean}
34
+ */
35
+ export function shouldReReview(prevVerdict, iteration) {
36
+ return prevVerdict !== 'PASS' && iteration < REVIEW_MAX_ITERATIONS;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Main export
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Run two-stage review for a completed task.
45
+ *
46
+ * @param {object} params
47
+ * @param {string} params.taskId Blackboard task ID
48
+ * @param {string} params.taskSpec Full task specification text
49
+ * @param {string} params.commitSha SHA of the implementer's commit
50
+ * @param {string} params.branch Branch name
51
+ * @param {string} [params.projectConventions] CLAUDE.md / AGENTS.md excerpt
52
+ * @param {Function} params.dispatch Injected reviewer dispatcher
53
+ *
54
+ * @returns {Promise<{
55
+ * ok: boolean,
56
+ * stage: 'spec' | 'quality',
57
+ * findings: string[]
58
+ * }>}
59
+ */
60
+ export async function reviewTask({
61
+ taskId,
62
+ taskSpec,
63
+ commitSha,
64
+ branch,
65
+ projectConventions = '',
66
+ dispatch,
67
+ }) {
68
+ // ------------------------------------------------------------------
69
+ // Stage 1: spec-compliance reviewer
70
+ // ------------------------------------------------------------------
71
+ const spec = await dispatch('spec-compliance', {
72
+ taskId,
73
+ taskSpec,
74
+ commitSha,
75
+ branch,
76
+ });
77
+
78
+ if (spec.verdict !== 'PASS') {
79
+ return {
80
+ ok: false,
81
+ stage: 'spec',
82
+ findings: spec.findings ?? [],
83
+ };
84
+ }
85
+
86
+ // ------------------------------------------------------------------
87
+ // Stage 2: code-quality reviewer (only runs after spec PASS)
88
+ // ------------------------------------------------------------------
89
+ const quality = await dispatch('code-quality', {
90
+ taskId,
91
+ commitSha,
92
+ branch,
93
+ projectConventions,
94
+ });
95
+
96
+ return {
97
+ ok: quality.verdict === 'PASS',
98
+ stage: 'quality',
99
+ findings: quality.findings ?? [],
100
+ };
101
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * status-protocol.js — 4-value agent status protocol + commit-before-report verification.
3
+ *
4
+ * Every implementer agent must end its report with:
5
+ * Status: <VALUE>
6
+ * Branch: <branch>
7
+ * Commit: <sha>
8
+ * Tests: <summary>
9
+ *
10
+ * Landed in W10-A1 (v1.4.4 N2).
11
+ */
12
+
13
+ import { execFileSync } from 'node:child_process';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const STATUS_VALUES = Object.freeze([
20
+ 'DONE',
21
+ 'DONE_WITH_CONCERNS',
22
+ 'NEEDS_CONTEXT',
23
+ 'BLOCKED',
24
+ ]);
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // ProtocolViolation
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export class ProtocolViolation extends Error {
31
+ /**
32
+ * @param {string} reason Human-readable explanation
33
+ * @param {string} raw The original report text
34
+ */
35
+ constructor(reason, raw) {
36
+ super(reason);
37
+ this.name = 'ProtocolViolation';
38
+ this.reason = reason;
39
+ this.raw = raw;
40
+ }
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // parseAgentReport
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Parse a structured agent report into its constituent fields.
49
+ *
50
+ * Required field: `Status: <VALUE>` — throws ProtocolViolation if missing or invalid.
51
+ * All other fields are extracted best-effort (undefined if absent).
52
+ *
53
+ * @param {string} reportText
54
+ * @returns {{ status: string, commit_sha?: string, branch?: string, tests?: string,
55
+ * concerns?: string, reason?: string, missing?: string, tried?: string,
56
+ * raw: string }}
57
+ * @throws {ProtocolViolation}
58
+ */
59
+ export function parseAgentReport(reportText) {
60
+ const raw = reportText;
61
+
62
+ const statusMatch = raw.match(/^Status:\s*(\S+)\s*$/m);
63
+ if (!statusMatch) {
64
+ throw new ProtocolViolation('missing Status: line in agent report', raw);
65
+ }
66
+ const status = statusMatch[1];
67
+ if (!STATUS_VALUES.includes(status)) {
68
+ throw new ProtocolViolation(
69
+ `invalid status "${status}"; expected one of ${STATUS_VALUES.join(', ')}`,
70
+ raw,
71
+ );
72
+ }
73
+
74
+ return {
75
+ status,
76
+ commit_sha: extract(raw, 'Commit'),
77
+ branch: extract(raw, 'Branch'),
78
+ tests: extract(raw, 'Tests'),
79
+ concerns: extract(raw, 'Concerns'),
80
+ reason: extract(raw, 'Reason'),
81
+ missing: extract(raw, 'Missing'),
82
+ tried: extract(raw, 'Tried'),
83
+ raw,
84
+ };
85
+ }
86
+
87
+ /** Extract a single-line field value, or undefined if absent. */
88
+ function extract(text, field) {
89
+ const m = text.match(new RegExp(`^${field}:\\s*(.+?)\\s*$`, 'm'));
90
+ return m ? m[1] : undefined;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // verifyFreshCommit (internal)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Returns true if the commit at `sha` was authored at or after
99
+ * (dispatchTimestamp - 5s tolerance).
100
+ *
101
+ * @param {string|undefined} sha
102
+ * @param {string|undefined} _branch (reserved for future ref-checking)
103
+ * @param {number} dispatchTimestamp Unix seconds
104
+ * @param {{ projectRoot: string }} ctx
105
+ * @returns {boolean}
106
+ */
107
+ function verifyFreshCommit(sha, _branch, dispatchTimestamp, ctx) {
108
+ if (!sha) return false;
109
+ try {
110
+ const out = execFileSync(
111
+ 'git',
112
+ ['log', '-1', '--format=%ct', sha],
113
+ { cwd: ctx.projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
114
+ ).trim();
115
+ const commitTs = parseInt(out, 10);
116
+ if (!Number.isFinite(commitTs)) return false;
117
+ // r13-M-02: 5s window was too generous — let pre-existing commits pass as "fresh"
118
+ // when the orchestrator dispatched ~4s after a stale commit. 1s preserves
119
+ // minimal clock-skew tolerance. Future v1.5.0: verify commit is on the
120
+ // dispatched branch (tuple check), not just newer-than-dispatch.
121
+ return commitTs >= dispatchTimestamp - 1;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // handleStatus
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Decide the orchestrator action based on a parsed agent report.
133
+ *
134
+ * @param {{ status: string, commit_sha?: string, branch?: string, concerns?: string,
135
+ * missing?: string, reason?: string, tried?: string }} parsed
136
+ * @param {number} dispatchTimestamp Unix seconds (Date.now()/1000 at dispatch)
137
+ * @param {{ projectRoot: string }} ctx
138
+ * @returns {{ action: string, [key: string]: unknown }}
139
+ */
140
+ export function handleStatus(parsed, dispatchTimestamp, ctx) {
141
+ switch (parsed.status) {
142
+ case 'DONE': {
143
+ const fresh = verifyFreshCommit(
144
+ parsed.commit_sha,
145
+ parsed.branch,
146
+ dispatchTimestamp,
147
+ ctx,
148
+ );
149
+ if (!fresh) {
150
+ return { action: 'redispatch_needs_context', missing: 'commit-before-report' };
151
+ }
152
+ return { action: 'proceed_to_review', commit_sha: parsed.commit_sha };
153
+ }
154
+
155
+ case 'DONE_WITH_CONCERNS':
156
+ return { action: 'proceed_with_flag', concerns: parsed.concerns };
157
+
158
+ case 'NEEDS_CONTEXT':
159
+ return { action: 'redispatch_with_context', missing: parsed.missing };
160
+
161
+ case 'BLOCKED':
162
+ return { action: 'escalate_to_user', reason: parsed.reason, tried: parsed.tried };
163
+
164
+ default:
165
+ // STATUS_VALUES is exhaustive; parseAgentReport guards this.
166
+ throw new ProtocolViolation(`unhandled status "${parsed.status}"`, parsed.raw ?? '');
167
+ }
168
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * verification-gate.js — Advisory lint: detects completion claims in a
3
+ * message that lack fresh verification evidence (a Bash test/build call
4
+ * in the same message).
5
+ *
6
+ * ADVISORY ONLY — never throws, never blocks. Returns { ok: true } or
7
+ * { ok: false, violation: string, claim: string }.
8
+ *
9
+ * Violations are persisted to .ijfw/memory/verification-violations.jsonl
10
+ * so the memory-feedback system (v1.4.1 B10) can pattern-detect over time.
11
+ *
12
+ * Landed in W10-A2 (v1.4.4 — N5).
13
+ */
14
+
15
+ import { appendFile, mkdir } from 'node:fs/promises';
16
+ import { dirname, join } from 'node:path';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Detection patterns
20
+ // ---------------------------------------------------------------------------
21
+
22
+ // r13-M-01 + r13-M-04: dropped bare `complete`, lowercase `done`, AND lowercase
23
+ // `pass(?:es)?` — all three fired falsely on common neutral language ("not yet
24
+ // complete", "to be done in v1.5", "pass the context"). Detection list is now:
25
+ // protocol literal `DONE`, `completed`/`shipped` (deliberate completion verbs),
26
+ // uppercase `PASS` (verdict literal), `✅` emoji, and explicit completion phrases
27
+ // ("all tests pass" / "build succeeded" / "deployed" / "ready to ship").
28
+ const COMPLETION_PATTERNS = [
29
+ /\b(?:DONE|completed|shipped|PASS)\b/,
30
+ /✅/,
31
+ /\b(?:all tests pass|build succeeded|deployed|ready to ship)\b/i,
32
+ ];
33
+
34
+ // Bash tool calls that count as fresh verification evidence.
35
+ const VERIFICATION_COMMAND_RE =
36
+ /(?:npm test|node --test|cargo test|pytest|preflight|ijfw preflight|build)/i;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Core gate
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Check whether a message that contains a completion claim also has fresh
44
+ * verification evidence (a Bash tool call running tests/build).
45
+ *
46
+ * @param {string} message Full text of the agent message.
47
+ * @param {Array<{tool: string, input?: {command?: string}}>} toolCallsInMessage
48
+ * Tool calls that appeared in the same message turn.
49
+ *
50
+ * @returns {{ ok: true } | { ok: false, violation: string, claim: string }}
51
+ */
52
+ export function checkVerificationGate(message, toolCallsInMessage) {
53
+ const claims = COMPLETION_PATTERNS.flatMap((p) => message.match(p) ?? []);
54
+ if (claims.length === 0) return { ok: true };
55
+
56
+ const verificationCalls = toolCallsInMessage.filter(
57
+ (t) =>
58
+ t.tool === 'Bash' &&
59
+ VERIFICATION_COMMAND_RE.test(t.input?.command ?? ''),
60
+ );
61
+
62
+ if (verificationCalls.length === 0) {
63
+ return {
64
+ ok: false,
65
+ violation: `Completion claim "${claims[0]}" without fresh verification in same message`,
66
+ claim: claims[0],
67
+ };
68
+ }
69
+
70
+ return { ok: true };
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Violation recorder
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Append a violation record to .ijfw/memory/verification-violations.jsonl.
79
+ * Auto-creates parent directories. Advisory — errors are silently swallowed
80
+ * so a write failure never blocks the orchestrator.
81
+ *
82
+ * @param {{ violation: string, claim: string, [key: string]: unknown }} violation
83
+ * @param {string} projectRoot Absolute path to the project root.
84
+ * @returns {Promise<void>}
85
+ */
86
+ export async function recordViolation(violation, projectRoot) {
87
+ const file = join(projectRoot, '.ijfw', 'memory', 'verification-violations.jsonl');
88
+ try {
89
+ await mkdir(dirname(file), { recursive: true });
90
+ await appendFile(
91
+ file,
92
+ JSON.stringify({ ...violation, recorded_at: new Date().toISOString() }) + '\n',
93
+ );
94
+ } catch {
95
+ // Advisory — never propagate write errors.
96
+ }
97
+ }
@@ -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
+ }
@@ -21,6 +21,10 @@ import { readFile, mkdir, appendFile, rename, stat } from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
 
24
+ // B18 — divergence helper imported lazily inside maybeWarnDivergence to keep
25
+ // the module side-effect-light. detectCrossIdeDivergence has its own internal
26
+ // stale-file cleanup + last-seen writer; we just consume the verdict.
27
+
24
28
  // Log rotation: when permission-events.jsonl exceeds this many lines, rename
25
29
  // to .0 (overwriting any prior .0) and start fresh. Total on disk = 2 * cap.
26
30
  const ROTATION_LINE_CAP = 10_000;
@@ -167,6 +171,33 @@ export async function logPermissionEvent(event, opts = {}) {
167
171
  }
168
172
  }
169
173
 
174
+ /**
175
+ * B18 — surface a cross-IDE divergence warning on stderr (does NOT block).
176
+ * Called by permission-check call sites once per dispatch. Returns the
177
+ * divergence verdict so callers can attach `divergent_ide: true` to event
178
+ * log entries.
179
+ *
180
+ * Best-effort: any error returns `{ divergent: false }` and never throws.
181
+ *
182
+ * @param {{ homeDir?: string }} [opts]
183
+ * @returns {Promise<{ divergent: boolean, last_writer?: string|null, current_ide?: string, age_seconds?: number|null }>}
184
+ */
185
+ export async function maybeWarnDivergence(opts = {}) {
186
+ try {
187
+ const { detectCrossIdeDivergence } = await import('./active-extension-writer.js');
188
+ const verdict = await detectCrossIdeDivergence({ homeDir: opts.homeDir });
189
+ if (verdict && verdict.divergent) {
190
+ const age = typeof verdict.age_seconds === 'number' ? `${verdict.age_seconds}s ago` : 'unknown time ago';
191
+ process.stderr.write(
192
+ `[ijfw] active extension last activated by '${verdict.last_writer}' ${age}; this IDE is '${verdict.current_ide}'\n`,
193
+ );
194
+ }
195
+ return verdict || { divergent: false };
196
+ } catch {
197
+ return { divergent: false };
198
+ }
199
+ }
200
+
170
201
  /**
171
202
  * Map an MCP tool name (+ args) to the (action, target) tuple used for
172
203
  * permission checks. Returns null for unrecognised tool names; callers