create-pmos 0.42.0 → 0.43.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@e99d9d4
1
+ kernel-source: pmos@e4f29fe
2
2
  assembled: 2026-10-04
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-04T01:33:06Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T02:41:17Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-04T01:33:06Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T02:41:17Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-04T01:33:06Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T02:41:17Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -86,6 +86,10 @@ The top of Today is a brief: a greeting, the day's calendar, ranked priorities a
86
86
 
87
87
  `pmos kg find|card|neighbors|section|why|stats` builds a map of the repository's tracked Markdown files from relations they already write down (links, frontmatter paths, `D<nn>` citations, `initiative_id`, `parent_kr`) and answers from it, so an agent reads one card or one section and not a 130 KB file. `lib/kg.js` is pure; `lib/kg-cli.js` reads the files, keeps the map in the runtime state directory (rebuilt when HEAD or an uncommitted `.md` changes) and writes nothing in the repository. A link to a missing file is listed as dangling and is never an edge; `lib/kg-dangling-rules.json` says which dangling links are history by design. It does not guess, use embeddings, or call a model or the network. `node scripts/kg-bench.js` replays two written strategies over the fixed questions in `planning/evals/knowledge-graph-questions.md`; it is a simulation, and says "NOT REPORTABLE" until three of the questions are the PM's. Guide page: `guide/knowledge-graph.md`. Tests: `kg.test.js`.
88
88
 
89
+ ## Background prepare (`proactive-engine`, slice D3)
90
+
91
+ From a Today item that names an initiative the PM can approve **one plan hash**: the step an agent may do now, with the exact first input. The server queues that approval (`prepare-queue.jsonl` in the runtime state directory) and a 60-second tick starts it through the same `startWork` path as the C1 "go" button — origin `next-step`, the same propose-only rules, the same session budget — only when the switch is on (default off), it is not quiet hours, the budget allows, and the plan hash has not changed. An approval expires after 24 hours, at most 5 can be queued, and a changed plan is skipped, never run. `lib/prepare-queue.js` holds the pure rules (`fold`, `checkRequest`, `canQueue`, `decide`); the routes are `/api/proactive/prepare/{plan,queue,cancel,run}` and `GET /api/proactive/prepare`; `pmos proactive prepare plan|queue|status|cancel|run` is the terminal face. "Run now" ignores quiet hours only. The C1 run record carries `via: 'background'`. Guide page: `guide/proactive-engine.md`. Tests: `proactive-prepare.test.js` (server, CLI, boundary) and `pm-r25-test.js` (browser). Not verified here: a real `claude` session doing the work; the suites use a stand-in agent that records what it was typed.
92
+
89
93
  ## Scheduled tasks from the terminal (`schedule-cli`)
90
94
 
91
95
  `pmos schedule` and `pmos skill` talk to the running workspace server, so the terminal and the workspace share one schedule store and one validator. Every change is saved the moment the command succeeds. A refused value names what is allowed and changes nothing. With no server running the command says so and exits 1.
@@ -22,6 +22,23 @@ The top of Today is the morning brief: a greeting, the day's calendar, the prior
22
22
  - A brief is refused, with the reason and with nothing stored, when it has a wrong shape, an unknown source, a confidence outside 0 to 1, more than 12 items or 20 events, or a text field that is too long.
23
23
  - The brief is stored in the workspace state folder on this computer. It is not committed.
24
24
 
25
+ ## Background preparation
26
+
27
+ From a Today item that names an initiative, you can ask for the plan and approve it. The workspace then starts that session itself, later. It uses the same path as the next-step go button, so the plan, the prompt, the propose-only rules and the session budget are the same.
28
+
29
+ - Press **prepare in the background** on an item. Today shows the step an agent session may work on now, where the work would happen, the budget and the exact first input. Press **queue it** to approve that plan.
30
+ - Background prepare is off by default. Turn it on in the settings. When it is off, nothing can be queued and nothing queued is started. A queued entry waits while it is off. It is not dropped.
31
+ - The approval is for one plan. If the plan has changed when the workspace is ready to start, the entry is skipped and the reason is shown. Queue it again to approve the new plan.
32
+ - An approval expires after 24 hours. An expired entry is never started.
33
+ - The workspace starts an entry only when the switch is on, it is not quiet hours, the session budget allows it, and the plan is the one you approved. If the budget is full, the entry waits. Entries start oldest first. At most 5 entries wait at once.
34
+ - **run the queue now** looks at the queue immediately. It ignores quiet hours and nothing else.
35
+ - A queued entry can be cancelled. A cancelled entry is never started.
36
+ - The session starts with no profile, so it names no outside source, and with typed scoring off. It commits only to a local initiative branch or writes drafts. It does not push, open a pull request, send, post, accept, approve a contract or merge. The contract, acceptance and open items are your steps and cannot be queued.
37
+ - A started session counts in the same session budget as a next-step go. Switch to it from Today, and review it from the initiative as for any prepared step.
38
+ - It runs only while the workspace server runs. The queue is stored in the workspace state folder on this computer.
39
+
40
+ The states of an entry are queued, started, skipped, expired and cancelled.
41
+
25
42
  ## What it shows
26
43
 
27
44
  Today scans for five conditions. Each one becomes one item with the reason and the evidence.
@@ -52,6 +69,7 @@ A verdict never changes a status, a lifecycle record or an acceptance. In this r
52
69
  | engine on | Off stops automatic scans. The scan now button still works. |
53
70
  | watch: needs, returned, review, schedule, stalled | Turn one trigger off. |
54
71
  | brief sources | One switch for each of calendar, wispr, mail, jira and confluence. |
72
+ | background prepare | Off by default. On lets the workspace start a session for a plan you approved. |
55
73
  | typed scoring | Off by default. On lets the skill send titles and metadata to the Typesafe service. |
56
74
  | name | Used in the greeting. |
57
75
  | stalled after N days | The stalled threshold, from 1 to 90 days. |
@@ -62,14 +80,15 @@ A verdict never changes a status, a lifecycle record or an acceptance. In this r
62
80
 
63
81
  - `pmos proactive today` lists the items.
64
82
  - `pmos proactive scan` scans now.
65
- - `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--brief-source NAME=on|off`, `--brief-scoring on|off` or `--name` to change one.
83
+ - `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--background-prepare on|off`, `--brief-source NAME=on|off`, `--brief-scoring on|off` or `--name` to change one.
66
84
  - `pmos proactive brief` shows today's brief. `pmos proactive brief submit --file PATH` submits one. `pmos proactive brief schedule` makes the 06:45 schedule for the current repository.
85
+ - `pmos proactive prepare` shows the queue. `pmos proactive prepare plan --initiative ID` shows a plan and its hash. `pmos proactive prepare queue --initiative ID --item ITEM --sha HASH` approves it. `pmos proactive prepare cancel ID` cancels an entry. `pmos proactive prepare run` looks at the queue now.
67
86
  - `pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed` records feedback. Use `--missed "text"` for a missed item.
68
87
 
69
88
  ## Limits
70
89
 
71
90
  - It runs only while the workspace server runs. It does not catch up for time the server was off. A sleeping computer gets no scan.
72
91
  - The items from the records come from this computer. The workspace itself does not read your mail or your calendar. Only the morning-brief session does, and only the sources you switched on.
73
- - It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback.
92
+ - It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback. The one exception is background preparation, which starts a session only for a plan you approved, as described above.
74
93
  - An unreadable repository is listed as not read. An empty list is not proof that nothing waits there.
75
94
  - The records stay in the workspace state folder on this computer. They are not committed.
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+ /* prepare-queue.js — the rules for background preparation: what may be queued, and when a queued entry may start.
3
+ *
4
+ * Pure: no I/O, no clock, no model. The server passes what is true now (the settings, the entries read from the ledger, a
5
+ * fresh prepare of the same step, the session budget) and this module decides. An approval is ONE plan hash, never a
6
+ * standing permission: a plan that changed is skipped, an approval older than 24 hours expires, and nothing is caught up.
7
+ *
8
+ * fold(events) → the entries, each with its latest status
9
+ * checkRequest(body) → the shape of a queue request, or why it is refused
10
+ * canQueue({settings, entries, req}) → null, or the reason a request is refused
11
+ * decide({entry, now, settings, quiet, manual, fresh, budget}) → {action, reason}
12
+ *
13
+ * Statuses: queued · started · skipped · expired · cancelled. Only `queued` can change; the other four are final.
14
+ */
15
+ const MAX_QUEUED = 5;
16
+ const EXPIRE_MS = 24 * 60 * 60 * 1000;
17
+ const FINAL = ['started', 'skipped', 'expired', 'cancelled'];
18
+ const HASH = /^[0-9a-f]{16}$/;
19
+ const ID = /^[a-z0-9][a-z0-9._-]*$/i;
20
+ const ITEM = /^[\w.:-]{1,120}$/;
21
+ const CAND = /^[0-9a-f]{12}$/;
22
+
23
+ /** The latest status of every entry, oldest first. A line that names no entry is ignored. */
24
+ function fold(events) {
25
+ const by = new Map();
26
+ for (const e of Array.isArray(events) ? events : []) {
27
+ if (!e || typeof e.id !== 'string') continue;
28
+ if (e.event === 'queued') { if (!by.has(e.id)) by.set(e.id, { id: e.id, status: 'queued', queued_at: e.at, ws: e.ws, initiative_id: e.initiative_id, item_id: e.item_id, key: e.key, candidate_id: e.candidate_id || null, prompt_sha: e.prompt_sha, branch: e.branch || null, reason: null, session_id: null, at: e.at }); continue; }
29
+ const cur = by.get(e.id);
30
+ if (!cur || FINAL.includes(cur.status) || !FINAL.includes(e.event)) continue;
31
+ cur.status = e.event; cur.reason = e.reason || null; cur.session_id = e.session_id || null; cur.at = e.at;
32
+ }
33
+ return [...by.values()];
34
+ }
35
+
36
+ /** @returns {{ok:true, req:object}|{ok:false, error:string}} */
37
+ function checkRequest(body) {
38
+ const b = body && typeof body === 'object' ? body : {};
39
+ if (b.confirm !== true) return { ok: false, error: 'confirm is required: this lets the workspace start a session later' };
40
+ if (typeof b.ws !== 'string' || !b.ws) return { ok: false, error: 'ws is required' };
41
+ if (typeof b.initiative_id !== 'string' || !ID.test(b.initiative_id)) return { ok: false, error: 'initiative_id is required' };
42
+ if (typeof b.item_id !== 'string' || !ITEM.test(b.item_id)) return { ok: false, error: 'item_id is required' };
43
+ if (typeof b.prompt_sha !== 'string' || !HASH.test(b.prompt_sha)) return { ok: false, error: 'prompt_sha is required: queue the plan you were shown' };
44
+ if (b.candidate_id !== undefined && b.candidate_id !== null && b.candidate_id !== '' && !CAND.test(String(b.candidate_id))) return { ok: false, error: 'candidate_id is not a candidate id' };
45
+ return { ok: true, req: { ws: b.ws, initiative_id: b.initiative_id, item_id: b.item_id, prompt_sha: b.prompt_sha, candidate_id: b.candidate_id || null } };
46
+ }
47
+
48
+ /** @returns {string|null} the reason a request is refused, or null */
49
+ function canQueue({ settings, entries, req }) {
50
+ if (!settings || settings.backgroundPrepare !== true) return 'background prepare is off. Turn it on in the Today settings first.';
51
+ const queued = (entries || []).filter((e) => e.status === 'queued');
52
+ if (queued.some((e) => e.ws === req.ws && e.initiative_id === req.initiative_id && e.item_id === req.item_id)) return 'this step is already queued';
53
+ if (queued.length >= MAX_QUEUED) return `${queued.length} entries are already waiting (the limit is ${MAX_QUEUED}). Cancel one or wait for it to start.`;
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * What the runner does with one queued entry right now.
59
+ * @param {{entry:object, now:number, settings:object, quiet:boolean, manual:boolean, fresh:object, budget:object}} s
60
+ * @returns {{action:'start'|'wait'|'skip'|'expire', reason:string|null}}
61
+ */
62
+ function decide(s) {
63
+ const e = s.entry;
64
+ const age = s.now - Date.parse(e.queued_at);
65
+ if (!Number.isFinite(age) || age > EXPIRE_MS) return { action: 'expire', reason: 'the approval is older than 24 hours — queue it again if you still want it' };
66
+ if (!s.settings || s.settings.backgroundPrepare !== true) return { action: 'wait', reason: 'background prepare is off' };
67
+ if (s.quiet && !s.manual) return { action: 'wait', reason: 'quiet hours' };
68
+ const f = s.fresh || {};
69
+ if (!f.ok) return { action: 'skip', reason: f.error || 'the step could not be prepared again' };
70
+ if (f.refused) return { action: 'skip', reason: f.refused.why };
71
+ if (f.prompt_sha !== e.prompt_sha) return { action: 'skip', reason: 'the plan changed since you approved it — queue it again to approve the new one' };
72
+ if (!s.budget || !s.budget.allowed) return { action: 'wait', reason: (s.budget && s.budget.reason) || 'no session budget' };
73
+ return { action: 'start', reason: null };
74
+ }
75
+
76
+ module.exports = { MAX_QUEUED, EXPIRE_MS, FINAL, fold, checkRequest, canQueue, decide };
@@ -13,13 +13,16 @@
13
13
  * pmos proactive brief [show]
14
14
  * pmos proactive brief submit --file PATH
15
15
  * pmos proactive brief schedule [--repo PATH]
16
+ * pmos proactive prepare plan --initiative ID [--repo PATH]
17
+ * pmos proactive prepare queue --initiative ID --item ITEM --sha HASH [--repo PATH]
18
+ * pmos proactive prepare status | cancel ID | run
16
19
  */
17
20
  const fs = require('fs');
18
21
  const path = require('path');
19
22
  const { execFileSync } = require('child_process');
20
23
  const { parseArgs } = require('./schedule-cli');
21
24
 
22
- const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief'] };
25
+ const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare'] };
23
26
  const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
24
27
  const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
25
28
 
@@ -63,6 +66,7 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
63
66
  patch.triggers = { [m[1]]: m[2] === 'on' };
64
67
  }
65
68
  if ('name' in flags) patch.name = flags.name;
69
+ if ('background-prepare' in flags) { const v = onOff(flags['background-prepare'], '--background-prepare'); if (v.err) return fail(v.err); patch.backgroundPrepare = v; }
66
70
  if ('brief-scoring' in flags) { const v = onOff(flags['brief-scoring'], '--brief-scoring'); if (v.err) return fail(v.err); patch.briefScoring = v; }
67
71
  if ('brief-source' in flags) {
68
72
  const m = /^([a-z]+)=(on|off)$/.exec(flags['brief-source']);
@@ -82,9 +86,61 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
82
86
  }
83
87
  say(`engine ${s.enabled ? 'on' : 'off'} · stalled after ${s.stalledDays} days · quiet ${s.quiet ? s.quiet.start + '-' + s.quiet.end : 'none'} · learning ${s.pauseLearning ? 'paused' : 'on'}`);
84
88
  say('brief sources: ' + BRIEF_SOURCES.map((k) => `${k} ${s.briefSources && s.briefSources[k] ? 'on' : 'off'}`).join(', ') + ' · scoring ' + (s.briefScoring ? 'on' : 'off') + (s.name ? ' · name ' + s.name : ''));
89
+ say('background prepare ' + (s.backgroundPrepare ? 'on' : 'off'));
85
90
  say('watching: ' + Object.entries(s.triggers).map(([k, v]) => `${k} ${v ? 'on' : 'off'}`).join(', '));
86
91
  return 0;
87
92
  }
93
+ if (verb === 'prepare') {
94
+ const sub = pos[0] || 'status';
95
+ const wsOf = async () => {
96
+ let root; try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: flags.repo ? path.resolve(cwd, flags.repo) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return { err: 'this is not inside a git repository — use --repo PATH' }; }
97
+ const wl = await call('GET', '/api/workspaces');
98
+ const hit = ((wl && wl.workspaces) || []).find((w) => path.resolve(w.root) === path.resolve(root));
99
+ return hit ? { ws: hit.id } : { err: `${root} is not a registered workspace — open it with pmos first` };
100
+ };
101
+ if (sub === 'plan' || sub === 'queue') {
102
+ if (!flags.initiative) return fail(`usage: pmos proactive prepare ${sub} --initiative ID${sub === 'queue' ? ' --item ITEM --sha HASH' : ''}`);
103
+ const w = await wsOf(); if (w.err) return fail(w.err);
104
+ if (sub === 'plan') {
105
+ const r = await call('POST', '/api/proactive/prepare/plan', { ws: w.ws, initiative_id: flags.initiative });
106
+ if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
107
+ say(`step: ${r.item.key} — ${r.item.text} (item ${r.item.id})`);
108
+ if (r.refused) { say('refused: ' + r.refused.why); return 1; }
109
+ say(r.plan.why || r.plan.plan);
110
+ say(`budget: ${r.budget.running} of ${r.budget.limits.concurrent} running · ${r.budget.startedToday} of ${r.budget.limits.perDay} started today`);
111
+ say('background prepare is ' + (r.background.enabled ? 'on' : 'off. Turn it on with: pmos proactive settings --background-prepare on'));
112
+ say('plan ' + r.prompt_sha + ' — the exact first input:');
113
+ say(r.prompt);
114
+ say(`queue it with: pmos proactive prepare queue --initiative ${r.initiative_id} --item ${r.item.id} --sha ${r.prompt_sha}`);
115
+ return 0;
116
+ }
117
+ if (!flags.item || !flags.sha) return fail('usage: pmos proactive prepare queue --initiative ID --item ITEM --sha HASH');
118
+ const r = await call('POST', '/api/proactive/prepare/queue', { ws: w.ws, initiative_id: flags.initiative, item_id: flags.item, prompt_sha: flags.sha, confirm: true });
119
+ if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was queued.`);
120
+ say(`queued ${r.entry.id} — the workspace starts it when the budget allows. It expires in 24 hours.`);
121
+ return 0;
122
+ }
123
+ if (sub === 'cancel') {
124
+ if (!pos[1]) return fail('usage: pmos proactive prepare cancel ID');
125
+ const r = await call('POST', '/api/proactive/prepare/cancel', { id: pos[1] });
126
+ if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
127
+ say('cancelled'); return 0;
128
+ }
129
+ if (sub === 'run') {
130
+ const r = await call('POST', '/api/proactive/prepare/run', {});
131
+ if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
132
+ if (!r.results.length) say('nothing is queued');
133
+ for (const x of r.results) say(`${x.id} ${x.action}${x.reason ? ' — ' + x.reason : ''}${x.session_id ? ' (session ' + x.session_id + ')' : ''}`);
134
+ return 0;
135
+ }
136
+ if (sub !== 'status') return fail('usage: pmos proactive prepare [status|plan|queue|cancel ID|run]');
137
+ const r = await call('GET', '/api/proactive/prepare');
138
+ if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
139
+ say(`background prepare ${r.enabled ? 'on' : 'off'}${r.quiet ? ' · quiet hours now' : ''} · ${r.budget.running} of ${r.budget.limits.concurrent} running · ${r.budget.startedToday} of ${r.budget.limits.perDay} started today`);
140
+ if (!r.entries.length) say('nothing has been queued');
141
+ for (const e of r.entries) say(`${e.id} ${e.status} ${e.label}${e.reason ? ' — ' + e.reason : ''}`);
142
+ return 0;
143
+ }
88
144
  if (verb === 'brief') {
89
145
  const sub = pos[0] || 'show';
90
146
  if (sub === 'submit') {
@@ -32,7 +32,7 @@ const OWNER = { needs: 'pm', returned: 'pm', review: 'pm', schedule: 'pm', stall
32
32
  const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
33
33
  // Mail is off by default: a brief reads titles and metadata of the other sources, and the PM turns mail on.
34
34
  const BRIEF_DEFAULT = Object.freeze({ calendar: true, wispr: true, mail: false, jira: true, confluence: true });
35
- const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
35
+ const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, backgroundPrepare: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
36
36
 
37
37
  const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
38
38
  const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
@@ -161,10 +161,11 @@ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
161
161
  /** @returns {{ok:true, settings:object}|{ok:false, error:string}} — `raw` is merged over the defaults; nothing is partly applied. */
162
162
  function normalizeSettings(raw) {
163
163
  const r = raw && typeof raw === 'object' ? raw : {};
164
- const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning };
164
+ const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, backgroundPrepare: DEFAULTS.backgroundPrepare, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning };
165
165
  if (r.enabled !== undefined) { if (typeof r.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' }; s.enabled = r.enabled; }
166
166
  if (r.name !== undefined) { if (typeof r.name !== 'string' || r.name.length > 40) return { ok: false, error: 'name must be text of at most 40 characters' }; s.name = r.name.replace(/\s+/g, ' ').trim(); }
167
167
  if (r.briefScoring !== undefined) { if (typeof r.briefScoring !== 'boolean') return { ok: false, error: 'briefScoring must be true or false' }; s.briefScoring = r.briefScoring; }
168
+ if (r.backgroundPrepare !== undefined) { if (typeof r.backgroundPrepare !== 'boolean') return { ok: false, error: 'backgroundPrepare must be true or false' }; s.backgroundPrepare = r.backgroundPrepare; }
168
169
  if (r.briefSources !== undefined) {
169
170
  if (!r.briefSources || typeof r.briefSources !== 'object' || Array.isArray(r.briefSources)) return { ok: false, error: `briefSources must be an object of ${BRIEF_SOURCES.join(', ')} → true or false` };
170
171
  for (const [k, v] of Object.entries(r.briefSources)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -10,12 +10,12 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js",
13
+ "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node proactive-prepare.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
17
  "doctor": "node doctor.js",
18
- "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js",
18
+ "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js",
19
19
  "test:offline": "node offline-tarball.test.js"
20
20
  },
21
21
  "engines": {
@@ -831,7 +831,7 @@ async function tScan() {
831
831
  }
832
832
  async function tSettings(patch) {
833
833
  const out = await post('/api/proactive/settings', patch);
834
- reload('today'); reload('brief');
834
+ reload('today'); reload('brief'); reload('prepare');
835
835
  tSay(!out || out.ok === false ? 'bad' : 'ok', !out || out.ok === false ? ((out && out.error) || 'not saved') : 'saved');
836
836
  }
837
837
  function tCard(c) {
@@ -843,6 +843,7 @@ function tCard(c) {
843
843
  el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground);margin:2px 0 4px' }, iv(c.why + (c.evidence ? ' — ' + c.evidence : ''))),
844
844
  el('span', { style: 'display:flex;gap:4px;flex-wrap:wrap' },
845
845
  c.open ? b('open', 'open', () => tOpen(c)) : null,
846
+ c.initiative_id && c.ws ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r23': 'prepare', onClick: () => bgShowPlan(c) }, 'prepare in the background') : null,
846
847
  b('useful', 'useful', () => tFeedback({ candidate_id: c.id, verdict: 'useful' }, 'thanks — recorded as useful')),
847
848
  b('not useful', 'not-useful', () => tFeedback({ candidate_id: c.id, verdict: 'not-useful' }, 'recorded as not useful')),
848
849
  b('wrong priority', 'wrong-priority', () => tFeedback({ candidate_id: c.id, verdict: 'wrong-priority' }, 'recorded as wrong priority')),
@@ -900,6 +901,72 @@ function briefSection() {
900
901
  }
901
902
  return kids;
902
903
  }
904
+
905
+ /* background preparation (proactive-engine D3): the PM approves one plan; the workspace starts it later, under the same rules as a next-step go */
906
+ let bgPlan = null; // { cid, state: 'loading'|'ok'|'error', data, error }
907
+ let bgNote = null;
908
+ async function bgShowPlan(c) {
909
+ if (bgPlan && bgPlan.cid === c.id) { bgPlan = null; setState({}); return; }
910
+ bgPlan = { cid: c.id, state: 'loading' }; setState({});
911
+ const out = await post('/api/proactive/prepare/plan', { ws: c.ws, initiative_id: c.initiative_id });
912
+ bgPlan = !out || out.ok === false ? { cid: c.id, state: 'error', error: (out && out.error) || 'no plan' } : { cid: c.id, state: 'ok', data: out, c };
913
+ setState({});
914
+ }
915
+ async function bgQueue(c, d) {
916
+ const out = await post('/api/proactive/prepare/queue', { ws: d.ws, initiative_id: d.initiative_id, item_id: d.item.id, prompt_sha: d.prompt_sha, candidate_id: c.id, confirm: true });
917
+ bgNote = !out || out.ok === false ? { tone: 'bad', text: (out && out.error) || 'not queued' } : { tone: 'ok', text: 'queued — the workspace starts it when the budget allows' + (d.background.quiet ? ', after quiet hours' : '') + '. It expires in ' + d.background.expire_hours + ' hours.' };
918
+ if (out && out.ok) bgPlan = null;
919
+ reload('prepare'); setState({});
920
+ }
921
+ async function bgCancel(e) {
922
+ const out = await post('/api/proactive/prepare/cancel', { id: e.id });
923
+ bgNote = !out || out.ok === false ? { tone: 'bad', text: (out && out.error) || 'not cancelled' } : { tone: 'ok', text: 'cancelled' };
924
+ reload('prepare'); setState({});
925
+ }
926
+ async function bgRun() {
927
+ const out = await post('/api/proactive/prepare/run', {});
928
+ bgNote = !out || out.ok === false ? { tone: 'bad', text: (out && out.error) || 'the run failed' }
929
+ : { tone: 'ok', text: out.results.length ? out.results.map((x) => x.action + (x.reason ? ' (' + x.reason + ')' : '')).join('; ') : 'nothing is queued' };
930
+ reload('prepare'); setState({});
931
+ }
932
+ function bgPlanView() {
933
+ if (!bgPlan) return null;
934
+ if (bgPlan.state === 'loading') return el('div', { style: NOTE, 'data-r23': 'plan-loading' }, iv('reading the plan …'));
935
+ if (bgPlan.state === 'error') return el('div', { style: NOTE + ';color:var(--destructive)', 'data-r23': 'plan-error' }, iv(bgPlan.error));
936
+ const d = bgPlan.data; const c = bgPlan.c;
937
+ const kids = [el('div', { style: 'font-size:12px', 'data-r23': 'plan-step' }, iv('Step: ' + d.item.key + ' — ' + d.item.text))];
938
+ if (d.refused) { kids.push(el('div', { style: NOTE + ';color:var(--destructive)', 'data-r23': 'plan-refused' }, iv(d.refused.why))); }
939
+ else {
940
+ kids.push(el('div', { style: NOTE, 'data-r23': 'plan-where' }, iv(d.plan.why || d.plan.plan)));
941
+ kids.push(el('div', { style: NOTE, 'data-r23': 'plan-budget' }, iv(d.budget.running + ' of ' + d.budget.limits.concurrent + ' prepared sessions running · ' + d.budget.startedToday + ' of ' + d.budget.limits.perDay + ' started today. The session proposes only: no push, pull request, send, post, acceptance or merge. No outside source and no typed scoring.')));
942
+ kids.push(el('pre', { 'data-r23': 'plan-prompt', style: 'white-space:pre-wrap;font-family:var(--mono);font-size:10.5px;max-height:140px;overflow:auto;border:1px solid var(--border);padding:6px;margin:4px 0' }, iv(d.prompt)));
943
+ if (!d.background.enabled) kids.push(el('div', { style: NOTE, 'data-r23': 'plan-off' }, iv('Background prepare is off. Turn it on in the settings below to queue this.')));
944
+ else kids.push(el('button', { style: BTN + ';color:var(--foreground);border-color:var(--foreground)', hover: 'background:var(--accent)', 'data-r23': 'queue', onClick: () => bgQueue(c, d) }, 'queue it (plan ' + d.prompt_sha + ')'));
945
+ }
946
+ return el('div', { 'data-r23': 'plan', style: 'padding:6px 14px 8px;border-bottom:1px solid var(--border)' }, kids);
947
+ }
948
+ function backgroundSection() {
949
+ const r = read('prepare', '/api/proactive/prepare');
950
+ const st = status(r, 'the background queue');
951
+ const kids = [el('div', { style: LABEL }, iv('background preparation'))];
952
+ if (st) return [...kids, st];
953
+ const d = r.data;
954
+ kids.push(el('div', { style: NOTE, 'data-r23': 'bg-state' }, iv((d.enabled ? 'On' : 'Off') + '. ' + d.budget.running + ' of ' + d.budget.limits.concurrent + ' prepared sessions running · ' + d.budget.startedToday + ' of ' + d.budget.limits.perDay + ' started today' + (d.quiet ? ' · quiet hours now' : '') + '. An approval is for one plan and expires in ' + d.expire_hours + ' hours. It runs only while the workspace server runs.')));
955
+ if (bgNote) kids.push(el('div', { 'data-r23': 'bg-note', style: NOTE + ';color:' + (bgNote.tone === 'bad' ? 'var(--destructive)' : 'var(--foreground)') }, iv(bgNote.text)));
956
+ if (!d.entries.length) kids.push(el('div', { style: NOTE, 'data-r23': 'bg-empty' }, iv('Nothing has been queued.')));
957
+ for (const e of d.entries) {
958
+ kids.push(el('div', { 'data-r23': 'entry', 'data-id': e.id, 'data-status': e.status, style: 'padding:6px 14px;border-bottom:1px solid var(--border)' },
959
+ el('span', { style: 'display:flex;gap:8px;align-items:baseline' },
960
+ el('span', { style: 'font-size:12px' }, iv(e.label)),
961
+ el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(e.status))),
962
+ e.reason ? el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground)', 'data-r23': 'entry-reason' }, iv(e.reason)) : null,
963
+ el('span', { style: 'display:flex;gap:4px;margin-top:3px' },
964
+ e.status === 'queued' ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r23': 'cancel', onClick: () => bgCancel(e) }, 'cancel') : null,
965
+ e.status === 'started' && e.session_id ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r23': 'switch', onClick: async () => { const ini = await import('./panel-initiatives.js'); ini.openTerm(null, { id: e.session_id, name: e.label }); } }, 'switch to it') : null)));
966
+ }
967
+ if (d.entries.some((e) => e.status === 'queued')) kids.push(el('div', { style: 'padding:4px 14px' }, el('button', { style: BTN, hover: 'color:var(--foreground)', title: 'look at the queue now; ignores quiet hours only', 'data-r23': 'run', onClick: bgRun }, 'run the queue now')));
968
+ return kids;
969
+ }
903
970
  export function todayList() {
904
971
  const r = read('today', '/api/proactive/today');
905
972
  const kids = [el('div', { style: NOTE, 'data-r22': 'intro' }, iv('What is waiting for you, worked out from the ledgers and git on this computer. It runs only while the workspace server runs, reads no mail or calendar, and changes nothing: your feedback only changes how this list is ordered later.'))];
@@ -925,7 +992,8 @@ export function todayList() {
925
992
  if ((d.unreadable || []).length && !tRetry && typeof setTimeout === 'function') tRetry = setTimeout(() => { tRetry = null; reload('today'); setState({}); }, 4000);
926
993
  for (const u of d.unreadable || []) kids.push(el('div', { style: NOTE + ';color:var(--destructive)', 'data-r22': 'unreadable' }, iv('Not read — ' + (u.name || u.repo || 'a repository') + ': ' + (u.error || 'unknown reason') + '. Nothing is shown for it, which is not the same as nothing waiting.')));
927
994
  if (d.empty) kids.push(el('div', { style: NOTE, 'data-r22': 'empty' }, iv('Nothing is waiting on any repository that was read.')));
928
- for (const c of d.candidates || []) kids.push(tCard(c));
995
+ for (const c of d.candidates || []) { kids.push(tCard(c)); if (bgPlan && bgPlan.cid === c.id) kids.push(bgPlanView()); }
996
+ kids.push(...backgroundSection());
929
997
  if (d.dismissed) kids.push(el('div', { style: NOTE, 'data-r22': 'dismissed' }, iv(d.dismissed + ' dismissed item(s) hidden for 7 days.')));
930
998
  // settings
931
999
  kids.push(el('div', { style: LABEL }, iv('settings')));
@@ -933,6 +1001,7 @@ export function todayList() {
933
1001
  el('input', { type: 'checkbox', checked: on ? 'checked' : null, 'data-r22': attr, onChange: () => tSettings(patch(!on)) }), iv(label));
934
1002
  kids.push(chk('engine on', s.enabled, 'set-enabled', (v) => ({ enabled: v })));
935
1003
  for (const t of Object.keys(s.triggers)) kids.push(chk('watch: ' + t, s.triggers[t], 'set-' + t, (v) => ({ triggers: { [t]: v } })));
1004
+ kids.push(chk('background prepare (starts approved sessions by itself)', s.backgroundPrepare, 'set-bg', (v) => ({ backgroundPrepare: v })));
936
1005
  kids.push(el('div', { style: LABEL }, iv('morning brief')));
937
1006
  for (const k of Object.keys(s.briefSources || {})) kids.push(chk('read ' + k + (k === 'mail' ? ' (titles and senders only)' : ''), s.briefSources[k], 'set-src-' + k, (v) => ({ briefSources: { [k]: v } })));
938
1007
  kids.push(chk('typed scoring (sends titles and metadata to the Typesafe service)', s.briefScoring, 'set-scoring', (v) => ({ briefScoring: v })));
@@ -79,6 +79,7 @@ const sessionBudget = require('./lib/session-budget');
79
79
  const judgmentLib = require('./lib/judgment');
80
80
  const proactiveLib = require('./lib/proactive');
81
81
  const briefLib = require('./lib/brief');
82
+ const prepareQueue = require('./lib/prepare-queue');
82
83
  const harness = require('./lib/harness-parse');
83
84
  const transcript = require('./lib/transcript-read');
84
85
  const skillStore = require('./lib/skill-store');
@@ -2189,7 +2190,7 @@ function checkDueSchedules() {
2189
2190
  return fired;
2190
2191
  }
2191
2192
  // the tick — the persistent server checks on an interval; unref'd so it never blocks exit
2192
- try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); }, 60000); if (_tick.unref) _tick.unref(); } catch {}
2193
+ try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } }, 60000); if (_tick.unref) _tick.unref(); } catch {}
2193
2194
 
2194
2195
  // ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
2195
2196
  /** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
@@ -3358,6 +3359,58 @@ function prepareNextStep(body) {
3358
3359
  judgment: { on: judgmentOn, outside_service: judgmentOn, needs_key: judgmentOn, note: judgmentOn ? 'The question text leaves this machine for the Typesafe service. It needs TYPESAFE_API_KEY in the session.' : 'No outside service is used.' } };
3359
3360
  }
3360
3361
 
3362
+ // ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
3363
+ // The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
3364
+ // `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
3365
+ // hours, with session budget, and while the plan still has the approved hash. Every rule is in lib/prepare-queue.js.
3366
+ const PRO_QUEUE = () => path.join(STATE, 'prepare-queue.jsonl');
3367
+ function queueEntries() { return prepareQueue.fold(nsRead(PRO_QUEUE())); }
3368
+ // The step an agent session may work on now for an initiative: the first `work` step the ladder lets it start.
3369
+ function currentWorkItem(w, id) {
3370
+ const got = nextStepsFor(w, id);
3371
+ if (!got.ok) return { ok: false, status: got.status || 500, error: got.error };
3372
+ const item = got.ns.items.find((x) => x.act && x.act.type === 'work' && !nextStepActions.refusal(x, got.ns));
3373
+ if (item) return { ok: true, item };
3374
+ const first = got.ns.items.find((x) => x.first) || got.ns.items[0];
3375
+ const why = first ? nextStepActions.refusal(first, got.ns) : null;
3376
+ return { ok: false, status: 409, error: 'No step is open for an agent session right now' + (why ? ': ' + why.why : '.') };
3377
+ }
3378
+ function preparePlanFor(w, id, itemId) {
3379
+ return prepareNextStep({ ws: w.id, initiative_id: id, item_id: itemId, agent: 'claude', judgment: false });
3380
+ }
3381
+ function prepareState() {
3382
+ const settings = proSettings();
3383
+ return { enabled: settings.backgroundPrepare === true, quiet: proactiveLib.inQuietHours(Date.now(), settings.quiet), budget: nsBudget(), max_queued: prepareQueue.MAX_QUEUED, expire_hours: prepareQueue.EXPIRE_MS / 3600000 };
3384
+ }
3385
+ function prepareTick({ manual = false } = {}) {
3386
+ const out = [];
3387
+ const settings = proSettings();
3388
+ const queued = queueEntries().filter((e) => e.status === 'queued');
3389
+ if (!queued.length) return out;
3390
+ const now = Date.now();
3391
+ const quiet = proactiveLib.inQuietHours(now, settings.quiet);
3392
+ for (const e of queued) {
3393
+ const w = getWs(e.ws);
3394
+ const fresh = w ? preparePlanFor(w, e.initiative_id, e.item_id) : { ok: false, error: 'the repository is no longer registered' };
3395
+ const d = prepareQueue.decide({ entry: e, now, settings, quiet, manual, fresh, budget: nsBudget() });
3396
+ if (d.action === 'wait') { out.push({ id: e.id, action: 'wait', reason: d.reason }); continue; }
3397
+ if (d.action === 'expire' || d.action === 'skip') {
3398
+ nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: d.action === 'expire' ? 'expired' : 'skipped', reason: d.reason }, 'prepare queue');
3399
+ out.push({ id: e.id, action: d.action, reason: d.reason }); continue;
3400
+ }
3401
+ const started = startWork({ initiative: fresh.initiative_id, ws: fresh.ws, agent: fresh.agent, name: `${fresh.item.key} · ${fresh.initiative_id} (background)`, origin: 'next-step', initialInput: fresh.prompt + '\r' });
3402
+ if (!started.ok) {
3403
+ nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: 'skipped', reason: 'it could not start — ' + started.error }, 'prepare queue');
3404
+ out.push({ id: e.id, action: 'skip', reason: started.error }); continue;
3405
+ }
3406
+ nsAppend(NS_RUNS(), { id: 'nsr-' + Date.now().toString(36), at: nowISO(), ws: fresh.ws, initiative_id: fresh.initiative_id, item_id: fresh.item.id, key: fresh.item.key, session_id: started.session.id,
3407
+ branch: fresh.plan.branch || null, plan: fresh.plan.plan, prompt_sha: fresh.prompt_sha, judgment: false, profile: null, via: 'background', queue_id: e.id }, 'next-step run');
3408
+ nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: 'started', session_id: started.session.id }, 'prepare queue');
3409
+ out.push({ id: e.id, action: 'start', session_id: started.session.id });
3410
+ }
3411
+ return out;
3412
+ }
3413
+
3361
3414
  // ── Proactive engine, slice D1 (initiative proactive-engine) ─────────────────────────────────────────────────────
3362
3415
  // A scan reads ledgers and git for five conditions and appends each new one to signals.jsonl; Today is computed from what
3363
3416
  // is true now. No model call, no network call, no write into any repository, no session started (lib/proactive.js is
@@ -6221,6 +6274,53 @@ async function handleRequest(req, res) {
6221
6274
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6222
6275
  return json(res, { ok: true, ws: w.id, name: w.name, ...validateLib.readFindings(validationFindingsFile(w)) });
6223
6276
  }
6277
+ /* ── background prepare (D3): plan is read-only; queue records an approval; the runner starts it later ── */
6278
+ if (p === '/api/proactive/prepare/plan' && req.method === 'POST') {
6279
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6280
+ const w = getWs(body.ws);
6281
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6282
+ const id = String(body.initiative_id || '');
6283
+ if (!fourPanel.validId(id)) return json(res, { ok: false, error: 'an initiative id is required' }, 400);
6284
+ const cur = currentWorkItem(w, id);
6285
+ if (!cur.ok) return json(res, { ok: false, error: cur.error }, cur.status || 409);
6286
+ const r = preparePlanFor(w, id, cur.item.id);
6287
+ return json(res, { ...r, background: prepareState() }, r.ok ? 200 : (r.status || 400));
6288
+ }
6289
+ if (p === '/api/proactive/prepare/queue' && req.method === 'POST') {
6290
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6291
+ const c = prepareQueue.checkRequest(body);
6292
+ if (!c.ok) return json(res, { ok: false, error: c.error }, 400);
6293
+ const why = prepareQueue.canQueue({ settings: proSettings(), entries: queueEntries(), req: c.req });
6294
+ if (why) return json(res, { ok: false, error: why }, 409);
6295
+ const w = getWs(c.req.ws);
6296
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6297
+ const r = preparePlanFor(w, c.req.initiative_id, c.req.item_id);
6298
+ if (!r.ok) return json(res, { ok: false, error: r.error }, r.status || 400);
6299
+ if (r.refused) return json(res, { ok: false, refused: r.refused, error: r.refused.why }, 409);
6300
+ if (r.prompt_sha !== c.req.prompt_sha) return json(res, { ok: false, error: 'the plan changed since you reviewed it — read it again', prompt_sha: r.prompt_sha }, 409);
6301
+ const rec = { id: 'pq-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5), at: nowISO(), event: 'queued', ws: w.id, initiative_id: c.req.initiative_id, item_id: c.req.item_id, key: r.item.key,
6302
+ candidate_id: c.req.candidate_id, prompt_sha: r.prompt_sha, branch: r.plan.branch || null };
6303
+ const wrote = nsAppend(PRO_QUEUE(), rec, 'prepare queue');
6304
+ if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
6305
+ return json(res, { ok: true, entry: rec });
6306
+ }
6307
+ if (p === '/api/proactive/prepare/cancel' && req.method === 'POST') {
6308
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6309
+ const e = queueEntries().find((x) => x.id === String(body.id || ''));
6310
+ if (!e) return json(res, { ok: false, error: 'no such queued entry' }, 404);
6311
+ if (e.status !== 'queued') return json(res, { ok: false, error: `it is already ${e.status} — only a queued entry can be cancelled` }, 409);
6312
+ const wrote = nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: 'cancelled', reason: 'cancelled by you' }, 'prepare queue');
6313
+ if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
6314
+ return json(res, { ok: true });
6315
+ }
6316
+ if (p === '/api/proactive/prepare/run' && req.method === 'POST') {
6317
+ // A PM press: look at the queue now. Quiet hours are ignored; the switch, the budget, the plan hash and the 24 hours are not.
6318
+ return json(res, { ok: true, results: prepareTick({ manual: true }) });
6319
+ }
6320
+ if (p === '/api/proactive/prepare' && req.method === 'GET') {
6321
+ const entries = queueEntries().slice(-30).reverse().map((e) => ({ ...e, label: e.key + ' · ' + e.initiative_id }));
6322
+ return json(res, { ok: true, ...prepareState(), entries });
6323
+ }
6224
6324
  /* ── proactive engine (D1): Today is computed, feedback is recorded, nothing is sent, started or changed ── */
6225
6325
  if (p === '/api/proactive/today' && req.method === 'GET') {
6226
6326
  const now = Date.now();