@stravica-ai/digital-operator-pa 0.1.54 → 0.1.56

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 (97) hide show
  1. package/dist/engineering-pa-cli/src/coordinator-hook/config.mjs +2 -2
  2. package/dist/engineering-pa-cli/src/environment/probes.mjs +1 -1
  3. package/dist/engineering-pa-cli/src/pa/cli.mjs +28 -0
  4. package/dist/engineering-pa-cli/src/pa/projects.mjs +7 -0
  5. package/dist/engineering-pa-cli/src/pa/work-cli.mjs +142 -0
  6. package/dist/engineering-pa-cli/src/pa/work.mjs +9 -0
  7. package/dist/engineering-pa-cli/src/research/engine/finalise.mjs +14 -4
  8. package/dist/engineering-pa-cli/src/research/workflows/research-deep.js +15 -5
  9. package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +60 -14
  10. package/dist/engineering-pa-cli/src/research/wrappers/normal.mjs +46 -10
  11. package/dist/engineering-pa-core/docs/autonomy-envelope-tracker.md +22 -22
  12. package/dist/engineering-pa-core/docs/blueprints/git-identity/blueprint.md +21 -19
  13. package/dist/engineering-pa-core/docs/blueprints/scheduled-distillation/blueprint.md +2 -2
  14. package/dist/engineering-pa-core/docs/commands/init.md +1 -1
  15. package/dist/engineering-pa-core/docs/commands/onboard.md +6 -6
  16. package/dist/engineering-pa-core/docs/commands/research.md +66 -66
  17. package/dist/engineering-pa-core/docs/commands/upgrade.md +4 -4
  18. package/dist/engineering-pa-core/docs/entity-location-map.md +4 -4
  19. package/dist/engineering-pa-core/docs/lock.md +18 -18
  20. package/dist/engineering-pa-core/docs/memory-sync.md +1 -1
  21. package/dist/engineering-pa-core/docs/pa-operating-model.md +87 -87
  22. package/dist/engineering-pa-core/docs/scheduling.md +1 -1
  23. package/dist/engineering-pa-core/docs/standards/README.md +2 -2
  24. package/dist/engineering-pa-core/docs/standards/briefing-contract.md +18 -18
  25. package/dist/engineering-pa-core/docs/standards/tooling.md +41 -41
  26. package/dist/engineering-pa-core/docs/tools/distill.md +19 -19
  27. package/dist/engineering-pa-core/docs/tools/memory-sync.md +14 -14
  28. package/dist/engineering-pa-core/docs/tools/scheduler.md +15 -15
  29. package/dist/engineering-pa-core/docs/tools/slice.md +18 -18
  30. package/dist/engineering-pa-core/docs/upstream-issue-filing.md +6 -6
  31. package/dist/engineering-pa-core/docs/working-directory.md +3 -3
  32. package/dist/engineering-pa-core/docs/workspace-operations.md +11 -11
  33. package/dist/engineering-pa-core/package.json +3 -3
  34. package/dist/engineering-pa-core/roles/agentic-workflow-designer/definition.md +1 -1
  35. package/dist/engineering-pa-core/src/distill/health.mjs +16 -6
  36. package/dist/engineering-pa-core/src/instance-root/find.mjs +14 -3
  37. package/dist/engineering-pa-core/src/memory-sync/repo.mjs +2 -2
  38. package/dist/engineering-pa-core/src/scheduler/install.mjs +8 -7
  39. package/dist/engineering-pa-core/src/scheduler/list.mjs +2 -2
  40. package/dist/engineering-pa-core/src/scheduler/remove.mjs +65 -11
  41. package/dist/engineering-pa-core/src/scheduler/verify.mjs +2 -2
  42. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +3 -2
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +2 -1
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +5 -1
  45. package/dist/engineering-pa-library-cli/package.json +1 -0
  46. package/dist/engineering-pa-library-cli/src/pull-clones.mjs +12 -3
  47. package/dist/engineering-pa-library-tunnel/package.json +3 -0
  48. package/dist/engineering-pa-library-tunnel/src/config.mjs +2 -2
  49. package/dist/engineering-pa-migrate-inspect/package.json +4 -1
  50. package/dist/engineering-pa-migrate-inspect/src/age-key-migrate.mjs +2 -2
  51. package/dist/engineering-pa-migrate-inspect/src/inspector.mjs +2 -2
  52. package/dist/engineering-pa-migrate-inspect/src/platforms/macos.mjs +2 -1
  53. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +8 -0
  54. package/dist/engineering-pa-workspace/app/assets/brand/icons/app-icon-192.png +0 -0
  55. package/dist/engineering-pa-workspace/app/assets/brand/icons/app-icon-512.png +0 -0
  56. package/dist/engineering-pa-workspace/app/assets/brand/icons/apple-touch-icon.png +0 -0
  57. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-16.png +0 -0
  58. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-32.png +0 -0
  59. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-48.png +0 -0
  60. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon.ico +0 -0
  61. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon.svg +1 -0
  62. package/dist/engineering-pa-workspace/app/assets/brand/icons/maskable-icon-512.png +0 -0
  63. package/dist/engineering-pa-workspace/app/assets/brand/icons/maskable-icon.svg +1 -0
  64. package/dist/engineering-pa-workspace/app/assets/brand/icons/safari-pinned-tab.svg +1 -0
  65. package/dist/engineering-pa-workspace/app/assets/brand/stravica-logo-dark.svg +1 -0
  66. package/dist/engineering-pa-workspace/app/assets/brand/stravica-logo-light.svg +1 -0
  67. package/dist/engineering-pa-workspace/app/assets/brand/stravica-mark.svg +1 -0
  68. package/dist/engineering-pa-workspace/app/assets/modal.js +17 -17
  69. package/dist/engineering-pa-workspace/app/assets/theme.css +332 -99
  70. package/dist/engineering-pa-workspace/app/assets/theme.js +259 -107
  71. package/dist/engineering-pa-workspace/app/banner.js +16 -12
  72. package/dist/engineering-pa-workspace/app/delegations.html +3 -3
  73. package/dist/engineering-pa-workspace/app/home.html +2 -2
  74. package/dist/engineering-pa-workspace/app/index.html +6 -3
  75. package/dist/engineering-pa-workspace/app/knowledge-map.html +2 -2
  76. package/dist/engineering-pa-workspace/app/knowledge.html +6 -6
  77. package/dist/engineering-pa-workspace/app/notes.html +2 -2
  78. package/dist/engineering-pa-workspace/app/onboarding.html +12 -11
  79. package/dist/engineering-pa-workspace/app/profile.html +4 -4
  80. package/dist/engineering-pa-workspace/app/project-detail.html +25 -18
  81. package/dist/engineering-pa-workspace/app/projects.html +4 -4
  82. package/dist/engineering-pa-workspace/app/repos.html +15 -14
  83. package/dist/engineering-pa-workspace/app/review-annotator.js +38 -38
  84. package/dist/engineering-pa-workspace/app/reviews.html +7 -7
  85. package/dist/engineering-pa-workspace/app/schedules.html +11 -11
  86. package/dist/engineering-pa-workspace/app/secrets.html +6 -6
  87. package/dist/engineering-pa-workspace/app/staging.html +15 -15
  88. package/dist/engineering-pa-workspace/app/tasks.html +4 -4
  89. package/dist/engineering-pa-workspace/app/viewer.html +14 -9
  90. package/dist/engineering-pa-workspace/index.mjs +2 -0
  91. package/dist/engineering-pa-workspace/src/install-daemon.mjs +6 -5
  92. package/dist/engineering-pa-workspace/src/review/api-handler.mjs +53 -2
  93. package/dist/engineering-pa-workspace/src/server.mjs +6 -6
  94. package/dist/engineering-pa-workspace/src/supervisor.mjs +5 -4
  95. package/dist/engineering-pa-workspace/src/workspace-page-contract.mjs +47 -19
  96. package/docs/onboarding-solo.md +1 -0
  97. package/package.json +5 -4
@@ -20,7 +20,7 @@
20
20
  import { readFile } from 'node:fs/promises';
21
21
  import { dirname, join, resolve } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
- import { homedir } from 'node:os';
23
+ import { resolveHome } from '../../../engineering-pa-core/src/home/resolve.mjs';
24
24
 
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
26
  const CORE_CONFIG_PATH = resolve(__dirname, '..', '..', 'config', 'coordinator-allowlist.json');
@@ -98,7 +98,7 @@ export function mergeOverlay(config, overlay, warnings = []) {
98
98
  * lives at `.claude/coordinator-overlay.json` under that directory.
99
99
  */
100
100
  export function overlayPaths({ repoRoot, cwd, env = process.env }) {
101
- const home = env.HOME || homedir();
101
+ const home = resolveHome({ env });
102
102
  const workspaceBase = repoRoot || cwd;
103
103
  return {
104
104
  home: home ? join(home, '.claude', 'coordinator-overlay.json') : null,
@@ -6,7 +6,7 @@
6
6
  import { execFile } from 'node:child_process';
7
7
  import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
8
8
  import { promisify } from 'node:util';
9
- import { arch, platform, release, homedir } from 'node:os';
9
+ import { arch, platform, release } from 'node:os';
10
10
  import { basename, join, resolve, dirname } from 'node:path';
11
11
  import { resolve4 } from 'node:dns/promises';
12
12
  import { fileURLToPath } from 'node:url';
@@ -67,6 +67,34 @@ export async function main(argv) {
67
67
  return 0;
68
68
  }
69
69
 
70
+ // 0.1.55 item 4 (w-2026-09-21-dave-004): `pa --version` / `pa -v`
71
+ // prints the installed package version. Before this landing there
72
+ // was no way to ask an installed CLI which version it was, so the
73
+ // 0.1.54 sweep had to `cat node_modules/.../package.json` by hand.
74
+ if (argv[0] === '--version' || argv[0] === '-v') {
75
+ const { readFile } = await import('node:fs/promises');
76
+ const { fileURLToPath } = await import('node:url');
77
+ const { dirname: dn, join: jn } = await import('node:path');
78
+ try {
79
+ // packages/cli/src/pa/cli.mjs -> ../../..(three-up)/package.json
80
+ // works in-repo. The installed tarball layout resolves the
81
+ // package.json via the exports map added in this same release.
82
+ let pkgJson;
83
+ try {
84
+ pkgJson = await import('@stravica-ai/digital-operator-pa/package.json', { with: { type: 'json' } });
85
+ } catch {
86
+ const here = dn(fileURLToPath(import.meta.url));
87
+ const local = jn(here, '..', '..', '..', 'engineering-pa', 'package.json');
88
+ pkgJson = { default: JSON.parse(await readFile(local, 'utf8')) };
89
+ }
90
+ process.stdout.write((pkgJson.default?.version || 'unknown') + '\n');
91
+ return 0;
92
+ } catch (e) {
93
+ process.stderr.write(`[pa] failed to resolve version: ${e?.message || e}\n`);
94
+ return 1;
95
+ }
96
+ }
97
+
70
98
  const verb = argv[0];
71
99
  const rest = argv.slice(1);
72
100
 
@@ -17,6 +17,7 @@ import { writeFile } from 'node:fs/promises';
17
17
  import { join } from 'node:path';
18
18
 
19
19
  import { getRepoRoot } from './repo-root.mjs';
20
+ import { findRepoRootSync } from '../../../engineering-pa-core/src/instance-root/find.mjs';
20
21
  import { readJson, writeJson, ensureDir, listSubdirs, today } from './io.mjs';
21
22
  import { addDoc, removeDocByPath, setDocStatus, DEFAULT_GROUP, VALID_DOC_STATUSES } from './doc-groups.mjs';
22
23
  import { normaliseDocPath } from './doc-path.mjs';
@@ -111,6 +112,12 @@ export async function createProject(opts) {
111
112
  validateCategory(opts.category);
112
113
  validatePriority(opts.priority);
113
114
 
115
+ // 0.1.55 F1: refuse a project create from outside any operator instance
116
+ // with the strict findRepoRootSync error (exit 2, message naming the
117
+ // start directory and the markers walked). Keeps the failure shape
118
+ // aligned with pa work create and the scaffold write paths.
119
+ findRepoRootSync({ strict: true });
120
+
114
121
  const repoRoot = await getRepoRoot();
115
122
  const projDir = join(repoRoot, 'projects', opts.id);
116
123
  const projectJsonPath = join(projDir, 'project.json');
@@ -19,10 +19,14 @@
19
19
  // fall-back-to-default case. Full rationale + tests in
20
20
  // `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs`.
21
21
 
22
+ import { existsSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
22
25
  import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
23
26
 
24
27
  import { createWork, setWorkStatus, listWork } from './work.mjs';
25
28
  import { getRepoRoot } from './repo-root.mjs';
29
+ import { listSubdirs } from './io.mjs';
26
30
 
27
31
  const USAGE = `pa work — manage operator-repo work items
28
32
 
@@ -126,8 +130,49 @@ async function readPreferencesPaName() {
126
130
  }
127
131
  }
128
132
 
133
+ // 0.1.55 item 4 (w-2026-09-21-dave-004): refuse unknown flags loudly.
134
+ // The pre-0.1.55 parser silently dropped anything not consumed downstream,
135
+ // so a typo like `--status` (only valid on `work status`) or an assumed
136
+ // `--description` (there is no such flag; the field is `summary`) went
137
+ // unnoticed and the created record was missing the value the operator
138
+ // meant to set. Recorded as an incident in w-2026-09-21-dave-002's
139
+ // summary.
140
+ const CREATE_ALLOWED_FLAGS = new Set([
141
+ 'project',
142
+ 'title',
143
+ 'category',
144
+ 'priority',
145
+ 'summary',
146
+ 'owner',
147
+ 'assignee',
148
+ 'author',
149
+ ]);
150
+
129
151
  async function doCreate(args) {
130
152
  const { flags } = parseFlags(args);
153
+ const unknown = Object.keys(flags).filter((k) => !CREATE_ALLOWED_FLAGS.has(k));
154
+ if (unknown.length > 0) {
155
+ const list = unknown.map((k) => `--${k}`).join(', ');
156
+ const known = Array.from(CREATE_ALLOWED_FLAGS).map((k) => `--${k}`).sort().join(', ');
157
+ process.stderr.write(`[pa work create] unknown flag(s): ${list}\n`);
158
+ process.stderr.write(` accepted flags: ${known}\n`);
159
+ return 1;
160
+ }
161
+ // Project existence gate (w-2026-09-30-dave-005 estate chore). Before this gate,
162
+ // `pa work create --project <nonexistent>` silently minted a work item pointing at
163
+ // a project that did not exist; the mistake surfaced later as an orphan in listings.
164
+ // Refuse loudly with the nearest matches so a typo is a fast fix, not a silent
165
+ // corruption. Only the CLI enforces this — programmatic callers of createWork()
166
+ // stay permissive (test fixtures use --project 'proj-a' without a real project on
167
+ // disk); wider grep found no CLI caller depending on the old behaviour, so no
168
+ // escape hatch is exposed.
169
+ if (typeof flags.project === 'string') {
170
+ const projectErr = await validateProjectExists(flags.project);
171
+ if (projectErr) {
172
+ process.stderr.write(projectErr);
173
+ return 1;
174
+ }
175
+ }
131
176
  const priority = flags.priority != null && flags.priority !== true
132
177
  ? Number(flags.priority)
133
178
  : null;
@@ -200,3 +245,100 @@ async function doList(args) {
200
245
  for (const r of rows) process.stdout.write(cols.map((c) => pad(r[c], widths[c])).join(' ') + '\n');
201
246
  return 0;
202
247
  }
248
+
249
+ // ── project-existence validation (w-2026-09-30-dave-005 estate chore) ──────────
250
+
251
+ /**
252
+ * Levenshtein distance between two short strings. Used to rank nearest project ids
253
+ * for a typo; not exported.
254
+ */
255
+ function levenshtein(a, b) {
256
+ if (a === b) return 0;
257
+ if (!a.length) return b.length;
258
+ if (!b.length) return a.length;
259
+ const prev = new Array(b.length + 1);
260
+ const curr = new Array(b.length + 1);
261
+ for (let j = 0; j <= b.length; j += 1) prev[j] = j;
262
+ for (let i = 1; i <= a.length; i += 1) {
263
+ curr[0] = i;
264
+ for (let j = 1; j <= b.length; j += 1) {
265
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
266
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
267
+ }
268
+ for (let j = 0; j <= b.length; j += 1) prev[j] = curr[j];
269
+ }
270
+ return prev[b.length];
271
+ }
272
+
273
+ /**
274
+ * List every projects/<id>/ whose project.json exists on disk. Names only, sorted
275
+ * lexicographically. Empty when the projects/ dir is absent (accepted by listSubdirs).
276
+ */
277
+ async function listExistingProjectIds() {
278
+ const repoRoot = await getRepoRoot();
279
+ const projectsDir = join(repoRoot, 'projects');
280
+ const subdirs = await listSubdirs(projectsDir);
281
+ const ids = [];
282
+ for (const name of subdirs) {
283
+ if (existsSync(join(projectsDir, name, 'project.json'))) ids.push(name);
284
+ }
285
+ ids.sort();
286
+ return ids;
287
+ }
288
+
289
+ /**
290
+ * Nearest candidates for a missing project id: substring matches first (both
291
+ * directions), then the closest by Levenshtein distance (up to length/2 + 1). At
292
+ * most 5 suggestions; sorted deterministically.
293
+ */
294
+ function nearestProjectMatches(target, candidates, max = 5) {
295
+ const seen = new Set();
296
+ const out = [];
297
+ const push = (id) => { if (!seen.has(id)) { seen.add(id); out.push(id); } };
298
+ for (const id of candidates) {
299
+ if (id.toLowerCase().includes(target.toLowerCase()) || target.toLowerCase().includes(id.toLowerCase())) {
300
+ push(id);
301
+ }
302
+ if (out.length >= max) break;
303
+ }
304
+ if (out.length < max) {
305
+ const threshold = Math.max(1, Math.floor(target.length / 2) + 1);
306
+ const scored = candidates
307
+ .filter((id) => !seen.has(id))
308
+ .map((id) => ({ id, d: levenshtein(target.toLowerCase(), id.toLowerCase()) }))
309
+ .filter((r) => r.d <= threshold)
310
+ .sort((a, b) => a.d - b.d || a.id.localeCompare(b.id));
311
+ for (const { id } of scored) {
312
+ push(id);
313
+ if (out.length >= max) break;
314
+ }
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /**
320
+ * Returns null when the project id resolves to an existing project.json on disk, or
321
+ * a preformatted stderr message (with trailing newline) when it does not. Split so
322
+ * doCreate can print + return exit 1 without repeating the plumbing.
323
+ */
324
+ async function validateProjectExists(projectId) {
325
+ const repoRoot = await getRepoRoot();
326
+ const path = join(repoRoot, 'projects', projectId, 'project.json');
327
+ if (existsSync(path)) return null;
328
+ let candidates = [];
329
+ try { candidates = await listExistingProjectIds(); } catch { candidates = []; }
330
+ const nearest = nearestProjectMatches(projectId, candidates);
331
+ const lines = [`[pa work create] unknown project: ${projectId}`];
332
+ lines.push(` expected projects/${projectId}/project.json on disk (not found)`);
333
+ if (nearest.length) {
334
+ lines.push(` did you mean: ${nearest.join(', ')}`);
335
+ } else if (candidates.length) {
336
+ lines.push(` existing projects: ${candidates.slice(0, 10).join(', ')}${candidates.length > 10 ? ', ...' : ''}`);
337
+ } else {
338
+ lines.push(' no projects on disk yet — create one with `pa project create` first');
339
+ }
340
+ return lines.map((l) => `${l}\n`).join('');
341
+ }
342
+
343
+ // Exported for tests only — pure helpers, no filesystem side-effects.
344
+ export const __testables = { levenshtein, nearestProjectMatches };
@@ -25,6 +25,7 @@ import {
25
25
  maxNForAuthorOnDate,
26
26
  } from '../../../engineering-pa-core/src/workItemId/index.mjs';
27
27
 
28
+ import { findRepoRootSync } from '../../../engineering-pa-core/src/instance-root/find.mjs';
28
29
  import { getRepoRoot } from './repo-root.mjs';
29
30
  import {
30
31
  readJson,
@@ -128,6 +129,14 @@ export async function createWork(opts) {
128
129
  validateCategory(opts.category);
129
130
  validatePriority(opts.priority);
130
131
 
132
+ // 0.1.55 F1: refuse a mint from outside any operator instance BEFORE the
133
+ // author-token check. Pre-fix the flow bailed with the author-token error
134
+ // (exit 1), which read as a config problem in the caller's terminal; the
135
+ // real cause is that the cwd carries none of the instance markers. Strict
136
+ // findRepoRootSync throws with exit 2 and names both the start directory
137
+ // and the markers walked, matching the sibling rule in projects.mjs.
138
+ findRepoRootSync({ strict: true });
139
+
131
140
  let author;
132
141
  try {
133
142
  author = resolveAuthorToken({
@@ -134,9 +134,13 @@ export async function degradedSynthAgent(ctx, sq, reason) {
134
134
 
135
135
  // Pure coverage computation. Given the planner's sub-question ids and the synth
136
136
  // receipts, decide which SQs have a finding (any receipt with written=true), which ran
137
- // as a degraded write (empty finding, still counted as covered), and which are wholly
138
- // uncovered (no receipt at all — the failure this gate exists to catch). Used by the
139
- // wrapper to fail loudly instead of silently reporting status='done' on a partial run.
137
+ // as a degraded write (empty finding, covered on disk but zero surviving claims), and
138
+ // which are wholly uncovered (no receipt at all). `complete` is true only when every
139
+ // planned SQ has a receipt AND every receipt carries at least one claim — a degraded
140
+ // zero-claim finding is a coverage gap the wrapper must surface (status='partial'),
141
+ // not silently mark done (w-2026-09-30-dave-005). The wrapper still distinguishes the
142
+ // two failure modes: uncovered aborts finalise; degraded lets the answer.md be
143
+ // written and returns status='partial' with the degraded[] list.
140
144
  export function coverageReport(subQuestionIds, synthReceipts) {
141
145
  const receiptsBySq = new Map();
142
146
  for (const r of synthReceipts || []) {
@@ -152,7 +156,13 @@ export function coverageReport(subQuestionIds, synthReceipts) {
152
156
  covered.push(id);
153
157
  if ((r.claimCount || 0) === 0) degraded.push(id);
154
158
  }
155
- return { planned, covered, degraded, uncovered, complete: uncovered.length === 0 };
159
+ return {
160
+ planned,
161
+ covered,
162
+ degraded,
163
+ uncovered,
164
+ complete: uncovered.length === 0 && degraded.length === 0,
165
+ };
156
166
  }
157
167
 
158
168
  // -- Finalise (persist gate records, write sources.json + the session answer, round-trip, mark done) --
@@ -1,6 +1,6 @@
1
1
  // GENERATED by assemble.mjs — DO NOT EDIT. Edit wrappers/deep.mjs + engine/*.mjs and re-run `pnpm assemble`.
2
2
  // sources: wrappers/deep.mjs, engine/io.mjs, engine/plan.mjs, engine/agents.mjs, engine/finalise.mjs, engine/rank.mjs, engine/verify.mjs, engine/deepen.mjs, engine/deep.mjs
3
- // contentHash: sha256:cbdbff5d260df77a3b56a60d37bd75a58f0fa38968dbd0f0181c71fb6d8b3d29
3
+ // contentHash: sha256:b7c50f3547c79b4c63bb54f14d1c0675a9af3a1c871d7866050cd22ff3d28fb5
4
4
 
5
5
  export const meta = {
6
6
  name: 'research-deep',
@@ -511,9 +511,13 @@ async function degradedSynthAgent(ctx, sq, reason) {
511
511
 
512
512
  // Pure coverage computation. Given the planner's sub-question ids and the synth
513
513
  // receipts, decide which SQs have a finding (any receipt with written=true), which ran
514
- // as a degraded write (empty finding, still counted as covered), and which are wholly
515
- // uncovered (no receipt at all — the failure this gate exists to catch). Used by the
516
- // wrapper to fail loudly instead of silently reporting status='done' on a partial run.
514
+ // as a degraded write (empty finding, covered on disk but zero surviving claims), and
515
+ // which are wholly uncovered (no receipt at all). `complete` is true only when every
516
+ // planned SQ has a receipt AND every receipt carries at least one claim — a degraded
517
+ // zero-claim finding is a coverage gap the wrapper must surface (status='partial'),
518
+ // not silently mark done (w-2026-09-30-dave-005). The wrapper still distinguishes the
519
+ // two failure modes: uncovered aborts finalise; degraded lets the answer.md be
520
+ // written and returns status='partial' with the degraded[] list.
517
521
  function coverageReport(subQuestionIds, synthReceipts) {
518
522
  const receiptsBySq = new Map();
519
523
  for (const r of synthReceipts || []) {
@@ -529,7 +533,13 @@ function coverageReport(subQuestionIds, synthReceipts) {
529
533
  covered.push(id);
530
534
  if ((r.claimCount || 0) === 0) degraded.push(id);
531
535
  }
532
- return { planned, covered, degraded, uncovered, complete: uncovered.length === 0 };
536
+ return {
537
+ planned,
538
+ covered,
539
+ degraded,
540
+ uncovered,
541
+ complete: uncovered.length === 0 && degraded.length === 0,
542
+ };
533
543
  }
534
544
 
535
545
  // -- Finalise (persist gate records, write sources.json + the session answer, round-trip, mark done) --
@@ -1,6 +1,6 @@
1
1
  // GENERATED by assemble.mjs — DO NOT EDIT. Edit wrappers/normal.mjs + engine/*.mjs and re-run `pnpm assemble`.
2
2
  // sources: wrappers/normal.mjs, engine/io.mjs, engine/plan.mjs, engine/agents.mjs, engine/finalise.mjs, engine/rank.mjs, engine/verify.mjs, engine/deepen.mjs
3
- // contentHash: sha256:579795fe41f91c096a6894492e6e0e0291b9ada32aff3ed81b5c31213c51335d
3
+ // contentHash: sha256:9ea786a5296dd54d14bbc662d1d47a87f437829fc5d394c3a83dd64f65ff1236
4
4
 
5
5
  export const meta = {
6
6
  name: 'research-normal',
@@ -510,9 +510,13 @@ async function degradedSynthAgent(ctx, sq, reason) {
510
510
 
511
511
  // Pure coverage computation. Given the planner's sub-question ids and the synth
512
512
  // receipts, decide which SQs have a finding (any receipt with written=true), which ran
513
- // as a degraded write (empty finding, still counted as covered), and which are wholly
514
- // uncovered (no receipt at all — the failure this gate exists to catch). Used by the
515
- // wrapper to fail loudly instead of silently reporting status='done' on a partial run.
513
+ // as a degraded write (empty finding, covered on disk but zero surviving claims), and
514
+ // which are wholly uncovered (no receipt at all). `complete` is true only when every
515
+ // planned SQ has a receipt AND every receipt carries at least one claim — a degraded
516
+ // zero-claim finding is a coverage gap the wrapper must surface (status='partial'),
517
+ // not silently mark done (w-2026-09-30-dave-005). The wrapper still distinguishes the
518
+ // two failure modes: uncovered aborts finalise; degraded lets the answer.md be
519
+ // written and returns status='partial' with the degraded[] list.
516
520
  function coverageReport(subQuestionIds, synthReceipts) {
517
521
  const receiptsBySq = new Map();
518
522
  for (const r of synthReceipts || []) {
@@ -528,7 +532,13 @@ function coverageReport(subQuestionIds, synthReceipts) {
528
532
  covered.push(id);
529
533
  if ((r.claimCount || 0) === 0) degraded.push(id);
530
534
  }
531
- return { planned, covered, degraded, uncovered, complete: uncovered.length === 0 };
535
+ return {
536
+ planned,
537
+ covered,
538
+ degraded,
539
+ uncovered,
540
+ complete: uncovered.length === 0 && degraded.length === 0,
541
+ };
532
542
  }
533
543
 
534
544
  // -- Finalise (persist gate records, write sources.json + the session answer, round-trip, mark done) --
@@ -823,7 +833,14 @@ async function runDeepenRound(ctx, state, { lenses = VERIFY_LENSES, quorum = 2,
823
833
  // the parent session's 200/200 WebSearch budget — the tools shipped in agents.mjs are
824
834
  // the same tool surface the parent uses, so caps have to leave headroom).
825
835
  const NORMAL_SEARCH_TOOL_CAP = 12; // matches agents.searchAgent default
826
- const NORMAL_CLAIM_BUDGET = 25; // matches rankAndCap call below
836
+ const NORMAL_CLAIM_BUDGET = 25; // matches rankAndCapPerSq call below
837
+ // Per-sub-question retention floor for normal mode (w-2026-09-30-dave-005). Sessions
838
+ // 01M3S5PXQTCZR70MGDQRZWN6ZM (4 SQs, sq-3/sq-4 zero-claim, budgetDropped 56, 5.47M
839
+ // tokens) and 01M3S55WDKSYSC7Z7S8VRRJ9PC (6 SQs, sq-3/sq-4 zero-claim, budgetDropped
840
+ // 75, 10.97M tokens) both had later sub-questions silently starved by the earlier
841
+ // global rankAndCap cap while coverage.complete still reported true. Floor is the
842
+ // same "top-1 per SQ" guarantee deep already uses (DEEP_RETAIN_FLOOR_PER_SQ).
843
+ const NORMAL_RETAIN_FLOOR_PER_SQ = 1;
827
844
  function normalWebSearchCeiling(subQuestionCount, centralClaimCount) {
828
845
  const searchers = subQuestionCount * NORMAL_SEARCH_TOOL_CAP;
829
846
  const verifiers = centralClaimCount * VERIFY_LENSES.length * VERIFY_TOOL_CALL_CAP_DEFAULT;
@@ -847,10 +864,19 @@ phase('Search');
847
864
  const sqResults = await runSearchExtract(ctx, subQuestions);
848
865
 
849
866
  // -- Rank + budget cap (deterministic code — §4.1) --
867
+ // Per-SQ floor applied BEFORE global trimming (w-2026-09-30-dave-005): reserve at
868
+ // least NORMAL_RETAIN_FLOOR_PER_SQ retained claim(s) per sub-question that had any
869
+ // claim extracted, then fill the remainder by global rank. A pure global cap starves
870
+ // later sub-questions silently (they end with zero retained claims, hence a degraded
871
+ // finding) while the wrapper still marks done.
850
872
  const urlBySourceId = new Map();
851
873
  const allClaims = flattenClaims(sqResults, urlBySourceId);
852
- const { retained, extractedCount, budgetDropped } = rankAndCap(allClaims, NORMAL_CLAIM_BUDGET); // CLAIM_BUDGET 25 (harness-skill cap)
853
- log(`extracted ${extractedCount} claims; retained ${retained.length} (budgetDropped ${budgetDropped})`);
874
+ const { retained, extractedCount, budgetDropped } = rankAndCapPerSq(
875
+ allClaims,
876
+ NORMAL_CLAIM_BUDGET, // 25 (harness-skill cap)
877
+ { floorPerSq: NORMAL_RETAIN_FLOOR_PER_SQ },
878
+ );
879
+ log(`extracted ${extractedCount} claims; retained ${retained.length} (budgetDropped ${budgetDropped}, floorPerSq ${NORMAL_RETAIN_FLOOR_PER_SQ})`);
854
880
 
855
881
  // -- Verify (3-vote adversarial on central claims only — §4 normal row) --
856
882
  phase('Verify');
@@ -877,10 +903,16 @@ phase('Synthesize');
877
903
  const synthReceipts = await runSynth(ctx, subQuestions, retained, verdictByClaimId);
878
904
 
879
905
  // -- Coverage gate --
880
- // Fail loudly when a planned sub-question has no findings receipt. Before this gate
881
- // normal returned status='done' on 4-SQ plans that only synthesised 2 of them.
906
+ // Two failure modes, handled separately (w-2026-09-30-dave-005):
907
+ // 1. UNCOVERED — a planned SQ has no findings receipt at all. Abort finalise; no
908
+ // answer.md written; status='partial'. This is the original gate.
909
+ // 2. DEGRADED — every SQ has a receipt but one or more carry zero surviving claims
910
+ // (the per-SQ floor kicks in for the extractor pool, but verify/dedupe may still
911
+ // leave an SQ empty). Let finalise run so answer.md is written, then override the
912
+ // returned status to 'partial' and surface the degraded[] list. Do NOT mark done
913
+ // — a partial run is not a done run.
882
914
  const coverage = coverageReport(subQuestions.map((s) => s.id), synthReceipts);
883
- if (!coverage.complete) {
915
+ if (coverage.uncovered.length) {
884
916
  log(`ABORT: coverage gap — ${coverage.uncovered.length} of ${coverage.planned} sub-question(s) have no findings receipt: [${coverage.uncovered.join(', ')}]. Not marking done.`);
885
917
  return {
886
918
  sessionId: ctx.sessionId,
@@ -891,7 +923,7 @@ if (!coverage.complete) {
891
923
  };
892
924
  }
893
925
  if (coverage.degraded.length) {
894
- log(`coverage: ${coverage.covered.length}/${coverage.planned} covered, ${coverage.degraded.length} degraded (zero-claim finding): [${coverage.degraded.join(', ')}]`);
926
+ log(`coverage: ${coverage.covered.length}/${coverage.planned} covered, ${coverage.degraded.length} degraded (zero-claim finding): [${coverage.degraded.join(', ')}] — run will return status='partial'`);
895
927
  } else {
896
928
  log(`coverage: ${coverage.covered.length}/${coverage.planned} covered, all with substantive findings`);
897
929
  }
@@ -905,6 +937,12 @@ const unsourcedRatio = totalClaimCount ? totalUnsourced / totalClaimCount : 0;
905
937
  const sourcesJson = buildSourcesJson(sqResults);
906
938
  const gateRecords = buildGateRecords(sqResults);
907
939
 
940
+ // Subagent token cost — surfaced in runStats + the returned summary so an operator can
941
+ // see what a normal-mode session actually spent, without opening the harness manifest
942
+ // (w-2026-09-30-dave-005). Same shape deep uses.
943
+ const outputTokensSpent = (typeof budget !== 'undefined' && budget && budget.spent) ? budget.spent() : 0;
944
+ const budgetTotal = (typeof budget !== 'undefined' && budget && budget.total != null) ? budget.total : null;
945
+
908
946
  const runStats = {
909
947
  extracted: extractedCount, central: centralClaims.length,
910
948
  confirmed: confirmedN, refuted: refutedN, unverifiedInfra: infraN,
@@ -913,6 +951,8 @@ const runStats = {
913
951
  gatedDropped: gateRecords.length,
914
952
  coverage,
915
953
  webSearchCeiling: ceilingUpperBound,
954
+ outputTokensSpent,
955
+ budgetTotal,
916
956
  };
917
957
 
918
958
  if (unsourcedRatio > UNSOURCED_ABORT_RATIO) {
@@ -924,12 +964,18 @@ if (unsourcedRatio > UNSOURCED_ABORT_RATIO) {
924
964
  phase('Finalise');
925
965
  const finalise = await runFinalise(ctx, { gateRecords, sourcesJson, runStats });
926
966
 
927
- const status = finalise.markedDone ? 'done' : 'in-progress';
928
- log(`finalise: sources=${finalise.wroteSources} answer=${finalise.wroteAnswer} roundTrip=${finalise.roundTripOk} markedDone=${finalise.markedDone} gatesAppended=${finalise.gatesAppended ?? 0}/${gateRecords.length}`);
967
+ // A degraded run reports 'partial' regardless of the finaliser's markedDone flag —
968
+ // the answer.md is written and the session may be marked done in the store, but the
969
+ // returned summary tells the caller a sub-question was not answered
970
+ // (w-2026-09-30-dave-005). coverage.complete is already false in that case.
971
+ const baseStatus = finalise.markedDone ? 'done' : 'in-progress';
972
+ const status = coverage.degraded.length ? 'partial' : baseStatus;
973
+ log(`finalise: sources=${finalise.wroteSources} answer=${finalise.wroteAnswer} roundTrip=${finalise.roundTripOk} markedDone=${finalise.markedDone} gatesAppended=${finalise.gatesAppended ?? 0}/${gateRecords.length} status=${status}`);
929
974
 
930
975
  return {
931
976
  sessionId: ctx.sessionId,
932
977
  status,
978
+ ...(coverage.degraded.length ? { reason: `${coverage.degraded.length} sub-question(s) with zero surviving claims`, degraded: coverage.degraded } : {}),
933
979
  question: ctx.baseQuestion,
934
980
  outputMode: ctx.baseOutputMode,
935
981
  subQuestions: subQuestions.map((s) => ({ id: s.id, text: s.text, kind: s.kind })),
@@ -4,7 +4,7 @@
4
4
  import { deriveContext } from '../engine/io.mjs';
5
5
  import { runPlan } from '../engine/plan.mjs';
6
6
  import { runSearchExtract } from '../engine/agents.mjs';
7
- import { flattenClaims, rankAndCap } from '../engine/rank.mjs';
7
+ import { flattenClaims, rankAndCapPerSq } from '../engine/rank.mjs';
8
8
  import { runVerify, VERIFY_LENSES, VERIFY_TOOL_CALL_CAP_DEFAULT } from '../engine/verify.mjs';
9
9
  import { runDeepenRound } from '../engine/deepen.mjs';
10
10
  import { UNSOURCED_ABORT_RATIO, buildSourcesJson, buildGateRecords, runSynth, runFinalise, coverageReport } from '../engine/finalise.mjs';
@@ -16,7 +16,14 @@ import { UNSOURCED_ABORT_RATIO, buildSourcesJson, buildGateRecords, runSynth, ru
16
16
  // the parent session's 200/200 WebSearch budget — the tools shipped in agents.mjs are
17
17
  // the same tool surface the parent uses, so caps have to leave headroom).
18
18
  const NORMAL_SEARCH_TOOL_CAP = 12; // matches agents.searchAgent default
19
- const NORMAL_CLAIM_BUDGET = 25; // matches rankAndCap call below
19
+ const NORMAL_CLAIM_BUDGET = 25; // matches rankAndCapPerSq call below
20
+ // Per-sub-question retention floor for normal mode (w-2026-09-30-dave-005). Sessions
21
+ // 01M3S5PXQTCZR70MGDQRZWN6ZM (4 SQs, sq-3/sq-4 zero-claim, budgetDropped 56, 5.47M
22
+ // tokens) and 01M3S55WDKSYSC7Z7S8VRRJ9PC (6 SQs, sq-3/sq-4 zero-claim, budgetDropped
23
+ // 75, 10.97M tokens) both had later sub-questions silently starved by the earlier
24
+ // global rankAndCap cap while coverage.complete still reported true. Floor is the
25
+ // same "top-1 per SQ" guarantee deep already uses (DEEP_RETAIN_FLOOR_PER_SQ).
26
+ const NORMAL_RETAIN_FLOOR_PER_SQ = 1;
20
27
  function normalWebSearchCeiling(subQuestionCount, centralClaimCount) {
21
28
  const searchers = subQuestionCount * NORMAL_SEARCH_TOOL_CAP;
22
29
  const verifiers = centralClaimCount * VERIFY_LENSES.length * VERIFY_TOOL_CALL_CAP_DEFAULT;
@@ -54,10 +61,19 @@ phase('Search');
54
61
  const sqResults = await runSearchExtract(ctx, subQuestions);
55
62
 
56
63
  // -- Rank + budget cap (deterministic code — §4.1) --
64
+ // Per-SQ floor applied BEFORE global trimming (w-2026-09-30-dave-005): reserve at
65
+ // least NORMAL_RETAIN_FLOOR_PER_SQ retained claim(s) per sub-question that had any
66
+ // claim extracted, then fill the remainder by global rank. A pure global cap starves
67
+ // later sub-questions silently (they end with zero retained claims, hence a degraded
68
+ // finding) while the wrapper still marks done.
57
69
  const urlBySourceId = new Map();
58
70
  const allClaims = flattenClaims(sqResults, urlBySourceId);
59
- const { retained, extractedCount, budgetDropped } = rankAndCap(allClaims, NORMAL_CLAIM_BUDGET); // CLAIM_BUDGET 25 (harness-skill cap)
60
- log(`extracted ${extractedCount} claims; retained ${retained.length} (budgetDropped ${budgetDropped})`);
71
+ const { retained, extractedCount, budgetDropped } = rankAndCapPerSq(
72
+ allClaims,
73
+ NORMAL_CLAIM_BUDGET, // 25 (harness-skill cap)
74
+ { floorPerSq: NORMAL_RETAIN_FLOOR_PER_SQ },
75
+ );
76
+ log(`extracted ${extractedCount} claims; retained ${retained.length} (budgetDropped ${budgetDropped}, floorPerSq ${NORMAL_RETAIN_FLOOR_PER_SQ})`);
61
77
 
62
78
  // -- Verify (3-vote adversarial on central claims only — §4 normal row) --
63
79
  phase('Verify');
@@ -84,10 +100,16 @@ phase('Synthesize');
84
100
  const synthReceipts = await runSynth(ctx, subQuestions, retained, verdictByClaimId);
85
101
 
86
102
  // -- Coverage gate --
87
- // Fail loudly when a planned sub-question has no findings receipt. Before this gate
88
- // normal returned status='done' on 4-SQ plans that only synthesised 2 of them.
103
+ // Two failure modes, handled separately (w-2026-09-30-dave-005):
104
+ // 1. UNCOVERED — a planned SQ has no findings receipt at all. Abort finalise; no
105
+ // answer.md written; status='partial'. This is the original gate.
106
+ // 2. DEGRADED — every SQ has a receipt but one or more carry zero surviving claims
107
+ // (the per-SQ floor kicks in for the extractor pool, but verify/dedupe may still
108
+ // leave an SQ empty). Let finalise run so answer.md is written, then override the
109
+ // returned status to 'partial' and surface the degraded[] list. Do NOT mark done
110
+ // — a partial run is not a done run.
89
111
  const coverage = coverageReport(subQuestions.map((s) => s.id), synthReceipts);
90
- if (!coverage.complete) {
112
+ if (coverage.uncovered.length) {
91
113
  log(`ABORT: coverage gap — ${coverage.uncovered.length} of ${coverage.planned} sub-question(s) have no findings receipt: [${coverage.uncovered.join(', ')}]. Not marking done.`);
92
114
  return {
93
115
  sessionId: ctx.sessionId,
@@ -98,7 +120,7 @@ if (!coverage.complete) {
98
120
  };
99
121
  }
100
122
  if (coverage.degraded.length) {
101
- log(`coverage: ${coverage.covered.length}/${coverage.planned} covered, ${coverage.degraded.length} degraded (zero-claim finding): [${coverage.degraded.join(', ')}]`);
123
+ log(`coverage: ${coverage.covered.length}/${coverage.planned} covered, ${coverage.degraded.length} degraded (zero-claim finding): [${coverage.degraded.join(', ')}] — run will return status='partial'`);
102
124
  } else {
103
125
  log(`coverage: ${coverage.covered.length}/${coverage.planned} covered, all with substantive findings`);
104
126
  }
@@ -112,6 +134,12 @@ const unsourcedRatio = totalClaimCount ? totalUnsourced / totalClaimCount : 0;
112
134
  const sourcesJson = buildSourcesJson(sqResults);
113
135
  const gateRecords = buildGateRecords(sqResults);
114
136
 
137
+ // Subagent token cost — surfaced in runStats + the returned summary so an operator can
138
+ // see what a normal-mode session actually spent, without opening the harness manifest
139
+ // (w-2026-09-30-dave-005). Same shape deep uses.
140
+ const outputTokensSpent = (typeof budget !== 'undefined' && budget && budget.spent) ? budget.spent() : 0;
141
+ const budgetTotal = (typeof budget !== 'undefined' && budget && budget.total != null) ? budget.total : null;
142
+
115
143
  const runStats = {
116
144
  extracted: extractedCount, central: centralClaims.length,
117
145
  confirmed: confirmedN, refuted: refutedN, unverifiedInfra: infraN,
@@ -120,6 +148,8 @@ const runStats = {
120
148
  gatedDropped: gateRecords.length,
121
149
  coverage,
122
150
  webSearchCeiling: ceilingUpperBound,
151
+ outputTokensSpent,
152
+ budgetTotal,
123
153
  };
124
154
 
125
155
  if (unsourcedRatio > UNSOURCED_ABORT_RATIO) {
@@ -131,12 +161,18 @@ if (unsourcedRatio > UNSOURCED_ABORT_RATIO) {
131
161
  phase('Finalise');
132
162
  const finalise = await runFinalise(ctx, { gateRecords, sourcesJson, runStats });
133
163
 
134
- const status = finalise.markedDone ? 'done' : 'in-progress';
135
- log(`finalise: sources=${finalise.wroteSources} answer=${finalise.wroteAnswer} roundTrip=${finalise.roundTripOk} markedDone=${finalise.markedDone} gatesAppended=${finalise.gatesAppended ?? 0}/${gateRecords.length}`);
164
+ // A degraded run reports 'partial' regardless of the finaliser's markedDone flag —
165
+ // the answer.md is written and the session may be marked done in the store, but the
166
+ // returned summary tells the caller a sub-question was not answered
167
+ // (w-2026-09-30-dave-005). coverage.complete is already false in that case.
168
+ const baseStatus = finalise.markedDone ? 'done' : 'in-progress';
169
+ const status = coverage.degraded.length ? 'partial' : baseStatus;
170
+ log(`finalise: sources=${finalise.wroteSources} answer=${finalise.wroteAnswer} roundTrip=${finalise.roundTripOk} markedDone=${finalise.markedDone} gatesAppended=${finalise.gatesAppended ?? 0}/${gateRecords.length} status=${status}`);
136
171
 
137
172
  return {
138
173
  sessionId: ctx.sessionId,
139
174
  status,
175
+ ...(coverage.degraded.length ? { reason: `${coverage.degraded.length} sub-question(s) with zero surviving claims`, degraded: coverage.degraded } : {}),
140
176
  question: ctx.baseQuestion,
141
177
  outputMode: ctx.baseOutputMode,
142
178
  subQuestions: subQuestions.map((s) => ({ id: s.id, text: s.text, kind: s.kind })),