@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.
@@ -0,0 +1,374 @@
1
+ /**
2
+ * debug-trident-trigger.js — v1.5.1: the LIVE production trigger for the
3
+ * Trident-powered debug loop (T29, debug-trident.js).
4
+ *
5
+ * --------------------------------------------------------------------------
6
+ * WHY THIS MODULE EXISTS
7
+ * --------------------------------------------------------------------------
8
+ *
9
+ * debug-trident.js (`runDebugCampaign`) is the cross-AI debug pillar — when a
10
+ * single-lens hypothesis tree stalls, it dispatches codex + gemini lenses in
11
+ * parallel to generate competing hypotheses. v1.5.1 W2.C wired it into
12
+ * `runPostDone` (post-done-runner.js) — but `runPostDone` is itself NOT on the
13
+ * live subagent-completion path. The live path is the `subagent.post-done`
14
+ * verb in state-sdk.js, which calls `runSelfCheck`, never `runPostDone`. So
15
+ * T29 was "tested but never firing in production".
16
+ *
17
+ * This module is the genuine wiring. It exposes ONE entrypoint —
18
+ * `maybeFireDebugTrident` — designed to be called fire-and-forget from the
19
+ * live gate-failure branch of the `subagent.post-done` verb. It mirrors the
20
+ * A-Mem auto-linking precedent in memory/fts5.js exactly:
21
+ *
22
+ * - The caller does NOT await it. The verb's return value and timing are
23
+ * unchanged — STATE-SDK-CONTRACT §8 classes `subagent.post-done` as a
24
+ * fast read verb; an inline blocking multi-lens AI call would violate
25
+ * that contract. The campaign runs in the background.
26
+ * - Env-gated. `IJFW_DEBUG_TRIDENT=1` enables it; default is OFF. (A-Mem's
27
+ * auto-linker is default-ON with an `IJFW_AUTOLINK_OFF` kill switch;
28
+ * debug-trident spawns EXTERNAL codex/gemini processes, so the safe
29
+ * default for an unattended gate-failure path is opt-in — but the
30
+ * env-gate + silent-no-op shape is identical.)
31
+ * - Silent no-op on missing deps. No codex/gemini CLI reachable, no
32
+ * dispatcher, a thrown import — every failure mode resolves to a quiet
33
+ * skip. It NEVER throws into the caller.
34
+ * - Result persistence. The competing-hypotheses output is written to an
35
+ * append-only JSONL receipt under `.ijfw/receipts/debug-campaigns.jsonl`
36
+ * so the dashboard / next phase can read it. (mirrors receipts.js).
37
+ *
38
+ * --------------------------------------------------------------------------
39
+ * THE DISPATCHER
40
+ * --------------------------------------------------------------------------
41
+ *
42
+ * runDebugCampaign needs a `tridentDispatch({ lens, evidencePack,
43
+ * currentHypotheses }) => { lens, hypotheses }`. IJFW's production multi-lens
44
+ * dispatcher is `defaultConvergeDispatch` in cross-orchestrator.js (the same
45
+ * one runPhaseEConverge + ijfw_cross_audit_converge use to spawn real
46
+ * codex/gemini). We obtain it via a DYNAMIC import() — static import would
47
+ * create a require cycle (cross-orchestrator.js → state-sdk.js telemetry →
48
+ * back here), and truncation.js was wired the same way (state-sdk.js:1246,
49
+ * commit 75e5894). The adapter wraps `defaultConvergeDispatch` (an audit
50
+ * dispatcher returning `{ verdict, findings }`) into the hypothesis-gen shape
51
+ * runDebugCampaign expects: each audit finding becomes one competing
52
+ * hypothesis row.
53
+ *
54
+ * Zero new prod deps. ESM. Node ≥18. No emoji.
55
+ */
56
+
57
+ import fs from 'node:fs';
58
+ import path from 'node:path';
59
+
60
+ import { runDebugCampaign } from './debug-trident.js';
61
+
62
+ /**
63
+ * Env gate. `IJFW_DEBUG_TRIDENT=1` (or `true`/`on`) turns the live trigger
64
+ * on. Default OFF — the campaign spawns external codex/gemini processes, so
65
+ * an unattended gate-failure path stays opt-in. Read on every call so a test
66
+ * harness can flip it without re-importing.
67
+ */
68
+ export function debugTridentEnabled() {
69
+ const v = String(process.env.IJFW_DEBUG_TRIDENT || '').trim().toLowerCase();
70
+ return v === '1' || v === 'true' || v === 'on' || v === 'yes';
71
+ }
72
+
73
+ /**
74
+ * Receipt path — append-only JSONL, sibling of the cross-run receipts file.
75
+ */
76
+ export function debugCampaignReceiptPath(projectRoot) {
77
+ return path.join(projectRoot, '.ijfw', 'receipts', 'debug-campaigns.jsonl');
78
+ }
79
+
80
+ // Cap on receipt entries — same MAX_RECEIPTS posture as receipts.js so the
81
+ // file never grows unbounded under a flapping gate.
82
+ const MAX_DEBUG_RECEIPTS = 100;
83
+
84
+ /**
85
+ * Append one debug-campaign record to the JSONL receipt. Best-effort: a
86
+ * write failure is swallowed (the campaign verdict is never altered by a
87
+ * diagnostic-write failure — mirrors fts5.js graph-errors.jsonl discipline).
88
+ */
89
+ function writeDebugCampaignReceipt(projectRoot, record) {
90
+ try {
91
+ const dest = debugCampaignReceiptPath(projectRoot);
92
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
93
+ fs.appendFileSync(dest, JSON.stringify(record) + '\n');
94
+ // Prune to the last MAX_DEBUG_RECEIPTS lines.
95
+ try {
96
+ const raw = fs.readFileSync(dest, 'utf8');
97
+ const lines = raw.split('\n').filter((l) => l.trim());
98
+ if (lines.length > MAX_DEBUG_RECEIPTS) {
99
+ fs.writeFileSync(dest, lines.slice(-MAX_DEBUG_RECEIPTS).join('\n') + '\n');
100
+ }
101
+ } catch { /* prune is best-effort */ }
102
+ } catch { /* receipt write must never throw into the caller */ }
103
+ }
104
+
105
+ /**
106
+ * Read all debug-campaign receipts for a project. Skips corrupt lines.
107
+ * Used by the integration test + (future) the dashboard.
108
+ */
109
+ export function readDebugCampaignReceipts(projectRoot) {
110
+ const file = debugCampaignReceiptPath(projectRoot);
111
+ if (!fs.existsSync(file)) return [];
112
+ const out = [];
113
+ try {
114
+ const raw = fs.readFileSync(file, 'utf8');
115
+ for (const line of raw.split('\n')) {
116
+ if (!line.trim()) continue;
117
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
118
+ }
119
+ } catch { /* unreadable -> empty */ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Build the production `tridentDispatch` function runDebugCampaign needs.
125
+ *
126
+ * `defaultConvergeDispatch` (cross-orchestrator.js) is an AUDIT dispatcher:
127
+ * it spawns a lens CLI with an audit prompt and returns
128
+ * `{ lens, verdict, findings:[...] }`. runDebugCampaign instead wants a
129
+ * hypothesis generator returning `{ lens, hypotheses:[{hypothesis,rationale}] }`.
130
+ * The adapter bridges the two: the evidence pack is handed to the lens as the
131
+ * audit target, and each returned finding is mapped to one competing
132
+ * hypothesis (finding text → hypothesis, severity/category → rationale).
133
+ *
134
+ * A lens that is unreachable returns verdict UNREACHABLE / zero findings,
135
+ * which the adapter passes through as zero hypotheses — runDebugCampaign
136
+ * already treats that as a non-contributing lens (no crash).
137
+ *
138
+ * Returns `null` when the dispatcher cannot be loaded at all (missing
139
+ * module) — the caller treats that as a silent no-op.
140
+ */
141
+ export async function buildTridentDispatch() {
142
+ let defaultConvergeDispatch;
143
+ try {
144
+ // DYNAMIC import — avoids a static require cycle through
145
+ // cross-orchestrator.js -> receipts/telemetry -> state-sdk.js.
146
+ ({ defaultConvergeDispatch } = await import('../cross-orchestrator.js'));
147
+ } catch {
148
+ return null;
149
+ }
150
+ if (typeof defaultConvergeDispatch !== 'function') return null;
151
+
152
+ return async function tridentDispatch({ lens, evidencePack, currentHypotheses, signal } = {}) {
153
+ // Embed the already-tried hypotheses so the lens avoids re-proposing
154
+ // refuted theory (runDebugCampaign also dedups, this just saves tokens).
155
+ const triedBlock = Array.isArray(currentHypotheses) && currentHypotheses.length > 0
156
+ ? '\n\n## Hypotheses already considered (propose DIFFERENT ones)\n'
157
+ + currentHypotheses
158
+ .map((h) => `- ${h && h.hypothesis ? h.hypothesis : ''} `
159
+ + `[${h && h.status ? h.status : 'open'}]`)
160
+ .join('\n')
161
+ : '';
162
+ const target = `## Stalled debug investigation — generate competing root-cause hypotheses\n\n`
163
+ + `${typeof evidencePack === 'string' ? evidencePack : ''}${triedBlock}`;
164
+ let raw;
165
+ try {
166
+ raw = await defaultConvergeDispatch({
167
+ lens,
168
+ commitRange: target,
169
+ iteration: 1,
170
+ cycleSummary: null,
171
+ signal: signal || null,
172
+ });
173
+ } catch (err) {
174
+ return { lens, hypotheses: [], ok: false, reason: err && err.message ? err.message : String(err) };
175
+ }
176
+ const findings = raw && Array.isArray(raw.findings) ? raw.findings : [];
177
+ const hypotheses = findings
178
+ .map((f) => {
179
+ if (!f || typeof f !== 'object') return null;
180
+ const text = typeof f.finding === 'string' && f.finding.trim()
181
+ ? f.finding.trim()
182
+ : (typeof f.title === 'string' ? f.title.trim() : '');
183
+ if (!text) return null;
184
+ const rationaleParts = [];
185
+ if (f.severity) rationaleParts.push(`severity:${f.severity}`);
186
+ if (f.category) rationaleParts.push(`category:${f.category}`);
187
+ if (typeof f.rationale === 'string' && f.rationale.trim()) {
188
+ rationaleParts.push(f.rationale.trim());
189
+ }
190
+ return { hypothesis: text, rationale: rationaleParts.join(' ') };
191
+ })
192
+ .filter(Boolean);
193
+ return { lens, hypotheses };
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Fire-and-forget entrypoint — call this from the LIVE `subagent.post-done`
199
+ * gate-failure branch. It returns IMMEDIATELY; the actual campaign runs in a
200
+ * detached promise. The verb's return value and timing are unchanged.
201
+ *
202
+ * @param {object} opts
203
+ * @param {string} opts.projectRoot project root (where `.ijfw/` lives)
204
+ * @param {string} [opts.subagentId] the subagent whose gate failed
205
+ * @param {string} [opts.reason] the gate-failure reason string
206
+ * @param {string} [opts.reportText] the subagent's DONE report (evidence)
207
+ * @param {object} [opts.selfCheck] the failed self-check result (evidence)
208
+ * @returns {void} nothing — never throws
209
+ *
210
+ * Diagnostic hook: `maybeFireDebugTrident.__lastCampaignPromise` holds the
211
+ * most recent background promise so integration tests can `await` it before
212
+ * asserting on the receipt (mirrors `indexEntry.__lastAutoLinkPromise` in
213
+ * memory/fts5.js). Production callers do NOT read this.
214
+ */
215
+ export function maybeFireDebugTrident(opts = {}) {
216
+ // Gate 1 — env opt-in. Disabled => true no-op. No promise, no receipt.
217
+ if (!debugTridentEnabled()) {
218
+ maybeFireDebugTrident.__lastCampaignPromise = null;
219
+ return;
220
+ }
221
+ const projectRoot = typeof opts.projectRoot === 'string' && opts.projectRoot
222
+ ? opts.projectRoot : null;
223
+ if (!projectRoot) {
224
+ maybeFireDebugTrident.__lastCampaignPromise = null;
225
+ return;
226
+ }
227
+
228
+ const subagentId = typeof opts.subagentId === 'string' && opts.subagentId
229
+ ? opts.subagentId : 'unknown';
230
+ const reason = typeof opts.reason === 'string' ? opts.reason : 'gate-failure';
231
+ const reportText = typeof opts.reportText === 'string' ? opts.reportText : '';
232
+ const selfCheck = opts.selfCheck && typeof opts.selfCheck === 'object'
233
+ ? opts.selfCheck : null;
234
+ // A test-supplied dispatcher (stub) short-circuits the dynamic import —
235
+ // lets the integration test prove the wiring fires WITHOUT spawning real
236
+ // codex/gemini. Production callers never pass this.
237
+ const injectedDispatch = typeof opts.tridentDispatch === 'function'
238
+ ? opts.tridentDispatch : null;
239
+
240
+ // Compose the evidence pack from the gate-failure context. This is what
241
+ // the codex/gemini lenses reason over.
242
+ const evidenceLines = [
243
+ `Subagent ${subagentId} failed its post-done self-check gate.`,
244
+ `Gate-failure reason: ${reason}`,
245
+ ];
246
+ if (selfCheck) {
247
+ if (Array.isArray(selfCheck.files_missing) && selfCheck.files_missing.length) {
248
+ evidenceLines.push(`Missing claimed files: ${selfCheck.files_missing.join(', ')}`);
249
+ }
250
+ if (Array.isArray(selfCheck.commits_missing) && selfCheck.commits_missing.length) {
251
+ evidenceLines.push(`Missing claimed commits: ${selfCheck.commits_missing.join(', ')}`);
252
+ }
253
+ }
254
+ if (reportText) {
255
+ evidenceLines.push('', '--- Subagent DONE report ---', reportText.slice(0, 4000));
256
+ }
257
+ const evidencePack = evidenceLines.join('\n');
258
+
259
+ // The seed hypothesis — the single-lens reading that "stalled" (the gate
260
+ // failed). Trident dispatches codex+gemini for competing alternatives.
261
+ const seedHypotheses = [
262
+ {
263
+ id: 'H1',
264
+ hypothesis: `Subagent ${subagentId} reported DONE but did not produce the claimed artifacts (${reason}).`,
265
+ status: 'open',
266
+ evidence: reason,
267
+ refuted_by: '',
268
+ },
269
+ ];
270
+
271
+ // Fire-and-forget — NOT awaited. Any failure resolves to a quiet skip.
272
+ const campaignPromise = (async () => {
273
+ let tridentDispatch = injectedDispatch;
274
+ if (!tridentDispatch) {
275
+ tridentDispatch = await buildTridentDispatch();
276
+ }
277
+ if (typeof tridentDispatch !== 'function') {
278
+ // No dispatcher (missing module / missing CLIs) — silent no-op.
279
+ writeDebugCampaignReceipt(projectRoot, {
280
+ ts: new Date().toISOString(),
281
+ subagentId,
282
+ reason,
283
+ outcome: 'skipped',
284
+ skipReason: 'no-trident-dispatcher',
285
+ });
286
+ return { skipped: true, skipReason: 'no-trident-dispatcher' };
287
+ }
288
+
289
+ // The per-cycle `dispatch` for the live trigger: cycle 1 reports the
290
+ // single-lens stall (INVESTIGATION_INCONCLUSIVE) so the campaign
291
+ // immediately escalates to Trident; cycle 2 reports inconclusive again
292
+ // so the campaign terminates cleanly once competing hypotheses exist.
293
+ // The point of the LIVE trigger is to GENERATE the competing-hypotheses
294
+ // set off a real gate failure, not to auto-resolve the bug — resolution
295
+ // is the ijfw-debugger agent's job, this just seeds it.
296
+ let stallCycles = 0;
297
+ const dispatch = async () => {
298
+ stallCycles += 1;
299
+ return { terminator: 'INVESTIGATION_INCONCLUSIVE' };
300
+ };
301
+
302
+ let campaign;
303
+ try {
304
+ campaign = await runDebugCampaign({
305
+ sessionId: `gate-failure-${subagentId}`,
306
+ symptoms: `post-done self-check FAILED for ${subagentId} — ${reason}`,
307
+ hypotheses: seedHypotheses,
308
+ dispatch,
309
+ tridentDispatch,
310
+ tridentLenses: ['codex', 'gemini'],
311
+ maxCycles: 2,
312
+ evidencePack,
313
+ projectRoot,
314
+ // recordTelemetry stays on (default) — the campaign also writes a
315
+ // telemetry.record via the state-SDK, same as the unit-tested path.
316
+ });
317
+ } catch (err) {
318
+ writeDebugCampaignReceipt(projectRoot, {
319
+ ts: new Date().toISOString(),
320
+ subagentId,
321
+ reason,
322
+ outcome: 'failed',
323
+ error: err && err.message ? err.message : String(err),
324
+ });
325
+ return { skipped: false, error: err && err.message ? err.message : String(err) };
326
+ }
327
+ void stallCycles;
328
+
329
+ // Persist the competing-hypotheses output. This is the receipt the
330
+ // dashboard / next phase reads — the campaign output is NOT lost.
331
+ const competing = Array.isArray(campaign.hypothesesFinal)
332
+ ? campaign.hypothesesFinal.filter(
333
+ (h) => h && typeof h.from === 'string' && h.from.startsWith('trident:'),
334
+ )
335
+ : [];
336
+ writeDebugCampaignReceipt(projectRoot, {
337
+ ts: new Date().toISOString(),
338
+ subagentId,
339
+ reason,
340
+ outcome: campaign.outcome,
341
+ sessionId: campaign.sessionId,
342
+ cycles: campaign.cycles,
343
+ stalls: campaign.stalls,
344
+ tridentInvocations: campaign.tridentInvocations,
345
+ hypothesesAdded: campaign.hypothesesAdded,
346
+ competingHypotheses: competing.map((h) => ({
347
+ id: h.id,
348
+ from: h.from,
349
+ hypothesis: h.hypothesis,
350
+ rationale: h.rationale || '',
351
+ })),
352
+ durationMs: campaign.duration_ms,
353
+ });
354
+ return { skipped: false, campaign };
355
+ })().catch((err) => {
356
+ // Last-resort guard — the background promise must NEVER surface an
357
+ // unhandled rejection. Best-effort receipt, then swallow.
358
+ try {
359
+ writeDebugCampaignReceipt(projectRoot, {
360
+ ts: new Date().toISOString(),
361
+ subagentId,
362
+ reason,
363
+ outcome: 'failed',
364
+ error: err && err.message ? err.message : String(err),
365
+ });
366
+ } catch { /* nothing more we can do */ }
367
+ return { skipped: false, error: err && err.message ? err.message : String(err) };
368
+ });
369
+
370
+ // Expose for deterministic test awaiting only.
371
+ maybeFireDebugTrident.__lastCampaignPromise = campaignPromise;
372
+ }
373
+
374
+ maybeFireDebugTrident.__lastCampaignPromise = null;
@@ -1,16 +1,27 @@
1
1
  /**
2
- * post-done-runner.js — v1.5.0-major S02: enforced post-DONE pipeline.
2
+ * post-done-runner.js — v1.5.0-major S02: post-DONE pipeline primitives.
3
3
  *
4
- * Runs after a subagent's DONE has been verified by runtime-loop.js. Wraps
5
- * reviewTask (v1.4.4 N3 two-stage review) and checkVerificationGate
6
- * (v1.4.4 N5) into a single callable the orchestrator-LLM invokes via MCP,
7
- * so the post-DONE contract isn't satisfied by markdown prose.
4
+ * WHAT IS LIVE: `runSelfCheck` is the only export on the production path. The
5
+ * live DONE-handler is the `subagent.post-done` state-SDK verb, which calls
6
+ * `runSelfCheck` directly (and fires debug-trident via debug-trident-trigger.js
7
+ * on a self-check failure). The verification gate itself is also enforced live
8
+ * — `state-sdk.js` calls `enforceVerificationGate` directly.
9
+ *
10
+ * WHAT IS NOT LIVE: `runPostDone` is a library/test surface — NOT the live
11
+ * DONE-handler. It is a convenience wrapper that bundles reviewTask (v1.4.4 N3
12
+ * two-stage review) + checkVerificationGate (v1.4.4 N5) for direct-import
13
+ * callers and the test path (`test-orchestrator-post-done-runner.js`). The
14
+ * production two-stage spec+quality review happens via agent dispatch
15
+ * (spec-reviewer + quality-reviewer agents), not through this wrapper. Its
16
+ * original S02 caller (`runtime-loop.js`) was never wired; that file is now
17
+ * removed. `runPostDone` is kept for its test surface and for any future
18
+ * caller that wants the two checks bundled — it does not carry production
19
+ * traffic today.
8
20
  *
9
21
  * v1.5.0 T13: the standalone `ijfw_subagent_post_done` MCP tool was retired and
10
22
  * absorbed into the single `ijfw_state` MCP tool as the `subagent.post-done`
11
23
  * verb (see STATE-SDK-CONTRACT §7). `runSelfCheck` is re-exported through
12
- * `state-sdk.js` for that verb; `runPostDone` is still exported here for the
13
- * direct-import test path (`test-orchestrator-post-done-runner.js`).
24
+ * `state-sdk.js` for that verb.
14
25
  *
15
26
  * Outcome shape (uniform regardless of branch taken):
16
27
  * {
@@ -46,6 +57,13 @@ import { existsSync } from 'node:fs';
46
57
  import { execFileSync } from 'node:child_process';
47
58
  import { reviewTask } from './review.js';
48
59
  import { checkVerificationGate, recordViolation } from './verification-gate.js';
60
+ // debug-trident (T29) is wired on the LIVE path only: `subagent.post-done` in
61
+ // state-sdk.js fires debug-trident fire-and-forget when its self-check gate
62
+ // fails, via `maybeFireDebugTrident` in debug-trident-trigger.js. That is the
63
+ // genuine production caller — codex+gemini are dispatched against the real
64
+ // gate-failure evidence whenever IJFW_DEBUG_TRIDENT is enabled. runPostDone
65
+ // deliberately does NOT invoke debug-trident (the earlier W2.C inline-
66
+ // annotation hook was dead — computed but never returned — and was removed).
49
67
 
50
68
  /**
51
69
  * Extract paths claimed in the report. Naive but effective: looks for
@@ -123,7 +141,17 @@ export function runSelfCheck(reportText, projectRoot) {
123
141
  }
124
142
 
125
143
  /**
126
- /**
144
+ * runPostDone — library/test surface. NOT the live DONE-handler.
145
+ *
146
+ * The live subagent-completion path is the `subagent.post-done` state-SDK verb
147
+ * (which runs `runSelfCheck` + fires debug-trident on failure), plus the
148
+ * verification gate enforced directly in `state-sdk.js`; the production
149
+ * two-stage spec+quality review runs via agent dispatch (spec-reviewer +
150
+ * quality-reviewer agents). This wrapper bundles reviewTask (N3) +
151
+ * checkVerificationGate (N5) + runSelfCheck (S09) for direct-import callers
152
+ * and `test-orchestrator-post-done-runner.js`. It carries no production
153
+ * traffic — keep it honest: do not describe it as the live handler.
154
+ *
127
155
  * @param {object} params
128
156
  * @param {string} params.taskId
129
157
  * @param {string} [params.taskSpec]