@stravica-ai/digital-operator-pa 0.1.64 → 0.1.66
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/dist/engineering-pa-cli/src/research/README.md +42 -7
- package/dist/engineering-pa-cli/src/research/cli.mjs +9 -0
- package/dist/engineering-pa-cli/src/research/commands/sources.mjs +64 -0
- package/dist/engineering-pa-cli/src/research/engine/deep.mjs +2 -3
- package/dist/engineering-pa-cli/src/research/engine/deepen.mjs +21 -1
- package/dist/engineering-pa-cli/src/research/engine/finalise.mjs +168 -30
- package/dist/engineering-pa-cli/src/research/engine/plan.mjs +17 -2
- package/dist/engineering-pa-cli/src/research/schema/plan.schema.json +6 -0
- package/dist/engineering-pa-cli/src/research/store/plan.mjs +7 -1
- package/dist/engineering-pa-cli/src/research/store/sources.mjs +29 -0
- package/dist/engineering-pa-cli/src/research/workflows/research-deep.js +213 -39
- package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +316 -73
- package/dist/engineering-pa-cli/src/research/wrappers/deep.mjs +7 -4
- package/dist/engineering-pa-cli/src/research/wrappers/normal.mjs +111 -40
- package/dist/engineering-pa-core/docs/commands/research.md +24 -12
- package/dist/engineering-pa-library-cli/README.md +179 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +185 -3
- package/dist/engineering-pa-library-cli/src/envelope.mjs +6 -1
- package/dist/engineering-pa-library-cli/src/migrate/apply.mjs +479 -0
- package/dist/engineering-pa-library-cli/src/migrate/kit.mjs +108 -0
- package/dist/engineering-pa-library-cli/src/migrate/plan.mjs +593 -0
- package/dist/engineering-pa-library-cli/src/migrate/verb.mjs +222 -0
- package/dist/engineering-pa-workspace/app/assets/md.js +91 -1
- package/package.json +1 -1
|
@@ -167,6 +167,26 @@ of `--list`/`--retry`/`--append` is given, an invalid `<sessionId>`, bad JSON, o
|
|
|
167
167
|
invalid record; `2` if an explicitly named session does not exist, or `--append` was
|
|
168
168
|
handed zero records; `3` disk read/write failure.
|
|
169
169
|
|
|
170
|
+
### `sources`: merge into the source registry
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
research sources --merge <sessionId> [--json]
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Reads a source registry on stdin (`{ "items": [...] }` or a bare array of items) and
|
|
177
|
+
merges it into the session's `sources.json` (w-2026-10-19-dave-009). The registry
|
|
178
|
+
never loses items: every existing item is kept in place, an item with a new `id` is
|
|
179
|
+
appended, and an item whose `id` is already present only fills fields the existing
|
|
180
|
+
one lacks. Merging the same set twice adds nothing, so every workflow writer (the
|
|
181
|
+
registry checkpoint, the finaliser, deep's finaliser) sends the full set through this
|
|
182
|
+
verb instead of overwriting the file. The write goes through the store chokepoint
|
|
183
|
+
(`store.writeSources` -> `validateSources`).
|
|
184
|
+
|
|
185
|
+
Exit: `0` merged (prints `merged N new source(s) into <id> (M total)`, or
|
|
186
|
+
`{ added, total }` with `--json`); `1` no `--merge`, a missing or invalid
|
|
187
|
+
`<sessionId>`, bad JSON, or an item that fails the sources schema (nothing written);
|
|
188
|
+
`2` the session does not exist; `3` disk read/write failure.
|
|
189
|
+
|
|
170
190
|
### `prune` — delete old / matching sessions
|
|
171
191
|
|
|
172
192
|
```
|
|
@@ -198,10 +218,25 @@ Sets `manifest.status` to a terminal value and bumps `updatedAt`:
|
|
|
198
218
|
- **`done`** — the session finalised successfully. A completed research run (light
|
|
199
219
|
recipe or the `research-normal.js` workflow) calls it to move the session out of
|
|
200
220
|
`draft`/`in-progress` so `list --status done` becomes meaningful.
|
|
201
|
-
- **`aborted
|
|
202
|
-
`research-normal.js` workflow
|
|
203
|
-
|
|
204
|
-
|
|
221
|
+
- **`aborted`**: the run stopped short of a finished answer. The
|
|
222
|
+
`research-normal.js` workflow stamps `aborted` itself on every abort path, so the
|
|
223
|
+
session reads correctly in `list`/`show` instead of lingering as `draft`
|
|
224
|
+
(w-2026-10-19-dave-009):
|
|
225
|
+
- **Early unsourced guard** (after Search+Extract, before Rank, Verify, Deepen,
|
|
226
|
+
Synthesize and Finalise): more than 25 percent of the extracted assertions on
|
|
227
|
+
sourced-class sub-questions could not be grounded in a quote. The registry
|
|
228
|
+
checkpoint has already merged every searcher source into `sources.json` and
|
|
229
|
+
appended every `gatedDropped` record to `gates.json`; the same agent runs
|
|
230
|
+
`mark aborted`. No synthesis ran, so there is no `answer.md`.
|
|
231
|
+
- **Late abort** (after synthesis): the synth receipts show more than 10 percent
|
|
232
|
+
unsourced claims on sourced-class sub-questions (build-spec §8), or a planned
|
|
233
|
+
sub-question has no finding (coverage gap). The finaliser still merges the
|
|
234
|
+
registry, writes a partial `answer.md` (headed `# Partial answer (run aborted)`,
|
|
235
|
+
unsourced assertions tagged `[UNSOURCED]`, a `## Unsourced claims` section),
|
|
236
|
+
round-trips `show --json`, and runs `mark aborted`. The work is never discarded.
|
|
237
|
+
|
|
238
|
+
Sub-questions the planner classed `claimClass: "analysis"` (allocation shapes,
|
|
239
|
+
trade-offs, recommendations) are left out of both guards.
|
|
205
240
|
|
|
206
241
|
This is the one verb that mutates an existing session's status. Both transitions are
|
|
207
242
|
**one-way**: a session already `done` or `aborted` is refused (exit `2`), the same
|
|
@@ -295,9 +330,9 @@ All camelCase.
|
|
|
295
330
|
| File | Location | Written by | Purpose |
|
|
296
331
|
|---|---|---|---|
|
|
297
332
|
| `manifest.json` | session root | this tool (`run`) | session identity, mode, gate policy, status, budget, assumptions |
|
|
298
|
-
| `plan.json` | session root | `run` (skeleton) → workflow (subQuestions) | dependency-classified decomposition, rounds, verification tier |
|
|
333
|
+
| `plan.json` | session root | `run` (skeleton) → workflow (subQuestions) | dependency-classified decomposition (each sub-question may carry `claimClass`: `sourced`, the default, or `analysis`), rounds, verification tier |
|
|
299
334
|
| `findings/sq-<id>.json` | `findings/` | **workers** | one finding per sub-question: summary, quotes, sources, confidence, votes |
|
|
300
|
-
| `sources.json` | session root | workflow | aggregated source registry referenced by findings |
|
|
335
|
+
| `sources.json` | session root | `run` (empty) → `sources --merge` (workflow registry checkpoint after Search, after a deepen round, and the finaliser) | aggregated source registry referenced by findings; merged, never overwritten |
|
|
301
336
|
| `gates.json` | session root | `gates --append` / `gates --retry` | human-gate retry queue |
|
|
302
337
|
|
|
303
338
|
Enum values follow the build-spec's own spelling: `gatePolicy` is
|
|
@@ -322,7 +357,7 @@ packages/cli/src/research/
|
|
|
322
357
|
validate.mjs # shared hand-rolled validation primitives (zero-dep)
|
|
323
358
|
manifest.mjs plan.mjs findings.mjs sources.mjs gates.mjs
|
|
324
359
|
commands/
|
|
325
|
-
run.mjs list.mjs show.mjs gates.mjs prune.mjs mark.mjs paths.mjs
|
|
360
|
+
run.mjs list.mjs show.mjs gates.mjs sources.mjs prune.mjs mark.mjs paths.mjs
|
|
326
361
|
schema/
|
|
327
362
|
*.schema.json # documentation-grade JSON Schemas (camelCase)
|
|
328
363
|
briefs/ # dispatch briefs the /research skill workflow feeds to subagents
|
|
@@ -41,6 +41,11 @@ Usage:
|
|
|
41
41
|
--append reads gate records (JSON array on stdin, or --record '<json>' for a
|
|
42
42
|
single one) and appends them to the session's gates.json with fresh g-N ids.
|
|
43
43
|
|
|
44
|
+
research sources --merge <sessionId> [--json]
|
|
45
|
+
Merge a source registry ({ "items": [...] } or an array, on stdin) into the
|
|
46
|
+
session's sources.json. Existing items are never dropped or overwritten; new
|
|
47
|
+
ids are appended. Idempotent, so every workflow writer can send the full set.
|
|
48
|
+
|
|
44
49
|
research prune [--older-than <days>] [--status <status>] [--dry-run]
|
|
45
50
|
Delete sessions older than N days and/or matching a status. At least one
|
|
46
51
|
filter is required. --dry-run prints what would be deleted without deleting.
|
|
@@ -92,6 +97,10 @@ export async function main(argv) {
|
|
|
92
97
|
const { gatesCommand } = await import('./commands/gates.mjs');
|
|
93
98
|
return (await gatesCommand(rest)) ?? 0;
|
|
94
99
|
}
|
|
100
|
+
case 'sources': {
|
|
101
|
+
const { sourcesCommand } = await import('./commands/sources.mjs');
|
|
102
|
+
return (await sourcesCommand(rest)) ?? 0;
|
|
103
|
+
}
|
|
95
104
|
case 'prune': {
|
|
96
105
|
const { pruneCommand } = await import('./commands/prune.mjs');
|
|
97
106
|
return (await pruneCommand(rest)) ?? 0;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// research sources --merge <sessionId> [--json]
|
|
2
|
+
//
|
|
3
|
+
// Reads a source registry from stdin ({ "items": [...] } or a bare array of items)
|
|
4
|
+
// and merges it into the session's sources.json (w-2026-10-19-dave-009). Existing
|
|
5
|
+
// items are never dropped or overwritten: new ids are appended, a known id only gains
|
|
6
|
+
// fields it lacked. Merging the same set twice is a no-op, so the workflow's
|
|
7
|
+
// persister and finaliser can both send the full registry. The write goes through
|
|
8
|
+
// the store chokepoint (store.writeSources -> validateSources).
|
|
9
|
+
import { parseArgs } from 'node:util';
|
|
10
|
+
import { getDefaultStore } from '../store/index.mjs';
|
|
11
|
+
import { mergeSources } from '../store/sources.mjs';
|
|
12
|
+
|
|
13
|
+
function usage(msg) {
|
|
14
|
+
return Object.assign(new Error(`usage: research sources --merge <sessionId> [--json]${msg ? `\n${msg}` : ''}`), { exitCode: 1 });
|
|
15
|
+
}
|
|
16
|
+
function notFound(msg) {
|
|
17
|
+
return Object.assign(new Error(msg), { exitCode: 2 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readAllStdin(stream = process.stdin) {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
for await (const chunk of stream) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
23
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function sourcesCommand(argv, { store, stdout = process.stdout, readStdin = readAllStdin } = {}) {
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = parseArgs({ args: argv, options: { merge: { type: 'boolean' }, json: { type: 'boolean' } }, allowPositionals: true, strict: true });
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw usage(e.message);
|
|
32
|
+
}
|
|
33
|
+
if (!parsed.values.merge) throw usage('--merge is required');
|
|
34
|
+
const id = parsed.positionals[0];
|
|
35
|
+
if (!id) throw usage('--merge requires a <sessionId>');
|
|
36
|
+
if (parsed.positionals.length > 1) throw usage('--merge takes exactly one <sessionId>');
|
|
37
|
+
if (!store) store = await getDefaultStore();
|
|
38
|
+
if (!store.isValidSessionId(id)) throw usage(`"${id}" is not a valid sessionId`);
|
|
39
|
+
if (!store.exists(id)) throw notFound(`session ${id} not found`);
|
|
40
|
+
|
|
41
|
+
const text = await readStdin();
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = JSON.parse(text);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw usage(`invalid JSON on stdin: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
const incoming = Array.isArray(raw) ? { items: raw } : raw;
|
|
49
|
+
const existing = await store.readSources(id);
|
|
50
|
+
let merged;
|
|
51
|
+
try {
|
|
52
|
+
merged = mergeSources(existing, incoming, `session ${id} sources`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// A malformed incoming registry is caller input: usage error, nothing written.
|
|
55
|
+
throw usage(e.message);
|
|
56
|
+
}
|
|
57
|
+
await store.writeSources(id, merged.sources);
|
|
58
|
+
if (parsed.values.json) {
|
|
59
|
+
stdout.write(JSON.stringify({ added: merged.added, total: merged.sources.items.length }, null, 2) + '\n');
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
stdout.write(`merged ${merged.added} new source(s) into ${id} (${merged.sources.items.length} total)\n`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
@@ -17,7 +17,7 @@ import { flattenClaims, rankAndCapPerSq } from './rank.mjs';
|
|
|
17
17
|
import { adjudicate } from './verify.mjs';
|
|
18
18
|
import { verifyAgent } from './agents.mjs';
|
|
19
19
|
import { runDeepenRound, deepenStopReason } from './deepen.mjs';
|
|
20
|
-
import { buildSourcesJson, buildGateRecords, bucketsFor, synthOrDegrade } from './finalise.mjs';
|
|
20
|
+
import { buildSourcesJson, buildGateRecords, bucketsFor, synthOrDegrade, sourcesMergeStep } from './finalise.mjs';
|
|
21
21
|
|
|
22
22
|
// Guaranteed floor of top claims kept per sub-question when the claim budget is capped
|
|
23
23
|
// (deep-mode fix for SQ starvation). 1 = every planned sub-question keeps its single best
|
|
@@ -401,8 +401,7 @@ export async function runDeepFinalise(ctx, { sqResults, roundsJson, costFields,
|
|
|
401
401
|
`You are the **finaliser** for deep-research session \`${ctx.sessionId}\` (output mode: \`${ctx.baseOutputMode}\`).`,
|
|
402
402
|
`Do these in order; every JSON below is already validated by the workflow — write it VERBATIM.`,
|
|
403
403
|
`1. ${gatesStep}`,
|
|
404
|
-
|
|
405
|
-
'```json', sourcesJson, '```',
|
|
404
|
+
sourcesMergeStep(ctx, sourcesJson, 2),
|
|
406
405
|
`3. Write this EXACT JSON to \`${ctx.sessionDir}/rounds.json\` (the deepen audit trail):`,
|
|
407
406
|
'```json', roundsJson, '```',
|
|
408
407
|
`4. Compose cost.json. Take these workflow-measured fields VERBATIM:`,
|
|
@@ -37,6 +37,25 @@ export function deepenStopReason({ sufficient, round, roundCap, budgetRemaining,
|
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Give every deepen proposal an id no live sub-question holds (w-2026-10-19-dave-009).
|
|
41
|
+
// A reused id (deep round 2 proposing sq-d1 again, or a clash with a planner id) would
|
|
42
|
+
// overwrite that sub-question's entry in sqResults, and its searcher sources with it,
|
|
43
|
+
// so the registry built from sqResults would lose them. Clashing ids take the next free
|
|
44
|
+
// sq-dN; fresh ids are kept. Pure.
|
|
45
|
+
export function uniqueDeepenIds(proposals, liveIds) {
|
|
46
|
+
const taken = new Set(liveIds);
|
|
47
|
+
for (const p of proposals) if (!taken.has(p.id)) taken.add(p.id);
|
|
48
|
+
const seen = new Set(liveIds);
|
|
49
|
+
let n = 1;
|
|
50
|
+
return proposals.map((p) => {
|
|
51
|
+
if (!seen.has(p.id)) { seen.add(p.id); return p; }
|
|
52
|
+
while (taken.has(`sq-d${n}`)) n += 1;
|
|
53
|
+
const id = `sq-d${n}`;
|
|
54
|
+
taken.add(id); seen.add(id);
|
|
55
|
+
return { ...p, id };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
// One gap-driven deepen round. Asks the deepener whether a real gap remains; if so,
|
|
41
60
|
// searches+extracts the proposed facets, appends their claims to `state.retained`,
|
|
42
61
|
// and re-verifies newly-central claims. Mutates state.{sqResults,urlBySourceId,
|
|
@@ -64,7 +83,8 @@ export async function runDeepenRound(ctx, state, { lenses = VERIFY_LENSES, quoru
|
|
|
64
83
|
let deepenAdded = 0;
|
|
65
84
|
let newSqs = [];
|
|
66
85
|
if (!deepen.sufficient && (deepen.newSubQuestions || []).length) {
|
|
67
|
-
|
|
86
|
+
const liveIds = [...subQuestions.map((s) => s.id), ...sqResults.keys()];
|
|
87
|
+
newSqs = uniqueDeepenIds(deepen.newSubQuestions.slice(0, 4), liveIds).map((s) => ({ ...s, kind: 'independent', dependsOn: [] }));
|
|
68
88
|
deepenAdded = newSqs.length;
|
|
69
89
|
log(`deepen: gap round — ${deepenAdded} new sub-question(s)`);
|
|
70
90
|
const deepPairs = await pipeline(
|
|
@@ -1,9 +1,67 @@
|
|
|
1
1
|
// Shared research engine — synthesis bucketing, deterministic aggregation
|
|
2
2
|
// (sources.json / gate records), and the synth + finalise phases.
|
|
3
3
|
import { synthAgent, SYNTH_SCHEMA } from './agents.mjs';
|
|
4
|
+
import { effectiveClaimClass } from './plan.mjs';
|
|
4
5
|
|
|
5
6
|
export const UNSOURCED_ABORT_RATIO = 0.10; // §8 guardrail: >10% unsourced fails the run
|
|
6
7
|
|
|
8
|
+
// Early unsourced guard (w-2026-10-19-dave-009). Runs on the extractor returns,
|
|
9
|
+
// before Verify and the deepen round, so an abort skips the expensive stages. Its
|
|
10
|
+
// measure is the share of answer-relevant assertions the extractors could not ground
|
|
11
|
+
// in a quote: unsourcedClaims / (claims emitted + unsourcedClaims), over sourced-class
|
|
12
|
+
// sub-questions only. Those assertions are dropped, not answered, so the bar is not
|
|
13
|
+
// the answer's 10 percent: across the nine normal runs journalled to 2026-10-19 the
|
|
14
|
+
// runs that synthesized cleanly measured 7 to 17 percent here, and the two that
|
|
15
|
+
// failed measured 30 and 37 percent. 0.25 separates them; tune it from new runs.
|
|
16
|
+
export const EXTRACT_UNSOURCED_ABORT_RATIO = 0.25;
|
|
17
|
+
|
|
18
|
+
// Pure: extractor-level unsourced counts over the planner's sub-questions. Analysis
|
|
19
|
+
// sub-questions (claimClass 'analysis') are left out of both sides of the ratio and
|
|
20
|
+
// listed in `ignored`.
|
|
21
|
+
export function extractUnsourcedReport(subQuestions, sqResults) {
|
|
22
|
+
let sourcedCount = 0;
|
|
23
|
+
let unsourcedCount = 0;
|
|
24
|
+
const ignored = [];
|
|
25
|
+
const unsourcedBySq = {};
|
|
26
|
+
for (const sq of subQuestions) {
|
|
27
|
+
if (effectiveClaimClass(sq) === 'analysis') { ignored.push(sq.id); continue; }
|
|
28
|
+
const r = sqResults.get(sq.id);
|
|
29
|
+
if (!r || !r.extract) continue;
|
|
30
|
+
const emitted = (r.extract.claims || []).length;
|
|
31
|
+
const unsourced = Number.isInteger(r.extract.unsourcedClaims) ? r.extract.unsourcedClaims : 0;
|
|
32
|
+
sourcedCount += emitted;
|
|
33
|
+
unsourcedCount += unsourced;
|
|
34
|
+
if (unsourced) unsourcedBySq[sq.id] = unsourced;
|
|
35
|
+
}
|
|
36
|
+
const total = sourcedCount + unsourcedCount;
|
|
37
|
+
return { sourcedCount, unsourcedCount, ratio: total ? unsourcedCount / total : 0, ignored, unsourcedBySq };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pure: synthesis-level unsourced counts from the synth receipts (claimCount and
|
|
41
|
+
// unsourcedCount), with analysis sub-questions left out. Used after synthesis, where an
|
|
42
|
+
// over-threshold ratio no longer discards the work: the finaliser writes a partial
|
|
43
|
+
// answer.md with the unsourced claims marked and stamps the session aborted.
|
|
44
|
+
export function synthUnsourcedReport(subQuestions, synthReceipts) {
|
|
45
|
+
const analysis = new Set(subQuestions.filter((sq) => effectiveClaimClass(sq) === 'analysis').map((sq) => sq.id));
|
|
46
|
+
let claimCount = 0;
|
|
47
|
+
let unsourcedCount = 0;
|
|
48
|
+
const ignored = [];
|
|
49
|
+
const unsourcedBySq = {};
|
|
50
|
+
for (const r of synthReceipts || []) {
|
|
51
|
+
if (!r || !r.subQuestionId) continue;
|
|
52
|
+
if (analysis.has(r.subQuestionId)) { ignored.push(r.subQuestionId); continue; }
|
|
53
|
+
claimCount += r.claimCount || 0;
|
|
54
|
+
unsourcedCount += r.unsourcedCount || 0;
|
|
55
|
+
if (r.unsourcedCount) unsourcedBySq[r.subQuestionId] = r.unsourcedCount;
|
|
56
|
+
}
|
|
57
|
+
return { claimCount, unsourcedCount, ratio: claimCount ? unsourcedCount / claimCount : 0, ignored, unsourcedBySq };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The guard decision: strictly over the threshold aborts; equal does not.
|
|
61
|
+
export function unsourcedAbort(report, threshold) {
|
|
62
|
+
return report.ratio > threshold;
|
|
63
|
+
}
|
|
64
|
+
|
|
7
65
|
export const FINALISE_SCHEMA = {
|
|
8
66
|
type: 'object', additionalProperties: false,
|
|
9
67
|
required: ['wroteSources', 'wroteAnswer', 'roundTripOk', 'markedDone'],
|
|
@@ -13,6 +71,19 @@ export const FINALISE_SCHEMA = {
|
|
|
13
71
|
roundTripOk: { type: 'boolean' },
|
|
14
72
|
markedDone: { type: 'boolean' },
|
|
15
73
|
gatesAppended: { type: 'integer' }, // count of gatedDropped records persisted to gates.json (w-018)
|
|
74
|
+
markedAborted: { type: 'boolean' }, // partial finalise only: `research mark aborted` exited 0
|
|
75
|
+
note: { type: 'string' },
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const PERSIST_SCHEMA = {
|
|
80
|
+
type: 'object', additionalProperties: false,
|
|
81
|
+
required: ['mergedSources', 'gatesAppended', 'roundTripOk'],
|
|
82
|
+
properties: {
|
|
83
|
+
mergedSources: { type: 'boolean' },
|
|
84
|
+
gatesAppended: { type: 'integer' },
|
|
85
|
+
roundTripOk: { type: 'boolean' },
|
|
86
|
+
markedAborted: { type: 'boolean' },
|
|
16
87
|
note: { type: 'string' },
|
|
17
88
|
},
|
|
18
89
|
};
|
|
@@ -165,43 +236,110 @@ export function coverageReport(subQuestionIds, synthReceipts) {
|
|
|
165
236
|
};
|
|
166
237
|
}
|
|
167
238
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
239
|
+
// Prompt step: merge the registry through `research sources --merge` (never an
|
|
240
|
+
// overwrite of sources.json, so an item another writer already put there survives).
|
|
241
|
+
export function sourcesMergeStep(ctx, sourcesJson, n) {
|
|
242
|
+
return [
|
|
243
|
+
`${n}. Merge the source registry. Pipe this EXACT JSON to \`research sources --merge\` (use a heredoc so it`,
|
|
244
|
+
` reaches stdin verbatim). It merges into \`${ctx.sessionDir}/sources.json\`; never write that file directly:`,
|
|
245
|
+
'```bash',
|
|
246
|
+
`cat <<'SOURCES_EOF' | ${ctx.cli} sources --merge ${ctx.sessionId}`,
|
|
247
|
+
sourcesJson,
|
|
248
|
+
`SOURCES_EOF`,
|
|
249
|
+
'```',
|
|
250
|
+
` Expect exit 0 and a "merged N new source(s)" line.`,
|
|
251
|
+
].join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Prompt step: append gate records through `research gates --append`, or skip. A
|
|
255
|
+
// non-zero append exit is reported but not fatal (a missed gate log is degraded).
|
|
256
|
+
export function gateAppendStep(ctx, gateRecords, n) {
|
|
257
|
+
if (!gateRecords.length) return `${n}. No gate records to persist at this step. SKIP gate persistence and set \`gatesAppended\` to 0.`;
|
|
258
|
+
return [
|
|
259
|
+
`${n}. Persist gate records. ${gateRecords.length} background-posture gate(s) were dropped by searchers`,
|
|
260
|
+
` during retrieval and must be logged to \`gates.json\` so a later operator-present session can sweep`,
|
|
261
|
+
` them. Append them by piping this EXACT JSON array to \`research gates --append\` (use a heredoc so`,
|
|
262
|
+
` the JSON reaches stdin verbatim):`,
|
|
263
|
+
'```bash',
|
|
264
|
+
`cat <<'GATES_EOF' | ${ctx.cli} gates --append ${ctx.sessionId}`,
|
|
265
|
+
JSON.stringify(gateRecords),
|
|
266
|
+
`GATES_EOF`,
|
|
267
|
+
'```',
|
|
268
|
+
` Expect exit 0 and an "appended N gate(s)" line; set \`gatesAppended\` to N in your receipt. If it exits`,
|
|
269
|
+
` NON-ZERO, put the error in \`note\`, set \`gatesAppended\` to 0, and CONTINUE: a missed gate log is`,
|
|
270
|
+
` degraded, not fatal. Do NOT abort over it.`,
|
|
271
|
+
].join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// True when a receipt shows every record of this batch landed in gates.json
|
|
275
|
+
// (`gates --append` validates the whole file before writing, so it is all or none).
|
|
276
|
+
export function gatesPersisted(receipt, gateRecords) {
|
|
277
|
+
return !gateRecords.length || (receipt && receipt.gatesAppended === gateRecords.length);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// -- Registry checkpoint (w-2026-10-19-dave-009) --
|
|
281
|
+
// The workflow has no fs, so the sources and gate records the searchers return live
|
|
282
|
+
// only in memory until an agent persists them. Before this checkpoint they were
|
|
283
|
+
// written by the finaliser alone, after the unsourced guard, so a guard abort lost
|
|
284
|
+
// them all. The persister runs right after Search+Extract (and after a deepen round
|
|
285
|
+
// that added sub-questions): it merges the registry, appends the gate records and,
|
|
286
|
+
// when the early guard fired, stamps the session aborted. Mechanical, so a cheap tier.
|
|
287
|
+
export async function runPersistRegistry(ctx, { sourcesJson, gateRecords, markAborted = false, reason = '', label = 'persist', phase: agentPhase = 'Search' }) {
|
|
288
|
+
const steps = [
|
|
289
|
+
`You are the **registry persister** for research session \`${ctx.sessionId}\`. Do these in order:`,
|
|
290
|
+
sourcesMergeStep(ctx, sourcesJson, 1),
|
|
291
|
+
gateAppendStep(ctx, gateRecords, 2),
|
|
292
|
+
`3. Round-trip: run \`${ctx.cli} show ${ctx.sessionId} --json\` and confirm exit 0; set \`roundTripOk\`.`,
|
|
293
|
+
];
|
|
294
|
+
if (markAborted) {
|
|
295
|
+
steps.push(`4. The run is aborting (${reason}). Run \`${ctx.cli} mark aborted ${ctx.sessionId}\` (expect exit 0) and set \`markedAborted\`.`);
|
|
296
|
+
}
|
|
297
|
+
steps.push(`Return the structured receipt (mergedSources, gatesAppended, roundTripOk${markAborted ? ', markedAborted' : ''}).`);
|
|
298
|
+
// A failed persister agent (null return) reads as nothing persisted, so the caller
|
|
299
|
+
// keeps the gate records pending for the finaliser instead of throwing.
|
|
300
|
+
const receipt = await agent(steps.join('\n'), { model: ctx.modelOverride || 'haiku', effort: 'low', phase: agentPhase, label, schema: PERSIST_SCHEMA });
|
|
301
|
+
return receipt || { mergedSources: false, gatesAppended: 0, roundTripOk: false, markedAborted: false };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// -- Finalise (merge sources.json, persist pending gate records, write the session answer, round-trip, mark) --
|
|
305
|
+
// `gateRecords` are the records the registry checkpoints could not persist (normally
|
|
306
|
+
// none). With `partial` set, the run is aborting after synthesis: the finaliser still
|
|
307
|
+
// writes answer.md, as a partial answer with the unsourced claims marked, and stamps
|
|
308
|
+
// the session aborted instead of done, so the work is never discarded.
|
|
309
|
+
export async function runFinalise(ctx, { gateRecords, sourcesJson, runStats, partial = null }) {
|
|
310
|
+
const answerStep = partial
|
|
174
311
|
? [
|
|
175
|
-
`
|
|
176
|
-
`
|
|
177
|
-
`
|
|
178
|
-
` the
|
|
179
|
-
|
|
180
|
-
`
|
|
181
|
-
JSON.stringify(
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
` Expect exit 0 and an "appended N gate(s)" line; set \`gatesAppended\` to N in your receipt. If it exits`,
|
|
185
|
-
` NON-ZERO, put the error in \`note\`, set \`gatesAppended\` to 0, and CONTINUE — a missed gate log is`,
|
|
186
|
-
` degraded, not fatal. Do NOT abort the finalise over it.`,
|
|
312
|
+
`3. This run is ABORTING after synthesis: ${partial.reason}. Do not discard the work. Read every`,
|
|
313
|
+
` \`${ctx.sessionDir}/findings/*.json\` and write a PARTIAL answer to \`${ctx.sessionDir}/answer.md\`:`,
|
|
314
|
+
` - First line: \`# Partial answer (run aborted)\`, then a line \`> Run aborted: ${partial.reason}. Claims marked [UNSOURCED] have no supporting quote.\``,
|
|
315
|
+
` - Then the answer in the \`${ctx.baseOutputMode}\` form, built only from the findings on disk.`,
|
|
316
|
+
` - Mark every assertion from a finding with \`"unsourced": true\` that has no quote behind it with the literal tag \`[UNSOURCED]\`.`,
|
|
317
|
+
` - A "## Unsourced claims" section listing the per-sub-question unsourced counts ${JSON.stringify(partial.unsourcedBySq || {})}`,
|
|
318
|
+
` and the analysis sub-questions the guard ignored ${JSON.stringify(partial.ignored || [])}.`,
|
|
319
|
+
` - ALWAYS a "## Refuted / unverified claims" section (write "none" if there were none) and a "## Run stats" footer`,
|
|
320
|
+
` with: ${JSON.stringify(runStats)}.`,
|
|
187
321
|
].join('\n')
|
|
188
|
-
:
|
|
322
|
+
: [
|
|
323
|
+
`3. Read every \`${ctx.sessionDir}/findings/*.json\` and compose the session answer, writing it to \`${ctx.sessionDir}/answer.md\`:`,
|
|
324
|
+
` - \`shortAnswer\`: a terse 2–4 sentence answer.`,
|
|
325
|
+
` - \`article\`: prose with inline \`[source-id]\` citations.`,
|
|
326
|
+
` - \`structured\`: a JSON code block with named sections.`,
|
|
327
|
+
` For output mode \`${ctx.baseOutputMode}\`, use that form. ALWAYS include a "## Refuted / unverified claims" section`,
|
|
328
|
+
` listing any refuted central claims for transparency (write "none" if there were none), and a "## Run stats" footer`,
|
|
329
|
+
` with: ${JSON.stringify(runStats)}.`,
|
|
330
|
+
].join('\n');
|
|
331
|
+
const markStep = partial
|
|
332
|
+
? `5. Only if the round-trip is green, run \`${ctx.cli} mark aborted ${ctx.sessionId}\` (expect exit 0; sets status=aborted, never promoted) and set \`markedAborted\`; set \`markedDone\` to false.`
|
|
333
|
+
: `5. Only if the round-trip is green, run \`${ctx.cli} mark done ${ctx.sessionId}\` (expect exit 0; sets status=done).`;
|
|
189
334
|
|
|
190
335
|
return await agent([
|
|
191
336
|
`You are the **finaliser** for session \`${ctx.sessionId}\` (output mode: \`${ctx.baseOutputMode}\`).`,
|
|
192
337
|
`Do these in order:`,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
`3. Read every \`${ctx.sessionDir}/findings/*.json\` and compose the session answer, writing it to \`${ctx.sessionDir}/answer.md\`:`,
|
|
197
|
-
` - \`shortAnswer\`: a terse 2–4 sentence answer.`,
|
|
198
|
-
` - \`article\`: prose with inline \`[source-id]\` citations.`,
|
|
199
|
-
` - \`structured\`: a JSON code block with named sections.`,
|
|
200
|
-
` For output mode \`${ctx.baseOutputMode}\`, use that form. ALWAYS include a "## Refuted / unverified claims" section`,
|
|
201
|
-
` listing any refuted central claims for transparency (write "none" if there were none), and a "## Run stats" footer`,
|
|
202
|
-
` with: ${JSON.stringify(runStats)}.`,
|
|
338
|
+
gateAppendStep(ctx, gateRecords, 1),
|
|
339
|
+
sourcesMergeStep(ctx, sourcesJson, 2),
|
|
340
|
+
answerStep,
|
|
203
341
|
`4. Round-trip: run \`${ctx.cli} show ${ctx.sessionId} --json\` — it must exit 0 (validates plan, findings, sources, gates).`,
|
|
204
|
-
|
|
342
|
+
markStep,
|
|
205
343
|
`Return the structured receipt (including \`gatesAppended\`).`,
|
|
206
344
|
].join('\n'), { model: 'sonnet', effort: 'medium', phase: 'Finalise', label: 'finalise', schema: FINALISE_SCHEMA });
|
|
207
345
|
}
|
|
@@ -53,6 +53,14 @@ export function topoOrder(subQuestions) {
|
|
|
53
53
|
return order;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Claim class per sub-question (w-2026-10-19-dave-009). 'analysis' marks an
|
|
57
|
+
// allocation shape, trade-off or recommendation: its claims are inference over other
|
|
58
|
+
// findings, so the unsourced guard ignores it. Anything else counts as 'sourced'.
|
|
59
|
+
export const CLAIM_CLASSES = ['sourced', 'analysis'];
|
|
60
|
+
export function effectiveClaimClass(sq) {
|
|
61
|
+
return sq && sq.claimClass === 'analysis' ? 'analysis' : 'sourced';
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
// The plan schema, parameterised by facet band (normal 2–4, deep 4–12).
|
|
57
65
|
export function makePlanSchema({ minItems = 2, maxItems = 4 } = {}) {
|
|
58
66
|
return {
|
|
@@ -68,12 +76,13 @@ export function makePlanSchema({ minItems = 2, maxItems = 4 } = {}) {
|
|
|
68
76
|
type: 'array', minItems, maxItems,
|
|
69
77
|
items: {
|
|
70
78
|
type: 'object', additionalProperties: false,
|
|
71
|
-
required: ['id', 'text', 'kind', 'dependsOn'],
|
|
79
|
+
required: ['id', 'text', 'kind', 'dependsOn', 'claimClass'],
|
|
72
80
|
properties: {
|
|
73
81
|
id: { type: 'string' },
|
|
74
82
|
text: { type: 'string' },
|
|
75
83
|
kind: { enum: ['independent', 'dependent'] },
|
|
76
84
|
dependsOn: { type: 'array', items: { type: 'string' } },
|
|
85
|
+
claimClass: { enum: CLAIM_CLASSES },
|
|
77
86
|
},
|
|
78
87
|
},
|
|
79
88
|
},
|
|
@@ -97,8 +106,12 @@ export async function runPlan(ctx, { min = 2, max = 4 } = {}) {
|
|
|
97
106
|
` Classify each \`independent\` or \`dependent\`; a dependent one lists its \`dependsOn\` ids.`,
|
|
98
107
|
` Prefer independent facets (they parallelise). Use ids \`sq-1\`,\`sq-2\`,… (these become finding filenames).`,
|
|
99
108
|
` 44% of multi-agent failures start at planning — make the facets genuinely distinct and jointly cover the question.`,
|
|
109
|
+
` Set each sub-question's \`claimClass\` from its shape: \`analysis\` when it asks for an allocation shape, a`,
|
|
110
|
+
` trade-off or a recommendation (typically a dependent facet that weighs the others' findings), because its`,
|
|
111
|
+
` claims are inference and the unsourced guard ignores them; \`sourced\` for every facet answered from quotable`,
|
|
112
|
+
` sources (facts, figures, rules, prices, mechanisms). When unsure, use \`sourced\`.`,
|
|
100
113
|
`3. WRITE the filled plan back to \`${ctx.sessionDir}/plan.json\`: keep the existing \`deepeningRounds\` and`,
|
|
101
|
-
` \`verificationTier\`, replace \`subQuestions\` with your list (each {id,text,kind,dependsOn}). No extra fields.`,
|
|
114
|
+
` \`verificationTier\`, replace \`subQuestions\` with your list (each {id,text,kind,dependsOn,claimClass}). No extra fields.`,
|
|
102
115
|
`4. Round-trip: run \`${ctx.cli} show ${ctx.sessionId} --json\` and confirm exit 0 (it validates plan.json).`,
|
|
103
116
|
`Return the structured object: question, intent, outputMode, gatePolicy (echo from the manifest),`,
|
|
104
117
|
`subQuestions, and wrotePlan=true once the round-trip is green.`,
|
|
@@ -124,6 +137,8 @@ export async function runPlan(ctx, { min = 2, max = 4 } = {}) {
|
|
|
124
137
|
if (errs.length) throw new Error(`${ctx.workflow}: planner produced an invalid plan twice (${errs.join('; ')}) — escalating`);
|
|
125
138
|
}
|
|
126
139
|
log(`plan: ${subQuestions.length} sub-questions — ${subQuestions.map((s) => s.id).join(', ')}`);
|
|
140
|
+
const analysisIds = subQuestions.filter((s) => effectiveClaimClass(s) === 'analysis').map((s) => s.id);
|
|
141
|
+
if (analysisIds.length) log(`plan: claimClass analysis (ignored by the unsourced guard): ${analysisIds.join(', ')}`);
|
|
127
142
|
ctx.subQuestions = subQuestions;
|
|
128
143
|
return subQuestions;
|
|
129
144
|
}
|
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
"type": "array",
|
|
23
23
|
"items": { "type": "string" },
|
|
24
24
|
"description": "Sub-question ids this one depends on. Empty for independent facets."
|
|
25
|
+
},
|
|
26
|
+
"claimClass": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"enum": ["sourced", "analysis"],
|
|
29
|
+
"default": "sourced",
|
|
30
|
+
"description": "Set by the planner from the sub-question shape. analysis = an allocation shape, trade-off or recommendation whose claims are inference; the unsourced guard ignores it. Optional; absent means sourced (w-2026-10-19-dave-009)."
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
}
|
|
@@ -6,10 +6,15 @@ import {
|
|
|
6
6
|
} from './validate.mjs';
|
|
7
7
|
|
|
8
8
|
export const SQ_KINDS = ['independent', 'dependent'];
|
|
9
|
+
// claimClass (w-2026-10-19-dave-009): 'analysis' marks an allocation, trade-off or
|
|
10
|
+
// recommendation sub-question whose claims are inference, so the unsourced guard
|
|
11
|
+
// ignores it. Optional; absent means 'sourced', so every older plan.json validates.
|
|
12
|
+
export const CLAIM_CLASSES = ['sourced', 'analysis'];
|
|
9
13
|
export const VERIFICATION_TIERS = ['none', 'central-3vote', 'diverse-lens-3vote'];
|
|
10
14
|
|
|
11
15
|
const TOP = ['subQuestions', 'deepeningRounds', 'verificationTier'];
|
|
12
16
|
const SQ_FIELDS = ['id', 'text', 'kind', 'dependsOn'];
|
|
17
|
+
const SQ_OPTIONAL = ['claimClass'];
|
|
13
18
|
|
|
14
19
|
// Mode-derived plan defaults. `run` writes the skeleton; the workflow (w-017)
|
|
15
20
|
// fills subQuestions. Single source of truth for the light/normal/deep mapping.
|
|
@@ -41,12 +46,13 @@ export function validatePlan(p, where = 'plan') {
|
|
|
41
46
|
const w = `${where}.subQuestions[${i}]`;
|
|
42
47
|
requireObject(sq, w);
|
|
43
48
|
requireFields(sq, SQ_FIELDS, w);
|
|
44
|
-
noExtraFields(sq, SQ_FIELDS, w);
|
|
49
|
+
noExtraFields(sq, [...SQ_FIELDS, ...SQ_OPTIONAL], w);
|
|
45
50
|
const id = requireString(sq, 'id', w);
|
|
46
51
|
if (seen.has(id)) fail(`duplicate sub-question id "${id}"`, w);
|
|
47
52
|
seen.add(id);
|
|
48
53
|
requireString(sq, 'text', w);
|
|
49
54
|
requireEnum(sq, 'kind', SQ_KINDS, w);
|
|
55
|
+
if (sq.claimClass !== undefined) requireEnum(sq, 'claimClass', CLAIM_CLASSES, w);
|
|
50
56
|
const deps = requireArray(sq, 'dependsOn', w);
|
|
51
57
|
deps.forEach((d, j) => {
|
|
52
58
|
if (typeof d !== 'string') fail(`dependsOn[${j}] must be a string`, w);
|
|
@@ -42,3 +42,32 @@ export function validateSources(s, where = 'sources') {
|
|
|
42
42
|
});
|
|
43
43
|
return s;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
// Merge an incoming registry into an existing one (w-2026-10-19-dave-009). The
|
|
47
|
+
// registry never loses items: every existing item is kept in its place, an incoming
|
|
48
|
+
// item with a new id is appended, and an incoming item whose id is already present
|
|
49
|
+
// only fills fields the existing item lacks (an existing field is never overwritten).
|
|
50
|
+
// Pure; both inputs are validated, and the merged result is validated before return.
|
|
51
|
+
// Merging the same set twice adds nothing, so writers can re-send the full registry.
|
|
52
|
+
export function mergeSources(existing, incoming, where = 'sources') {
|
|
53
|
+
validateSources(existing, `${where} (existing)`);
|
|
54
|
+
validateSources(incoming, `${where} (incoming)`);
|
|
55
|
+
const items = existing.items.map((it) => ({ ...it }));
|
|
56
|
+
const byId = new Map(items.map((it) => [it.id, it]));
|
|
57
|
+
let added = 0;
|
|
58
|
+
for (const it of incoming.items) {
|
|
59
|
+
const have = byId.get(it.id);
|
|
60
|
+
if (!have) {
|
|
61
|
+
const copy = { ...it };
|
|
62
|
+
items.push(copy);
|
|
63
|
+
byId.set(copy.id, copy);
|
|
64
|
+
added += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const k of ITEM_FIELDS) {
|
|
68
|
+
if (have[k] === undefined && it[k] !== undefined) have[k] = it[k];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const sources = validateSources({ items }, where);
|
|
72
|
+
return { sources, added };
|
|
73
|
+
}
|