@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.
- package/dist/engineering-pa-cli/src/pa/cli.mjs +8 -0
- package/dist/engineering-pa-cli/src/pa/status-board-cli.mjs +425 -0
- package/dist/engineering-pa-cli/src/pa/status-board.mjs +259 -0
- 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-core/knowledge/delegation-dispatch/delegation-modes.md +2 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/README.md +2 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/session-boot-and-end.md +5 -1
- package/dist/engineering-pa-core/knowledge/work-management/README.md +2 -1
- package/dist/engineering-pa-core/knowledge/work-management/status-board.md +50 -0
- package/dist/engineering-pa-workspace/README.md +9 -0
- package/dist/engineering-pa-workspace/app/assets/md.js +91 -1
- package/dist/engineering-pa-workspace/app/nav.json +6 -0
- package/dist/engineering-pa-workspace/app/notes.html +3 -1
- package/dist/engineering-pa-workspace/app/status-board.html +503 -0
- package/dist/engineering-pa-workspace/src/api.mjs +79 -29
- 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
|
|
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(
|