@stravica-ai/digital-operator-pa 0.1.65 → 0.1.67

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.
Files changed (30) hide show
  1. package/dist/engineering-pa-cli/src/pa/cli.mjs +8 -0
  2. package/dist/engineering-pa-cli/src/pa/status-board-cli.mjs +425 -0
  3. package/dist/engineering-pa-cli/src/pa/status-board.mjs +259 -0
  4. package/dist/engineering-pa-cli/src/research/README.md +42 -7
  5. package/dist/engineering-pa-cli/src/research/cli.mjs +9 -0
  6. package/dist/engineering-pa-cli/src/research/commands/sources.mjs +64 -0
  7. package/dist/engineering-pa-cli/src/research/engine/deep.mjs +2 -3
  8. package/dist/engineering-pa-cli/src/research/engine/deepen.mjs +21 -1
  9. package/dist/engineering-pa-cli/src/research/engine/finalise.mjs +168 -30
  10. package/dist/engineering-pa-cli/src/research/engine/plan.mjs +17 -2
  11. package/dist/engineering-pa-cli/src/research/schema/plan.schema.json +6 -0
  12. package/dist/engineering-pa-cli/src/research/store/plan.mjs +7 -1
  13. package/dist/engineering-pa-cli/src/research/store/sources.mjs +29 -0
  14. package/dist/engineering-pa-cli/src/research/workflows/research-deep.js +213 -39
  15. package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +316 -73
  16. package/dist/engineering-pa-cli/src/research/wrappers/deep.mjs +7 -4
  17. package/dist/engineering-pa-cli/src/research/wrappers/normal.mjs +111 -40
  18. package/dist/engineering-pa-core/docs/commands/research.md +24 -12
  19. package/dist/engineering-pa-core/knowledge/delegation-dispatch/delegation-modes.md +2 -0
  20. package/dist/engineering-pa-core/knowledge/operator-interface/README.md +2 -0
  21. package/dist/engineering-pa-core/knowledge/operator-interface/session-boot-and-end.md +5 -1
  22. package/dist/engineering-pa-core/knowledge/work-management/README.md +2 -1
  23. package/dist/engineering-pa-core/knowledge/work-management/status-board.md +50 -0
  24. package/dist/engineering-pa-workspace/README.md +9 -0
  25. package/dist/engineering-pa-workspace/app/assets/md.js +91 -1
  26. package/dist/engineering-pa-workspace/app/nav.json +6 -0
  27. package/dist/engineering-pa-workspace/app/notes.html +3 -1
  28. package/dist/engineering-pa-workspace/app/status-board.html +503 -0
  29. package/dist/engineering-pa-workspace/src/api.mjs +79 -29
  30. package/package.json +1 -1
@@ -0,0 +1,259 @@
1
+ // status-board.mjs - schema, agent resolution and atomic write for the
2
+ // per-agent daily status board.
3
+ //
4
+ // The board lives at `agents/<agent>/workspace/status-board.json` in the
5
+ // operator repo. The workspace Status page reads it (through
6
+ // `/__api/status-board`) and `pa status set | patch | show` writes it. This
7
+ // module is the single validator both sides use, so the page and the CLI
8
+ // can never disagree about what a valid board is.
9
+ //
10
+ // Shape (camelCase keys):
11
+ //
12
+ // {
13
+ // "updatedAt": "2026-10-22T09:25:00Z", required, ISO timestamp
14
+ // "headline": "One sentence on today.", required, string
15
+ // "onMe": [ { question (required), options?, since?, workstream?, ids?, link? } ],
16
+ // "groups": [ { label, items: [ { title (required), status, where, stage, note, ids, updatedAt, link? } ] } ],
17
+ // "done": [ same shape as an item, plus optional workstream ]
18
+ // }
19
+ //
20
+ // Legacy alias: boards written before the platform feature used the key
21
+ // `onBaz` and the status value `onBaz`. `normaliseBoard` maps both to `onMe`
22
+ // on input, so an existing board file loads unchanged; every write goes out
23
+ // as `onMe`.
24
+ //
25
+ // Unknown keys are kept (the reference board carries `since` on an item).
26
+ // An unknown status value is refused on write (`strictStatus`) and allowed
27
+ // on read, where the page shows it as a grey chip so a typo stays visible.
28
+
29
+ import { existsSync, readdirSync } from 'node:fs';
30
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
31
+ import { dirname, join } from 'node:path';
32
+
33
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
34
+
35
+ export const STATUS_VALUES = ['queued', 'running', 'review', 'blocked', 'onMe', 'done'];
36
+ export const LEGACY_ON_ME_KEY = 'onBaz';
37
+ export const BOARD_FILE = 'status-board.json';
38
+
39
+ // Agent folder names: lowercase letters, digits and hyphens. Anything else
40
+ // (dots, slashes) is refused so a query parameter can never walk out of
41
+ // `agents/`.
42
+ const AGENT_RE = /^[a-z0-9][a-z0-9-]*$/;
43
+
44
+ export function isValidAgentName(name) {
45
+ return typeof name === 'string' && AGENT_RE.test(name);
46
+ }
47
+
48
+ /**
49
+ * Resolve the running PA's agent folder name. Order:
50
+ * 1. operator/config/preferences.json `paName`
51
+ * 2. the single `agents/*` folder holding a next-session.md
52
+ * (`dave` when several do and one of them is `dave`)
53
+ * 3. `dave`, the documented default for deployments that predate
54
+ * preferences
55
+ * The workspace's `/__api/next-session` uses the same function.
56
+ */
57
+ export function resolveAgentName(repoRoot) {
58
+ try {
59
+ const prefs = readPreferencesSync(repoRoot);
60
+ if (prefs && typeof prefs.paName === 'string' && prefs.paName.length) return prefs.paName;
61
+ } catch { /* non-fatal */ }
62
+
63
+ const agentsDir = join(repoRoot, 'agents');
64
+ if (existsSync(agentsDir)) {
65
+ let entries;
66
+ try { entries = readdirSync(agentsDir, { withFileTypes: true }); } catch { entries = []; }
67
+ const hits = [];
68
+ for (const e of entries) {
69
+ if (e.isDirectory() && existsSync(join(agentsDir, e.name, 'next-session.md'))) hits.push(e.name);
70
+ }
71
+ if (hits.length === 1) return hits[0];
72
+ if (hits.length > 1 && hits.includes('dave')) return 'dave';
73
+ }
74
+ return 'dave';
75
+ }
76
+
77
+ /** Repo-relative path of an agent's board file. */
78
+ export function boardRelPath(agent) {
79
+ return 'agents/' + agent + '/workspace/' + BOARD_FILE;
80
+ }
81
+
82
+ /** Absolute path of an agent's board file. */
83
+ export function boardPath(repoRoot, agent) {
84
+ return join(repoRoot, 'agents', agent, 'workspace', BOARD_FILE);
85
+ }
86
+
87
+ /**
88
+ * Map the legacy `onBaz` key and status value to `onMe`. Returns a new
89
+ * object with key order preserved (the alias takes the old key's slot).
90
+ * Throws when a board carries both keys, since one would silently win.
91
+ */
92
+ export function normaliseBoard(input) {
93
+ if (!isPlainObject(input)) return input;
94
+ if (LEGACY_ON_ME_KEY in input && 'onMe' in input) {
95
+ throw new BoardError(['board has both "onMe" and the legacy "onBaz"; keep one']);
96
+ }
97
+ const out = {};
98
+ for (const k of Object.keys(input)) {
99
+ if (k === LEGACY_ON_ME_KEY) out.onMe = input[k];
100
+ else out[k] = input[k];
101
+ }
102
+ if (Array.isArray(out.groups)) {
103
+ out.groups = out.groups.map((g) => (isPlainObject(g) && Array.isArray(g.items)
104
+ ? { ...g, items: g.items.map(normaliseStatus) }
105
+ : g));
106
+ }
107
+ if (Array.isArray(out.done)) out.done = out.done.map(normaliseStatus);
108
+ return out;
109
+ }
110
+
111
+ function normaliseStatus(it) {
112
+ if (isPlainObject(it) && it.status === LEGACY_ON_ME_KEY) return { ...it, status: 'onMe' };
113
+ return it;
114
+ }
115
+
116
+ export class BoardError extends Error {
117
+ constructor(problems) {
118
+ super('invalid status board: ' + problems.join('; '));
119
+ this.name = 'BoardError';
120
+ this.problems = problems;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Validate a board (after `normaliseBoard`). Returns a list of problems;
126
+ * an empty list means valid.
127
+ *
128
+ * @param {object} board
129
+ * @param {{ strictStatus?: boolean }} [opts] strictStatus refuses status
130
+ * values outside STATUS_VALUES (write side). Off for the read side.
131
+ */
132
+ export function validateBoard(board, opts = {}) {
133
+ const strictStatus = opts.strictStatus === true;
134
+ const problems = [];
135
+ if (!isPlainObject(board)) return ['board must be a JSON object'];
136
+
137
+ checkIso(board.updatedAt, 'updatedAt', true, problems);
138
+ if (typeof board.headline !== 'string') problems.push('headline is required and must be a string');
139
+ if (LEGACY_ON_ME_KEY in board) problems.push('legacy key "onBaz" must be normalised to "onMe"');
140
+
141
+ if (board.onMe !== undefined) {
142
+ if (!Array.isArray(board.onMe)) problems.push('onMe must be an array');
143
+ else board.onMe.forEach((a, i) => checkAsk(a, 'onMe[' + i + ']', problems));
144
+ }
145
+ if (board.groups !== undefined) {
146
+ if (!Array.isArray(board.groups)) problems.push('groups must be an array');
147
+ else board.groups.forEach((g, gi) => {
148
+ const at = 'groups[' + gi + ']';
149
+ if (!isPlainObject(g)) { problems.push(at + ' must be an object'); return; }
150
+ checkString(g.label, at + '.label', false, problems);
151
+ if (!Array.isArray(g.items)) { problems.push(at + '.items must be an array'); return; }
152
+ g.items.forEach((it, ii) => checkItem(it, at + '.items[' + ii + ']', strictStatus, problems));
153
+ });
154
+ }
155
+ if (board.done !== undefined) {
156
+ if (!Array.isArray(board.done)) problems.push('done must be an array');
157
+ else board.done.forEach((it, i) => checkItem(it, 'done[' + i + ']', strictStatus, problems));
158
+ }
159
+ return problems;
160
+ }
161
+
162
+ function checkItem(it, at, strictStatus, problems) {
163
+ if (!isPlainObject(it)) { problems.push(at + ' must be an object'); return; }
164
+ checkString(it.title, at + '.title', true, problems);
165
+ if (it.status !== undefined) {
166
+ if (typeof it.status !== 'string') problems.push(at + '.status must be a string');
167
+ else if (strictStatus && !STATUS_VALUES.includes(it.status)) {
168
+ problems.push(at + '.status "' + it.status + '" is not one of ' + STATUS_VALUES.join(', '));
169
+ }
170
+ }
171
+ for (const k of ['where', 'stage', 'note', 'link', 'workstream']) checkString(it[k], at + '.' + k, false, problems);
172
+ checkStringArray(it.ids, at + '.ids', problems);
173
+ checkIso(it.updatedAt, at + '.updatedAt', false, problems);
174
+ checkIso(it.since, at + '.since', false, problems);
175
+ }
176
+
177
+ function checkAsk(a, at, problems) {
178
+ if (!isPlainObject(a)) { problems.push(at + ' must be an object'); return; }
179
+ checkString(a.question, at + '.question', true, problems);
180
+ checkStringArray(a.options, at + '.options', problems);
181
+ checkStringArray(a.ids, at + '.ids', problems);
182
+ for (const k of ['workstream', 'link']) checkString(a[k], at + '.' + k, false, problems);
183
+ checkIso(a.since, at + '.since', false, problems);
184
+ }
185
+
186
+ function checkString(v, at, required, problems) {
187
+ if (v === undefined) {
188
+ if (required) problems.push(at + ' is required');
189
+ return;
190
+ }
191
+ if (typeof v !== 'string') problems.push(at + ' must be a string');
192
+ else if (required && !v.trim()) problems.push(at + ' must not be empty');
193
+ }
194
+
195
+ function checkStringArray(v, at, problems) {
196
+ if (v === undefined) return;
197
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) problems.push(at + ' must be an array of strings');
198
+ }
199
+
200
+ function checkIso(v, at, required, problems) {
201
+ if (v === undefined) {
202
+ if (required) problems.push(at + ' is required');
203
+ return;
204
+ }
205
+ if (typeof v !== 'string' || Number.isNaN(Date.parse(v))) problems.push(at + ' must be an ISO timestamp');
206
+ }
207
+
208
+ function isPlainObject(v) {
209
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
210
+ }
211
+
212
+ /**
213
+ * Parse board text, normalise the legacy alias and validate. Returns
214
+ * `{ board, problems }`; throws a SyntaxError on unparseable text.
215
+ */
216
+ export function parseBoard(text, opts = {}) {
217
+ const raw = JSON.parse(text);
218
+ let board;
219
+ try {
220
+ board = normaliseBoard(raw);
221
+ } catch (err) {
222
+ if (err instanceof BoardError) return { board: raw, problems: err.problems };
223
+ throw err;
224
+ }
225
+ return { board, problems: validateBoard(board, opts) };
226
+ }
227
+
228
+ /** Read and parse an agent's board. Returns null when the file is absent. */
229
+ export async function readBoard(file, opts = {}) {
230
+ let text;
231
+ try {
232
+ text = await readFile(file, 'utf8');
233
+ } catch (err) {
234
+ if (err.code === 'ENOENT') return null;
235
+ throw err;
236
+ }
237
+ return parseBoard(text, opts);
238
+ }
239
+
240
+ /**
241
+ * Validate (strict) and write a board atomically: a temp file beside the
242
+ * target, then rename over it, so a reader never sees a half-written file
243
+ * and a refused board never lands. 2-space indent, trailing newline.
244
+ */
245
+ export async function writeBoardAtomic(file, board) {
246
+ const normalised = normaliseBoard(board);
247
+ const problems = validateBoard(normalised, { strictStatus: true });
248
+ if (problems.length) throw new BoardError(problems);
249
+ await mkdir(dirname(file), { recursive: true });
250
+ const tmp = file + '.tmp-' + process.pid + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
251
+ try {
252
+ await writeFile(tmp, JSON.stringify(normalised, null, 2) + '\n', 'utf8');
253
+ await rename(tmp, file);
254
+ } catch (err) {
255
+ try { await unlink(tmp); } catch { /* already gone */ }
256
+ throw err;
257
+ }
258
+ return normalised;
259
+ }
@@ -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`** — the session was abandoned before a usable answer. The
202
- `research-normal.js` workflow's `>10%`-unsourced guardrail (build-spec §8) stamps a
203
- run `aborted` so it reads correctly in `list`/`show` instead of lingering as
204
- `in-progress`.
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
- `2. Write this EXACT JSON to \`${ctx.sessionDir}/sources.json\`:`,
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
- newSqs = deepen.newSubQuestions.slice(0, 4).map((s) => ({ ...s, kind: 'independent', dependsOn: [] }));
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(