create-pmos 0.58.0 → 0.59.1

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.58.0",
3
+ "version": "0.59.1",
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@e8fcb2a
1
+ kernel-source: pmos@2d3ee9f
2
2
  assembled: 2026-10-06
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-06T11:54:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T13:37:09Z"}
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-06T11:54:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T13:37:09Z"}
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-06T11:54:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T13:37:09Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -25,7 +25,7 @@ Each place is drawn when its run is built. Until then the place says which run b
25
25
 
26
26
  ## Settings
27
27
 
28
- Settings holds settings only, in six sections:
28
+ Settings holds settings only, in seven sections:
29
29
 
30
30
  - **Repositories**: every registered repository. Press a repository to make it the current one. Press its ⋯ for **Rename** or **Remove from PMOS**. Each asks in a sheet, and a remove asks you to confirm. **Add repository** shows the scan of a folder before you add it.
31
31
  - **Profiles**: the Claude profiles a session can start with. They open in the detail.
@@ -33,6 +33,9 @@ Settings holds settings only, in six sections:
33
33
  - **Validate consent**: the consent of the current repository. Turn it on or off here. **Open Validate** opens the Validate page as a tab.
34
34
  - **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then press **Turn on**. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
35
35
  - **Theme**: Dark or Light.
36
+ - **Updates**: the version of the workspace app and the version of the kit in the current repository. Press **Check for updates** to compare them with the latest published version of PMOS. This is the only time the workspace asks the internet for a version, and it asks only when you press. When a newer version is available, a button shows for each part that is older:
37
+ - **Update the kit** does what `npx create-pmos@latest --update` does in a terminal. It changes only the kit's own files under `pmos/` (skills, scripts, templates and guides). Your records in `state/`, `planning/` and `okf/product/` do not change. The last lines of the output show under the button. The changed files are under `pmos/`, which Sync treats as records, so Sync (or automatic sync) sends them with your other records. To read them first, look at `git diff pmos/` before you sync.
38
+ - **Update the workspace** does what `pmos upgrade` does in a terminal. It asks you first. The workspace stops, installs the new version and starts again, and the page reloads when the new version starts. Sessions keep running in tmux. Press it on the computer that runs the workspace. From a phone it is refused, because the phone cannot see the restart through. When the workspace runs from a PMOS checkout, update it with `git pull` instead.
36
39
 
37
40
  Your work is never in Settings. It is in Today, Initiatives, Board and Reports.
38
41
 
@@ -20,6 +20,16 @@ If the morning-brief session stops before it submits a brief, the Read step says
20
20
 
21
21
  Each agent job runs in its own session, so you can open any job and follow or steer it. The workspace types the job's prompt into the session when the agent is ready to read, and presses Enter for you. When you approve or revise a job and its session is still open, the workspace uses that same session again. Prepare again works the same way: when the morning-brief session is still open and has finished its read, or has printed nothing for two minutes, the workspace types the read into that session and runs it again.
22
22
 
23
+ An agent job can stop with its session still open, for example when the agent asks a question or stops without a draft. When a job's session prints nothing for five minutes and has no draft, the job fails with that reason, so the other jobs can start. Press **Open session** to see what the agent waits on, or **Run it again** to start the job in a new session. **Prepare again** also starts each of these jobs again in a new session. If the first session still delivers its draft, the job takes it.
24
+
25
+ ### What Prepare again, Check now and deleting a session change
26
+
27
+ - **Prepare again** reads every source again. For each source it reads, the new read replaces that source's items, digests and, for the calendar, the events. The notes of a meeting stay with the meeting. A source it cannot read this time keeps what it gave before. Your goals and every job stay: a job that has a draft, an approval or a result keeps it, and a new item becomes a new job.
28
+ - **Check now** adds what is new since the last read. It also looks once at each open item: it takes off an item that is done or no longer true, and updates an item whose facts changed, for example a new ask or a new due date. It replaces nothing else, and every job stays.
29
+ - **Deleting a session** does not delete its job. A job that was working fails with "the session ended without a draft", so press **Run it again** to start it in a new session. A job that has a draft keeps it. A job that was sending after your approval fails too, so check the place it was for before you run it again: the workspace cannot tell whether it sent.
30
+
31
+ Each job session is named "Job ·" and the first words of the job, so the session list stays short.
32
+
23
33
  **Agent results**, at the top of the left column, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under it, above the calendar. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
24
34
 
25
35
  ### One day for every repository
@@ -387,5 +387,37 @@ function mergeBrief(stored, incoming, { now = Date.now() } = {}) {
387
387
  } };
388
388
  }
389
389
 
390
+ /**
391
+ * A second full read of the same day (Prepare again) over the stored brief. Pure. The new read wins for every source
392
+ * it READ; what came from a source it could not read this time is kept, so a source that fails once does not wipe
393
+ * what it gave before.
394
+ * items the incoming ones, plus the stored ones from a source the incoming brief did not read (and not resolved).
395
+ * calendar the incoming events when it read the calendar (notes of an event with the same id are kept), else the
396
+ * stored events.
397
+ * digests the incoming ones, plus the stored ones from a source it did not read.
398
+ * places a service's list is replaced when the incoming brief lists that service.
399
+ * resolved kept.
400
+ * @returns {{ok:true, brief:object, kept:number}|{ok:false, error:string}}
401
+ */
402
+ function rereadBrief(stored, incoming) {
403
+ if (!incoming || typeof incoming !== 'object') return { ok: false, error: 'nothing to read again' };
404
+ if (!stored || typeof stored !== 'object' || stored.date !== incoming.date) return { ok: true, brief: { ...incoming, resolved: [] }, kept: 0 };
405
+ const read = (k) => k === 'derived' || (incoming.sources || {})[k] === 'read';
406
+ const gone = new Set(stored.resolved || []);
407
+ const inItems = [...(incoming.priorities || []), ...(incoming.worth_asking || [])];
408
+ const ids = new Set(inItems.map((x) => x.id));
409
+ const keep = [...(stored.priorities || []), ...(stored.worth_asking || [])].filter((x) => !read(x.source) && !ids.has(x.id) && !gone.has(x.id));
410
+ const items = sortItems([...inItems, ...keep]).slice(0, MAX_ITEMS);
411
+ const oldEv = new Map((stored.calendar || []).map((e) => [e.id, e]));
412
+ const calendar = read('calendar')
413
+ ? (incoming.calendar || []).map((e) => { const o = oldEv.get(e.id); return o && o.notes ? { ...e, notes: { ...o.notes, ...(e.notes || {}) } } : e; })
414
+ : (stored.calendar || []);
415
+ const digests = [...(incoming.digests || []), ...(stored.digests || []).filter((d) => !read(d.source))].slice(0, MAX_DIGESTS);
416
+ const listed = new Set((incoming.places || []).map((x) => x.source));
417
+ const places = [...(stored.places || []).filter((x) => !listed.has(x.source)), ...(incoming.places || [])];
418
+ return { ok: true, kept: keep.length, brief: { ...incoming, calendar, priorities: items.filter((x) => !x.worth_asking), worth_asking: items.filter((x) => x.worth_asking),
419
+ digests, places, resolved: [...gone].slice(-MAX_RESOLVED) } };
420
+ }
421
+
390
422
  module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, KINDS, DIGEST_SOURCES, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, MAX_DIGESTS, MAX_PLACES,
391
- isUrl, validateBrief, mergeBrief, greeting, localDate };
423
+ isUrl, validateBrief, mergeBrief, rereadBrief, greeting, localDate };
@@ -15,13 +15,17 @@
15
15
  * newRun(date, now) · readRun(raw, date)
16
16
  * addTasks(run, brief) agent priorities (kind approve|auto) → waiting tasks
17
17
  * toStart(run, limit) the tasks to start now, each with its mode (do | revise | send)
18
- * started · deliver · decide · sent · fail · sessionEnded
18
+ * started · deliver · decide · sent · fail · sessionEnded · sessionStalled
19
19
  * stage(run, brief) reading | working | review | done
20
20
  * checkDue(run, {settings, now, events}) · checkStarted · checkDone
21
21
  *
22
22
  * Status of a task: waiting → working → review (approve) | done (auto)
23
23
  * review --approve--> sending → sent review|done --revise--> waiting (with the note)
24
24
  * working|sending --fail or session ended--> failed --retry--> waiting
25
+ * working|sending --session quiet too long--> failed (stalled) --retry--> waiting, in a NEW session
26
+ *
27
+ * A stalled task keeps its session in `stalled_session`, so the PM can open it. When that session still delivers its
28
+ * draft (or reports sent) the task takes it, as if it had never stalled.
25
29
  */
26
30
 
27
31
  const MAX_PARALLEL = 5;
@@ -134,6 +138,7 @@ function started(run, taskId, sessionId, now) {
134
138
  /** The task session's ONE draft. An `auto` task is then done; an `approve` task waits for the PM. */
135
139
  function deliver(run, taskId, d, now) {
136
140
  return withTask(run, taskId, (t) => {
141
+ revive(t);
137
142
  if (t.status !== 'working') return `task ${taskId} is ${t.status} — only a working task can deliver a draft`;
138
143
  const b = d && typeof d === 'object' ? d : {};
139
144
  const summary = text(b.summary); const body = typeof b.body === 'string' ? b.body.trim() : '';
@@ -183,7 +188,9 @@ function decide(run, taskId, op, now) {
183
188
  return null;
184
189
  }
185
190
  if (t.status !== 'failed') return `task ${taskId} is ${t.status} — only a failed task can be run again`;
191
+ const stalled = !!t.stalled_session;
186
192
  t.status = 'waiting'; keepSession(t); t.error = null; t.decided = { action: 'retry', at };
193
+ if (stalled) { t.prev_session = null; delete t.stalled_session; delete t.stalled_status; }
187
194
  return null;
188
195
  });
189
196
  }
@@ -191,6 +198,7 @@ function decide(run, taskId, op, now) {
191
198
  /** The send session reports the approved step done, with the link to what it posted. */
192
199
  function sent(run, taskId, s, now) {
193
200
  return withTask(run, taskId, (t) => {
201
+ revive(t);
194
202
  if (t.status !== 'sending') return `task ${taskId} is ${t.status} — only an approved task being sent can be reported sent`;
195
203
  const link = s && s.link;
196
204
  if (link !== undefined && link !== null && link !== '' && !isUrl(link)) return 'the link must be an http(s) address of at most 500 characters';
@@ -226,6 +234,29 @@ function sessionEnded(run, sessionId, now) {
226
234
  return { ok: true, run: next, changed };
227
235
  }
228
236
 
237
+ /** A stalled task whose session reports after all goes back to the step it stalled in. */
238
+ function revive(t) {
239
+ if (t.status !== 'failed' || !t.stalled_session || !t.stalled_status) return;
240
+ t.status = t.stalled_status; t.session_id = t.stalled_session; t.error = null;
241
+ delete t.stalled_session; delete t.stalled_status; delete t.failed_at;
242
+ }
243
+
244
+ /**
245
+ * A task session is open but has printed nothing for a long time and delivered nothing: it waits on a question, or its
246
+ * agent stopped without reporting. The task fails with `why`, which frees its place, and a retry starts a NEW session.
247
+ */
248
+ function sessionStalled(run, sessionId, why, now) {
249
+ if (!run) return { ok: false, error: 'no day run today' };
250
+ const next = clone(run);
251
+ let changed = false;
252
+ for (const t of next.tasks) {
253
+ if (!sessionId || t.session_id !== sessionId || (t.status !== 'working' && t.status !== 'sending')) continue;
254
+ t.stalled_status = t.status; t.stalled_session = sessionId; t.session_id = null; t.prev_session = null;
255
+ t.status = 'failed'; t.error = text(why).slice(0, 240) || 'the session stopped without a draft'; t.failed_at = iso(now); changed = true;
256
+ }
257
+ return { ok: true, run: next, changed };
258
+ }
259
+
229
260
  /** Where the run is: reading (no brief for the day yet), working, review (drafts wait for the PM) or done. */
230
261
  function stage(run, brief) {
231
262
  if (!run) return null;
@@ -299,5 +330,5 @@ function checkDone(run, { found = 0, resolved = 0 } = {}, now) {
299
330
  }
300
331
 
301
332
  module.exports = { MAX_PARALLEL, MAX_REVISIONS, MAX_CHECK_LOG, MAX_INTERVAL_MIN, STATUSES, KINDS, ACTIONS, TASK_RE,
302
- newRun, readRun, findTask, modeOf, addTasks, toStart, started, deliver, decide, sent, fail, sessionEnded, stage, intervalMin,
333
+ newRun, readRun, findTask, modeOf, addTasks, toStart, started, deliver, decide, sent, fail, sessionEnded, sessionStalled, stage, intervalMin,
303
334
  checkDue, checkStarted, checkDone };
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+ /* kit-update.js — check for a newer create-pmos, and update the kit or the workspace app, from the page (workspace-kit-update).
3
+ *
4
+ * readKit(root) → { present:false } | { present:true, version, kernelSource, installed, mode }
5
+ * status({ root, appVersion, appDir }) → { app: { version, fromCheckout }, kit } local only, no network
6
+ * latest({ exec }) → Promise<{ ok, version } | { ok:false, error }> npm view create-pmos version
7
+ * compare(st, latestVersion) → { appBehind, kitBehind }
8
+ * updateKit(root, { version, exec }) → Promise<{ ok, output, kit } | { ok:false, error, output }>
9
+ * upgradeApp(root, { version, logFile, spawn }) → { ok, log } | { ok:false, error }
10
+ *
11
+ * The page does what the terminal does, and nothing more. Each action runs the command the README tells the PM to type:
12
+ * check npm view create-pmos version
13
+ * kit npx -y create-pmos@<version> --update <repo> (the kernel only; state/ and planning/ untouched)
14
+ * app npx -y create-pmos@<version> workspace --reinstall <repo> (what `pmos upgrade` runs)
15
+ * The version is the one the check found, so what is installed is what the PM was shown, not whatever `latest` is a
16
+ * minute later. The app upgrade stops this server (create-pmos runs `pmos stop`), so it runs DETACHED, writes its output
17
+ * to a log file, and the page waits for the server to come back.
18
+ *
19
+ * NETWORK. These are the workspace's only egress to the npm registry. No HTTP client is opened here: npm and npx open
20
+ * their own. Every call is reached only from a POST the page sends when the PM presses a button (server.js
21
+ * /api/kit/check, /api/kit/update, /api/kit/upgrade-app); no timer and no GET reaches them. no-network.test.js holds
22
+ * that.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const cp = require('child_process');
27
+
28
+ const PKG_NAME = 'create-pmos';
29
+ const IS_WIN = process.platform === 'win32';
30
+ const NPM = IS_WIN ? 'npm.cmd' : 'npm';
31
+ const NPX = IS_WIN ? 'npx.cmd' : 'npx';
32
+ const CHECK_TIMEOUT_MS = 30000;
33
+ const UPDATE_TIMEOUT_MS = 5 * 60000;
34
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
35
+ const OUTPUT_LINES = 20;
36
+
37
+ /** -1 when a < b, 0 when equal, 1 when a > b. Dotted numeric; a missing or non-numeric part counts as 0. */
38
+ function cmpVer(a, b) {
39
+ const pa = String(a || '0').split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
40
+ const pb = String(b || '0').split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
41
+ for (let i = 0; i < 3; i++) {
42
+ const d = (pa[i] || 0) - (pb[i] || 0);
43
+ if (d) return d < 0 ? -1 : 1;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ const validVersion = (v) => typeof v === 'string' && VERSION_RE.test(v);
49
+
50
+ /**
51
+ * The repository's vendored kit, from pmos/KIT-VERSION. build-kit.sh writes `kernel-source:` and create-pmos appends an
52
+ * `installed-via: create-pmos@<v>` block on each vendor and update, so the LAST installed-via is the version in use.
53
+ */
54
+ function readKit(root) {
55
+ let text;
56
+ try { text = fs.readFileSync(path.join(String(root || ''), 'pmos', 'KIT-VERSION'), 'utf8'); }
57
+ catch { return { present: false }; }
58
+ const last = (key) => {
59
+ let v = null;
60
+ for (const line of text.split(/\r?\n/)) {
61
+ const i = line.indexOf(':');
62
+ if (i > 0 && line.slice(0, i).trim() === key) v = line.slice(i + 1).trim();
63
+ }
64
+ return v;
65
+ };
66
+ const via = last('installed-via');
67
+ const m = via ? /create-pmos@(\S+)/.exec(via) : null;
68
+ return {
69
+ present: true,
70
+ version: m && validVersion(m[1]) ? m[1] : null,
71
+ kernelSource: last('kernel-source'),
72
+ installed: last('installed'),
73
+ mode: last('install-mode'),
74
+ };
75
+ }
76
+
77
+ /** True when the app runs from a PMOS checkout (workspace/ beside a .git): there `git pull` updates it, not npm. */
78
+ function fromCheckout(appDir) {
79
+ try { return fs.existsSync(path.join(String(appDir), '..', '.git')); } catch { return false; }
80
+ }
81
+
82
+ function status({ root, appVersion, appDir }) {
83
+ return { app: { version: appVersion || null, fromCheckout: fromCheckout(appDir) }, kit: readKit(root) };
84
+ }
85
+
86
+ function compare(st, latestVersion) {
87
+ if (!validVersion(latestVersion)) return { appBehind: false, kitBehind: false };
88
+ const app = st && st.app && st.app.version;
89
+ const kit = st && st.kit && st.kit.present ? st.kit.version : null;
90
+ return {
91
+ appBehind: !!app && cmpVer(app, latestVersion) < 0,
92
+ // A kit with no installed-via line (vendored by hand from a checkout) has no version to compare: offer the update.
93
+ kitBehind: !!(st && st.kit && st.kit.present) && (!kit || cmpVer(kit, latestVersion) < 0),
94
+ };
95
+ }
96
+
97
+ const tail = (s) => String(s || '').trim().split(/\r?\n/).slice(-OUTPUT_LINES).join('\n');
98
+
99
+ /** execFile as a promise that never rejects. */
100
+ function run(exec, cmd, args, opts) {
101
+ return new Promise((resolve) => {
102
+ exec(cmd, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, ...opts }, (err, stdout, stderr) => {
103
+ resolve({ err: err || null, stdout: String(stdout || ''), stderr: String(stderr || '') });
104
+ });
105
+ });
106
+ }
107
+
108
+ function why(r, what) {
109
+ if (!r.err) return null;
110
+ if (r.err.code === 'ENOENT') return `${what} could not run: the command was not found on this computer's PATH`;
111
+ if (r.err.killed) return `${what} took too long and was stopped`;
112
+ const said = tail(r.stderr).split('\n').filter(Boolean).pop();
113
+ return `${what} did not finish${said ? ': ' + said : ''}`;
114
+ }
115
+
116
+ async function latest({ exec = cp.execFile } = {}) {
117
+ const r = await run(exec, NPM, ['view', PKG_NAME, 'version', '--json'], { timeout: CHECK_TIMEOUT_MS });
118
+ const bad = why(r, 'The check');
119
+ if (bad) return { ok: false, error: bad };
120
+ let v = r.stdout.trim();
121
+ try { const j = JSON.parse(v); if (typeof j === 'string') v = j; } catch { /* plain text */ }
122
+ if (!validVersion(v)) return { ok: false, error: 'The check did not return a version number' };
123
+ return { ok: true, version: v };
124
+ }
125
+
126
+ async function updateKit(root, { version, exec = cp.execFile } = {}) {
127
+ if (!validVersion(version)) return { ok: false, error: 'Check for updates first' };
128
+ if (!readKit(root).present) return { ok: false, error: 'This repository has no PMOS kit folder (pmos/KIT-VERSION)' };
129
+ const r = await run(exec, NPX, ['-y', `${PKG_NAME}@${version}`, '--update', String(root)], { timeout: UPDATE_TIMEOUT_MS, cwd: String(root) });
130
+ const output = tail(r.stdout + (r.stderr ? '\n' + r.stderr : ''));
131
+ const bad = why(r, 'The kit update');
132
+ if (bad) return { ok: false, error: bad, output };
133
+ return { ok: true, output, kit: readKit(root) };
134
+ }
135
+
136
+ /**
137
+ * Start the app reinstall and return at once. The child is detached from this server, because create-pmos stops this
138
+ * server before it reinstalls, and it starts the new one after. Its output goes to `logFile`.
139
+ */
140
+ function upgradeApp(root, { version, logFile, spawn = cp.spawn } = {}) {
141
+ if (!validVersion(version)) return { ok: false, error: 'Check for updates first' };
142
+ let fd;
143
+ try {
144
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
145
+ fd = fs.openSync(logFile, 'w');
146
+ fs.writeSync(fd, `${new Date().toISOString()} ${NPX} -y ${PKG_NAME}@${version} workspace --reinstall ${root}\n`);
147
+ } catch (e) { return { ok: false, error: `Could not write the log file: ${e.message}` }; }
148
+ try {
149
+ const child = spawn(NPX, ['-y', `${PKG_NAME}@${version}`, 'workspace', '--reinstall', String(root)],
150
+ { cwd: String(root), detached: true, stdio: ['ignore', fd, fd], windowsHide: true, shell: IS_WIN });
151
+ if (child && typeof child.on === 'function') child.on('error', () => { /* reported in the log by npx, or the page times out */ });
152
+ if (child && typeof child.unref === 'function') child.unref();
153
+ return { ok: true, log: logFile };
154
+ } catch (e) {
155
+ return { ok: false, error: `The workspace update could not start: ${e.message}` };
156
+ } finally {
157
+ try { fs.closeSync(fd); } catch { /* the child holds its own copy */ }
158
+ }
159
+ }
160
+
161
+ module.exports = { PKG_NAME, cmpVer, validVersion, readKit, fromCheckout, status, compare, latest, updateKit, upgradeApp };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
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.58.0",
3
+ "version": "0.59.1",
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",
@@ -2245,7 +2245,7 @@
2245
2245
  margin: 12px 16px 0;
2246
2246
  color: var(--muted-foreground);
2247
2247
  }
2248
- /* Settings: one column of five sections. */
2248
+ /* Settings: one column of seven sections. */
2249
2249
  .fr-set {
2250
2250
  display: flex;
2251
2251
  flex-direction: column;
@@ -2299,6 +2299,22 @@
2299
2299
  .fr-set__add {
2300
2300
  padding: 12px 0;
2301
2301
  }
2302
+ .fr-set__error {
2303
+ color: var(--destructive);
2304
+ }
2305
+ .fr-set__output {
2306
+ margin: 0;
2307
+ max-height: 200px;
2308
+ overflow: auto;
2309
+ padding: 8px;
2310
+ border: 1px solid var(--border);
2311
+ background: var(--muted);
2312
+ color: var(--foreground);
2313
+ font-family: var(--mono);
2314
+ font-size: 11px;
2315
+ white-space: pre-wrap;
2316
+ word-break: break-word;
2317
+ }
2302
2318
  @media (max-width: 760px) {
2303
2319
  .fr-detail,
2304
2320
  .fr-dock {
@@ -1,6 +1,6 @@
1
1
  /* pm/dest-settings.js — the Settings destination, the frame's ⚙ (pm-os-absorb Run 3, slice 3A; PRD R9).
2
2
  *
3
- * Settings holds settings only, in six sections and in this order:
3
+ * Settings holds settings only, in seven sections and in this order:
4
4
  * Repositories every registered repository as a row (open it to make it current; ⋯ for more), and Add repository,
5
5
  * which draws the existing add-repository flow (view-addrepo.js) in place
6
6
  * Profiles one row that opens the Claude profiles in the detail
@@ -8,6 +8,10 @@
8
8
  * Validate consent the current repository's consent switch (GET and POST /api/validate/repo)
9
9
  * Online the Online switch and its five settings (GET and POST /api/online; workspace-online-mode, D89)
10
10
  * Theme Dark or Light
11
+ * Updates the workspace app's version and the current repository's kit version; Check for updates asks the
12
+ * npm registry for the latest create-pmos (POST /api/kit/check, the PM's press only), and Update the
13
+ * kit / Update the workspace run what `npx create-pmos@latest --update` and `pmos upgrade` run
14
+ * (workspace-kit-update). The workspace update restarts the server, so the page waits and reloads.
11
15
  * The work itself (what needs you, initiatives, items, reports) is never here.
12
16
  *
13
17
  * The mount contract (registry.js): mountDestination(host, ctx) is called on every render with the same host. The page
@@ -23,10 +27,13 @@ import { repoTuples, activeProduct, renameRepo, removeRepo } from './panel-repos
23
27
  import { repoRow } from './fixture.js';
24
28
  import { sessionDefaults, setSessionDefaults } from './dock.js';
25
29
  import { COPY } from './frame-copy.js';
30
+ import { askConfirm } from './dialogs.js';
26
31
 
27
32
  const P = COPY.page;
28
33
  const roots = new WeakMap(); // host -> { root, sig }
29
34
  const consentByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
35
+ const kitByWs = new Map(); // ws -> { st: GET /api/kit/status body | null, at, pending }
36
+ const upd = { phase: null, latest: null, compare: null, message: '', error: '', output: '' }; // the Updates section
30
37
  const online = { data: null, error: null, note: null, at: 0, pending: false }; // GET /api/online, and the last refusal
31
38
  let addOpen = false;
32
39
  let addMod = null;
@@ -76,7 +83,7 @@ const section = (id, title, kids, action) => el('section', { class: 'fr-set__sec
76
83
  el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, title), action || null),
77
84
  kids);
78
85
 
79
- /* ── the six sections ───────────────────────────────────────────────────────────────────────────────────────────── */
86
+ /* ── the seven sections ────────────────────────────────────────────────────────────────────────────────────────── */
80
87
 
81
88
  function reposSection(scope) {
82
89
  const tuples = repoTuples();
@@ -227,6 +234,100 @@ function themeSection() {
227
234
  })]);
228
235
  }
229
236
 
237
+ function readKitStatus(ws, force) {
238
+ if (!ws || FIXTURE) return;
239
+ const have = kitByWs.get(ws);
240
+ if (!force && have && (have.pending || Date.now() - have.at < 30000)) return;
241
+ const entry = { st: have ? have.st : null, at: Date.now(), pending: true };
242
+ kitByWs.set(ws, entry);
243
+ pmGet('/api/kit/status?ws=' + encodeURIComponent(ws)).then((j) => {
244
+ entry.pending = false; entry.at = Date.now();
245
+ if (j && j.ok !== false) entry.st = j;
246
+ setState({});
247
+ });
248
+ }
249
+
250
+ const say = (fields) => { Object.assign(upd, fields); setState({}); };
251
+
252
+ async function checkUpdates(ws) {
253
+ say({ phase: 'check', message: '', error: '', output: '' });
254
+ const j = await pmPost('/api/kit/check', { ws });
255
+ if (j && j.ok !== false) {
256
+ kitByWs.set(ws, { st: j, at: Date.now() });
257
+ say({ phase: null, latest: j.latest, compare: { appBehind: !!j.appBehind, kitBehind: !!j.kitBehind } });
258
+ } else say({ phase: null, error: (j && j.error) || P.updateFailed });
259
+ }
260
+
261
+ async function updateKit(ws) {
262
+ say({ phase: 'kit', message: '', error: '', output: '' });
263
+ const j = await pmPost('/api/kit/update', { ws, version: upd.latest });
264
+ if (j && j.ok !== false) {
265
+ kitByWs.set(ws, { st: j, at: Date.now() });
266
+ say({ phase: null, compare: { ...(upd.compare || {}), kitBehind: false }, message: P.updateKitDone((j.kit && j.kit.version) || upd.latest), output: j.output || '' });
267
+ } else say({ phase: null, error: (j && j.error) || P.updateFailed, output: (j && j.output) || '' });
268
+ }
269
+
270
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
271
+
272
+ /** Wait for the server to come back after the app update: reload on a new version, or say what happened. */
273
+ async function waitForRestart(from, log) {
274
+ let sawDown = false;
275
+ const until = Date.now() + 5 * 60000;
276
+ while (Date.now() < until) {
277
+ await pause(2000);
278
+ const j = await pmGet('/api/sessions');
279
+ if (!j || j.ok === false || !j.version) { sawDown = true; continue; }
280
+ if (j.version !== from) { location.reload(); return; }
281
+ if (sawDown) { say({ phase: null, error: P.updateAppSame(j.version, log) }); return; }
282
+ }
283
+ say({ phase: null, error: P.updateAppLost(log) });
284
+ }
285
+
286
+ async function updateApp(ws, from) {
287
+ const yes = await askConfirm({ title: P.updateAppTitle, body: P.updateAppBody(from || '?', upd.latest), confirmLabel: P.updateApp });
288
+ if (!yes) return;
289
+ say({ phase: 'app', message: P.updateAppBusy, error: '', output: '' });
290
+ const j = await pmPost('/api/kit/upgrade-app', { ws, version: upd.latest });
291
+ if (!j || j.ok === false) { say({ phase: null, message: '', error: (j && j.error) || P.updateFailed }); return; }
292
+ waitForRestart(j.from || from, j.log || '');
293
+ }
294
+
295
+ function updatesSection(scope) {
296
+ const repo = scope === 'all' ? null : activeProduct();
297
+ const ws = wsOfRepo(repo) || wsOfRepo(activeProduct());
298
+ readKitStatus(ws);
299
+ const st = (ws && kitByWs.get(ws) && kitByWs.get(ws).st) || null;
300
+ const app = st && st.app ? st.app : {};
301
+ const kit = st && st.kit ? st.kit : null;
302
+ const busy = !!upd.phase;
303
+ const cmp = upd.compare || {};
304
+ const kids = [];
305
+ const appLine = el('div', { class: 'fr-set__line' }, el('span', { 'data-fr': 'app-version' }, P.updatesApp(app.version)),
306
+ cmp.appBehind && !app.fromCheckout ? button({
307
+ label: P.updateApp, variant: 'primary', size: 'sm', disabled: busy, attrs: { 'data-fr': 'update-app' },
308
+ onClick: () => updateApp(ws, app.version),
309
+ }) : null);
310
+ kids.push(appLine);
311
+ if (cmp.appBehind && app.fromCheckout) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'app-checkout' }, P.updatesCheckout));
312
+ const kitText = scope === 'all' || !ws ? P.updatesNoRepo : !kit ? '' : kit.present ? P.updatesKit(kit.version) : P.updatesNoKit;
313
+ kids.push(el('div', { class: 'fr-set__line' }, el('span', { 'data-fr': 'kit-version' }, kitText),
314
+ cmp.kitBehind && kit && kit.present ? button({
315
+ label: P.updateKit, variant: 'primary', size: 'sm', disabled: busy, attrs: { 'data-fr': 'update-kit' },
316
+ onClick: () => updateKit(ws),
317
+ }) : null));
318
+ const line = upd.phase === 'check' ? P.updatesChecking : upd.phase === 'kit' ? P.updateKitBusy
319
+ : upd.message ? upd.message : upd.latest ? (cmp.appBehind || cmp.kitBehind ? P.updatesLatest(upd.latest) : P.updatesCurrent(upd.latest)) : P.updatesNever;
320
+ kids.push(el('p', { class: 'fr-set__note', role: 'status', 'aria-live': 'polite', 'data-fr': 'update-state' }, line));
321
+ if (upd.error) kids.push(el('p', { class: 'fr-set__note fr-set__error', role: 'alert', 'data-fr': 'update-error' }, upd.error));
322
+ if (upd.output) kids.push(el('pre', { class: 'fr-set__output', 'data-fr': 'update-output' }, upd.output));
323
+ kids.push(el('p', { class: 'fr-set__note' }, P.updatesNote));
324
+ const check = button({
325
+ label: P.updatesCheck, variant: 'outline', size: 'sm', icon: 'refresh-cw', disabled: busy || !ws,
326
+ attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws),
327
+ });
328
+ return section('updates', P.updates, kids, check);
329
+ }
330
+
230
331
  /* ── mount ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
231
332
 
232
333
  function signature(scope) {
@@ -234,7 +335,9 @@ function signature(scope) {
234
335
  const addKeys = addOpen ? Object.keys(state).filter((k) => /^add[A-Z]/.test(k)).sort().map((k) => [k, state[k]]) : null;
235
336
  let add = '';
236
337
  try { add = JSON.stringify(addKeys); } catch { add = String(Date.now()); }
237
- return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note]) + add;
338
+ const kit = ws ? kitByWs.get(ws) : null;
339
+ return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note,
340
+ kit ? kit.st : null, upd]) + add;
238
341
  }
239
342
 
240
343
  /** True while the PM is typing in the page or a menu of it is open: a rebuild then would take the focus away. */
@@ -262,6 +365,7 @@ export function mountDestination(host, ctx) {
262
365
  validateSection(scope),
263
366
  onlineSection(),
264
367
  themeSection(),
368
+ updatesSection(scope),
265
369
  ]);
266
370
  }
267
371
 
@@ -170,6 +170,28 @@ export const COPY = {
170
170
  onlineEnv: (host) => `Online at https://${host}/, set by PMOS_PUBLIC_HOST when the workspace started. It signs in with the token. Restart the workspace without PMOS_PUBLIC_HOST to use this switch.`,
171
171
  onlineRemote: 'Only the computer that runs the workspace can change this.',
172
172
  onlineUnread: 'The Online settings did not load.',
173
+ updates: 'Updates',
174
+ updatesCheck: 'Check for updates',
175
+ updatesChecking: 'Checking for updates…',
176
+ updatesApp: (v) => 'Workspace app: ' + (v || 'version not known'),
177
+ updatesKit: (v) => 'Kit in this repository: ' + (v || 'version not recorded'),
178
+ updatesNoKit: 'This repository has no PMOS kit folder (pmos/), so it has no kit to update.',
179
+ updatesNoRepo: 'Choose a repository to see its kit.',
180
+ updatesNever: 'Press Check for updates to compare with the latest published version.',
181
+ updatesLatest: (v) => 'Latest published version: ' + v + '.',
182
+ updatesCurrent: (v) => 'Up to date. The latest published version is ' + v + '.',
183
+ updatesCheckout: 'This workspace runs from a PMOS checkout. Update it with git pull.',
184
+ updatesNote: 'Updating the kit changes only its own files (skills, scripts, templates and guides). Your records in state/, planning/ and okf/product/ do not change.',
185
+ updateKit: 'Update the kit',
186
+ updateKitBusy: 'Updating the kit…',
187
+ updateKitDone: (v) => 'The kit is now ' + v + '. The changed files are under pmos/, and Sync sends them with your other records.',
188
+ updateApp: 'Update the workspace',
189
+ updateAppTitle: 'Update the workspace?',
190
+ updateAppBody: (from, to) => 'The workspace stops, installs ' + to + ' in place of ' + from + ', and starts again. This takes about a minute. Sessions keep running in tmux.\n\nThis page reloads when the new version starts.',
191
+ updateAppBusy: 'Updating the workspace. This page reloads when the new version starts.',
192
+ updateAppSame: (v, log) => 'The workspace started again on ' + v + ', so the update did not finish. The update log is ' + log + '.',
193
+ updateAppLost: (log) => 'The workspace did not come back on this address. Type pmos in a terminal to open it. The update log is ' + log + '.',
194
+ updateFailed: 'The update did not finish.',
173
195
  },
174
196
  };
175
197
 
@@ -553,7 +553,8 @@ function jobCard(ctx, j) {
553
553
  acts.push(extLink(D.openConversation, j.link, { 'data-day-link': 'conversation' }));
554
554
  if (t && t.draft) acts.push(extLink(D.openDraft, t.draft.link, { 'data-day-link': 'draft' }));
555
555
  if (t && t.sent) acts.push(extLink(D.openSent, t.sent.link, { 'data-day-link': 'sent' }));
556
- if (t && t.session_id) acts.push(button({ label: D.openSession, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'session' }, onClick: () => openSession({ id: t.session_id, name: 'day-task' }) }));
556
+ const sid = t && (t.session_id || t.stalled_session);
557
+ if (sid) acts.push(button({ label: D.openSession, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'session' }, onClick: () => openSession({ id: sid, name: 'day-task' }) }));
557
558
  let form = null;
558
559
  if (t && ui.revising.has(t.id) && canRevise) {
559
560
  const f = field({ label: D.noteLabel, kind: 'textarea', value: ui.notes.get(t.id) || '', placeholder: D.noteHint, hideLabel: true, attrs: { maxlength: '500', 'data-day-input': 'note-' + t.id } });
@@ -112,6 +112,7 @@ const modelLib = require('./lib/models'); // tier routing — one
112
112
  const memoryLib = require('./lib/memory'); // curated repository memory — one ledger code path, shared with the agents' CLI (D72)
113
113
  const audit = require('./lib/audit'); // the workflow audit — the sweep the agents' CLI runs, read-only here (D77)
114
114
  const syncLib = require('./lib/sync'); // press-only fetch / integrate / commit records / push (D70)
115
+ const kitUpdate = require('./lib/kit-update.js'); // workspace-kit-update: press-only check and update of the kit and the app
115
116
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
116
117
  const startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
117
118
  const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
@@ -1868,6 +1869,7 @@ function readLedger(w, basename) {
1868
1869
  // D70 — the lifecycle ledger: the latest line for an initiative wins; `active` reopens. The same rule
1869
1870
  // as scripts/workflow-state.js#readLifecycle (a parity case in workspace/test.js keeps them in step).
1870
1871
  const syncing = new Set(); // repositories with a sync in flight — one at a time
1872
+ let kitBusy = null; // workspace-kit-update: the one kit or app update in flight ('kit' | 'app'), or null
1871
1873
  // D70 as amended 2026-10-06: the same Sync, run without the press at startup and when work sits unpushed and quiet.
1872
1874
  // Every rule it must pass is in lib/auto-sync.js; a scratch server (its own PMOS_STATE_DIR) does not auto-sync.
1873
1875
  const autoSync = require('./lib/auto-sync').createAutoSync({
@@ -3705,7 +3707,8 @@ function readDayRun(date) {
3705
3707
  return run;
3706
3708
  }
3707
3709
  function writeDayRun(run) {
3708
- try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); fs.writeFileSync(dayRunFile(run.date), JSON.stringify(run) + '\n'); return true; }
3710
+ // Written to a side file and renamed into place, so a reader (the page, the CLI, a suite) never sees a half-written run.
3711
+ try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); const f = dayRunFile(run.date); const tmp = `${f}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(run) + '\n'); fs.renameSync(tmp, f); return true; }
3709
3712
  catch (e) { warnWriteFail('day run (goals/<date>.run.json)', e); return false; }
3710
3713
  }
3711
3714
  function briefOfDay(date) {
@@ -3724,6 +3727,20 @@ function briefSessionDone(run, date, now) {
3724
3727
  if (now - Math.max(s.lastOutputAt || 0, Date.parse(bs.started_at || 0) || 0) >= BRIEF_IDLE_MS) return 'idle';
3725
3728
  return null;
3726
3729
  }
3730
+ // A day-task session that is open but has printed nothing for TASK_IDLE_MS, without a draft, waits on a question or
3731
+ // stopped without reporting. Its task fails with that reason, so its place frees and Retry or Prepare again starts a new
3732
+ // session. A working agent redraws its spinner many times a second, so a quiet of minutes is not work in progress.
3733
+ const TASK_IDLE_MS = Number(process.env.PMOS_DAY_TASK_IDLE_MS || 5 * 60 * 1000);
3734
+ const TASK_STALLED_WHY = 'the session has printed nothing for five minutes and has not delivered a draft. Open the session to see what it waits on, or run the job again.';
3735
+ function stallQuietTasks(run, now) {
3736
+ for (const t of run.tasks) {
3737
+ if ((t.status !== 'working' && t.status !== 'sending') || !t.session_id) continue;
3738
+ const s = sessions.get(t.session_id);
3739
+ if (!s || s.state === 'exited') continue;
3740
+ if (now - Math.max(s.lastOutputAt || 0, Date.parse(t.started_at || 0) || 0) >= TASK_IDLE_MS) run = dayRunLib.sessionStalled(run, t.session_id, TASK_STALLED_WHY, now).run;
3741
+ }
3742
+ return run;
3743
+ }
3727
3744
  // The skill's own body is the prompt, as fireSchedule hands it over; the line after it names this run's task and mode.
3728
3745
  function daySkillPrompt(run, skillId) {
3729
3746
  const w = getWs(run.ws);
@@ -3731,6 +3748,13 @@ function daySkillPrompt(run, skillId) {
3731
3748
  if (!task || task.kind !== 'agent' || !task.prompt) return { error: `the ${skillId} skill is not installed for this repository` };
3732
3749
  return { w, task, prompt: skillStore.retargetFiling(task.prompt, filingContract(w)) };
3733
3750
  }
3751
+ /** A short session name for a job, so the session list stays readable: "Job · " and the first words of its title. */
3752
+ function jobSessionName(title) {
3753
+ const t = String(title || '').replace(/\s+/g, ' ').trim();
3754
+ if (t.length <= 28) return 'Job · ' + (t || 'untitled');
3755
+ const cut = t.slice(0, 28); const sp = cut.lastIndexOf(' ');
3756
+ return 'Job · ' + (sp > 12 ? cut.slice(0, sp) : cut).replace(/[\s,.;:·—-]+$/, '') + '…';
3757
+ }
3734
3758
  /** Start one task session; a start that fails marks the task failed with the reason. Returns the new run. */
3735
3759
  function startDayTask(run, t, now) {
3736
3760
  const sk = daySkillPrompt(run, 'day-task');
@@ -3744,7 +3768,7 @@ function startDayTask(run, t, now) {
3744
3768
  }
3745
3769
  if (!sk.error && !s) {
3746
3770
  const prompt = `${sk.prompt}\n\n${line}`;
3747
- s = spawnSession({ name: `${t.title} · day task`, agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-task', initialInput: prompt + '\r',
3771
+ s = spawnSession({ name: jobSessionName(t.title), agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-task', initialInput: prompt + '\r',
3748
3772
  modelAsk: { scheduleTier: null, skillTier: sk.task.tier || null, skill: sk.task.skill } });
3749
3773
  }
3750
3774
  const live = s && s.state !== 'exited';
@@ -3795,6 +3819,7 @@ function dayTick() {
3795
3819
  const ids = new Set([...run.tasks.filter((t) => (t.status === 'working' || t.status === 'sending') && t.session_id).map((t) => t.session_id),
3796
3820
  run.brief_session && !run.brief_session.ended_at ? run.brief_session.id : null, run.checks.session_id].filter(Boolean));
3797
3821
  for (const id of ids) if (!daySessionAlive(id)) run = dayRunLib.sessionEnded(run, id, now).run;
3822
+ run = stallQuietTasks(run, now);
3798
3823
  const brief = briefOfDay(date);
3799
3824
  const a = dayRunLib.addTasks(run, brief);
3800
3825
  if (a.ok) run = a.run;
@@ -6922,6 +6947,50 @@ async function handleRequest(req, res) {
6922
6947
  return json(res, { ws: w.id, name: w.name, ...out });
6923
6948
  } finally { syncing.delete(w.root); }
6924
6949
  }
6950
+ /* ── workspace-kit-update: Settings › Updates. GET reads versions from disk only. Each POST is the PM's press, and is
6951
+ * the only way the workspace reaches the npm registry (lib/kit-update.js; no-network.test.js holds that). ── */
6952
+ if (p === '/api/kit/status' && req.method === 'GET') {
6953
+ const w = getWs(url.searchParams.get('ws'));
6954
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6955
+ return json(res, { ok: true, ws: w.id, name: w.name, busy: kitBusy, ...kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname }) });
6956
+ }
6957
+ if ((p === '/api/kit/check' || p === '/api/kit/update' || p === '/api/kit/upgrade-app') && req.method === 'POST') {
6958
+ let body = {};
6959
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6960
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6961
+ const g = pmGuard.requirePagePress(req, body);
6962
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
6963
+ // A named workspace only: getWs falls back to the home repository, and an update must never land on a repository
6964
+ // the page did not show.
6965
+ const w = body.ws ? workspaces.get(String(body.ws)) : null;
6966
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
6967
+ const st = () => kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname });
6968
+ if (p === '/api/kit/check') {
6969
+ const r = await kitUpdate.latest();
6970
+ const now = st();
6971
+ if (!r.ok) return json(res, { ok: false, error: r.error, ws: w.id, ...now }, 502);
6972
+ return json(res, { ok: true, ws: w.id, ...now, latest: r.version, ...kitUpdate.compare(now, r.version) });
6973
+ }
6974
+ if (kitBusy) return json(res, { ok: false, error: kitBusy === 'app' ? 'The workspace is already updating' : 'A kit update is already running' }, 409);
6975
+ if (p === '/api/kit/update') {
6976
+ kitBusy = 'kit';
6977
+ try {
6978
+ const r = await kitUpdate.updateKit(w.root, { version: body.version });
6979
+ boardCache.invalidate(w.root);
6980
+ return json(res, { ws: w.id, ...r, ...st() }, r.ok ? 200 : 502);
6981
+ } finally { kitBusy = null; }
6982
+ }
6983
+ // The app update stops this server and starts a new one. It is refused from a phone: the new server starts on
6984
+ // loopback only, so the phone that asked would lose the page. It is refused from a checkout, which git updates.
6985
+ if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, BIND.publicHost)) {
6986
+ return json(res, { ok: false, error: 'Update the workspace from the computer it runs on' }, 403);
6987
+ }
6988
+ if (st().app.fromCheckout) return json(res, { ok: false, error: 'This workspace runs from a PMOS checkout. Update it with git pull' }, 409);
6989
+ const r = kitUpdate.upgradeApp(w.root, { version: body.version, logFile: path.join(STATE, 'app-update.log') });
6990
+ if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
6991
+ kitBusy = 'app';
6992
+ return json(res, { ok: true, ws: w.id, from: APP_VERSION, to: body.version, log: r.log });
6993
+ }
6925
6994
  /* ── next-step actions: prepare is read-only; go starts exactly what was shown; review records the PM's word ── */
6926
6995
  if (p === '/api/next-step/prepare' && req.method === 'POST') {
6927
6996
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
@@ -7436,6 +7505,15 @@ async function handleRequest(req, res) {
7436
7505
  const open = readGoalsDay(pdate).goals.filter((g) => g.status === 'open');
7437
7506
  let run = readDayRun(pdate) || dayRunLib.newRun(pdate, pnow);
7438
7507
  run.ws = w.id;
7508
+ // Prepare again also runs again every job whose session went quiet, each in a new session: the PM pressed it to
7509
+ // get the day moving, and a job stuck on a silent session would otherwise hold the day at "working".
7510
+ if (run.tasks.length) {
7511
+ run = stallQuietTasks(run, pnow);
7512
+ for (const t of run.tasks.filter((x) => x.status === 'failed' && x.stalled_session)) {
7513
+ const d = dayRunLib.decide(run, t.id, { action: 'retry' }, pnow); if (d.ok) run = d.run;
7514
+ }
7515
+ run = startDayTasks(run, pnow);
7516
+ }
7439
7517
  if (run.brief_session && !run.brief_session.ended_at) {
7440
7518
  // A session that finished its read (or sits idle) is used again: the skill's prompt is typed into it and run.
7441
7519
  const done = daySessionAlive(run.brief_session.id) ? briefSessionDone(run, pdate, pnow) : null;
@@ -7606,7 +7684,7 @@ async function handleRequest(req, res) {
7606
7684
  }
7607
7685
  if (p === '/api/proactive/brief') {
7608
7686
  // The brief is submitted by a scheduled skill in an agent session and only CHECKED and stored here. One file per day;
7609
- // a second submission for the same day replaces the first. Nothing is sent, started or changed.
7687
+ // a second submission for the same day replaces what it read, and keeps what came from a source it could not read. Nothing is sent, started or changed.
7610
7688
  const dir = path.join(STATE, 'briefs');
7611
7689
  if (req.method === 'POST') {
7612
7690
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
@@ -7615,8 +7693,10 @@ async function handleRequest(req, res) {
7615
7693
  if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
7616
7694
  // `merge: true` (a daytime check, G22) merges into the stored brief of that day; with none stored it is stored as it is.
7617
7695
  let out = { ...v.brief, resolved: [] }; let merged = null;
7696
+ let stored = null; try { stored = JSON.parse(fs.readFileSync(path.join(dir, v.brief.date + '.json'), 'utf8')); } catch { /* none yet */ }
7697
+ // A second full read of the day (Prepare again) keeps what came from a source it could not read this time.
7698
+ if (body.merge !== true && stored) { const rr = briefLib.rereadBrief(stored, v.brief); if (rr.ok) out = rr.brief; }
7618
7699
  if (body.merge === true) {
7619
- let stored = null; try { stored = JSON.parse(fs.readFileSync(path.join(dir, v.brief.date + '.json'), 'utf8')); } catch { /* none yet */ }
7620
7700
  const m = briefLib.mergeBrief(stored, v.brief, { now: Date.now() });
7621
7701
  if (!m.ok) return json(res, { ok: false, error: m.error }, 400);
7622
7702
  out = m.brief; merged = { found: m.added, resolved: m.removed };
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: day-check
3
3
  label: "Day check"
4
- description: "The daytime check of today's run: read only what changed since the last check, and merge new items, resolved items, meeting notes and updated digests into the stored brief. Read-only on every source; it sends nothing."
4
+ description: "The daytime check of today's run: read only what changed since the last check, check the open items once, and merge new, changed and resolved items, meeting notes and updated digests into the stored brief. Read-only on every source; it sends nothing."
5
5
  run: agent
6
6
  agent: claude
7
7
  output:
@@ -14,7 +14,7 @@ output:
14
14
 
15
15
  Rules that always hold:
16
16
  1. READ-ONLY on every source. Never send, reply, forward, post, react, comment, create, edit, accept, decline, transition, label, archive, mark read or delete anything in mail, Slack, Jira, Confluence, GitHub, Drive, Figma, a website, calendar, Wispr Flow or any other source.
17
- 2. ONLY WHAT CHANGED. Do not read the day again. Read only what is new since the last check. The time of the last check is in `pmos proactive day`.
17
+ 2. ONLY WHAT CHANGED. Do not read the day again. Read only what is new since the last check, and look once at each open item of the stored brief. The time of the last check is in `pmos proactive day`.
18
18
  3. SETTINGS DECIDE THE SOURCES AND THE SCOPES, as in the morning brief. Read a source only if it is on. In Slack, Jira and Confluence, read a place with scope watch since the last check, look in a place with scope search only for mentions of the PM and the words of the goals, and do not touch a place with scope ignore.
19
19
  4. LIGHT ON CONTENT. Never copy a message body, an attachment, a token or a credential. Paraphrase in the limits of each field. Skip anything marked private, personal, confidential or HR.
20
20
  5. NOTHING INVENTED. Add an item only for something you read. A check that finds nothing is a correct check.
@@ -28,10 +28,12 @@ Procedure:
28
28
  - Mentions of the PM since the last check, in Slack, Jira and Confluence, in every place that is not ignored. Keep where, who, the link to the conversation itself and a short paraphrase of what is asked. To tie a Jira issue to an initiative, run `pmos jira which <KEY>` (it reads local files only) and use the initiative it names. A status change of a ticket that `pmos jira which` names is not an item: the Jira status check shows it on the initiative's Jira tickets tab.
29
29
  - Places with scope watch: read every message or comment since the last check. Update the digest of the place (decisions, open questions, what touches a goal). A digest you submit replaces the stored digest of that place, so carry over what still holds.
30
30
  - GitHub, Drive, Figma and websites, if on: only what changed since the last check and matters to a goal or a meeting.
31
+ - The open items of the stored brief: look once at each item that has a link, in a source that is on. Note an item that is now done or no longer true, and an item whose facts changed: a new ask, a new due date, a new owner, or an answer that changes what the PM must do.
31
32
  - Wispr Flow: notes for meetings that ended since the last check. Read the summary, the decisions and the actions. Read the related mail for the same meeting only if mail is on, and keep the subject, the sender and a gist.
32
33
  3. Decide what the brief needs.
33
34
  - New items: a thing the PM should act on that is not in the stored brief. Fill the fields as the morning-brief skill does: link, where, who, about, ask, reply_needed, how and kind (you, approve or auto, with the same meaning). Set "mention": true on an item that comes from a mention. Only give an item a source that is "read".
34
35
  - Resolved items: an item in the stored brief that is now done, answered or no longer true, for example a thread the PM answered, an issue that was closed, a meeting that was cancelled. List its id in "resolved". Never resolve an item only because it is old.
36
+ - Changed items: an open item whose facts changed. Submit it again with the SAME "title" and "source" as in the stored brief, because the workspace derives its id from them, and with the fields updated. The workspace replaces the stored item. Do not submit an item that did not change.
35
37
  - Event notes: for a meeting that ended, add "notes": {"wispr": {"link", "summary", "decisions", "actions"}, "mail": {"subject", "from", "gist", "link"}} to that event, and mark the "after" steps that are now done with "done": true. Give the event by the same "start", "end" and "title" it has in the stored brief, because the workspace derives its id from them.
36
38
  - Updated digests: only for the places you read.
37
39
  4. Write one JSON file in the scratch folder of this session, in the shape of the morning brief, with only what is new or changed, and a list of resolved ids:
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "workspace-kit-update: Settings › Updates — the check and the kit and app updates (lib/kit-update.js, the /api/kit routes).",
3
+ "modules": ["kit-update.test.js"],
4
+ "seconds": { "kit-update.test.js": 6 }
5
+ }