@ijfw/memory-server 1.5.0 → 1.5.3
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/bin/ijfw-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- package/package.json +8 -4
- package/src/brain/budget-guard.js +86 -0
- package/src/brain/citation-resolver.js +41 -0
- package/src/brain/context-injection.js +69 -0
- package/src/brain/discovery.js +83 -0
- package/src/brain/dream-pipeline.js +324 -0
- package/src/brain/dump-ingest.js +88 -0
- package/src/brain/entity-collapse.js +28 -0
- package/src/brain/export.js +112 -0
- package/src/brain/extractors/index.js +24 -0
- package/src/brain/extractors/markdown.js +27 -0
- package/src/brain/extractors/pdf.js +31 -0
- package/src/brain/extractors/transcript.js +38 -0
- package/src/brain/first-run-scan.js +61 -0
- package/src/brain/index.js +1 -0
- package/src/brain/layout-sentinel.js +29 -0
- package/src/brain/migrate-facts-internal-once.js +87 -0
- package/src/brain/path-guard.js +103 -0
- package/src/brain/paths.js +26 -0
- package/src/brain/promotion-suggester.js +41 -0
- package/src/brain/stub-detector.js +33 -0
- package/src/brain/tiered-llm.js +83 -0
- package/src/brain/wiki-compiler.js +144 -0
- package/src/brain/wiki-sentinels.js +45 -0
- package/src/brain/wiki-templates.js +94 -0
- package/src/cross-orchestrator-cli.js +336 -150
- package/src/cross-orchestrator.js +52 -3
- package/src/dashboard-server.js +1 -1
- package/src/dispatch/extension.js +1 -1
- package/src/dream/runner.mjs +21 -0
- package/src/extension-registry.js +2 -2
- package/src/handlers/brain-handler.js +319 -0
- package/src/hardware-signer.js +4 -2
- package/src/lib/ui-review-runner.js +48 -7
- package/src/memory/auto-linker.js +121 -2
- package/src/memory/benchmark.js +4 -3
- package/src/memory/layout-migrations/001-visible-layer.js +131 -0
- package/src/memory/layout-migrations/index.js +50 -0
- package/src/memory/migration-runner.js +37 -3
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +65 -2
- package/src/memory/reader.js +2 -1
- package/src/memory/search.js +190 -41
- package/src/memory/temporal.js +40 -1
- package/src/orchestrator/agents-md-blackboard.js +114 -1
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/discipline-selector.js +276 -0
- package/src/orchestrator/merge-block-aware.js +15 -5
- package/src/orchestrator/post-done-runner.js +36 -8
- package/src/orchestrator/state-sdk.js +216 -10
- package/src/orchestrator/subagent-telemetry.js +19 -0
- package/src/orchestrator/wave-state.js +38 -0
- package/src/override-resolver.js +5 -3
- package/src/recovery/code-fixer.js +311 -6
- package/src/runtime-mediator.js +0 -1
- package/src/server.js +486 -132
- package/src/swarm-config.js +30 -22
- package/src/team/domain-templates/business.json +4 -1
- package/src/team/domain-templates/research.json +4 -1
- package/src/team/generator.js +162 -0
- package/src/update-apply.js +1 -1
- package/src/dashboard-charts.js +0 -239
- package/src/orchestrator/runtime-loop.js +0 -430
|
@@ -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;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discipline-selector.js — pure functions to pick and load a project-discipline
|
|
3
|
+
* template body for the DISCIPLINE AGENTS.md marker block.
|
|
4
|
+
*
|
|
5
|
+
* Two exports:
|
|
6
|
+
*
|
|
7
|
+
* selectDisciplineTemplate(projectType)
|
|
8
|
+
* Synchronous. Returns the template body string (utf-8) for the given
|
|
9
|
+
* project type, or an empty string for `unknown` / `mixed`. Throws a
|
|
10
|
+
* TypeError when projectType is null or undefined. Resolves template
|
|
11
|
+
* paths relative to this module, using the same fileURLToPath + dirname
|
|
12
|
+
* pattern as merge-block-aware.js.
|
|
13
|
+
*
|
|
14
|
+
* detectProjectTypeFromRepo(repoRoot)
|
|
15
|
+
* Synchronous, deterministic. Inspects the repo root directory to infer
|
|
16
|
+
* project type. Priority order:
|
|
17
|
+
* (a) .ijfw/memory/brief.md frontmatter `type` key — highest fidelity,
|
|
18
|
+
* set explicitly by the user or brainstorm-LOCK hook.
|
|
19
|
+
* (b) Well-known file/dir signals — cheap existsSync probes, no glob.
|
|
20
|
+
* Returns one of: 'code' | 'narrative' | 'business' | 'design' |
|
|
21
|
+
* 'research' | 'unknown'
|
|
22
|
+
*
|
|
23
|
+
* No deps beyond node:fs and node:path (Node >=18, ESM).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { validateSafeRepoPath } from '../brain/path-guard.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Module-relative template root
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
36
|
+
const __dirname = dirname(__filename);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Root of the ijfw-agents-md templates directory, resolved relative to this
|
|
40
|
+
* module. Same anchor pattern used by merge-block-aware.js for DEFAULT_TEMPLATE.
|
|
41
|
+
*/
|
|
42
|
+
const TEMPLATES_DIR = join(
|
|
43
|
+
__dirname,
|
|
44
|
+
'..', '..', '..', 'claude', 'skills', 'ijfw-agents-md', 'templates',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Valid project types
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** All recognized project type codes (non-empty body returned for these). */
|
|
52
|
+
const TYPED_CODES = new Set(['code', 'narrative', 'business', 'design', 'research']);
|
|
53
|
+
|
|
54
|
+
/** Project types that legitimately produce an empty template body. */
|
|
55
|
+
const EMPTY_BODY_CODES = new Set(['unknown', 'mixed']);
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// selectDisciplineTemplate
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return the template body string for `projectType`.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} projectType one of: code | narrative | business | design |
|
|
65
|
+
* research | unknown | mixed
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {string} [opts.templatesDir] Override the templates directory. Used
|
|
68
|
+
* by tests to exercise the missing-file
|
|
69
|
+
* Error path against a directory that
|
|
70
|
+
* contains no `discipline-*.md` files
|
|
71
|
+
* (W3 D1 / L2-04 follow-up). Default:
|
|
72
|
+
* shipped TEMPLATES_DIR.
|
|
73
|
+
* @returns {string} template body (utf-8). For unknown/mixed (and any
|
|
74
|
+
* unrecognized string), returns an HTML-comment hint
|
|
75
|
+
* body documenting how the user activates a domain.
|
|
76
|
+
* @throws {TypeError} when projectType is null, undefined, or non-string.
|
|
77
|
+
* @throws {Error} when the template file is absent for a typed project.
|
|
78
|
+
*/
|
|
79
|
+
export function selectDisciplineTemplate(projectType, opts = {}) {
|
|
80
|
+
if (projectType === null || projectType === undefined) {
|
|
81
|
+
throw new TypeError(
|
|
82
|
+
'selectDisciplineTemplate: projectType must be a string (got null/undefined)',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (typeof projectType !== 'string') {
|
|
86
|
+
throw new TypeError(
|
|
87
|
+
`selectDisciplineTemplate: projectType must be a string (got ${typeof projectType})`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const type = String(projectType).trim().toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Helpful hint body for "no domain rules to apply" cases. Replaces what
|
|
94
|
+
// was previously an empty marker block (Wave 5B finding L3-03 + L3-04):
|
|
95
|
+
// a labelled-but-empty DISCIPLINE region left users unsure what to do.
|
|
96
|
+
// The hint is an HTML comment so it stays invisible in rendered markdown
|
|
97
|
+
// but visible to anyone reading the raw AGENTS.md, and documents the
|
|
98
|
+
// correction path inline.
|
|
99
|
+
const emptyBodyHint = (label) =>
|
|
100
|
+
`<!-- IJFW: project type is "${label}" -- domain-specific discipline rules not yet activated.\n`
|
|
101
|
+
+ `To activate, set frontmatter \`type: code\` (or narrative | business | design | research)\n`
|
|
102
|
+
+ `in .ijfw/memory/brief.md and re-run the brainstorm-LOCK or plan-LOCK in the IJFW workflow. -->`;
|
|
103
|
+
|
|
104
|
+
if (EMPTY_BODY_CODES.has(type)) {
|
|
105
|
+
return emptyBodyHint(type);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!TYPED_CODES.has(type)) {
|
|
109
|
+
// Unrecognized type string -- treat as 'unknown' for the hint label so
|
|
110
|
+
// the message stays sensible, rather than echoing whatever garbage came
|
|
111
|
+
// in. The graceful-degradation policy is preserved: no throw.
|
|
112
|
+
return emptyBodyHint('unknown');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const templatesDir = (opts && typeof opts.templatesDir === 'string')
|
|
116
|
+
? opts.templatesDir
|
|
117
|
+
: TEMPLATES_DIR;
|
|
118
|
+
const templatePath = join(templatesDir, `discipline-${type}.md`);
|
|
119
|
+
if (!existsSync(templatePath)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`selectDisciplineTemplate: template file missing for type "${type}" at ${templatePath}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return readFileSync(templatePath, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// detectProjectTypeFromRepo -- frontmatter + file-signal fallback
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Inline frontmatter parser -- minimal, zero deps.
|
|
134
|
+
* Reads only the `type` key from the first YAML fence (--- ... ---).
|
|
135
|
+
*
|
|
136
|
+
* Mirrors parseFrontmatter in mcp-server/src/memory/reader.js.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} raw file contents
|
|
139
|
+
* @returns {string|null} value of `type` key, or null if absent
|
|
140
|
+
*/
|
|
141
|
+
function extractTypeFromFrontmatter(raw) {
|
|
142
|
+
const stripped = String(raw).replace(/^/, '').replace(/^\s+/, '');
|
|
143
|
+
const m = stripped.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
144
|
+
if (!m) return null;
|
|
145
|
+
for (const line of m[1].split('\n')) {
|
|
146
|
+
const kv = line.match(/^type:\s*(.+)/i);
|
|
147
|
+
if (kv) return kv[1].trim().toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Return true if any direct child entry name of `dir` satisfies `predicate`.
|
|
154
|
+
* Best-effort: returns false on any readdir error.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} dir
|
|
157
|
+
* @param {(name: string) => boolean} predicate
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
function hasDirEntryMatching(dir, predicate) {
|
|
161
|
+
try {
|
|
162
|
+
const entries = readdirSync(dir);
|
|
163
|
+
return entries.some(predicate);
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Return true if `name` is a direct child of `repoRoot` that is a directory
|
|
171
|
+
* AND contains at least one `.md` file. Avoids false-positive on plain files
|
|
172
|
+
* named `chapters` or `manuscript`.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} repoRoot
|
|
175
|
+
* @param {string} name directory name to check
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function isDirWithMd(repoRoot, name) {
|
|
179
|
+
const p = join(repoRoot, name);
|
|
180
|
+
try {
|
|
181
|
+
if (!statSync(p).isDirectory()) return false;
|
|
182
|
+
return readdirSync(p).some((n) => n.endsWith('.md'));
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Detect the project type from a repository root.
|
|
190
|
+
*
|
|
191
|
+
* Priority:
|
|
192
|
+
* (a) .ijfw/memory/brief.md frontmatter `type` key -- set by brainstorm-LOCK.
|
|
193
|
+
* (b) Well-known file/dir signals via existsSync (no glob, cheap).
|
|
194
|
+
*
|
|
195
|
+
* Signal table (first match wins within each tier):
|
|
196
|
+
* code: package.json | tsconfig.json | Cargo.toml | go.mod |
|
|
197
|
+
* *.csproj | pyproject.toml | setup.py | Gemfile
|
|
198
|
+
* narrative: chapters/ dir | manuscript/ dir
|
|
199
|
+
* business: pitch-deck* | business-plan* | *.numbers
|
|
200
|
+
* design: figma-* | *.sketch | design-system/ dir
|
|
201
|
+
* research: research/ dir | notebooks/ dir | *.ipynb
|
|
202
|
+
* unknown: fallback
|
|
203
|
+
*
|
|
204
|
+
* @param {string} repoRoot absolute path to repository root
|
|
205
|
+
* @returns {'code'|'narrative'|'business'|'design'|'research'|'unknown'}
|
|
206
|
+
*/
|
|
207
|
+
export function detectProjectTypeFromRepo(repoRoot) {
|
|
208
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
209
|
+
return 'unknown';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// (a) Brief.md frontmatter -- highest fidelity
|
|
213
|
+
const briefPath = join(repoRoot, '.ijfw', 'memory', 'brief.md');
|
|
214
|
+
if (existsSync(briefPath)) {
|
|
215
|
+
try {
|
|
216
|
+
const guard = validateSafeRepoPath(repoRoot, briefPath);
|
|
217
|
+
if (guard.ok) {
|
|
218
|
+
const briefStat = statSync(briefPath);
|
|
219
|
+
if (briefStat.size <= 256 * 1024) {
|
|
220
|
+
const raw = readFileSync(briefPath, 'utf8');
|
|
221
|
+
const type = extractTypeFromFrontmatter(raw);
|
|
222
|
+
if (type && (TYPED_CODES.has(type) || EMPTY_BODY_CODES.has(type))) {
|
|
223
|
+
return /** @type {any} */ (type);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// brief.md too large -- fall through to file signals
|
|
227
|
+
}
|
|
228
|
+
// symlink outside repo (guard.ok === false) -- skip frontmatter
|
|
229
|
+
} catch {
|
|
230
|
+
// Brief read failure is non-fatal -- fall through to file signals.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// (b) File-signal fallback -- read root entries once, then use cached list.
|
|
235
|
+
// Priority: earlier tier wins. e.g., pyproject.toml beats notebooks/ when both present (code projects may contain notebooks).
|
|
236
|
+
let _entries = null;
|
|
237
|
+
try { _entries = readdirSync(repoRoot); } catch { _entries = []; }
|
|
238
|
+
function has(predicate) { return _entries.some(predicate); }
|
|
239
|
+
|
|
240
|
+
// --- code signals ---
|
|
241
|
+
const CODE_FILES = [
|
|
242
|
+
'package.json', 'tsconfig.json', 'Cargo.toml', 'go.mod',
|
|
243
|
+
'pyproject.toml', 'setup.py', 'Gemfile',
|
|
244
|
+
];
|
|
245
|
+
for (const f of CODE_FILES) {
|
|
246
|
+
if (_entries.includes(f)) return 'code';
|
|
247
|
+
}
|
|
248
|
+
// *.csproj -- project-named file; scan cached root entries.
|
|
249
|
+
if (has((n) => n.endsWith('.csproj'))) return 'code';
|
|
250
|
+
|
|
251
|
+
// --- narrative signals --- (must be a directory containing at least one .md)
|
|
252
|
+
if (isDirWithMd(repoRoot, 'chapters') || isDirWithMd(repoRoot, 'manuscript')) return 'narrative';
|
|
253
|
+
|
|
254
|
+
// --- business signals ---
|
|
255
|
+
if (
|
|
256
|
+
has((n) => n.startsWith('pitch-deck')) ||
|
|
257
|
+
has((n) => n.startsWith('business-plan')) ||
|
|
258
|
+
has((n) => n.endsWith('.numbers'))
|
|
259
|
+
) return 'business';
|
|
260
|
+
|
|
261
|
+
// --- design signals ---
|
|
262
|
+
if (
|
|
263
|
+
has((n) => n.startsWith('figma-')) ||
|
|
264
|
+
has((n) => n.endsWith('.sketch')) ||
|
|
265
|
+
_entries.includes('design-system')
|
|
266
|
+
) return 'design';
|
|
267
|
+
|
|
268
|
+
// --- research signals ---
|
|
269
|
+
if (
|
|
270
|
+
_entries.includes('research') ||
|
|
271
|
+
_entries.includes('notebooks') ||
|
|
272
|
+
has((n) => n.endsWith('.ipynb'))
|
|
273
|
+
) return 'research';
|
|
274
|
+
|
|
275
|
+
return 'unknown';
|
|
276
|
+
}
|