create-pmos 0.524.0 → 0.526.0

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 (83) hide show
  1. package/package.json +57 -56
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/briefing-premium-board-test.js +1 -1
  7. package/workspace/changelog.json +56 -0
  8. package/workspace/ev-everywhere-test.js +2 -2
  9. package/workspace/guide/agent-capabilities.md +28 -2
  10. package/workspace/guide/board.md +14 -1
  11. package/workspace/guide/claude-proposes.md +3 -1
  12. package/workspace/guide/data-freshness.md +13 -0
  13. package/workspace/guide/home.md +11 -5
  14. package/workspace/guide/idle-lab.md +2 -2
  15. package/workspace/guide/lifecycle.md +16 -3
  16. package/workspace/guide/published-board.md +2 -0
  17. package/workspace/guide/today.md +11 -3
  18. package/workspace/lib/bg-queue.js +7 -3
  19. package/workspace/lib/board-premium.js +4 -1
  20. package/workspace/lib/board-publish-parts.js +14 -2
  21. package/workspace/lib/engine/boards.js +3 -2
  22. package/workspace/lib/headless.js +19 -4
  23. package/workspace/lib/idle-lab-engine.js +531 -0
  24. package/workspace/lib/initiative-chat.js +12 -2
  25. package/workspace/lib/initiative-cli.js +1 -1
  26. package/workspace/lib/initiative-premium.js +11 -3
  27. package/workspace/lib/lab-cli.js +220 -0
  28. package/workspace/lib/lifecycle-stage.js +17 -3
  29. package/workspace/lib/one-stage.js +131 -0
  30. package/workspace/lib/pmos-verbs.js +6 -0
  31. package/workspace/lib/proposals.js +5 -2
  32. package/workspace/lib/read-fingerprint.js +127 -0
  33. package/workspace/lib/refresh-cli.js +75 -0
  34. package/workspace/lib/refresh-probe.js +110 -0
  35. package/workspace/lib/refresh-run.js +133 -14
  36. package/workspace/lib/report-draft.js +1 -1
  37. package/workspace/lib/route-modules.js +1 -0
  38. package/workspace/lib/routes-board-premium.js +22 -0
  39. package/workspace/lib/routes-boards.js +61 -12
  40. package/workspace/lib/routes-notes.js +1 -1
  41. package/workspace/lib/routes-your-move.js +64 -0
  42. package/workspace/lib/status-signal.js +123 -0
  43. package/workspace/lib/workflow-cli.js +3 -1
  44. package/workspace/lib/your-move.js +61 -0
  45. package/workspace/package-lock.json +2 -2
  46. package/workspace/package.json +1 -1
  47. package/workspace/public/pm/bd-acts.js +2 -1
  48. package/workspace/public/pm/bd-cols.js +8 -5
  49. package/workspace/public/pm/bd-prog-panel.js +3 -2
  50. package/workspace/public/pm/bd-span.js +2 -1
  51. package/workspace/public/pm/bd-views.js +8 -6
  52. package/workspace/public/pm/cozy-home.css +3 -0
  53. package/workspace/public/pm/cozy-today.css +151 -0
  54. package/workspace/public/pm/cozy.css +9 -0
  55. package/workspace/public/pm/good-morning.js +327 -0
  56. package/workspace/public/pm/grill-words.js +16 -1
  57. package/workspace/public/pm/home-app.js +187 -18
  58. package/workspace/public/pm/home-data.js +45 -13
  59. package/workspace/public/pm/home-day.js +66 -14
  60. package/workspace/public/pm/home-ini-model.js +1 -1
  61. package/workspace/public/pm/home-stage-card.js +7 -0
  62. package/workspace/public/pm/ini-tasks.js +2 -2
  63. package/workspace/public/pm/ini-view-model.js +10 -3
  64. package/workspace/public/pm/ini-view.css +9 -0
  65. package/workspace/public/pm/ini-view.js +19 -5
  66. package/workspace/public/pm/move-rank.js +155 -0
  67. package/workspace/public/pm/premium-board.js +10 -6
  68. package/workspace/public/pm/premium-initiative-head.js +39 -4
  69. package/workspace/public/pm/readonly-boot.js +8 -0
  70. package/workspace/public/pm/stage-chip.js +44 -0
  71. package/workspace/run-tests.js +1 -0
  72. package/workspace/server.js +63 -1
  73. package/workspace/skills/calendar-sync.skill +2 -1
  74. package/workspace/skills/idle-lab-prototype.skill +36 -0
  75. package/workspace/skills/idle-lab-scan.skill +32 -0
  76. package/workspace/skills/jira-sync.skill +2 -1
  77. package/workspace/skills/my-work-read.skill +2 -1
  78. package/workspace/skills/sources-read.skill +9 -6
  79. package/workspace/test-lists/good-morning.json +9 -0
  80. package/workspace/test-lists/idle-lab-engine.json +9 -0
  81. package/workspace/test-lists/one-stage.json +5 -0
  82. package/workspace/test-lists/status-signal.json +5 -0
  83. package/workspace/test-lists/your-move.json +9 -0
@@ -164,8 +164,9 @@ const TOOLS = Object.freeze({
164
164
 
165
165
  // on hold and cancelled come from a D70 lifecycle line (the PM stopped it); done is the end of the workflow. Only the first
166
166
  // three are ACTIVE: an initiative on hold, done or cancelled has no health and is not counted as open (c3-initiative-lifecycle).
167
- const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'on hold', 'done', 'cancelled']);
168
- const ACTIVE = Object.freeze(['on track', 'at risk', 'late']);
167
+ // 'no signal' (workspace grill W2): open, but nothing PMOS read says how it is going. It is active, and never counted as on track
168
+ const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'no signal', 'on hold', 'done', 'cancelled']);
169
+ const ACTIVE = Object.freeze(['on track', 'at risk', 'late', 'no signal']);
169
170
  const isActive = (r) => !!r && ACTIVE.includes(r.status);
170
171
  const VIEWS = Object.freeze(['now', 'mine', 'risk', 'noowner', 'all']);
171
172
  /** What a Horizon scan filed with an ask (initiative horizon-scan), cut to its shape; null for any other ask. */
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | 'report-structure-pull' | 'report-sample-fetch' | 'report-structure' | 'report-structure-dry' | 'ms-review' | 'propose' | 'metric-draft' | 'grill' | 'impact' | 'metric-read' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | 'report-structure-pull' | 'report-sample-fetch' | 'report-structure' | 'report-structure-dry' | 'ms-review' | 'propose' | 'metric-draft' | 'grill' | 'impact' | 'metric-read' | 'lab-proto' | 'lab-scan' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs, writePaths }) → the argv after `claude` (writePaths: the one render file a report-design job may write;
8
8
  * for the skill kind, the folders the run may write in, from skillWriteDirs)
9
9
  * skillWriteDirs(skill, { root, scratch }) → the absolute folders a background skill run may write in: its scratch folder and the state folders its skill writes
@@ -102,7 +102,7 @@ const SCAN_REPO_ORIGINS = Object.freeze(['scan-repo:', 'scan-suggest:']);
102
102
  // read the argv of each.
103
103
  const SCAN_RESEARCH_WEB_ORIGINS = Object.freeze(['scan-research-web:']);
104
104
  const SCAN_RESEARCH_REPO_ORIGINS = Object.freeze(['scan-research-repo:']);
105
- const WEB_KINDS = Object.freeze(['scan-web', 'scan-research-web']); // kinds that read untrusted pages: no directory, no Read, Glob or Grep
105
+ const WEB_KINDS = Object.freeze(['scan-web', 'scan-research-web', 'lab-scan']); // kinds that read untrusted pages: no directory, no Read, Glob or Grep
106
106
  // report-workspace: the Ask box run (origin `report-setup:<ask>`) and the weekly finder run (origin `report-find:<run>`).
107
107
  const REPORT_SETUP_ORIGINS = Object.freeze(['report-setup:']);
108
108
  const REPORT_FIND_ORIGINS = Object.freeze(['report-find:']);
@@ -134,7 +134,12 @@ const REPORT_STRUCTURE_DRY_ORIGINS = Object.freeze(['report-structure-dry:']);
134
134
  // report-structure-gaps: Test pull reads ONE source for the last complete period (origin `report-structure-pull:<id>:<source>`).
135
135
  const REPORT_STRUCTURE_PULL_ORIGINS = Object.freeze(['report-structure-pull:']);
136
136
  const WRITE_PATH_KINDS = Object.freeze(['report-design', 'report-sample-fetch', 'report-structure', 'report-structure-dry', 'report-structure-pull']); // kinds whose one writable file the caller names (argsFor writePaths)
137
- const WRITE_DIR_KINDS = Object.freeze(['skill']); // kinds whose writable folders the caller names (argsFor writePaths, each folder and what is under it)
137
+ // The idle lab (lib/idle-lab-engine.js): a prototype run (origin `idle-lab-proto:<ws>:<ini>:<run>`) reads the repository and writes specimens only
138
+ // in its own folder; a scan run (origin `idle-lab-scan:<ws>:<ini|->:<run>`) searches the web and reads pages only through `pmos lab page`, with no
139
+ // repository. Neither sends anything; each files only with `pmos lab file`.
140
+ const LAB_PROTO_ORIGINS = Object.freeze(['idle-lab-proto:']);
141
+ const LAB_SCAN_ORIGINS = Object.freeze(['idle-lab-scan:']);
142
+ const WRITE_DIR_KINDS = Object.freeze(['skill', 'lab-proto']); // kinds whose writable folders the caller names (argsFor writePaths, each folder and what is under it)
138
143
  // A background skill run reads untrusted text (Jira, mail, calendar), so it may write only in its own scratch folder and in the state folders its skill writes
139
144
  // by hand. Every other write goes through a `pmos` verb, which checks what it writes. The folders are relative to <state>: `pmos/state/` if it exists, else
140
145
  // `planning/` (the rule each skill states). A skill not named here writes only in its scratch folder.
@@ -195,6 +200,13 @@ const TOOLS = Object.freeze({
195
200
  // The reading run (lib/metric-read-runs.js): read and search, and the one verb that files a reading. The PM's analytics and Jira are read through
196
201
  // the PM's own connectors only where the PM's settings allow it. No Write, no Edit, no web fetch, nothing that sends.
197
202
  'metric-read': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos metrics reading *)', 'Bash(pmos source show *)', 'Bash(pmos source status *)', 'Bash(date *)']),
203
+ // The idle lab prototype run: read and search the repository and the earlier specimens, and the verb that files a round. Its one writable
204
+ // folder, the run's specimen folder, is added per run by argsFor (writePaths). No web, nothing that sends.
205
+ 'lab-proto': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos lab file *)', 'Bash(pmos lab status)', 'Bash(pmos initiative export *)', 'Bash(pmos initiative list *)',
206
+ 'Bash(pmos task list *)', 'Bash(git log *)', 'Bash(date *)']),
207
+ // The idle lab scan run: WebSearch, and pages only through `pmos lab page` (PMOS fetches public pages and keeps the text the claims quote).
208
+ // No Read, Glob or Grep, no directory, no WebFetch, nothing that sends.
209
+ 'lab-scan': Object.freeze(['WebSearch', 'Bash(pmos lab page *)', 'Bash(pmos lab file *)', 'Bash(pmos lab status)']),
198
210
  // The design run: read and search only. Its one write, the render file, is added per run by argsFor (writePaths). No Bash, no web, nothing that sends.
199
211
  'report-design': Object.freeze(['Read', 'Glob', 'Grep']),
200
212
  // The link fetch run: Read, and its one write (the text copy of the linked file) is added per run by argsFor. No Bash, no web, no connector tool named.
@@ -310,6 +322,8 @@ function kindFor({ agent, origin, skill, env } = {}) {
310
322
  if (GRILL_ORIGINS.some((p) => o.startsWith(p))) return 'grill';
311
323
  if (IMPACT_ORIGINS.some((p) => o.startsWith(p))) return 'impact';
312
324
  if (METRIC_READ_ORIGINS.some((p) => o.startsWith(p))) return 'metric-read';
325
+ if (LAB_PROTO_ORIGINS.some((p) => o.startsWith(p))) return 'lab-proto';
326
+ if (LAB_SCAN_ORIGINS.some((p) => o.startsWith(p))) return 'lab-scan';
313
327
  if (REPORT_DESIGN_ORIGINS.some((p) => o.startsWith(p))) return 'report-design';
314
328
  if (REPORT_SAMPLE_FETCH_ORIGINS.some((p) => o.startsWith(p))) return 'report-sample-fetch';
315
329
  // 'report-structure-dry:' is not a prefix of 'report-structure:' (the colon differs), so the two are told apart by their prefix alone
@@ -491,7 +505,8 @@ const RUN_ENV = Object.freeze(['PMOS_SCAN_FILE', 'PMOS_SCAN_ORIGIN', 'PMOS_SCAN_
491
505
  'PMOS_METRIC_READ_FILE', 'PMOS_METRIC_READ_RECORDS', 'PMOS_METRIC_READ_DRAFTS', 'PMOS_METRIC_READ_WS', 'PMOS_METRIC_READ_RUN', 'PMOS_METRIC_READ_RIDS', 'PMOS_METRIC_READ_TEST',
492
506
  'PMOS_PROPOSE_FILE', 'PMOS_PROPOSE_WS', 'PMOS_PROPOSE_BOARD', 'PMOS_PROPOSE_INI', 'PMOS_PROPOSE_RUN',
493
507
  'PMOS_GRILL_FILE', 'PMOS_GRILL_LOG', 'PMOS_GRILL_WS', 'PMOS_GRILL_BOARD', 'PMOS_GRILL_INI', 'PMOS_GRILL_RUN', 'PMOS_GRILL_BUDGET',
494
- 'PMOS_IMPACT_FILE', 'PMOS_IMPACT_RUN', 'PMOS_MS_REVIEW_FILE', 'PMOS_MS_REVIEW_RUN', 'PMOS_MS_REVIEW_PROGRAM']);
508
+ 'PMOS_IMPACT_FILE', 'PMOS_IMPACT_RUN', 'PMOS_MS_REVIEW_FILE', 'PMOS_MS_REVIEW_RUN', 'PMOS_MS_REVIEW_PROGRAM',
509
+ 'PMOS_LAB_RUN', 'PMOS_LAB_KIND', 'PMOS_LAB_PENDING', 'PMOS_LAB_INI', 'PMOS_LAB_BUDGET', 'PMOS_LAB_PAGES', 'PMOS_LAB_SPECIMENS']);
495
510
  function runEnvOf(extra) {
496
511
  const out = {};
497
512
  if (extra && typeof extra === 'object') for (const k of RUN_ENV) if (typeof extra[k] === 'string') out[k] = extra[k];
@@ -0,0 +1,531 @@
1
+ 'use strict';
2
+ /* idle-lab-engine.js — the idle lab engine (build E of the idle-lab B+C direction, agreed 2026-11-04; canvas main, In development I5 "How it
3
+ * works"; brief /mnt/project-files/idle-lab/brief.md). While the PM is away, Claude prototypes and scans for the PM's initiatives, proves
4
+ * what it made, and files it as a ✦ the PM keeps or drops. lib/idle-lab.js is what it files and what the screens read; this file decides
5
+ * what to do, starts the run, proves the result and files it.
6
+ *
7
+ * WHEN. Job `idle-lab`, step 5 of the shared queue (lib/bg-queue.js): it gets a turn only when the PM has been away 30 minutes (or in the
8
+ * day's last hour), outside quiet hours, under the spend limit and with fewer than 3 background runs live. The queue also checks the off
9
+ * switch (`pmos ai off idle-lab`). Here: ONE run at a time, at most MAX_RESULTS results in one idle stretch, and when the PM comes back the
10
+ * running step finishes and nothing new starts.
11
+ *
12
+ * WHAT (candidates, in this order):
13
+ * prototype-improve a new round of a prototype after something new about it: the PM's ✕ with a reason, a grill finding or a scan finding
14
+ * filed after the last round. Never while a round waits for the PM (no second ✦ of the same kind).
15
+ * prototype-new an initiative in Triage or Discover whose specimen test (D60) says a description is not enough and that has no
16
+ * prototype yet: 2–3 divergent specimens (skills/core/prototype.skill).
17
+ * scan-new a kept watch item no scan read in 14 days, or an initiative in Triage or Discover that was never scanned for
18
+ * scan-improve a scan finding on an open initiative that is 14 days old: the same ✦ again with only what is new since
19
+ * An initiative another background run holds (the Driver, a grill, bg-queue holder) is skipped. A key tried in the last 7 days is skipped.
20
+ *
21
+ * HOW. Two run kinds, so no run has both untrusted pages and the repository (lib/headless.js):
22
+ * lab-proto origin `idle-lab-proto:<ws>:<ini>:<run>`. Reads the repository; writes specimen HTML only in its own folder
23
+ * <runtime>/idle-lab/specimens/<run>/; files with `pmos lab file`. No web.
24
+ * lab-scan origin `idle-lab-scan:<ws>:<ini|->:<run>`. WebSearch and `pmos lab page <url>` (PMOS fetches the page and keeps its text in
25
+ * <runtime>/idle-lab/pages/<run>/<n>.txt); files with `pmos lab file`. No repository, no Read.
26
+ * The run's filings wait in <runtime>/idle-lab/pending/<run>.jsonl. Nothing shows until the run ends and the proof below passes.
27
+ *
28
+ * PROOF BEFORE FILING (proven-only rule, Wawan 2026-11-01). Unproven → dropped, and the reason is logged (runs.jsonl end line, ai-audit).
29
+ * a specimen opens and renders in a headless browser (lib/find-browser.js + playwright-core, network blocked but the fonts): a body with
30
+ * text or elements, no script error. No browser on the computer = not proven.
31
+ * every claim goes through lib/ai-guard.js file(): a prototype's basis quotes the repository at path:line; a scan claim quotes a page this
32
+ * run read, at state:idle-lab/pages/<run>/<n>.txt:<line>. The guard's replay gate, kill switch, cap of 3 and audit hold.
33
+ * Specimens stay in the runtime folder: nothing is written to a product repository before the PM's ✓.
34
+ *
35
+ * LEDGERS
36
+ * <runtime>/idle-lab/runs.jsonl start { run, kind, ws, ini?, key, budget } · end { run, ok, cost, ms, filed, dropped: [why] }
37
+ * (this month's spend counts toward the Settings › Claude's checks limit, with grill)
38
+ * <state>/proposals.jsonl a result on an initiative (lib/idle-lab.js result; groups prototyped, improved, scanned)
39
+ * <state>/idle-lab.jsonl a result that fits no initiative (loose scan), the `run` line of each idle stretch, the `next` line
40
+ *
41
+ * JOB · VERSION · MAX_RESULTS · candidates(input) PURE · nextOf(cands) PURE · promptOf(skill, c, o) PURE
42
+ * readRuns(rt) · appendRun(rt, line) · spentIn(rt, month) · pagesDir(rt, run) · pendingFile(rt, run) · runSpecimenDir(rt, run)
43
+ * pageRef(run, n, line) · guardProposal(entry, { run }) PURE · renderCheck(abs) · ensureReplay(rt)
44
+ * create(deps) → { tick(turn), finish(run, r), active(), status() }
45
+ */
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+ const crypto = require('crypto');
49
+ const LAB = require('./idle-lab.js');
50
+ const PR = require('./proposals.js');
51
+
52
+ const JOB = 'idle-lab';
53
+ const VERSION = '1';
54
+ const MAX_RESULTS = 3; // per idle stretch
55
+ const STALE_MS = 14 * 864e5; // a watch item or a scan finding is old after 14 days
56
+ const RETRY_MS = 7 * 864e5; // a key that was tried waits a week before it is tried again
57
+ const MAX_PAGES = 12; // pages one scan run may read
58
+ const KINDS = Object.freeze(['prototype-improve', 'prototype-new', 'scan-new', 'scan-improve']);
59
+ const ORIGINS = Object.freeze({ proto: 'idle-lab-proto', scan: 'idle-lab-scan' });
60
+ const EARLY = /triage|discover|intake|idea/i;
61
+ const CLOSED = /^(done|stopped|archived|won't do|closed)$/i;
62
+
63
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
64
+ const str = (v) => (typeof v === 'string' ? v : '');
65
+ const one = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
66
+ const hash = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 10);
67
+ const ms = (iso) => Date.parse(iso || '') || 0;
68
+ const monthOf = (now) => { const d = new Date(now); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); };
69
+ const shortDay = (iso) => { const d = new Date(ms(iso)); return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' + d.getDate(); };
70
+
71
+ /* ── folders and the runs ledger ── */
72
+ const dirOf = (rt) => (rt ? path.join(rt, 'idle-lab') : null);
73
+ const runsFile = (rt) => (rt ? path.join(dirOf(rt), 'runs.jsonl') : null);
74
+ const pagesDir = (rt, run) => path.join(dirOf(rt), 'pages', run);
75
+ const pendingFile = (rt, run) => path.join(dirOf(rt), 'pending', run + '.jsonl');
76
+ const runSpecimenDir = (rt, run) => path.join(LAB.specimenDir(rt), run);
77
+ /** The guard ref of line `line` of page `n` this run read (resolved against the runtime folder). */
78
+ const pageRef = (run, n, line) => `state:idle-lab/pages/${run}/${n}.txt:${line}`;
79
+
80
+ function appendRun(rt, line) {
81
+ const f = runsFile(rt); if (!f) return null;
82
+ fs.mkdirSync(path.dirname(f), { recursive: true });
83
+ const l = { at: new Date().toISOString(), ...line };
84
+ fs.appendFileSync(f, JSON.stringify(l) + '\n');
85
+ return l;
86
+ }
87
+ /** → { runs: Map run → { run, kind, ws, ini, key, budget, at, ended, ok, cost, filed, dropped }, tried: Map key → ms of its last start } */
88
+ function readRuns(rt) {
89
+ const runs = new Map(), tried = new Map();
90
+ let text = '';
91
+ try { text = fs.readFileSync(runsFile(rt), 'utf8'); } catch { return { runs, tried }; }
92
+ for (const raw of text.split('\n')) {
93
+ if (!raw.trim()) continue;
94
+ let l; try { l = JSON.parse(raw); } catch { continue; }
95
+ if (!isObj(l)) continue;
96
+ if (l.kind === 'start' && str(l.run)) {
97
+ runs.set(l.run, { run: l.run, kind: str(l.job), ws: str(l.ws), ini: str(l.ini), key: str(l.key), budget: Number(l.budget) || 0, at: str(l.at), ended: false, ok: null, cost: null, filed: 0, dropped: [] });
98
+ if (str(l.key)) tried.set(l.key, Math.max(tried.get(l.key) || 0, ms(l.at)));
99
+ } else if (l.kind === 'end' && runs.has(l.run)) {
100
+ const r = runs.get(l.run);
101
+ Object.assign(r, { ended: true, ok: !!l.ok, cost: Number.isFinite(l.cost) ? l.cost : null, filed: Number(l.filed) || 0, dropped: Array.isArray(l.dropped) ? l.dropped : [], endAt: str(l.at) });
102
+ }
103
+ }
104
+ return { runs, tried };
105
+ }
106
+ /** What the idle lab spent in a month (YYYY-MM): an ended run its cost (its budget when unknown), an open run its budget. */
107
+ function spentIn(rt, month) {
108
+ let s = 0;
109
+ for (const r of readRuns(rt).runs.values()) if (r.at.slice(0, 7) === month || monthOf(ms(r.at)) === month) s += r.ended ? (r.cost != null ? r.cost : r.budget) : r.budget;
110
+ return Math.round(s * 100) / 100;
111
+ }
112
+
113
+ /* ── what to do ── */
114
+ function stateOf(P, kept, p) { return kept.has(p.id) ? 'kept' : P.drops.has(PR.sigOf(p)) ? 'dropped' : 'open'; }
115
+ /**
116
+ * PURE. Everything the lab could do now, best first.
117
+ * products [{ ws, inis: [{ ini, board, title, stage, needsPrototype, closed }], P (lib/proposals.js fold), scan (lib/scan.js fold) | null,
118
+ * board (the product's first board, for a finding that fits no initiative) }]
119
+ * held(ws, ini) → true when another background run holds it · tried: Map key → ms
120
+ * → [{ kind, ws, board, ini, title, why, key, data }]
121
+ */
122
+ function candidates({ products = [], now = Date.now(), held = () => false, tried = new Map() } = {}) {
123
+ const out = [];
124
+ const fresh = (key) => !(tried.has(key) && now - tried.get(key) < RETRY_MS);
125
+ for (const pr of products) {
126
+ if (!pr || !pr.ws) continue;
127
+ const P = pr.P || PR.fold([]);
128
+ const kept = PR.keptIds(P);
129
+ const props = [...P.props.values()];
130
+ const open = (pr.inis || []).filter((i) => i && i.ini && !i.closed && !CLOSED.test(str(i.stage)));
131
+ for (const i of open) {
132
+ if (held(pr.ws, i.ini)) continue;
133
+ const mine = props.filter((p) => p.ini === i.ini);
134
+ const roots = mine.filter((p) => p.group === 'prototyped').sort((a, b) => ms(a.at) - ms(b.at));
135
+ const root = roots[roots.length - 1] || null;
136
+ if (root) {
137
+ // prototype-improve: something new since the last round, and no round waits for the PM
138
+ const rounds = mine.filter((p) => p.group === 'improved' && p.data && p.data.of === root.id).sort((a, b) => a.data.round - b.data.round);
139
+ const last = rounds[rounds.length - 1] || root;
140
+ const lastState = stateOf(P, kept, last);
141
+ if (lastState !== 'open') {
142
+ const signals = [];
143
+ const why = lastState === 'dropped' && P.dropWhy ? P.dropWhy.get(PR.sigOf(last)) : '';
144
+ if (why) signals.push({ from: 'drop', text: `Your ✕ on round ${last.group === 'improved' ? last.data.round : 1}: "${one(why, 200)}"` });
145
+ for (const p of mine) {
146
+ if (ms(p.at) <= ms(last.at)) continue;
147
+ if (p.group === 'grill' && stateOf(P, kept, p) !== 'dropped') signals.push({ from: 'grill', text: 'Grill finding: ' + one(p.title || p.why, 160) });
148
+ if (p.group === 'scanned' && stateOf(P, kept, p) !== 'dropped') signals.push({ from: 'scan', text: 'Scan finding: ' + one(p.title || p.why, 160) });
149
+ }
150
+ if (signals.length) {
151
+ const key = `proto-improve:${pr.ws}:${root.id}:${hash(signals.map((s) => s.text).join('|'))}`;
152
+ if (fresh(key)) out.push({ kind: 'prototype-improve', ws: pr.ws, board: i.board || root.board || '', ini: i.ini, title: i.title || i.ini, key,
153
+ why: 'Something new since round ' + (last.group === 'improved' ? last.data.round : 1) + ': ' + signals[0].text,
154
+ data: { of: root.id, round: (rounds.length ? rounds[rounds.length - 1].data.round : 1) + 1, keys: (root.data.specimens || []).map((s) => ({ key: s.key, title: s.title, file: s.file })),
155
+ last: last.group === 'improved' ? { round: last.data.round, base: last.data.base, file: last.data.file, changed: last.data.changed } : null, signals: signals.slice(0, 5) } });
156
+ }
157
+ }
158
+ } else if (i.needsPrototype && (!i.stage || EARLY.test(i.stage))) {
159
+ const key = `proto-new:${pr.ws}:${i.ini}`;
160
+ if (fresh(key)) out.push({ kind: 'prototype-new', ws: pr.ws, board: i.board || '', ini: i.ini, title: i.title || i.ini, key,
161
+ why: 'Its specimen test says a description is not enough, and it has no prototype yet.', data: {} });
162
+ }
163
+ const scans = mine.filter((p) => p.group === 'scanned' && stateOf(P, kept, p) !== 'dropped').sort((a, b) => ms(a.at) - ms(b.at));
164
+ const lastScan = scans[scans.length - 1] || null;
165
+ if (lastScan && now - ms(lastScan.at) >= STALE_MS) {
166
+ const key = `scan-refresh:${pr.ws}:${i.ini}:${lastScan.at}`;
167
+ if (fresh(key)) out.push({ kind: 'scan-improve', ws: pr.ws, board: i.board || '', ini: i.ini, title: i.title || i.ini, key,
168
+ why: 'Its scan finding is from ' + shortDay(lastScan.at) + '. Look again, and keep only what is new.',
169
+ data: { old: { title: one(lastScan.title, 140), why: one(lastScan.why, 300), at: lastScan.at, sources: (lastScan.sources || []).slice(0, 8) } } });
170
+ } else if (!lastScan && !mine.some((p) => p.group === 'scanned') && (!i.stage || EARLY.test(i.stage))) {
171
+ const key = `scan-ini:${pr.ws}:${i.ini}`;
172
+ if (fresh(key)) out.push({ kind: 'scan-new', ws: pr.ws, board: i.board || '', ini: i.ini, title: i.title || i.ini, key,
173
+ why: 'It is early and nobody looked outside for it yet: how do others solve this?', data: { question: i.title || i.ini } });
174
+ }
175
+ }
176
+ // the kept watch list: an item no scan read in 14 days
177
+ const S = pr.scan;
178
+ if (S && S.itemOrder) {
179
+ const lastRead = new Map();
180
+ for (const id of S.scanOrder || []) {
181
+ const sc = S.scans.get(id);
182
+ if (!sc || !sc.start) continue;
183
+ for (const w of sc.watch || []) if (w && w.id) lastRead.set(w.id, Math.max(lastRead.get(w.id) || 0, ms(sc.start)));
184
+ }
185
+ for (const id of S.itemOrder) {
186
+ const it = S.itemById.get(id);
187
+ if (!it || it.state !== 'kept') continue;
188
+ const read = lastRead.get(id) || 0;
189
+ if (now - read < STALE_MS || now - ms(it.at) < STALE_MS && !read && it.at) continue;
190
+ const ini = it.ini && open.some((x) => x.ini === it.ini) && !held(pr.ws, it.ini) ? it.ini : null;
191
+ const key = `scan-item:${pr.ws}:${id}:${Math.floor(now / RETRY_MS)}`;
192
+ if (!fresh(key)) continue;
193
+ out.push({ kind: 'scan-new', ws: pr.ws, board: (ini && (open.find((x) => x.ini === ini) || {}).board) || pr.board || '', ini, title: it.name, key,
194
+ why: (read ? 'No scan read it since ' + shortDay(new Date(read).toISOString()) : 'No scan read it yet') + ': a ' + it.kind + ' on your watch list.',
195
+ data: { item: { id, name: it.name, kind: it.kind, why: one(it.why, 200) } } });
196
+ }
197
+ }
198
+ }
199
+ const rank = (k) => KINDS.indexOf(k);
200
+ return out.map((c, n) => ({ c, n })).sort((a, b) => rank(a.c.kind) - rank(b.c.kind) || a.n - b.n).map((x) => x.c);
201
+ }
202
+
203
+ /** PURE. What the Lab shows under "Next time you step away" for one product: the first 3 candidates, read only. */
204
+ function nextOf(cands) {
205
+ const verb = { 'prototype-new': 'Prototype', 'prototype-improve': 'Improve the prototype of', 'scan-new': 'Scan for', 'scan-improve': 'Refresh the scan of' };
206
+ return (cands || []).slice(0, 3).map((c) => ({ kind: c.kind.startsWith('prototype') ? 'prototype' : 'scan', title: verb[c.kind] + ' ' + one(c.title, 100), why: one(c.why, 300) }));
207
+ }
208
+
209
+ /** PURE. The prompt of one run: the skill, then this run's facts. */
210
+ function promptOf(skill, c, o = {}) {
211
+ const L = [skill, '', 'This run:', `- Run: ${o.run}`, `- Kind: ${c.kind}`, `- Today: ${o.today || ''}`, `- Your budget: $${o.budget}. Stop when you have your result.`];
212
+ if (c.ini) L.push(`- Initiative: ${c.ini} · ${one(c.title, 140)}`);
213
+ L.push(`- Why now: ${one(c.why, 300)}`);
214
+ if (c.kind === 'prototype-new') L.push(`- Write each specimen as one .html file in ${o.specimens} (and nowhere else). File it with \`pmos lab file prototyped --json -\`.`);
215
+ if (c.kind === 'prototype-improve') {
216
+ const d = c.data;
217
+ L.push(`- This is round ${d.round}. The first round's specimens: ${d.keys.map((k) => k.key + ' · ' + k.title).join('; ')}.`);
218
+ if (d.last) L.push(`- The last round: round ${d.last.round} of ${d.last.base}, which changed: ${d.last.changed}.`);
219
+ L.push('- What is new since the last round (work from this):', ...d.signals.map((s) => ' - ' + s.text));
220
+ L.push(`- The earlier specimens are in ${o.specimenRoot} (Read them). Write the new round as ONE .html file in ${o.specimens}. File it with \`pmos lab file improved --json -\`.`);
221
+ }
222
+ if (c.kind === 'scan-new' && c.data.item) L.push(`- The watch item: ${c.data.item.name} (a ${c.data.item.kind})${c.data.item.why ? ': ' + c.data.item.why : ''}`);
223
+ if (c.kind === 'scan-new' && c.data.question) L.push(`- The question: how do other products and teams handle "${one(c.data.question, 140)}"?`);
224
+ if (c.kind === 'scan-improve') {
225
+ const d = c.data.old;
226
+ L.push(`- The finding from ${shortDay(d.at)}: ${d.title ? d.title + ': ' : ''}${d.why}`, '- Its pages then:', ...d.sources.map((s) => ' - ' + one(s.label, 80) + (s.ref ? ' ' + one(s.ref, 200) : '')));
227
+ L.push('- File only what is NEW since then, with "refreshed" saying how much is new. Nothing new: file nothing.');
228
+ }
229
+ if (c.kind.startsWith('scan')) L.push(`- Read pages ONLY with \`pmos lab page <url>\` (at most ${MAX_PAGES}). File with \`pmos lab file ${c.ini ? 'scanned' : 'scan'} --json -\`.`);
230
+ L.push('', 'File with `pmos lab file` only. Send nothing.');
231
+ return L.join('\n');
232
+ }
233
+
234
+ /* ── the proof ── */
235
+ /** PURE. A pending entry → the shape lib/ai-guard.js checks. A prototype's claim rests on its basis (repository quotes); a scan's claims
236
+ * cite the pages the run read (`page` n and `line`, turned into the run's page refs). */
237
+ function guardProposal(e, { run }) {
238
+ const id = 'il-' + hash([run, e.group, e.title || '', e.why || ''].join('|'));
239
+ let claims = [];
240
+ if (e.group === 'prototyped' || e.group === 'improved') {
241
+ claims = [{ text: one(e.why, 600), sources: (Array.isArray(e.basis) ? e.basis : []).slice(0, 8).map((b) => ({ label: one(b && b.label, 120), ref: one(b && b.ref, 300), quote: str(b && b.quote).slice(0, 2000) })) }];
242
+ } else {
243
+ claims = (Array.isArray(e.claims) ? e.claims : []).slice(0, 8).map((c) => ({ text: one(c && c.text, 600), sources: (Array.isArray(c && c.sources) ? c.sources : []).slice(0, 8)
244
+ .map((s) => ({ label: 'page ' + (s && s.page), ref: Number.isInteger(Number(s && s.page)) && Number.isInteger(Number(s && s.line)) ? pageRef(run, Number(s.page), Number(s.line)) : one(s && s.ref, 300), quote: str(s && s.quote).slice(0, 2000) })) }));
245
+ }
246
+ return { id, title: one(e.title || e.why, 200), say: one(e.why, 1200), confidence: 'medium', claims, data: { group: e.group } };
247
+ }
248
+
249
+ /** Open one specimen in a headless browser, with the network shut but the fonts. → { ok, w, h, why? } */
250
+ async function renderCheck(abs, { browser = null } = {}) {
251
+ let exe = browser;
252
+ try { if (!exe) exe = require('./find-browser.js').findBrowser(); } catch { exe = null; }
253
+ if (!exe) return { ok: false, why: 'there is no headless browser on this computer to prove it renders' };
254
+ let chromium;
255
+ try { ({ chromium } = require('playwright-core')); } catch { return { ok: false, why: 'playwright-core is not installed, so it could not be opened' }; }
256
+ let b = null;
257
+ try {
258
+ b = await chromium.launch({ executablePath: exe, headless: true, args: ['--no-sandbox'] });
259
+ const page = await b.newPage({ viewport: { width: 1280, height: 800 } });
260
+ const errors = [];
261
+ page.on('pageerror', (e) => errors.push(one(e && e.message, 160)));
262
+ await page.route('**/*', (r) => { const u = r.request().url(); return /^(file:|data:|blob:|about:)/.test(u) || /^https:\/\/fonts\.(googleapis|gstatic)\.com\//.test(u) ? r.continue() : r.abort(); });
263
+ await page.goto('file://' + abs, { waitUntil: 'load', timeout: 15000 });
264
+ const m = await page.evaluate(() => { const d = document.body; if (!d) return null; return { w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, els: d.querySelectorAll('*').length, text: (d.innerText || '').trim().length }; });
265
+ if (!m) return { ok: false, why: 'it has no body' };
266
+ if (errors.length) return { ok: false, why: 'a script error: ' + errors[0] };
267
+ if (m.h < 100 || m.w < 200 || (m.text < 20 && m.els < 10)) return { ok: false, why: `it renders almost nothing (${m.w}×${m.h}, ${m.els} elements)` };
268
+ return { ok: true, w: m.w, h: m.h };
269
+ } catch (e) {
270
+ return { ok: false, why: 'it did not open: ' + one(e && e.message, 160) };
271
+ } finally { if (b) try { await b.close(); } catch { /* closed */ } }
272
+ }
273
+
274
+ /** The guard's replay for (idle-lab, VERSION), once per version: a cited scan claim files; a made-up quote and a claim without a page do not. */
275
+ async function ensureReplay(rt, AG = require('./ai-guard.js')) {
276
+ if (AG.ready(rt, JOB, VERSION)) return { ok: true };
277
+ const run = 'replay';
278
+ const dir = pagesDir(rt, run);
279
+ fs.mkdirSync(dir, { recursive: true });
280
+ fs.writeFileSync(path.join(dir, '1.txt'), ['URL: https://example.com/replay', 'TITLE: Replay page', '', 'Teams invite people from the share menu in two steps.', 'Costs stay hidden from guests by default.'].join('\n') + '\n');
281
+ const entry = (quote, page = 1) => ({ group: 'scanned', title: 'Invite from the share menu', why: 'Two of the products read invite from the share menu.', claims: [{ text: 'Invites start from the share menu.', sources: [{ page, line: 4, quote }] }] });
282
+ const cases = [
283
+ { name: 'a claim that quotes the page it read is filed', run: async () => [guardProposal(entry('invite people from the share menu'), { run })], expect: { filed: 1 } },
284
+ { name: 'a quote that is not on the page is dropped', run: async () => [guardProposal(entry('every product hides the invite in settings'), { run })], expect: { none: true } },
285
+ { name: 'a page the run never read is dropped', run: async () => [guardProposal(entry('invite people from the share menu', 7), { run })], expect: { none: true } },
286
+ ];
287
+ return AG.replay(JOB, VERSION, cases, { root: rt, stateDir: rt });
288
+ }
289
+
290
+ /* ── the engine ── */
291
+ /**
292
+ * deps: { runtimeDir() → folder, queue? (lib/bg-queue.js: the engine registers itself as job `idle-lab`), products() → [{ ws, w: { id, root }, dir, board, inis, P, scan }] (the server reads them),
293
+ * held(ini) → bool (lib/bg-queue.js holder), budget(now) → { ok, budget, why? }, promptFor(skillId) → text | null,
294
+ * spawn(spec) → session (lib/orchestrator-spawn.js: { name, ws, origin, initialInput, initiative, env, writePaths, onDone }),
295
+ * stop(session), render?(abs) → Promise<{ ok, w, h, why? }>, guard? (lib/ai-guard.js), env, warn(what, e) }
296
+ */
297
+ function create(deps) {
298
+ const warn = deps.warn || (() => {});
299
+ const AG = deps.guard || require('./ai-guard.js');
300
+ const render = deps.render || ((abs) => renderCheck(abs));
301
+ const rt = () => (deps.runtimeDir ? deps.runtimeDir() : null);
302
+ let active = null; // { run, c, start, session, dir }
303
+ let stretch = null; // { since, filed, perWs: Map ws → { ms, n } } while the PM is away
304
+ let cache = { at: 0, products: null };
305
+ let replaying = false;
306
+ const nextSig = new Map(); // ws → signature of the last `next` line written
307
+
308
+ function products(now) {
309
+ if (cache.products && now - cache.at < 10 * 6e4) return cache.products;
310
+ let p = [];
311
+ try { p = deps.products() || []; } catch (e) { warn('idle lab products', e); p = []; }
312
+ cache = { at: now, products: p };
313
+ return p;
314
+ }
315
+
316
+ function writeNext(prods, cands) {
317
+ for (const pr of prods) {
318
+ if (!pr.dir) continue;
319
+ const items = nextOf(cands.filter((c) => c.ws === pr.ws));
320
+ const sig = JSON.stringify(items);
321
+ if (nextSig.get(pr.ws) === sig) continue;
322
+ try {
323
+ const L = LAB.read(pr.dir);
324
+ if (JSON.stringify(L.next ? L.next.items : []) !== sig) LAB.append(pr.dir, { by: null }, { kind: 'next', items });
325
+ nextSig.set(pr.ws, sig);
326
+ } catch (e) { warn('idle lab next', e); }
327
+ }
328
+ }
329
+
330
+ function closeStretch(prods) {
331
+ if (!stretch) return;
332
+ for (const [ws, v] of stretch.perWs) {
333
+ const pr = prods.find((x) => x.ws === ws);
334
+ if (pr && pr.dir && (v.ms > 0 || v.n > 0)) try { LAB.append(pr.dir, { by: null }, { kind: 'run', job: JOB, ms: Math.round(v.ms), n: v.n }); } catch (e) { warn('idle lab run line', e); }
335
+ }
336
+ stretch = null;
337
+ }
338
+
339
+ /** The queue's turn (step 5). turn: { now, idle } */
340
+ function tick(turn = {}) {
341
+ const now = turn.now || Date.now();
342
+ const r = rt(); if (!r) return { started: null };
343
+ if (!turn.idle) {
344
+ // the PM is back: the running step finishes by itself, nothing new starts, and the stretch is written down
345
+ if (!active && stretch) closeStretch(products(now));
346
+ return { started: null, back: true };
347
+ }
348
+ if (active) return { running: active.run };
349
+ if (!stretch) stretch = { since: now, filed: 0, perWs: new Map() };
350
+ const prods = products(now);
351
+ const F = readRuns(r);
352
+ const cands = candidates({ products: prods, now, held: (ws, ini) => { try { return !!deps.held(ini); } catch { return false; } }, tried: F.tried });
353
+ writeNext(prods, cands);
354
+ if (stretch.filed >= MAX_RESULTS) return { started: null, full: true };
355
+ const off = AG.off(JOB, { env: deps.env || process.env, settingsDir: r });
356
+ if (off) return { started: null, off };
357
+ if (!cands.length) return { started: null };
358
+ // the guard shows nothing of a job whose replay has not passed: run it once per version, then start on the next turn
359
+ if (!AG.ready(r, JOB, VERSION)) {
360
+ if (!replaying) { replaying = true; ensureReplay(r, AG).catch((e) => warn('idle lab replay', e)).finally(() => { replaying = false; }); }
361
+ return { started: null, replay: true };
362
+ }
363
+ for (const c of cands) {
364
+ const b = deps.budget ? deps.budget(now) : { ok: true, budget: 1 };
365
+ if (!b || !b.ok) return { started: null, paused: (b && b.why) || 'the spend limit is reached' };
366
+ const s = start(c, { now, budget: b.budget, prods });
367
+ if (s.ok) return { started: s.run, kind: c.kind };
368
+ if (s.held) return { started: null, held: true };
369
+ }
370
+ return { started: null };
371
+ }
372
+
373
+ function start(c, { now, budget, prods }) {
374
+ const r = rt();
375
+ const pr = prods.find((x) => x.ws === c.ws);
376
+ if (!pr || !pr.w) return { ok: false };
377
+ const proto = c.kind.startsWith('prototype');
378
+ const skill = deps.promptFor(proto ? 'idle-lab-prototype' : 'idle-lab-scan');
379
+ if (!skill) return { ok: false, error: 'the idle lab skill is not installed' };
380
+ const run = 'il' + now.toString(36) + crypto.randomBytes(3).toString('hex');
381
+ const spec = runSpecimenDir(r, run);
382
+ try {
383
+ fs.mkdirSync(path.dirname(pendingFile(r, run)), { recursive: true });
384
+ if (proto) fs.mkdirSync(spec, { recursive: true }); else fs.mkdirSync(pagesDir(r, run), { recursive: true });
385
+ } catch (e) { warn('idle lab folders', e); return { ok: false }; }
386
+ const day = new Date(now); const today = day.getFullYear() + '-' + String(day.getMonth() + 1).padStart(2, '0') + '-' + String(day.getDate()).padStart(2, '0');
387
+ const prompt = promptOf(skill, c, { run, today, budget, specimens: spec, specimenRoot: LAB.specimenDir(r) });
388
+ const env = { PMOS_LAB_RUN: run, PMOS_LAB_KIND: c.kind, PMOS_LAB_PENDING: pendingFile(r, run), PMOS_LAB_INI: c.ini || '', PMOS_LAB_BUDGET: String(budget),
389
+ ...(proto ? { PMOS_LAB_SPECIMENS: spec } : { PMOS_LAB_PAGES: pagesDir(r, run) }) };
390
+ active = { run, c, start: now, session: null, dir: pr.dir, root: pr.w.root };
391
+ let session = null;
392
+ try {
393
+ session = deps.spawn({ name: (proto ? 'Prototyping · ' : 'Scanning · ') + one(c.title, 60), ws: c.ws, origin: `${proto ? ORIGINS.proto : ORIGINS.scan}:${c.ws}:${c.ini || '-'}:${run}`,
394
+ initialInput: prompt + '\r', initiative: c.ini || undefined, env, writePaths: proto ? [spec] : [], addDirs: proto ? [LAB.specimenDir(r)] : [], onDone: (res) => { finish(run, res).catch((e) => warn('idle lab finish', e)); } });
395
+ } catch (e) { warn('idle lab run', e); }
396
+ if (session && session.held) { active = null; return { ok: false, held: true }; }
397
+ appendRun(r, { kind: 'start', run, job: c.kind, ws: c.ws, ini: c.ini || '', key: c.key, budget });
398
+ const kind = proto ? 'lab-proto' : 'lab-scan';
399
+ if (!session || session.state === 'exited' || session.headless !== kind) {
400
+ if (session && session.state !== 'exited' && deps.stop) try { deps.stop(session); } catch { /* best effort */ }
401
+ appendRun(r, { kind: 'end', run, ok: false, cost: 0, ms: 0, filed: 0, dropped: [(session && session.note) || 'the run could not start in the background'] });
402
+ active = null;
403
+ return { ok: false };
404
+ }
405
+ active.session = session;
406
+ return { ok: true, run };
407
+ }
408
+
409
+ function pending(run) {
410
+ let text = '';
411
+ try { text = fs.readFileSync(pendingFile(rt(), run), 'utf8'); } catch { return []; }
412
+ const out = [];
413
+ for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { const l = JSON.parse(raw); if (isObj(l)) out.push(l); } catch { /* torn */ } }
414
+ return out.slice(0, MAX_RESULTS);
415
+ }
416
+
417
+ /** The run ended: prove what it filed, file what holds, log the rest. */
418
+ async function finish(run, res = {}, now = Date.now()) {
419
+ const a = active && active.run === run ? active : null;
420
+ const r = rt();
421
+ if (!a || !r) return { filed: 0 };
422
+ const out = (res && res.result) || {};
423
+ const ok = !!(res && !res.timedOut && !res.stopped && res.code === 0 && out.ok !== false && !out.isError);
424
+ const cost = Number.isFinite(out.costUsd) ? Math.round(out.costUsd * 10000) / 10000 : null;
425
+ const dropped = [];
426
+ let filed = 0;
427
+ try {
428
+ const entries = pending(run);
429
+ const proven = [];
430
+ for (const e of entries) {
431
+ const p = await prove(e, a);
432
+ if (p.ok) proven.push({ e, checked: p.checked }); else dropped.push(one(p.why, 200));
433
+ }
434
+ if (proven.length) {
435
+ const g = await AG.file({ job: JOB, version: VERSION, run, root: a.root, stateDir: r, settingsDir: r, env: deps.env || process.env, max: MAX_RESULTS },
436
+ proven.map((x) => guardProposal(x.e, { run })),
437
+ async (acc) => { const ids = new Set(acc.map((x) => x.id)); for (const x of proven) if (ids.has(guardProposal(x.e, { run }).id)) { fileOne(x.e, x.checked, a, now); filed++; } });
438
+ for (const d of g.dropped || []) dropped.push(one(d.why, 200));
439
+ if (g.held) dropped.push('the guard holds the idle lab until its replay passes');
440
+ if (g.off) dropped.push(g.off);
441
+ }
442
+ } catch (e) { dropped.push('filing failed: ' + one(e && e.message, 160)); warn('idle lab filing', e); }
443
+ appendRun(r, { kind: 'end', run, ok, cost, ms: Math.max(0, now - a.start), filed, dropped: dropped.slice(0, 6) });
444
+ if (stretch) {
445
+ stretch.filed += filed;
446
+ const v = stretch.perWs.get(a.c.ws) || { ms: 0, n: 0 };
447
+ v.ms += Math.max(0, now - a.start); v.n += filed; stretch.perWs.set(a.c.ws, v);
448
+ }
449
+ cache.at = 0; // the next turn reads the ledgers again
450
+ active = null;
451
+ return { filed, dropped };
452
+ }
453
+
454
+ /** → { ok, checked? } | { ok: false, why } */
455
+ async function prove(e, a) {
456
+ const r = rt(), c = a.c;
457
+ if (!isObj(e)) return { ok: false, why: 'not a result' };
458
+ if (e.group === 'prototyped' || e.group === 'improved') {
459
+ if (!c.kind.startsWith('prototype')) return { ok: false, why: 'a scan run cannot file a prototype' };
460
+ const files = e.group === 'prototyped' ? (Array.isArray(e.specimens) ? e.specimens.map((s) => s && s.file) : []) : [e.file];
461
+ if (!files.length || files.some((f) => !str(f).startsWith(a.run + '/'))) return { ok: false, why: 'a specimen is not in this run\'s folder' };
462
+ const sizes = [];
463
+ for (const f of files) {
464
+ const abs = LAB.specimenPath(r, f);
465
+ let st = null; try { st = abs ? fs.statSync(abs) : null; } catch { st = null; }
466
+ if (!st || !st.isFile()) return { ok: false, why: `specimen ${f} was not written` };
467
+ if (st.size > 2e6) return { ok: false, why: `specimen ${f} is over 2 MB` };
468
+ const v = await render(abs);
469
+ if (!v || !v.ok) return { ok: false, why: `specimen ${f} is not proven: ${(v && v.why) || 'it did not render'}` };
470
+ sizes.push(`${v.w}×${v.h}`);
471
+ }
472
+ return { ok: true, checked: (files.length === 1 ? 'It opened and rendered' : `All ${files.length} opened and rendered`) + ' in a headless browser (' + sizes.join(', ') + ')' };
473
+ }
474
+ if (e.group === 'scanned' || e.group === 'scan') {
475
+ if (!c.kind.startsWith('scan')) return { ok: false, why: 'a prototype run cannot file a scan' };
476
+ return { ok: true }; // every claim's page and quote are checked by the guard
477
+ }
478
+ return { ok: false, why: 'unknown group ' + one(e.group, 20) };
479
+ }
480
+
481
+ /** The pages a scan run read, as the ✦'s sources: [{ label, ref: url }] */
482
+ function pagesOf(run, nums) {
483
+ const out = [];
484
+ for (const n of nums) {
485
+ let head = '';
486
+ try { head = fs.readFileSync(path.join(pagesDir(rt(), run), n + '.txt'), 'utf8').split('\n').slice(0, 2).join('\n'); } catch { continue; }
487
+ const url = (/^URL: (.+)$/m.exec(head) || [])[1] || '', title = (/^TITLE: (.*)$/m.exec(head) || [])[1] || '';
488
+ if (url) out.push({ label: one(title || url.replace(/^https?:\/\//, '').split('/')[0], 80), ref: url });
489
+ }
490
+ return out;
491
+ }
492
+
493
+ function fileOne(e, checked, a, now) {
494
+ const c = a.c, at = new Date(now).toISOString();
495
+ const pageNums = () => [...new Set((e.claims || []).flatMap((x) => (x && Array.isArray(x.sources) ? x.sources : []).map((s) => Number(s && s.page)).filter(Number.isInteger)))];
496
+ if (e.group === 'prototyped' || e.group === 'improved') {
497
+ const input = { title: e.title, why: e.why, checked, sources: (e.basis || []).map((b) => ({ label: one(b.label, 80) || 'basis', ref: one(b.ref, 500) })) };
498
+ if (e.group === 'prototyped') input.specimens = e.specimens;
499
+ else Object.assign(input, { of: c.data.of, round: c.data.round, base: e.base, changed: e.changed, file: e.file });
500
+ const R = LAB.result(e.group, input);
501
+ if (R.error) throw new Error(R.error);
502
+ const id = e.group === 'prototyped' ? PR.idOf(c.ini, 'prototyped', 'new', '') : PR.idOf(c.ini, 'improved', 'round', c.data.of + ':' + c.data.round);
503
+ fs.appendFileSync(PR.ledgerFile(a.dir), JSON.stringify({ at, by: null, kind: 'propose', ini: c.ini, board: c.board, run: a.run, id, ...R.prop }) + '\n');
504
+ return;
505
+ }
506
+ const nums = pageNums(), sources = pagesOf(a.run, nums);
507
+ const refreshed = c.kind === 'scan-improve' ? (one(e.refreshed, 200) || 'from the ' + shortDay(c.data.old.at) + ' scan') : '';
508
+ if (e.group === 'scanned' && c.ini) {
509
+ const R = LAB.result('scanned', { title: e.title, why: e.why, sources, pages: nums.length, ...(refreshed ? { refreshed } : {}) });
510
+ if (R.error) throw new Error(R.error);
511
+ // one scan ✦ per initiative: a refresh replaces it (the newest line for an id wins)
512
+ fs.appendFileSync(PR.ledgerFile(a.dir), JSON.stringify({ at, by: null, kind: 'propose', ini: c.ini, board: c.board, run: a.run, id: PR.idOf(c.ini, 'scanned', 'finding', ''), ...R.prop }) + '\n');
513
+ return;
514
+ }
515
+ const R = LAB.loose('scan', { title: e.title || e.why, why: e.why, sources, data: refreshed ? { refreshed } : {} });
516
+ if (R.error) throw new Error(R.error);
517
+ LAB.append(a.dir, { by: null }, { kind: 'propose', ...R.prop });
518
+ }
519
+
520
+ // the shared queue (lib/bg-queue.js) gives the turn: step 5, after the idle clock, quiet hours, the spend limit and the runs-at-once limit
521
+ if (deps.queue && typeof deps.queue.register === 'function') deps.queue.register(JOB, (turn) => { try { tick(turn); } catch (e) { warn('idle lab tick', e); } });
522
+
523
+ return {
524
+ tick, finish,
525
+ active: () => (active ? { run: active.run, kind: active.c.kind, ini: active.c.ini, ws: active.c.ws } : null),
526
+ status: () => ({ active: active ? active.run : null, stretch: stretch ? { since: new Date(stretch.since).toISOString(), filed: stretch.filed } : null }),
527
+ };
528
+ }
529
+
530
+ module.exports = { JOB, VERSION, MAX_RESULTS, MAX_PAGES, STALE_MS, RETRY_MS, KINDS, ORIGINS, candidates, nextOf, promptOf, guardProposal, renderCheck, ensureReplay,
531
+ readRuns, appendRun, spentIn, monthOf, dirOf, pagesDir, pendingFile, runSpecimenDir, pageRef, create };