@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,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
|
+
}
|