@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,128 @@
1
+ /**
2
+ * dispatch/wave-cli.js — IJFW v1.4.4 / N9 wave-status CLI handlers.
3
+ *
4
+ * Frozen export contract (v1.4.3 dispatch module convention):
5
+ * export const handlers = { '<subcommand>': async (args, ctx) => ({ ok, output?, error? }) };
6
+ * export const subcommandHelp = { '<subcommand>': 'one-line description' };
7
+ *
8
+ * Subcommands owned by this module:
9
+ * - wave-status [<id>|latest]
10
+ * - wave-list
11
+ *
12
+ * Reads via mcp-server/src/orchestrator/wave-state.js (W10-A0). Read-only,
13
+ * snapshot-based per lock-in #31 — no daemon, no subscriptions.
14
+ */
15
+
16
+ import { readdir, stat } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+
19
+ import { readWaveState } from '../orchestrator/wave-state.js';
20
+
21
+ const WAVE_DIR_PREFIX = 'wave-';
22
+
23
+ function tokenize(args) {
24
+ if (Array.isArray(args)) return args.filter((x) => x !== undefined && x !== null);
25
+ if (typeof args !== 'string') return [];
26
+ return args.split(/\s+/).filter(Boolean);
27
+ }
28
+
29
+ async function listWaveEntries(projectRoot) {
30
+ const ijfwDir = join(projectRoot, '.ijfw');
31
+ let entries = [];
32
+ try {
33
+ entries = await readdir(ijfwDir, { withFileTypes: true });
34
+ } catch (err) {
35
+ if (err.code === 'ENOENT') return [];
36
+ throw err;
37
+ }
38
+ const waves = [];
39
+ for (const ent of entries) {
40
+ if (!ent.isDirectory() || !ent.name.startsWith(WAVE_DIR_PREFIX)) continue;
41
+ const id = ent.name.slice(WAVE_DIR_PREFIX.length);
42
+ if (!id) continue;
43
+ const dir = join(ijfwDir, ent.name);
44
+ let mtimeMs = 0;
45
+ try {
46
+ const s = await stat(dir);
47
+ mtimeMs = s.mtimeMs;
48
+ } catch {
49
+ // tolerate vanished dirs
50
+ }
51
+ waves.push({ id, dir, mtimeMs });
52
+ }
53
+ return waves;
54
+ }
55
+
56
+ async function resolveLatestWaveId(projectRoot) {
57
+ const waves = await listWaveEntries(projectRoot);
58
+ if (waves.length === 0) return null;
59
+ waves.sort((a, b) => b.mtimeMs - a.mtimeMs);
60
+ return waves[0].id;
61
+ }
62
+
63
+ function renderStateForTerminal({ waveId, frontmatter, body }) {
64
+ const lines = [];
65
+ lines.push(`Wave: ${waveId}`);
66
+ for (const [key, val] of Object.entries(frontmatter || {})) {
67
+ if (key === 'wave_id') continue;
68
+ if (Array.isArray(val)) {
69
+ lines.push(`${key}: [${val.join(', ')}]`);
70
+ } else {
71
+ lines.push(`${key}: ${val}`);
72
+ }
73
+ }
74
+ if (body && body.trim()) {
75
+ lines.push('');
76
+ lines.push('--- notes ---');
77
+ lines.push(body.trim());
78
+ }
79
+ return lines.join('\n');
80
+ }
81
+
82
+ export const handlers = {
83
+ 'wave-status': async (args, ctx) => {
84
+ const tokens = tokenize(args);
85
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
86
+ let waveId = tokens[0];
87
+ if (!waveId || waveId === 'latest') {
88
+ waveId = await resolveLatestWaveId(projectRoot);
89
+ if (!waveId) {
90
+ return { ok: false, output: 'No waves found in .ijfw/wave-*/' };
91
+ }
92
+ }
93
+ const state = await readWaveState(waveId, projectRoot);
94
+ if (!state) {
95
+ return { ok: false, output: `Wave ${waveId} not found` };
96
+ }
97
+ return {
98
+ ok: true,
99
+ output: renderStateForTerminal({
100
+ waveId,
101
+ frontmatter: state.frontmatter,
102
+ body: state.body,
103
+ }),
104
+ };
105
+ },
106
+
107
+ 'wave-list': async (_args, ctx) => {
108
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
109
+ const waves = await listWaveEntries(projectRoot);
110
+ if (waves.length === 0) {
111
+ return { ok: true, output: '(no waves)' };
112
+ }
113
+ waves.sort((a, b) => b.mtimeMs - a.mtimeMs);
114
+ const rows = [];
115
+ for (const { id } of waves) {
116
+ const state = await readWaveState(id, projectRoot);
117
+ const status = state?.frontmatter?.status ?? '?';
118
+ const createdAt = state?.frontmatter?.created_at ?? '';
119
+ rows.push(`${id}\t${status}\t${createdAt}`);
120
+ }
121
+ return { ok: true, output: rows.join('\n') };
122
+ },
123
+ };
124
+
125
+ export const subcommandHelp = {
126
+ 'wave-status': 'wave-status [<id>|latest] — print live state of a wave',
127
+ 'wave-list': 'wave-list — list all known waves (newest first)',
128
+ };
@@ -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
+ }