@stravica-ai/digital-operator-pa 0.1.33 → 0.1.34

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 (37) hide show
  1. package/dist/engineering-pa-cli/bin/estate-sync +0 -0
  2. package/dist/engineering-pa-cli/bin/launch +0 -0
  3. package/dist/engineering-pa-cli/bin/pa +0 -0
  4. package/dist/engineering-pa-cli/bin/pa-coordinator-hook +0 -0
  5. package/dist/engineering-pa-cli/bin/research +0 -0
  6. package/dist/engineering-pa-cli/bin/scheduler +0 -0
  7. package/dist/engineering-pa-cli/bin/secrets +0 -0
  8. package/dist/engineering-pa-cli/bin/smoke +0 -0
  9. package/dist/engineering-pa-cli/bin/sync +0 -0
  10. package/dist/engineering-pa-cli/docs/secrets.md +34 -0
  11. package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +0 -0
  12. package/dist/engineering-pa-cli/src/research/briefs/deep-verifier.md +7 -2
  13. package/dist/engineering-pa-cli/src/research/briefs/verifier.md +6 -2
  14. package/dist/engineering-pa-cli/src/research/engine/agents.mjs +3 -0
  15. package/dist/engineering-pa-cli/src/research/engine/deep.mjs +5 -50
  16. package/dist/engineering-pa-cli/src/research/engine/finalise.mjs +80 -4
  17. package/dist/engineering-pa-cli/src/research/engine/verify.mjs +7 -0
  18. package/dist/engineering-pa-cli/src/research/workflows/research-deep.js +93 -53
  19. package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +137 -5
  20. package/dist/engineering-pa-cli/src/research/wrappers/normal.mjs +49 -3
  21. package/dist/engineering-pa-cli/src/secrets/cli.mjs +96 -3
  22. package/dist/engineering-pa-cli/src/secrets/commands/get.mjs +40 -9
  23. package/dist/engineering-pa-cli/src/secrets/vault.mjs +2 -1
  24. package/dist/engineering-pa-core/bin/desk +0 -0
  25. package/dist/engineering-pa-core/bin/distill +0 -0
  26. package/dist/engineering-pa-core/bin/memory-sync +0 -0
  27. package/dist/engineering-pa-core/bin/slice +0 -0
  28. package/dist/engineering-pa-core/src/scheduler/install.mjs +110 -4
  29. package/dist/engineering-pa-library-cli/bin/library +0 -0
  30. package/dist/engineering-pa-library-cli/package.json +1 -1
  31. package/dist/engineering-pa-library-cli/src/config.mjs +26 -4
  32. package/dist/engineering-pa-library-cli/src/verbs/status.mjs +40 -2
  33. package/dist/engineering-pa-library-tunnel/bin/library-tunnel +0 -0
  34. package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +0 -0
  35. package/dist/engineering-pa-scaffold/bin/pa-init +0 -0
  36. package/dist/engineering-pa-workspace/src/ws.mjs +0 -0
  37. package/package.json +5 -6
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -85,6 +85,40 @@ node core/tools/secrets/bin/secrets set API_KEY "sk-abc123" --no-canonical
85
85
  node core/tools/secrets/bin/secrets verify --values --against /path/to/other/vault/scope.enc.json
86
86
  ```
87
87
 
88
+ ### Full-scope reference form (get/set/delete/find)
89
+
90
+ Barry ruling 2026-07-15 (`w-2026-07-15-023`): every secret is referred
91
+ to by its full scope path at every non-interactive reference site. The
92
+ scope is named at the call site so provenance is visible and same-name
93
+ keys in different scopes cannot silently collide (a real problem today,
94
+ `secrets find CLOUDFLARE_WORKERS_ACCOUNT_ID_STRAVICA` returns matches
95
+ in both `hq-estate` and `wsd`).
96
+
97
+ ```bash
98
+ secrets get hq-estate/CLERK_AUTH_KEY
99
+ secrets set clients-clive/BUNNY_API_KEY_CLIVE -
100
+ secrets delete seo-ops/OPENAI_API_KEY
101
+ secrets find hq-estate/GITHUB_PAT_MAX_READ
102
+ ```
103
+
104
+ Each is equivalent to the corresponding `--scope <name> <verb> KEY`
105
+ form. The two may be combined when they agree; a mismatch is a hard
106
+ error (exit 1). `--file` is not combinable with the reference form (use
107
+ one or the other). Bare-key calls still work and remain the default for
108
+ interactive use, but emit a one-line deprecation notice on stderr
109
+ naming the preferred `scope/KEY` form for the target vault.
110
+
111
+ ### Presence check (`get KEY --exists`)
112
+
113
+ `secrets get KEY --exists` (alias `--dry-run`) probes for the key
114
+ without ever decrypting. Prints `present` and exits 0 when the key
115
+ resolves in the target vault; prints `absent` and exits 1 when it does
116
+ not. Combines with `--scope`, `--file`, and the `scope/KEY` reference
117
+ form. Use it as the migration proof primitive when moving a reference
118
+ site to the full-scope form: run `secrets get scope/KEY --exists` and
119
+ compare against the pre-migration equivalent to prove identity, without
120
+ ever handling the secret value itself.
121
+
88
122
  ### Canonical-first writes
89
123
 
90
124
  Canonical-first dual-write is the DEFAULT for scoped `set` since 0.1.20 —
@@ -57,8 +57,13 @@ Return one of three outcomes: `confirmed`, `refuted`, `unverified-infra`.
57
57
  ## 4. Tools & sources
58
58
 
59
59
  - `WebSearch` + `WebFetch` to seek contradiction, rival explanations, and counter-
60
- examples. Keep it lean — a few targeted calls, not a re-research. You are testing one
61
- synthesis, not rebuilding the sub-question.
60
+ examples. Keep it lean — you are testing one synthesis, not rebuilding the sub-
61
+ question.
62
+ - **Hard tool-call cap: {{verifyToolCallCap}} calls total** (searches + fetches
63
+ combined). This is a real budget, not a suggestion: workflow WebSearch calls draw
64
+ from the PARENT session's shared budget, and a fan-out of verifiers can drain it.
65
+ Reach the cap without a decisive check → vote per the adversarial default (§3, §8),
66
+ noting in `note` that the cap was reached.
62
67
 
63
68
  ## 5. Boundaries
64
69
 
@@ -47,8 +47,12 @@ Return one of three outcomes: `confirmed`, `refuted`, `unverified-infra`.
47
47
  ## 4. Tools & sources
48
48
 
49
49
  - `WebSearch` + `WebFetch` to seek contradiction and corroboration. Keep it lean —
50
- a few targeted calls, not a re-research. You are testing one claim, not rebuilding
51
- the sub-question.
50
+ you are testing one claim, not rebuilding the sub-question.
51
+ - **Hard tool-call cap: {{verifyToolCallCap}} calls total** (searches + fetches
52
+ combined). This is a real budget, not a suggestion: workflow WebSearch calls draw
53
+ from the PARENT session's shared budget, and a fan-out of verifiers can drain it.
54
+ Reach the cap without a decisive check → vote per the adversarial default (§3,
55
+ §8), noting in `note` that the cap was reached.
52
56
 
53
57
  ## 5. Boundaries
54
58
 
@@ -121,6 +121,9 @@ export function verifyAgent(ctx, claim, lens, model = 'sonnet', effort = 'high',
121
121
  return agent(brief(ctx, role, {
122
122
  sessionId: ctx.sessionId, claimText: claim.text, quote: claim.quote, sourceUrl: claim.url || claim.sourceId,
123
123
  subQuestionText: claim.sqText, lens,
124
+ // Verifier tool-call cap: shared WebSearch budget with the parent session, so keep
125
+ // per-verifier calls tight (w-2026-09-01-dave-010). Defaults to 2; deep can lift.
126
+ verifyToolCallCap: ctx.verifyToolCallCap ?? 2,
124
127
  }), { model, effort, phase: 'Verify', label: `verify:${claim.claimId}:${lens}`, schema: VERIFY_SCHEMA });
125
128
  }
126
129
 
@@ -15,9 +15,9 @@
15
15
  // stop, and testOverrides so a mechanical smoke run is self-identifying.
16
16
  import { flattenClaims, rankAndCapPerSq } from './rank.mjs';
17
17
  import { adjudicate } from './verify.mjs';
18
- import { verifyAgent, synthAgent, SYNTH_SCHEMA } from './agents.mjs';
18
+ import { verifyAgent } from './agents.mjs';
19
19
  import { runDeepenRound, deepenStopReason } from './deepen.mjs';
20
- import { buildSourcesJson, buildGateRecords, bucketsFor } from './finalise.mjs';
20
+ import { buildSourcesJson, buildGateRecords, bucketsFor, synthOrDegrade } 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
@@ -316,60 +316,15 @@ export async function recordRunStart(ctx) {
316
316
  // claim, but a searcher/extractor that found nothing still leaves an empty SQ). Such an
317
317
  // SQ gets a DEGRADED finding: schema-valid, confidence 'low', zero claims, and a summary
318
318
  // explaining the absence. This closes the deep-judge's "only N of M sub-questions
319
- // answered" gap and keeps the finding count == the plan's sub-question count.
319
+ // answered" gap and keeps the finding count == the plan's sub-question count. The
320
+ // synthOrDegrade/degradedFinding/degradedSynthAgent primitives now live in finalise.mjs
321
+ // (shared with normal — w-2026-09-01-dave-010 fix).
320
322
  export async function runDeepSynth(ctx, subQuestions, retained, verdictByClaimId) {
321
323
  return (await parallel(
322
324
  subQuestions.map((sq) => () => synthOrDegrade(ctx, sq, bucketsFor(retained, verdictByClaimId, sq.id))),
323
325
  )).filter(Boolean);
324
326
  }
325
327
 
326
- // Synthesize one sub-question, degrading to a zero-claim finding when there is nothing to
327
- // synthesize or the synthesizer fails to write a schema-valid finding — either way the SQ
328
- // keeps its file, so the invariant holds.
329
- async function synthOrDegrade(ctx, sq, buckets) {
330
- const hasClaims = buckets.verified.length || buckets.supporting.length || buckets.refuted.length;
331
- if (hasClaims) {
332
- const receipt = await synthAgent(ctx, sq, buckets);
333
- if (receipt && receipt.written === true) return receipt;
334
- log(`synth: ${sq.id} did not write a valid finding — writing a degraded finding to preserve the finding-per-SQ invariant`);
335
- return degradedSynthAgent(ctx, sq, 'the synthesizer did not produce a schema-valid finding');
336
- }
337
- log(`synth: ${sq.id} had no retained claims — writing a degraded (zero-claim) finding to preserve the finding-per-SQ invariant`);
338
- return degradedSynthAgent(ctx, sq, 'no claims survived ranking/verification for this sub-question');
339
- }
340
-
341
- // Pure: the exact degraded finding for a sub-question (fits the EXISTING finding schema —
342
- // no store change). Empty quotes/sources/votes, confidence 'low', a self-describing
343
- // summary, unsourced:false.
344
- export function degradedFinding(sq, reason) {
345
- return {
346
- subQuestionId: sq.id,
347
- summary: `No verifiable findings were produced for this sub-question (${sq.text}). Reason: ${reason}. `
348
- + `This degraded finding is written to preserve the one-finding-per-sub-question invariant; treat the `
349
- + `sub-question as unanswered and re-run research for it if a complete answer is required.`,
350
- quotes: [],
351
- sources: [],
352
- confidence: 'low',
353
- verificationVotes: [],
354
- unsourced: false,
355
- };
356
- }
357
-
358
- // Have an agent write the degraded finding verbatim and round-trip it. Returns a synth
359
- // receipt (claimCount 0) so the caller's aggregation (unsourced ratio, run stats) is
360
- // unaffected. No brief needed — this is a mechanical verbatim write.
361
- async function degradedSynthAgent(ctx, sq, reason) {
362
- const finding = degradedFinding(sq, reason);
363
- const path = `${ctx.sessionDir}/findings/${sq.id}.json`;
364
- return await agent([
365
- `You are writing a **degraded finding** for deep-research session \`${ctx.sessionId}\`, sub-question \`${sq.id}\`.`,
366
- `Write this EXACT JSON (verbatim, no edits) to \`${path}\`:`,
367
- '```json', JSON.stringify(finding, null, 2), '```',
368
- `Then round-trip: run \`${ctx.cli} show ${ctx.sessionId} --json\` and confirm exit 0 (it validates findings).`,
369
- `Return { subQuestionId: "${sq.id}", written: true, path: "${path}", confidence: "low", claimCount: 0,`,
370
- `centralConfirmed: 0, centralRefuted: 0, unsourcedCount: 0 } once the round-trip is green.`,
371
- ].join('\n'), { model: ctx.modelOverride || 'haiku', effort: 'low', phase: 'Synthesize', label: `synth-degraded:${sq.id}`, schema: SYNTH_SCHEMA });
372
- }
373
328
 
374
329
  // ── judge phase (advisory — spec §2.3, arch §4.2 phase 7) ──────────────────────
375
330
  export const JUDGE_RECEIPT_SCHEMA = {
@@ -1,6 +1,6 @@
1
1
  // Shared research engine — synthesis bucketing, deterministic aggregation
2
2
  // (sources.json / gate records), and the synth + finalise phases.
3
- import { synthAgent } from './agents.mjs';
3
+ import { synthAgent, SYNTH_SCHEMA } from './agents.mjs';
4
4
 
5
5
  export const UNSOURCED_ABORT_RATIO = 0.10; // §8 guardrail: >10% unsourced fails the run
6
6
 
@@ -71,14 +71,90 @@ export function buildGateRecords(sqResults) {
71
71
  }
72
72
 
73
73
  // -- Synthesize (per-SQ finding files — §4.1 lead tier) --
74
+ // Every planned sub-question gets a finding, even when no claim survived ranking or
75
+ // verification. Normal's global CLAIM_BUDGET cap (rankAndCap in wrappers/normal.mjs)
76
+ // concentrates retention on the highest-ranked SQs; without the degrade fallback the
77
+ // starved SQs silently vanish from the answer while the workflow still reports
78
+ // status='done' (see w-2026-09-01-dave-010 — 4-SQ plan, sq-3/sq-4 dropped, 5 findings
79
+ // presented as complete). runDeepSynth carries the same invariant for deep mode.
74
80
  export async function runSynth(ctx, subQuestions, retained, verdictByClaimId) {
75
81
  return (await parallel(
76
- subQuestions
77
- .filter((sq) => retained.some((c) => c.sqId === sq.id))
78
- .map((sq) => () => synthAgent(ctx, sq, bucketsFor(retained, verdictByClaimId, sq.id))),
82
+ subQuestions.map((sq) => () => synthOrDegrade(ctx, sq, bucketsFor(retained, verdictByClaimId, sq.id))),
79
83
  )).filter(Boolean);
80
84
  }
81
85
 
86
+ // Synthesize one sub-question, degrading to a zero-claim finding when there is nothing
87
+ // to synthesize or the synthesizer fails to write a schema-valid finding — either way
88
+ // the SQ keeps its file, so the finding-per-SQ invariant holds and the coverage gate in
89
+ // the wrapper can see the full picture.
90
+ export async function synthOrDegrade(ctx, sq, buckets) {
91
+ const hasClaims = buckets.verified.length || buckets.supporting.length || buckets.refuted.length;
92
+ if (hasClaims) {
93
+ const receipt = await synthAgent(ctx, sq, buckets);
94
+ if (receipt && receipt.written === true) return receipt;
95
+ log(`synth: ${sq.id} did not write a valid finding — writing a degraded finding to preserve the finding-per-SQ invariant`);
96
+ return degradedSynthAgent(ctx, sq, 'the synthesizer did not produce a schema-valid finding');
97
+ }
98
+ log(`synth: ${sq.id} had no retained claims — writing a degraded (zero-claim) finding to preserve the finding-per-SQ invariant`);
99
+ return degradedSynthAgent(ctx, sq, 'no claims survived ranking/verification for this sub-question');
100
+ }
101
+
102
+ // Pure: the exact degraded finding for a sub-question (fits the EXISTING finding schema —
103
+ // no store change). Empty quotes/sources/votes, confidence 'low', a self-describing
104
+ // summary, unsourced:false.
105
+ export function degradedFinding(sq, reason) {
106
+ return {
107
+ subQuestionId: sq.id,
108
+ summary: `No verifiable findings were produced for this sub-question (${sq.text}). Reason: ${reason}. `
109
+ + `This degraded finding is written to preserve the one-finding-per-sub-question invariant; treat the `
110
+ + `sub-question as unanswered and re-run research for it if a complete answer is required.`,
111
+ quotes: [],
112
+ sources: [],
113
+ confidence: 'low',
114
+ verificationVotes: [],
115
+ unsourced: false,
116
+ };
117
+ }
118
+
119
+ // Have an agent write the degraded finding verbatim and round-trip it. Returns a synth
120
+ // receipt (claimCount 0) so the caller's aggregation (unsourced ratio, coverage stats)
121
+ // is unaffected. No brief needed — this is a mechanical verbatim write.
122
+ export async function degradedSynthAgent(ctx, sq, reason) {
123
+ const finding = degradedFinding(sq, reason);
124
+ const path = `${ctx.sessionDir}/findings/${sq.id}.json`;
125
+ return await agent([
126
+ `You are writing a **degraded finding** for research session \`${ctx.sessionId}\`, sub-question \`${sq.id}\`.`,
127
+ `Write this EXACT JSON (verbatim, no edits) to \`${path}\`:`,
128
+ '```json', JSON.stringify(finding, null, 2), '```',
129
+ `Then round-trip: run \`${ctx.cli} show ${ctx.sessionId} --json\` and confirm exit 0 (it validates findings).`,
130
+ `Return { subQuestionId: "${sq.id}", written: true, path: "${path}", confidence: "low", claimCount: 0,`,
131
+ `centralConfirmed: 0, centralRefuted: 0, unsourcedCount: 0 } once the round-trip is green.`,
132
+ ].join('\n'), { model: ctx.modelOverride || 'haiku', effort: 'low', phase: 'Synthesize', label: `synth-degraded:${sq.id}`, schema: SYNTH_SCHEMA });
133
+ }
134
+
135
+ // Pure coverage computation. Given the planner's sub-question ids and the synth
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.
140
+ export function coverageReport(subQuestionIds, synthReceipts) {
141
+ const receiptsBySq = new Map();
142
+ for (const r of synthReceipts || []) {
143
+ if (r && r.subQuestionId) receiptsBySq.set(r.subQuestionId, r);
144
+ }
145
+ const planned = subQuestionIds.length;
146
+ const covered = [];
147
+ const degraded = [];
148
+ const uncovered = [];
149
+ for (const id of subQuestionIds) {
150
+ const r = receiptsBySq.get(id);
151
+ if (!r || r.written !== true) { uncovered.push(id); continue; }
152
+ covered.push(id);
153
+ if ((r.claimCount || 0) === 0) degraded.push(id);
154
+ }
155
+ return { planned, covered, degraded, uncovered, complete: uncovered.length === 0 };
156
+ }
157
+
82
158
  // -- Finalise (persist gate records, write sources.json + the session answer, round-trip, mark done) --
83
159
  export async function runFinalise(ctx, { gateRecords, sourcesJson, runStats }) {
84
160
  // The gate-persistence step is present only when searchers actually dropped gated
@@ -6,6 +6,13 @@ import { verifyAgent } from './agents.mjs';
6
6
 
7
7
  export const VERIFY_LENSES = ['quote-support', 'contradiction-search', 'recency-source-fit'];
8
8
 
9
+ // Hard per-verifier WebSearch/WebFetch cap. Workflow searchers observed to drain the
10
+ // PARENT session's shared WebSearch budget (w-2026-09-01-dave-010: 200/200 consumed
11
+ // on a normal run). The verifier brief already asks for "a few targeted calls"; this
12
+ // makes it a schema-enforceable ceiling the brief interpolates. Two calls per lens is
13
+ // enough to test one claim without re-researching the sub-question.
14
+ export const VERIFY_TOOL_CALL_CAP_DEFAULT = 2;
15
+
9
16
  // Decide one claim's verdict from its votes.
10
17
  // - `unverified-infra` votes are infrastructure failures, not evidence — they
11
18
  // count toward neither confirmation nor refutation.
@@ -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:c3afe42e6ee577590f7f69d13821218bd84f66440d8168162971bf0631cab4a0
3
+ // contentHash: sha256:cbdbff5d260df77a3b56a60d37bd75a58f0fa38968dbd0f0181c71fb6d8b3d29
4
4
 
5
5
  export const meta = {
6
6
  name: 'research-deep',
@@ -331,6 +331,9 @@ function verifyAgent(ctx, claim, lens, model = 'sonnet', effort = 'high', role =
331
331
  return agent(brief(ctx, role, {
332
332
  sessionId: ctx.sessionId, claimText: claim.text, quote: claim.quote, sourceUrl: claim.url || claim.sourceId,
333
333
  subQuestionText: claim.sqText, lens,
334
+ // Verifier tool-call cap: shared WebSearch budget with the parent session, so keep
335
+ // per-verifier calls tight (w-2026-09-01-dave-010). Defaults to 2; deep can lift.
336
+ verifyToolCallCap: ctx.verifyToolCallCap ?? 2,
334
337
  }), { model, effort, phase: 'Verify', label: `verify:${claim.claimId}:${lens}`, schema: VERIFY_SCHEMA });
335
338
  }
336
339
 
@@ -445,14 +448,90 @@ function buildGateRecords(sqResults) {
445
448
  }
446
449
 
447
450
  // -- Synthesize (per-SQ finding files — §4.1 lead tier) --
451
+ // Every planned sub-question gets a finding, even when no claim survived ranking or
452
+ // verification. Normal's global CLAIM_BUDGET cap (rankAndCap in wrappers/normal.mjs)
453
+ // concentrates retention on the highest-ranked SQs; without the degrade fallback the
454
+ // starved SQs silently vanish from the answer while the workflow still reports
455
+ // status='done' (see w-2026-09-01-dave-010 — 4-SQ plan, sq-3/sq-4 dropped, 5 findings
456
+ // presented as complete). runDeepSynth carries the same invariant for deep mode.
448
457
  async function runSynth(ctx, subQuestions, retained, verdictByClaimId) {
449
458
  return (await parallel(
450
- subQuestions
451
- .filter((sq) => retained.some((c) => c.sqId === sq.id))
452
- .map((sq) => () => synthAgent(ctx, sq, bucketsFor(retained, verdictByClaimId, sq.id))),
459
+ subQuestions.map((sq) => () => synthOrDegrade(ctx, sq, bucketsFor(retained, verdictByClaimId, sq.id))),
453
460
  )).filter(Boolean);
454
461
  }
455
462
 
463
+ // Synthesize one sub-question, degrading to a zero-claim finding when there is nothing
464
+ // to synthesize or the synthesizer fails to write a schema-valid finding — either way
465
+ // the SQ keeps its file, so the finding-per-SQ invariant holds and the coverage gate in
466
+ // the wrapper can see the full picture.
467
+ async function synthOrDegrade(ctx, sq, buckets) {
468
+ const hasClaims = buckets.verified.length || buckets.supporting.length || buckets.refuted.length;
469
+ if (hasClaims) {
470
+ const receipt = await synthAgent(ctx, sq, buckets);
471
+ if (receipt && receipt.written === true) return receipt;
472
+ log(`synth: ${sq.id} did not write a valid finding — writing a degraded finding to preserve the finding-per-SQ invariant`);
473
+ return degradedSynthAgent(ctx, sq, 'the synthesizer did not produce a schema-valid finding');
474
+ }
475
+ log(`synth: ${sq.id} had no retained claims — writing a degraded (zero-claim) finding to preserve the finding-per-SQ invariant`);
476
+ return degradedSynthAgent(ctx, sq, 'no claims survived ranking/verification for this sub-question');
477
+ }
478
+
479
+ // Pure: the exact degraded finding for a sub-question (fits the EXISTING finding schema —
480
+ // no store change). Empty quotes/sources/votes, confidence 'low', a self-describing
481
+ // summary, unsourced:false.
482
+ function degradedFinding(sq, reason) {
483
+ return {
484
+ subQuestionId: sq.id,
485
+ summary: `No verifiable findings were produced for this sub-question (${sq.text}). Reason: ${reason}. `
486
+ + `This degraded finding is written to preserve the one-finding-per-sub-question invariant; treat the `
487
+ + `sub-question as unanswered and re-run research for it if a complete answer is required.`,
488
+ quotes: [],
489
+ sources: [],
490
+ confidence: 'low',
491
+ verificationVotes: [],
492
+ unsourced: false,
493
+ };
494
+ }
495
+
496
+ // Have an agent write the degraded finding verbatim and round-trip it. Returns a synth
497
+ // receipt (claimCount 0) so the caller's aggregation (unsourced ratio, coverage stats)
498
+ // is unaffected. No brief needed — this is a mechanical verbatim write.
499
+ async function degradedSynthAgent(ctx, sq, reason) {
500
+ const finding = degradedFinding(sq, reason);
501
+ const path = `${ctx.sessionDir}/findings/${sq.id}.json`;
502
+ return await agent([
503
+ `You are writing a **degraded finding** for research session \`${ctx.sessionId}\`, sub-question \`${sq.id}\`.`,
504
+ `Write this EXACT JSON (verbatim, no edits) to \`${path}\`:`,
505
+ '```json', JSON.stringify(finding, null, 2), '```',
506
+ `Then round-trip: run \`${ctx.cli} show ${ctx.sessionId} --json\` and confirm exit 0 (it validates findings).`,
507
+ `Return { subQuestionId: "${sq.id}", written: true, path: "${path}", confidence: "low", claimCount: 0,`,
508
+ `centralConfirmed: 0, centralRefuted: 0, unsourcedCount: 0 } once the round-trip is green.`,
509
+ ].join('\n'), { model: ctx.modelOverride || 'haiku', effort: 'low', phase: 'Synthesize', label: `synth-degraded:${sq.id}`, schema: SYNTH_SCHEMA });
510
+ }
511
+
512
+ // Pure coverage computation. Given the planner's sub-question ids and the synth
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.
517
+ function coverageReport(subQuestionIds, synthReceipts) {
518
+ const receiptsBySq = new Map();
519
+ for (const r of synthReceipts || []) {
520
+ if (r && r.subQuestionId) receiptsBySq.set(r.subQuestionId, r);
521
+ }
522
+ const planned = subQuestionIds.length;
523
+ const covered = [];
524
+ const degraded = [];
525
+ const uncovered = [];
526
+ for (const id of subQuestionIds) {
527
+ const r = receiptsBySq.get(id);
528
+ if (!r || r.written !== true) { uncovered.push(id); continue; }
529
+ covered.push(id);
530
+ if ((r.claimCount || 0) === 0) degraded.push(id);
531
+ }
532
+ return { planned, covered, degraded, uncovered, complete: uncovered.length === 0 };
533
+ }
534
+
456
535
  // -- Finalise (persist gate records, write sources.json + the session answer, round-trip, mark done) --
457
536
  async function runFinalise(ctx, { gateRecords, sourcesJson, runStats }) {
458
537
  // The gate-persistence step is present only when searchers actually dropped gated
@@ -584,6 +663,13 @@ function rankAndCapPerSq(claims, budget, { floorPerSq = 1 } = {}) {
584
663
 
585
664
  const VERIFY_LENSES = ['quote-support', 'contradiction-search', 'recency-source-fit'];
586
665
 
666
+ // Hard per-verifier WebSearch/WebFetch cap. Workflow searchers observed to drain the
667
+ // PARENT session's shared WebSearch budget (w-2026-09-01-dave-010: 200/200 consumed
668
+ // on a normal run). The verifier brief already asks for "a few targeted calls"; this
669
+ // makes it a schema-enforceable ceiling the brief interpolates. Two calls per lens is
670
+ // enough to test one claim without re-researching the sub-question.
671
+ const VERIFY_TOOL_CALL_CAP_DEFAULT = 2;
672
+
587
673
  // Decide one claim's verdict from its votes.
588
674
  // - `unverified-infra` votes are infrastructure failures, not evidence — they
589
675
  // count toward neither confirmation nor refutation.
@@ -1040,61 +1126,15 @@ async function recordRunStart(ctx) {
1040
1126
  // claim, but a searcher/extractor that found nothing still leaves an empty SQ). Such an
1041
1127
  // SQ gets a DEGRADED finding: schema-valid, confidence 'low', zero claims, and a summary
1042
1128
  // explaining the absence. This closes the deep-judge's "only N of M sub-questions
1043
- // answered" gap and keeps the finding count == the plan's sub-question count.
1129
+ // answered" gap and keeps the finding count == the plan's sub-question count. The
1130
+ // synthOrDegrade/degradedFinding/degradedSynthAgent primitives now live in finalise.mjs
1131
+ // (shared with normal — w-2026-09-01-dave-010 fix).
1044
1132
  async function runDeepSynth(ctx, subQuestions, retained, verdictByClaimId) {
1045
1133
  return (await parallel(
1046
1134
  subQuestions.map((sq) => () => synthOrDegrade(ctx, sq, bucketsFor(retained, verdictByClaimId, sq.id))),
1047
1135
  )).filter(Boolean);
1048
1136
  }
1049
1137
 
1050
- // Synthesize one sub-question, degrading to a zero-claim finding when there is nothing to
1051
- // synthesize or the synthesizer fails to write a schema-valid finding — either way the SQ
1052
- // keeps its file, so the invariant holds.
1053
- async function synthOrDegrade(ctx, sq, buckets) {
1054
- const hasClaims = buckets.verified.length || buckets.supporting.length || buckets.refuted.length;
1055
- if (hasClaims) {
1056
- const receipt = await synthAgent(ctx, sq, buckets);
1057
- if (receipt && receipt.written === true) return receipt;
1058
- log(`synth: ${sq.id} did not write a valid finding — writing a degraded finding to preserve the finding-per-SQ invariant`);
1059
- return degradedSynthAgent(ctx, sq, 'the synthesizer did not produce a schema-valid finding');
1060
- }
1061
- log(`synth: ${sq.id} had no retained claims — writing a degraded (zero-claim) finding to preserve the finding-per-SQ invariant`);
1062
- return degradedSynthAgent(ctx, sq, 'no claims survived ranking/verification for this sub-question');
1063
- }
1064
-
1065
- // Pure: the exact degraded finding for a sub-question (fits the EXISTING finding schema —
1066
- // no store change). Empty quotes/sources/votes, confidence 'low', a self-describing
1067
- // summary, unsourced:false.
1068
- function degradedFinding(sq, reason) {
1069
- return {
1070
- subQuestionId: sq.id,
1071
- summary: `No verifiable findings were produced for this sub-question (${sq.text}). Reason: ${reason}. `
1072
- + `This degraded finding is written to preserve the one-finding-per-sub-question invariant; treat the `
1073
- + `sub-question as unanswered and re-run research for it if a complete answer is required.`,
1074
- quotes: [],
1075
- sources: [],
1076
- confidence: 'low',
1077
- verificationVotes: [],
1078
- unsourced: false,
1079
- };
1080
- }
1081
-
1082
- // Have an agent write the degraded finding verbatim and round-trip it. Returns a synth
1083
- // receipt (claimCount 0) so the caller's aggregation (unsourced ratio, run stats) is
1084
- // unaffected. No brief needed — this is a mechanical verbatim write.
1085
- async function degradedSynthAgent(ctx, sq, reason) {
1086
- const finding = degradedFinding(sq, reason);
1087
- const path = `${ctx.sessionDir}/findings/${sq.id}.json`;
1088
- return await agent([
1089
- `You are writing a **degraded finding** for deep-research session \`${ctx.sessionId}\`, sub-question \`${sq.id}\`.`,
1090
- `Write this EXACT JSON (verbatim, no edits) to \`${path}\`:`,
1091
- '```json', JSON.stringify(finding, null, 2), '```',
1092
- `Then round-trip: run \`${ctx.cli} show ${ctx.sessionId} --json\` and confirm exit 0 (it validates findings).`,
1093
- `Return { subQuestionId: "${sq.id}", written: true, path: "${path}", confidence: "low", claimCount: 0,`,
1094
- `centralConfirmed: 0, centralRefuted: 0, unsourcedCount: 0 } once the round-trip is green.`,
1095
- ].join('\n'), { model: ctx.modelOverride || 'haiku', effort: 'low', phase: 'Synthesize', label: `synth-degraded:${sq.id}`, schema: SYNTH_SCHEMA });
1096
- }
1097
-
1098
1138
  // ── judge phase (advisory — spec §2.3, arch §4.2 phase 7) ──────────────────────
1099
1139
  const JUDGE_RECEIPT_SCHEMA = {
1100
1140
  type: 'object', additionalProperties: false,