create-pmos 0.58.0 → 0.59.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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/layout.md +4 -1
- package/workspace/lib/kit-update.js +161 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +17 -1
- package/workspace/public/pm/dest-settings.js +107 -3
- package/workspace/public/pm/frame-copy.js +22 -0
- package/workspace/server.js +46 -0
- package/workspace/test-lists/workspace-kit-update.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T13:15:52Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T13:15:52Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T13:15:52Z"}
|
|
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
|
|
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
|
|
|
@@ -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 };
|
package/workspace/package.json
CHANGED
|
@@ -2245,7 +2245,7 @@
|
|
|
2245
2245
|
margin: 12px 16px 0;
|
|
2246
2246
|
color: var(--muted-foreground);
|
|
2247
2247
|
}
|
|
2248
|
-
/* Settings: one column of
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/workspace/server.js
CHANGED
|
@@ -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({
|
|
@@ -6922,6 +6924,50 @@ async function handleRequest(req, res) {
|
|
|
6922
6924
|
return json(res, { ws: w.id, name: w.name, ...out });
|
|
6923
6925
|
} finally { syncing.delete(w.root); }
|
|
6924
6926
|
}
|
|
6927
|
+
/* ── workspace-kit-update: Settings › Updates. GET reads versions from disk only. Each POST is the PM's press, and is
|
|
6928
|
+
* the only way the workspace reaches the npm registry (lib/kit-update.js; no-network.test.js holds that). ── */
|
|
6929
|
+
if (p === '/api/kit/status' && req.method === 'GET') {
|
|
6930
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6931
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6932
|
+
return json(res, { ok: true, ws: w.id, name: w.name, busy: kitBusy, ...kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname }) });
|
|
6933
|
+
}
|
|
6934
|
+
if ((p === '/api/kit/check' || p === '/api/kit/update' || p === '/api/kit/upgrade-app') && req.method === 'POST') {
|
|
6935
|
+
let body = {};
|
|
6936
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6937
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6938
|
+
const g = pmGuard.requirePagePress(req, body);
|
|
6939
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6940
|
+
// A named workspace only: getWs falls back to the home repository, and an update must never land on a repository
|
|
6941
|
+
// the page did not show.
|
|
6942
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : null;
|
|
6943
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
|
|
6944
|
+
const st = () => kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname });
|
|
6945
|
+
if (p === '/api/kit/check') {
|
|
6946
|
+
const r = await kitUpdate.latest();
|
|
6947
|
+
const now = st();
|
|
6948
|
+
if (!r.ok) return json(res, { ok: false, error: r.error, ws: w.id, ...now }, 502);
|
|
6949
|
+
return json(res, { ok: true, ws: w.id, ...now, latest: r.version, ...kitUpdate.compare(now, r.version) });
|
|
6950
|
+
}
|
|
6951
|
+
if (kitBusy) return json(res, { ok: false, error: kitBusy === 'app' ? 'The workspace is already updating' : 'A kit update is already running' }, 409);
|
|
6952
|
+
if (p === '/api/kit/update') {
|
|
6953
|
+
kitBusy = 'kit';
|
|
6954
|
+
try {
|
|
6955
|
+
const r = await kitUpdate.updateKit(w.root, { version: body.version });
|
|
6956
|
+
boardCache.invalidate(w.root);
|
|
6957
|
+
return json(res, { ws: w.id, ...r, ...st() }, r.ok ? 200 : 502);
|
|
6958
|
+
} finally { kitBusy = null; }
|
|
6959
|
+
}
|
|
6960
|
+
// The app update stops this server and starts a new one. It is refused from a phone: the new server starts on
|
|
6961
|
+
// loopback only, so the phone that asked would lose the page. It is refused from a checkout, which git updates.
|
|
6962
|
+
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, BIND.publicHost)) {
|
|
6963
|
+
return json(res, { ok: false, error: 'Update the workspace from the computer it runs on' }, 403);
|
|
6964
|
+
}
|
|
6965
|
+
if (st().app.fromCheckout) return json(res, { ok: false, error: 'This workspace runs from a PMOS checkout. Update it with git pull' }, 409);
|
|
6966
|
+
const r = kitUpdate.upgradeApp(w.root, { version: body.version, logFile: path.join(STATE, 'app-update.log') });
|
|
6967
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
|
|
6968
|
+
kitBusy = 'app';
|
|
6969
|
+
return json(res, { ok: true, ws: w.id, from: APP_VERSION, to: body.version, log: r.log });
|
|
6970
|
+
}
|
|
6925
6971
|
/* ── next-step actions: prepare is read-only; go starts exactly what was shown; review records the PM's word ── */
|
|
6926
6972
|
if (p === '/api/next-step/prepare' && req.method === 'POST') {
|
|
6927
6973
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|