@ijfw/memory-server 1.5.0 → 1.5.1

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.
@@ -1,430 +0,0 @@
1
- /**
2
- * runtime-loop.js — v1.5.0-major S02: callable runtime wrapper for the
3
- * v1.4.4 N2 status protocol. Closes the "discipline-in-markdown" gap by
4
- * giving the orchestrator-LLM an MCP tool to invoke per subagent report,
5
- * instead of reading SKILL.md and hoping it remembers to call the right
6
- * thing in the right order.
7
- *
8
- * Pipeline (per subagent message):
9
- * reviewSubagentReport(reportText, ctx)
10
- * 1. parseAgentReport(reportText) -- status-protocol.js N2
11
- * 2. handleStatus(parsed, dispatchTs, ctx)
12
- * 3. return { action, ...decision, parsed? }
13
- *
14
- * Failure modes are explicit, never thrown:
15
- * - ProtocolViolation -> { action: 'redispatch_needs_context',
16
- * missing: 'protocol-violation', error, raw }
17
- * - Stale commit -> { action: 'redispatch_needs_context',
18
- * missing: 'commit-before-report' } (from handleStatus)
19
- *
20
- * The MCP tool wrapper in server.js passes projectRoot from cwd.
21
- */
22
-
23
- import { parseAgentReport, handleStatus, ProtocolViolation } from './status-protocol.js';
24
- // v1.5.0 N4.obs M1: ensure a trace_id is minted at the start of the orchestrator
25
- // loop so every downstream checkpoint/receipt/session row can be rolled up by
26
- // session in the dashboard.
27
- import { ensureTraceId } from '../observability/trace-id.js';
28
- // v1.5.0 audit-MED-work-M1 / M3: resume_preference config loader + composable
29
- // termination conditions.
30
- import { existsSync, readFileSync } from 'node:fs';
31
- import { join } from 'node:path';
32
- import { defaultTermination } from './termination.js';
33
- // v1.5.0 wire-W1.A: opt-in repo-map prefix for subagent dispatch / redispatch.
34
- // Folds the importance-ranked file summary in front of the brief when
35
- // IJFW_REPO_MAP=1 is set. Default off so existing flows are byte-identical.
36
- import { buildRepoMap, compactBriefForSubagent } from '../lib/repo-map.js';
37
-
38
- /**
39
- * Review a subagent's report through the v1.4.4 4-value protocol.
40
- * Returns a route decision object the orchestrator should act on.
41
- *
42
- * @param {string} reportText - the subagent's final message
43
- * @param {object} ctx
44
- * @param {number} ctx.dispatchTimestamp - Unix seconds at dispatch
45
- * @param {string} [ctx.branch] - dispatched branch (for branch-tuple freshness check)
46
- * @param {string} ctx.projectRoot - absolute path of project root for git commands
47
- * @returns {{ action: string, parsed?: object, missing?: string, error?: string, raw?: string, [k: string]: unknown }}
48
- */
49
- export function reviewSubagentReport(reportText, ctx) {
50
- // v1.5.0 N4.obs M1: mint (or adopt) a session trace_id on the first
51
- // orchestrator call. Idempotent -- subsequent calls reuse the cached id, and
52
- // a subagent inheriting via IJFW_TRACE_ID env keeps the parent's id.
53
- ensureTraceId();
54
- if (typeof reportText !== 'string' || reportText.length === 0) {
55
- return {
56
- action: 'redispatch_needs_context',
57
- missing: 'protocol-violation',
58
- error: 'empty or non-string reportText',
59
- raw: String(reportText ?? ''),
60
- };
61
- }
62
- if (!ctx || typeof ctx !== 'object') {
63
- throw new TypeError('reviewSubagentReport: ctx is required');
64
- }
65
- if (typeof ctx.dispatchTimestamp !== 'number' || !Number.isFinite(ctx.dispatchTimestamp)) {
66
- throw new TypeError('reviewSubagentReport: ctx.dispatchTimestamp must be a finite number (unix seconds)');
67
- }
68
- if (typeof ctx.projectRoot !== 'string' || ctx.projectRoot.length === 0) {
69
- throw new TypeError('reviewSubagentReport: ctx.projectRoot is required');
70
- }
71
-
72
- let parsed;
73
- try {
74
- parsed = parseAgentReport(reportText);
75
- } catch (err) {
76
- if (err instanceof ProtocolViolation) {
77
- return {
78
- action: 'redispatch_needs_context',
79
- missing: 'protocol-violation',
80
- error: err.reason,
81
- raw: err.raw,
82
- };
83
- }
84
- throw err;
85
- }
86
-
87
- // If the parsed branch contradicts the dispatched branch, prefer the parsed
88
- // (agent-reported) value -- handleStatus uses it for the branch-tuple check.
89
- // The ctx.branch is informational; we don't override what the agent said.
90
- const decision = handleStatus(parsed, ctx.dispatchTimestamp, { projectRoot: ctx.projectRoot });
91
- return { ...decision, parsed };
92
- }
93
-
94
- /**
95
- * Cross-AI resume routing (v1.5.0 W12-C N02).
96
- *
97
- * When a subagent truncates mid-task, the orchestrator can resume the same
98
- * checkpoint on a *different* AI to avoid the same context-window/format
99
- * failure mode. These helpers encode the routing matrix + brief composition
100
- * so the orchestrator-LLM doesn't have to remember it.
101
- *
102
- * The matrix is deliberately small and hand-tuned: each entry picks an
103
- * alternate AI whose context window / output style differs from the one
104
- * that truncated. We never reselect the truncated AI itself.
105
- */
106
-
107
- // Built-in defaults. v1.5.0 audit-MED-work-M1 promoted this to a config-
108
- // readable field on `.ijfw/swarm.json::resume_preference` so rosters that
109
- // include `opencode`, `aider`, `copilot`, etc. get cross-AI resume too
110
- // instead of falling through to escalate_to_user.
111
- const DEFAULT_RESUME_PREFERENCE = {
112
- claude: ['gemini', 'codex'],
113
- gemini: ['claude', 'codex'],
114
- codex: ['claude', 'gemini'],
115
- };
116
-
117
- // Cache one read per projectRoot per process — `selectResumeAI` is hot on the
118
- // orchestrator critical path and re-reading swarm.json every call wastes I/O.
119
- const _resumePrefCache = new Map();
120
-
121
- /**
122
- * Load `resume_preference` from `<projectRoot>/.ijfw/swarm.json` if present,
123
- * shallow-merge over the built-in defaults. Missing file / malformed JSON /
124
- * missing field → defaults silently (this is advisory routing, never throws).
125
- *
126
- * Result shape: `{ [truncatedAI]: string[] }`. Keys NOT in defaults are kept
127
- * (so a project with `opencode: ['claude','gemini']` can have its entry
128
- * looked up by selectResumeAI), and entries in defaults stay unless the
129
- * config explicitly overrides them with an array.
130
- *
131
- * @param {string} [projectRoot]
132
- * @returns {Record<string, string[]>}
133
- */
134
- export function loadResumePreference(projectRoot) {
135
- if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
136
- return { ...DEFAULT_RESUME_PREFERENCE };
137
- }
138
- if (_resumePrefCache.has(projectRoot)) {
139
- return _resumePrefCache.get(projectRoot);
140
- }
141
- const merged = { ...DEFAULT_RESUME_PREFERENCE };
142
- try {
143
- const swarmPath = join(projectRoot, '.ijfw', 'swarm.json');
144
- if (existsSync(swarmPath)) {
145
- const raw = JSON.parse(readFileSync(swarmPath, 'utf8'));
146
- const cfg = raw && typeof raw === 'object' ? raw.resume_preference : null;
147
- if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
148
- for (const [k, v] of Object.entries(cfg)) {
149
- if (typeof k !== 'string' || k.length === 0) continue;
150
- if (!Array.isArray(v)) continue;
151
- const list = v.filter((x) => typeof x === 'string' && x.length > 0);
152
- if (list.length > 0) merged[k] = list;
153
- }
154
- }
155
- }
156
- } catch { /* advisory — fall back to defaults */ }
157
- _resumePrefCache.set(projectRoot, merged);
158
- return merged;
159
- }
160
-
161
- /**
162
- * Test-only helper to clear the resume-preference cache. Used by tests that
163
- * mutate `.ijfw/swarm.json` and re-call `selectResumeAI` in the same process.
164
- * @internal
165
- */
166
- export function _resetResumePrefCache() {
167
- _resumePrefCache.clear();
168
- }
169
-
170
- // Kept for backwards compat with any direct importers.
171
- // eslint-disable-next-line no-unused-vars -- exported binding read by external consumers; keep for backcompat
172
- const RESUME_PREFERENCE = DEFAULT_RESUME_PREFERENCE;
173
-
174
- /**
175
- * Pick a resume AI for a truncated subagent.
176
- *
177
- * @param {object} args
178
- * @param {string} args.truncatedAI - AI that truncated ('claude' | 'gemini' | 'codex')
179
- * @param {string[]} [args.available] - AIs the orchestrator can dispatch to
180
- * @param {string} [args.lastFailureReason] - e.g. 'context_window'
181
- * @returns {string|null} - resume target, or null when blocked
182
- */
183
- export function selectResumeAI({
184
- truncatedAI,
185
- available = ['claude', 'gemini', 'codex'],
186
- lastFailureReason,
187
- projectRoot,
188
- } = {}) {
189
- if (typeof truncatedAI !== 'string' || truncatedAI.length === 0) {
190
- return null;
191
- }
192
-
193
- // gemini already has the largest practical context window of the three.
194
- // If it truncated *because of* context-window pressure, no alternative
195
- // gives us a larger window -- escalate instead of pretending to resume.
196
- if (lastFailureReason === 'context_window' && truncatedAI === 'gemini') {
197
- return null;
198
- }
199
-
200
- // v1.5.0 audit-MED-work-M1: read resume_preference from swarm.json with a
201
- // fall-through to DEFAULT_RESUME_PREFERENCE. Projects with `opencode` /
202
- // `aider` / `copilot` in their roster get cross-AI resume routing instead
203
- // of an escalate_to_user black hole.
204
- const prefMap = loadResumePreference(projectRoot);
205
- const preferred = prefMap[truncatedAI] || [];
206
- for (const candidate of preferred) {
207
- if (candidate === truncatedAI) continue;
208
- if (available.includes(candidate)) {
209
- return candidate;
210
- }
211
- }
212
- return null;
213
- }
214
-
215
- /**
216
- * Build a resume brief composing the original spec + checkpoint state.
217
- * Intentionally omits the Step 0 workspace-setup boilerplate: the resume
218
- * agent inherits the branch + worktree from the first attempt and skips it.
219
- *
220
- * @param {object} args
221
- * @param {string} args.originalSpec - original task brief, verbatim
222
- * @param {object} args.checkpoint - { filesWritten?, commitSha?, partialProgress? }
223
- * @param {string} args.fromAI - AI that truncated
224
- * @param {string} args.toAI - AI taking over
225
- * @returns {string}
226
- */
227
- export function buildResumeBrief({ originalSpec, checkpoint = {}, fromAI, toAI } = {}) {
228
- const spec = typeof originalSpec === 'string' ? originalSpec : '';
229
- const files = Array.isArray(checkpoint.filesWritten) ? checkpoint.filesWritten : [];
230
- const sha = typeof checkpoint.commitSha === 'string' ? checkpoint.commitSha : '';
231
- const partial = typeof checkpoint.partialProgress === 'string' ? checkpoint.partialProgress : '';
232
-
233
- const filesLine = files.length > 0 ? ` Files written: ${files.join(', ')}` : ' Files written: (none recorded)';
234
- const shaLine = sha ? ` Commit: ${sha}` : ' Commit: (none yet)';
235
- const partialLine = partial ? ` Partial progress: ${partial}` : ' Partial progress: (none recorded)';
236
-
237
- // r15-M6: estimate brief size and surface a context-window advisory. The
238
- // receiving model may have a SMALLER window than the one that truncated
239
- // (selectResumeAI refuses gemini→larger when reason is context_window, but
240
- // it can't know the receiver's exact window from here). Tell the receiver
241
- // to summarise rather than re-quote if the prior context approaches its cap.
242
- const approxTokens = Math.ceil((spec.length + filesLine.length + shaLine.length + partialLine.length) / 4);
243
- const budgetLine = `Approx prior-context tokens: ~${approxTokens}. If this brief plus your reply would exceed your context window, summarise the prior agent's "Files written" + "Partial progress" lines instead of quoting verbatim and proceed.`;
244
-
245
- return [
246
- spec,
247
- '',
248
- '---',
249
- `RESUME CONTEXT — Prior agent (${fromAI}) truncated. Already done:`,
250
- filesLine,
251
- shaLine,
252
- partialLine,
253
- '',
254
- budgetLine,
255
- '',
256
- `You are ${toAI}. Continue from here. Do NOT redo completed work.`,
257
- 'Skip workspace setup (Step 0) -- branch + worktree already exist.',
258
- ].join('\n');
259
- }
260
-
261
- /**
262
- * Decide what to do when a subagent report indicates truncation.
263
- *
264
- * @param {object} args
265
- * @param {object} args.parsed - parsed report (must carry ai + reason if known)
266
- * @param {object} args.ctx - orchestrator context; ctx.checkpoint may be present
267
- * @param {string[]} [args.available] - AIs available to dispatch
268
- * @returns {{action:'resume_with_alt_ai', toAI:string, brief:string}
269
- * | {action:'escalate_to_user', reason:string}}
270
- */
271
- export function handleTruncation({ parsed = {}, ctx = {}, available = ['claude', 'gemini', 'codex'] } = {}) {
272
- const truncatedAI = typeof parsed.ai === 'string' && parsed.ai.length > 0 ? parsed.ai : 'claude';
273
- const lastFailureReason = parsed.reason;
274
- const projectRoot = typeof ctx.projectRoot === 'string' ? ctx.projectRoot : undefined;
275
- const toAI = selectResumeAI({ truncatedAI, available, lastFailureReason, projectRoot });
276
-
277
- if (!toAI) {
278
- return {
279
- action: 'escalate_to_user',
280
- reason: lastFailureReason === 'context_window' && truncatedAI === 'gemini'
281
- ? 'context_window_exceeded_on_largest_ai'
282
- : 'no_alternate_ai_available',
283
- };
284
- }
285
-
286
- const checkpoint = ctx.checkpoint && typeof ctx.checkpoint === 'object' ? ctx.checkpoint : {};
287
- const originalSpec = typeof ctx.originalSpec === 'string' ? ctx.originalSpec : '';
288
- const brief = buildResumeBrief({ originalSpec, checkpoint, fromAI: truncatedAI, toAI });
289
-
290
- return { action: 'resume_with_alt_ai', toAI, brief };
291
- }
292
-
293
- // ---------------------------------------------------------------------------
294
- // v1.5.0 wire-W1.A — opt-in repo-map prefix for subagent (re)dispatch briefs
295
- // ---------------------------------------------------------------------------
296
- //
297
- // Production wire-up for `mcp-server/src/lib/repo-map.js`. The orchestrator-LLM
298
- // calls the `ijfw_state` MCP tool with `verb: 'subagent.post-done'` after every
299
- // subagent turn (v1.5.0 T13 absorbed the retired `ijfw_subagent_post_done`
300
- // tool). When the route decision is a redispatch, the LLM composes a NEW brief
301
- // for the next subagent.
302
- // With this wire active, the response payload carries `repoMapPrefix` — a
303
- // pre-built importance-ranked file summary the LLM prepends to the redispatch
304
- // brief, so the downstream subagent doesn't have to crawl the tree.
305
- //
306
- // Default OFF. Activates only when `IJFW_REPO_MAP=1` is set in the calling
307
- // process env AND a valid `projectRoot` is supplied. Pure no-op on miss.
308
- //
309
- // Failure modes are explicit: any throw inside buildRepoMap (permissions,
310
- // unreadable dirs, etc.) is swallowed and yields an empty prefix. This lets
311
- // the orchestrator-LLM remain on the existing redispatch path without
312
- // degrading.
313
-
314
- /**
315
- * Build an opt-in repo-map prefix block for a subagent dispatch brief.
316
- * Returns '' when env opt-in is missing OR projectRoot is invalid OR the
317
- * map build throws. Never throws back to the caller.
318
- *
319
- * @param {object} args
320
- * @param {string} [args.projectRoot]
321
- * @param {object} [args.env] defaults to process.env
322
- * @param {number} [args.budgetTokens] default 1000
323
- * @returns {Promise<string>} empty string when disabled or on error
324
- */
325
- export async function buildSubagentRepoMapPrefix({ projectRoot, env = process.env, budgetTokens } = {}) {
326
- if (!env || env.IJFW_REPO_MAP !== '1') return '';
327
- if (typeof projectRoot !== 'string' || projectRoot.length === 0) return '';
328
- try {
329
- const budget = (typeof budgetTokens === 'number' && budgetTokens > 0) ? budgetTokens : 1000;
330
- const repoMap = buildRepoMap({ rootDir: projectRoot, budgetTokens: budget });
331
- // compactBriefForSubagent with an empty baseBrief returns just the
332
- // header/footer-wrapped prefix block. We surface that as a standalone
333
- // string the orchestrator-LLM splices in front of its own brief.
334
- const { brief } = compactBriefForSubagent({ baseBrief: '', repoMap, maxPrefixTokens: budget });
335
- return typeof brief === 'string' ? brief : '';
336
- } catch {
337
- // buildRepoMap throws on missing/inaccessible rootDir; on any error we
338
- // fall back to "no prefix" rather than corrupt the redispatch payload.
339
- return '';
340
- }
341
- }
342
-
343
- /**
344
- * Async wrapper around `reviewSubagentReport`. Returns the same decision shape
345
- * but attaches `repoMapPrefix` (string) to any redispatch action when the
346
- * env opt-in is on. The orchestrator-LLM consumes the prefix as the leading
347
- * block of the next subagent's brief.
348
- *
349
- * @param {string} reportText
350
- * @param {object} ctx - same shape as reviewSubagentReport
351
- * @param {object} [env] - defaults to process.env
352
- * @returns {Promise<object>} - decision (sync output + optional prefix)
353
- */
354
- export async function reviewSubagentReportWithRepoMap(reportText, ctx, env = process.env) {
355
- const decision = reviewSubagentReport(reportText, ctx);
356
- if (decision && (decision.action === 'redispatch_needs_context' || decision.action === 'redispatch_with_context')) {
357
- decision.repoMapPrefix = await buildSubagentRepoMapPrefix({
358
- projectRoot: ctx?.projectRoot,
359
- env,
360
- });
361
- }
362
- return decision;
363
- }
364
-
365
- /**
366
- * Async variant of `handleTruncation` that, when the env opt-in is set,
367
- * prepends the repo-map prefix to the resume brief. Falls back byte-identical
368
- * to sync handleTruncation when the prefix is empty.
369
- *
370
- * @param {object} args - same shape as handleTruncation, plus env
371
- * @returns {Promise<object>}
372
- */
373
- export async function handleTruncationWithRepoMap({ parsed = {}, ctx = {}, available, env = process.env } = {}) {
374
- const decision = handleTruncation({ parsed, ctx, available });
375
- if (decision && decision.action === 'resume_with_alt_ai' && typeof decision.brief === 'string') {
376
- const prefix = await buildSubagentRepoMapPrefix({ projectRoot: ctx?.projectRoot, env });
377
- if (prefix.length > 0) {
378
- decision.brief = prefix + decision.brief;
379
- decision.repoMapApplied = true;
380
- }
381
- }
382
- return decision;
383
- }
384
-
385
- /**
386
- * Generic iterative loop runner with a composable termination predicate
387
- * (v1.5.0 audit-MED-work-M3). Closes the "MaxAttempts is the only stop
388
- * rule" gap by letting callers compose WallClockTimeout, TokenBudget,
389
- * FindingSeverity, etc. via the `or` / `and` combinators in
390
- * `./termination.js`.
391
- *
392
- * The loop calls `step(iter, state)` on each iteration, expecting either:
393
- * - `{ done: true, result }` — natural completion; loop returns `{result}`
394
- * - `{ done: false, state?: object }` — keep iterating; new state merged in
395
- *
396
- * If the termination predicate fires before `done: true`, the loop returns
397
- * `{ terminated: true, reason: 'termination', iter, state }`.
398
- *
399
- * The default predicate is MaxAttempts(3), matching the v1.4.4 N3 cap.
400
- *
401
- * @param {object} args
402
- * @param {(iter: number, state: object) => Promise<{done:boolean, result?:unknown, state?:object}>} args.step
403
- * @param {object} [args.initialState] starting state object (shallow-merged with step output)
404
- * @param {(iter:number, state:object) => boolean} [args.termination]
405
- * @returns {Promise<{result?:unknown, terminated?:boolean, reason?:string, iter:number, state:object}>}
406
- */
407
- export async function runLoop({ step, initialState = {}, termination } = {}) {
408
- if (typeof step !== 'function') {
409
- throw new TypeError('runLoop: step is required');
410
- }
411
- const stop = typeof termination === 'function' ? termination : defaultTermination();
412
- let state = { ...initialState };
413
- let iter = 0;
414
- // Sentinel cap so a broken `termination` predicate can't pin the loop.
415
- const HARD_CAP = 10_000;
416
- while (iter < HARD_CAP) {
417
- const out = await step(iter, state);
418
- if (out && out.state && typeof out.state === 'object') {
419
- state = { ...state, ...out.state };
420
- }
421
- if (out && out.done) {
422
- return { result: out.result, iter, state };
423
- }
424
- if (stop(iter, state)) {
425
- return { terminated: true, reason: 'termination', iter, state };
426
- }
427
- iter += 1;
428
- }
429
- return { terminated: true, reason: 'hard-cap', iter, state };
430
- }