@stravica-ai/digital-operator-pa 0.1.55 → 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 (46) hide show
  1. package/dist/engineering-pa-cli/src/pa/work-cli.mjs +116 -0
  2. package/dist/engineering-pa-cli/src/research/engine/finalise.mjs +14 -4
  3. package/dist/engineering-pa-cli/src/research/workflows/research-deep.js +15 -5
  4. package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +60 -14
  5. package/dist/engineering-pa-cli/src/research/wrappers/normal.mjs +46 -10
  6. package/dist/engineering-pa-core/roles/agentic-workflow-designer/definition.md +1 -1
  7. package/dist/engineering-pa-workspace/app/assets/brand/icons/app-icon-192.png +0 -0
  8. package/dist/engineering-pa-workspace/app/assets/brand/icons/app-icon-512.png +0 -0
  9. package/dist/engineering-pa-workspace/app/assets/brand/icons/apple-touch-icon.png +0 -0
  10. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-16.png +0 -0
  11. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-32.png +0 -0
  12. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-48.png +0 -0
  13. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon.ico +0 -0
  14. package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon.svg +1 -0
  15. package/dist/engineering-pa-workspace/app/assets/brand/icons/maskable-icon-512.png +0 -0
  16. package/dist/engineering-pa-workspace/app/assets/brand/icons/maskable-icon.svg +1 -0
  17. package/dist/engineering-pa-workspace/app/assets/brand/icons/safari-pinned-tab.svg +1 -0
  18. package/dist/engineering-pa-workspace/app/assets/brand/stravica-logo-dark.svg +1 -0
  19. package/dist/engineering-pa-workspace/app/assets/brand/stravica-logo-light.svg +1 -0
  20. package/dist/engineering-pa-workspace/app/assets/brand/stravica-mark.svg +1 -0
  21. package/dist/engineering-pa-workspace/app/assets/modal.js +17 -17
  22. package/dist/engineering-pa-workspace/app/assets/theme.css +332 -99
  23. package/dist/engineering-pa-workspace/app/assets/theme.js +259 -107
  24. package/dist/engineering-pa-workspace/app/banner.js +16 -12
  25. package/dist/engineering-pa-workspace/app/delegations.html +3 -3
  26. package/dist/engineering-pa-workspace/app/home.html +2 -2
  27. package/dist/engineering-pa-workspace/app/index.html +6 -3
  28. package/dist/engineering-pa-workspace/app/knowledge-map.html +2 -2
  29. package/dist/engineering-pa-workspace/app/knowledge.html +6 -6
  30. package/dist/engineering-pa-workspace/app/notes.html +2 -2
  31. package/dist/engineering-pa-workspace/app/onboarding.html +12 -11
  32. package/dist/engineering-pa-workspace/app/profile.html +4 -4
  33. package/dist/engineering-pa-workspace/app/project-detail.html +18 -18
  34. package/dist/engineering-pa-workspace/app/projects.html +4 -4
  35. package/dist/engineering-pa-workspace/app/repos.html +15 -14
  36. package/dist/engineering-pa-workspace/app/review-annotator.js +38 -38
  37. package/dist/engineering-pa-workspace/app/reviews.html +7 -7
  38. package/dist/engineering-pa-workspace/app/schedules.html +11 -11
  39. package/dist/engineering-pa-workspace/app/secrets.html +6 -6
  40. package/dist/engineering-pa-workspace/app/staging.html +15 -15
  41. package/dist/engineering-pa-workspace/app/tasks.html +4 -4
  42. package/dist/engineering-pa-workspace/app/viewer.html +9 -9
  43. package/dist/engineering-pa-workspace/index.mjs +2 -0
  44. package/dist/engineering-pa-workspace/src/server.mjs +6 -6
  45. package/dist/engineering-pa-workspace/src/workspace-page-contract.mjs +47 -19
  46. package/package.json +1 -1
@@ -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
 
@@ -154,6 +158,21 @@ async function doCreate(args) {
154
158
  process.stderr.write(` accepted flags: ${known}\n`);
155
159
  return 1;
156
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
+ }
157
176
  const priority = flags.priority != null && flags.priority !== true
158
177
  ? Number(flags.priority)
159
178
  : null;
@@ -226,3 +245,100 @@ async function doList(args) {
226
245
  for (const r of rows) process.stdout.write(cols.map((c) => pad(r[c], widths[c])).join(' ') + '\n');
227
246
  return 0;
228
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 };
@@ -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 })),
@@ -95,7 +95,7 @@ Standing rules for the lane invocation, whatever engine the estate binds:
95
95
 
96
96
  - The lane is invoked as a **tool call from the designer's own session** (a CLI such as `codex exec`), never as an agent the designer spawns. Where an estate binds the lane to an agent role, the dispatching PA runs that role as a sibling dispatch and the designer verifies the render on a second pass.
97
97
  - The engine and model are **pinned explicitly per invocation**; never rely on a CLI config default.
98
- - **Subscription auth only**; if the tooling asks for an API key, stop and escalate.
98
+ - **Follow the instance's LLM credential policy** (subscription sign-in, API key, or other vendor key as the instance permits); if the tooling asks for a credential shape the instance does not permit, stop and escalate.
99
99
  - **Bounded iteration**: one build invocation plus at most one refinement.
100
100
  - **On lane failure, stop and report.** Never silently self-author the design on the orchestrating model; the parent re-routes.
101
101
 
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img"><title>Stravica favicon</title><desc></desc><rect x="0" y="0" width="64" height="64" rx="14" fill="#080F19"/><g fill="#7295FF"><path d="M38 9L17 21V32L38 20Z"/><path d="M30 27L46 36V46L30 37L23 33Z"/><path d="M46 46L25 58V47L37 40Z"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img"><title>Stravica maskable app icon</title><desc></desc><rect x="0" y="0" width="512" height="512" rx="0" fill="#080F19"/><defs><linearGradient id="ma" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#174CFF"/><stop offset="1" stop-color="#263BFF"/></linearGradient><linearGradient id="mb" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#1674FF"/><stop offset="1" stop-color="#2854FF"/></linearGradient></defs><g transform="translate(167 126) scale(1.3888888888888888)"><path fill="url(#ma)" d="M86 4 L10 48 Q4 51 4 58 L4 81 Q4 87 10 84 L80 45 Q86 42 86 35 Z"/><path fill="url(#ma)" d="M88 108 L129 125 L47 172 Q41 176 41 170 L41 142 Q41 136 47 133 Z"/><path fill="url(#mb)" d="M41 84 L74 65 Q78 63 83 66 L124 88 Q130 91 130 98 L130 121 Q130 126 124 123 Z"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 136 180" width="136" height="180" role="img"><title>Stravica pinned tab</title><desc></desc><g transform="translate(0 0) scale(1.0)"><path fill="#000000" d="M86 4 L10 48 Q4 51 4 58 L4 81 Q4 87 10 84 L80 45 Q86 42 86 35 Z"/><path fill="#000000" d="M88 108 L129 125 L47 172 Q41 176 41 170 L41 142 Q41 136 47 133 Z"/><path fill="#000000" d="M41 84 L74 65 Q78 63 83 66 L124 88 Q130 91 130 98 L130 121 Q130 126 124 123 Z"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" width="760" height="180" role="img"><title>Stravica logo for dark backgrounds</title><desc></desc><g transform="translate(0 0) scale(1.0)"><defs><linearGradient id="la" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#174CFF"/><stop offset="1" stop-color="#263BFF"/></linearGradient><linearGradient id="lb" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#1674FF"/><stop offset="1" stop-color="#2854FF"/></linearGradient></defs><g transform="translate(0 0) scale(1.0)"><path fill="url(#la)" d="M86 4 L10 48 Q4 51 4 58 L4 81 Q4 87 10 84 L80 45 Q86 42 86 35 Z"/><path fill="url(#la)" d="M88 108 L129 125 L47 172 Q41 176 41 170 L41 142 Q41 136 47 133 Z"/><path fill="url(#lb)" d="M41 84 L74 65 Q78 63 83 66 L124 88 Q130 91 130 98 L130 121 Q130 126 124 123 Z"/></g><g fill="#F2F5FA" fill-rule="evenodd" transform="translate(178 49) scale(1.036697247706422)"><path d="M56 0H22C8 0 1 9 1 20C1 31 9 38 22 38H34C40 38 42 40 42 44C42 47 39 49 34 49H6L0 62H34C49 62 57 54 57 43C57 32 50 25 36 25H23C17 25 15 23 15 19C15 15 18 13 23 13H51Z" transform="translate(0 0)"/><path d="M0 0H56V14H35V62H21V14H0Z" transform="translate(71 0)"/><path d="M0 0H32C48 0 56 8 56 20C56 29 51 35 44 38L60 62H42L28 40H15V62H0ZM15 13V27H31C38 27 41 25 41 20C41 15 38 13 31 13Z" transform="translate(140 0)"/><path d="M0 62L28 0H41L69 62H53L34.5 20L16 62Z" transform="translate(213 0)"/><path d="M0 0H16L35 42L54 0H70L42 62H28Z" transform="translate(295 0)"/><path d="M0 0H15V62H0Z" transform="translate(378 0)"/><path d="M57 0H31C12 0 0 13 0 31C0 49 12 62 31 62H55V48H31C21 48 15 41 15 31C15 21 21 14 31 14H51Z" transform="translate(406 0)"/><path d="M0 62L28 0H41L69 62H53L34.5 20L16 62Z" transform="translate(476 0)"/></g></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" width="760" height="180" role="img"><title>Stravica logo for light backgrounds</title><desc></desc><g transform="translate(0 0) scale(1.0)"><defs><linearGradient id="la" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#174CFF"/><stop offset="1" stop-color="#263BFF"/></linearGradient><linearGradient id="lb" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#1674FF"/><stop offset="1" stop-color="#2854FF"/></linearGradient></defs><g transform="translate(0 0) scale(1.0)"><path fill="url(#la)" d="M86 4 L10 48 Q4 51 4 58 L4 81 Q4 87 10 84 L80 45 Q86 42 86 35 Z"/><path fill="url(#la)" d="M88 108 L129 125 L47 172 Q41 176 41 170 L41 142 Q41 136 47 133 Z"/><path fill="url(#lb)" d="M41 84 L74 65 Q78 63 83 66 L124 88 Q130 91 130 98 L130 121 Q130 126 124 123 Z"/></g><g fill="#080F19" fill-rule="evenodd" transform="translate(178 49) scale(1.036697247706422)"><path d="M56 0H22C8 0 1 9 1 20C1 31 9 38 22 38H34C40 38 42 40 42 44C42 47 39 49 34 49H6L0 62H34C49 62 57 54 57 43C57 32 50 25 36 25H23C17 25 15 23 15 19C15 15 18 13 23 13H51Z" transform="translate(0 0)"/><path d="M0 0H56V14H35V62H21V14H0Z" transform="translate(71 0)"/><path d="M0 0H32C48 0 56 8 56 20C56 29 51 35 44 38L60 62H42L28 40H15V62H0ZM15 13V27H31C38 27 41 25 41 20C41 15 38 13 31 13Z" transform="translate(140 0)"/><path d="M0 62L28 0H41L69 62H53L34.5 20L16 62Z" transform="translate(213 0)"/><path d="M0 0H16L35 42L54 0H70L42 62H28Z" transform="translate(295 0)"/><path d="M0 0H15V62H0Z" transform="translate(378 0)"/><path d="M57 0H31C12 0 0 13 0 31C0 49 12 62 31 62H55V48H31C21 48 15 41 15 31C15 21 21 14 31 14H51Z" transform="translate(406 0)"/><path d="M0 62L28 0H41L69 62H53L34.5 20L16 62Z" transform="translate(476 0)"/></g></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 136 180" width="136" height="180" role="img"><title>Stravica ribbon symbol</title><desc></desc><defs><linearGradient id="ma" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#174CFF"/><stop offset="1" stop-color="#263BFF"/></linearGradient><linearGradient id="mb" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#1674FF"/><stop offset="1" stop-color="#2854FF"/></linearGradient></defs><g transform="translate(0 0) scale(1.0)"><path fill="url(#ma)" d="M86 4 L10 48 Q4 51 4 58 L4 81 Q4 87 10 84 L80 45 Q86 42 86 35 Z"/><path fill="url(#ma)" d="M88 108 L129 125 L47 172 Q41 176 41 170 L41 142 Q41 136 47 133 Z"/><path fill="url(#mb)" d="M41 84 L74 65 Q78 63 83 66 L124 88 Q130 91 130 98 L130 121 Q130 126 124 123 Z"/></g></svg>
@@ -20,30 +20,30 @@
20
20
  'use strict';
21
21
 
22
22
  var CSS =
23
- '.ws-modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:200;align-items:center;justify-content:center;padding:1rem}' +
23
+ '.ws-modal-overlay{display:none;position:fixed;inset:0;background:color-mix(in srgb, var(--text) 65%, transparent);z-index:200;align-items:center;justify-content:center;padding:1rem}' +
24
24
  '.ws-modal-overlay.open{display:flex}' +
25
- '.ws-modal{background:var(--bg-surface,#1f2335);border:1px solid var(--border,#3b4261);border-radius:10px;max-width:780px;width:100%;max-height:85vh;display:flex;flex-direction:column;position:relative;font-family:inherit}' +
26
- '.ws-modal-header{padding:1rem 1.25rem 0.5rem;border-bottom:1px solid var(--border,#3b4261);display:flex;align-items:center;gap:0.75rem}' +
27
- '.ws-modal-title{color:var(--text-bright,#e0e6ff);font-size:1rem;font-weight:600;flex:1;min-width:0;word-break:break-word}' +
28
- '.ws-modal-close{background:var(--bg-highlight,#292e42);border:1px solid var(--border,#3b4261);border-radius:4px;color:var(--text-dim,#565f89);font-size:0.72rem;padding:0.25rem 0.6rem;cursor:pointer;font-family:inherit;flex-shrink:0}' +
29
- '.ws-modal-close:hover{color:var(--text-bright,#e0e6ff);border-color:var(--accent,#7aa2f7)}' +
30
- '.ws-modal-body{padding:1rem 1.25rem 1.25rem;overflow-y:auto;color:var(--text,#c0caf5);font-size:0.8rem;line-height:1.6}' +
31
- '.ws-modal-body pre{background:var(--bg-highlight,#292e42);border:1px solid var(--border,#3b4261);border-radius:5px;padding:0.6rem;overflow-x:auto;font-size:0.72rem;white-space:pre-wrap;word-break:break-word}' +
32
- '.ws-modal-body code{background:var(--bg-highlight,#292e42);border:1px solid var(--border,#3b4261);border-radius:3px;padding:0.05rem 0.3rem;font-size:0.78em;color:var(--cyan,#7dcfff)}' +
25
+ '.ws-modal{background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;max-width:780px;width:100%;max-height:85vh;display:flex;flex-direction:column;position:relative;font-family:inherit}' +
26
+ '.ws-modal-header{padding:1rem 1.25rem 0.5rem;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:0.75rem}' +
27
+ '.ws-modal-title{color:var(--text-bright);font-size:1rem;font-weight:600;flex:1;min-width:0;word-break:break-word}' +
28
+ '.ws-modal-close{background:var(--bg-highlight);border:1px solid var(--border);border-radius:4px;color:var(--text-dim);font-size:0.72rem;padding:0.25rem 0.6rem;cursor:pointer;font-family:inherit;flex-shrink:0}' +
29
+ '.ws-modal-close:hover{color:var(--text-bright);border-color:var(--accent)}' +
30
+ '.ws-modal-body{padding:1rem 1.25rem 1.25rem;overflow-y:auto;color:var(--text);font-size:0.8rem;line-height:1.6}' +
31
+ '.ws-modal-body pre{background:var(--bg-highlight);border:1px solid var(--border);border-radius:5px;padding:0.6rem;overflow-x:auto;font-size:0.72rem;white-space:pre-wrap;word-break:break-word}' +
32
+ '.ws-modal-body code{background:var(--bg-highlight);border:1px solid var(--border);border-radius:3px;padding:0.05rem 0.3rem;font-size:0.78em;color:var(--cyan)}' +
33
33
  '.ws-modal-body pre code{background:transparent;border:none;padding:0;color:inherit}' +
34
- '.ws-modal-body h1,.ws-modal-body h2,.ws-modal-body h3,.ws-modal-body h4{color:var(--text-bright,#e0e6ff);margin:0.75rem 0 0.35rem}' +
35
- '.ws-modal-body h1{font-size:1rem}.ws-modal-body h2{font-size:0.9rem;border-bottom:1px solid var(--border,#3b4261);padding-bottom:0.15rem}' +
36
- '.ws-modal-body h3{font-size:0.82rem;color:var(--purple,#bb9af7)}' +
34
+ '.ws-modal-body h1,.ws-modal-body h2,.ws-modal-body h3,.ws-modal-body h4{color:var(--text-bright);margin:0.75rem 0 0.35rem}' +
35
+ '.ws-modal-body h1{font-size:1rem}.ws-modal-body h2{font-size:0.9rem;border-bottom:1px solid var(--border);padding-bottom:0.15rem}' +
36
+ '.ws-modal-body h3{font-size:0.82rem;color:var(--purple)}' +
37
37
  '.ws-modal-body p{margin-bottom:0.5rem}' +
38
38
  '.ws-modal-body ul,.ws-modal-body ol{padding-left:1.3rem;margin-bottom:0.5rem}' +
39
39
  '.ws-modal-body li{margin-bottom:0.1rem}' +
40
- '.ws-modal-body a{color:var(--accent,#7aa2f7);text-decoration:none}' +
40
+ '.ws-modal-body a{color:var(--accent);text-decoration:none}' +
41
41
  '.ws-modal-body a:hover{text-decoration:underline}' +
42
42
  '.ws-modal-body .kv{display:grid;grid-template-columns:7rem 1fr;gap:0.35rem 0.75rem;font-size:0.76rem;margin-bottom:0.75rem}' +
43
- '.ws-modal-body .kv .k{color:var(--text-dim,#565f89);text-transform:uppercase;letter-spacing:0.05em;font-size:0.64rem;padding-top:0.1rem}' +
44
- '.ws-modal-body .kv .v{color:var(--text,#c0caf5);word-break:break-word}' +
45
- '.ws-modal-body .kv .v.mono{font-family:"SF Mono","JetBrains Mono",monospace;font-size:0.72rem;color:var(--cyan,#7dcfff)}' +
46
- '.ws-modal-body .section-label{color:var(--text-dim,#565f89);text-transform:uppercase;letter-spacing:0.05em;font-size:0.64rem;margin-top:0.75rem;margin-bottom:0.3rem}';
43
+ '.ws-modal-body .kv .k{color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-size:0.64rem;padding-top:0.1rem}' +
44
+ '.ws-modal-body .kv .v{color:var(--text);word-break:break-word}' +
45
+ '.ws-modal-body .kv .v.mono{font-family:var(--font-mono);font-size:0.72rem;color:var(--cyan)}' +
46
+ '.ws-modal-body .section-label{color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-size:0.64rem;margin-top:0.75rem;margin-bottom:0.3rem}';
47
47
 
48
48
  var initialised = false;
49
49
  var keyHandlerAttached = false;