@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.
- package/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/cross-orchestrator.js +164 -2
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +213 -1
- package/src/dashboard-server.js +186 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +40 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/dispatch/wave-cli.js +128 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -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/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
- package/src/swarm-config.js +32 -8
|
@@ -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
|
+
}
|
package/src/runtime-mediator.js
CHANGED
|
@@ -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
|