create-pmos 0.57.1 → 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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/index.md +1 -0
  7. package/workspace/guide/jira.md +141 -0
  8. package/workspace/guide/layout.md +4 -1
  9. package/workspace/lib/jira-cli.js +194 -0
  10. package/workspace/lib/jira-status.js +239 -0
  11. package/workspace/lib/jira-tickets.js +668 -0
  12. package/workspace/lib/kit-update.js +161 -0
  13. package/workspace/lib/pm-guard.js +2 -0
  14. package/workspace/lib/pmos-verbs.js +3 -0
  15. package/workspace/lib/report-draft.js +19 -0
  16. package/workspace/package-lock.json +1 -1
  17. package/workspace/package.json +1 -1
  18. package/workspace/public/pm/components.css +223 -1
  19. package/workspace/public/pm/dest-settings.js +107 -3
  20. package/workspace/public/pm/frame-copy.js +22 -0
  21. package/workspace/public/pm/initiative-sheet.js +7 -3
  22. package/workspace/public/pm/initiatives-copy.js +1 -0
  23. package/workspace/public/pm/sheet-jira.js +353 -0
  24. package/workspace/server.js +115 -0
  25. package/workspace/skills/day-check.skill +1 -1
  26. package/workspace/skills/gather-requirements.skill +1 -1
  27. package/workspace/skills/jira-push.skill +38 -0
  28. package/workspace/skills/jira-sync.skill +42 -0
  29. package/workspace/skills/morning-brief.skill +1 -1
  30. package/workspace/skills/report-biweekly.skill +1 -1
  31. package/workspace/skills/tracker-refresh.skill +1 -0
  32. package/workspace/test-lists/jira-wbs.json +6 -0
  33. package/workspace/test-lists/workspace-kit-update.json +5 -0
@@ -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 };
@@ -54,6 +54,7 @@ const PM_ONLY_ACTS = Object.freeze([
54
54
  'bind', // bind a session to an initiative (/api/session/initiative)
55
55
  'issue', // Issue a report (issues.jsonl)
56
56
  'release', // the D65 Release of a factory batch (one grant per batch)
57
+ 'push', // select Jira tickets for a push (D88: /api/jira/push, pmos jira approve)
57
58
  ]);
58
59
  /** What each act is, as the refusal says it: "only the PM can <phrase>". */
59
60
  const ACT_PHRASE = Object.freeze({
@@ -66,6 +67,7 @@ const ACT_PHRASE = Object.freeze({
66
67
  bind: 'bind a session to an initiative',
67
68
  issue: 'issue a report',
68
69
  release: 'release a batch',
70
+ push: 'select tickets to push to Jira',
69
71
  });
70
72
  /** Fields only a writer sets. A body that carries one is refused, even with the value the writer would choose. */
71
73
  const WRITER_FIELDS = Object.freeze(['v', 'eid', 'by', 'via', 'at', 'override', 'pm', 'actor', 'nonce', 'grant']);
@@ -34,6 +34,9 @@ const VERBS = Object.freeze({
34
34
  help: 'pmos verify-work — mark work items integrated from the default branch (local refs only)' }),
35
35
  report: Object.freeze({ module: 'report-cli.js', run: 5, guide: 'reports.md',
36
36
  help: 'pmos report draft|issue|list … — draft and issue reports (pmos report help)' }),
37
+ // Not a pm-os-absorb verb (run: null): initiative jira-wbs added it through the same table.
38
+ jira: Object.freeze({ module: 'jira-cli.js', run: null, guide: 'jira.md',
39
+ help: 'pmos jira draft|show|epic|approve|pending|record|query|sync|which … — Jira tickets drafted from the PRD, and their status (pmos jira help)' }),
37
40
  // Not a pm-os-absorb verb (run: null): the workspace-launcher initiative added it through the same table.
38
41
  // Not a pm-os-absorb verb (run: null): docs-three-way-sync added it through the same table.
39
42
  docs: Object.freeze({ module: 'docs-cli.js', run: null, guide: 'docs.md',
@@ -431,12 +431,31 @@ function draftOf(root, { cadence, slot, now, health } = {}) {
431
431
  const pmosWritten = (ref) => /\.jsonl$/.test(ref) || /(^|\/)(runs|reports)\//.test(ref);
432
432
  const isEvidence = (h) => h.ev === 'moved' && h.evidence && h.evidence.kind === 'file' && isStr(h.evidence.ref) && !pmosWritten(h.evidence.ref);
433
433
  const trackerItems = fold.order.map((x) => fold.items[x]).filter((it) => it && work.has(it.initiative) && isStr(it.owner) && it.owner.startsWith('tracker:'));
434
+ /* The Jira status check (initiative jira-wbs, J14) writes a snapshot per initiative each time an agent read Jira. Its
435
+ * timestamp is a read of the tracker on its own, with no plan item: the check writes none (D88). */
436
+ const jiraReads = (() => {
437
+ const dir = path.join(path.dirname(L.initiatives), 'tracker', 'jira');
438
+ let names = [];
439
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md') && work.has(n.slice(0, -3))); } catch { return []; }
440
+ const out = [];
441
+ for (const n of names) {
442
+ let fm = null;
443
+ try { fm = E.parseLeadingFrontmatter(fs.readFileSync(path.join(dir, n), 'utf8')); } catch { fm = null; }
444
+ if (fm && isStr(fm.timestamp)) out.push(fm.timestamp);
445
+ }
446
+ return out.sort();
447
+ })();
434
448
  if (trackerItems.length) {
435
449
  const seen = trackerItems.flatMap((it) => it.history.filter(isEvidence).map((h) => String(h.at))).sort();
436
450
  const lastSeen = seen.length ? seen.pop() : null;
437
451
  if (lastSeen && inWin(lastSeen)) reached.push('tracker (file evidence on tracker-owned moves in plan-items)');
438
452
  else notReached.push({ source: 'tracker', why: lastSeen ? `not refreshed since ${short(lastSeen)}` : 'no refresh with file evidence on record' });
439
453
  }
454
+ if (jiraReads.length) {
455
+ const lastRead = jiraReads[jiraReads.length - 1];
456
+ if (inWin(lastRead)) reached.push(`Jira (status check ${short(lastRead)})`);
457
+ else notReached.push({ source: 'Jira', why: `not checked since ${short(lastRead)}` });
458
+ }
440
459
  const fetched = lastFetch(abs);
441
460
  if (fetched) reached.push(`remote refs (last fetch ${short(fetched)}; not fetched for this draft)`);
442
461
  else notReached.push({ source: 'remote refs', why: 'never fetched; this draft reads local refs only' });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1853,6 +1853,212 @@
1853
1853
  }
1854
1854
 
1855
1855
 
1856
+ /* the initiative sheet's Jira tickets tab (initiative jira-wbs, sheet-jira.js) */
1857
+ .in-jira {
1858
+ display: flex;
1859
+ flex-direction: column;
1860
+ gap: 12px;
1861
+ min-width: 0;
1862
+ }
1863
+ .in-jira-head {
1864
+ display: flex;
1865
+ flex-wrap: wrap;
1866
+ align-items: flex-end;
1867
+ justify-content: space-between;
1868
+ gap: 8px 16px;
1869
+ }
1870
+ .in-jira-epic {
1871
+ display: flex;
1872
+ flex-wrap: wrap;
1873
+ align-items: flex-end;
1874
+ gap: 8px;
1875
+ min-width: 0;
1876
+ }
1877
+ .in-jira-epic__text,
1878
+ .in-jira-count {
1879
+ font-size: 14px;
1880
+ font-weight: 600;
1881
+ }
1882
+ .in-jira-status-line {
1883
+ display: flex;
1884
+ flex-wrap: wrap;
1885
+ align-items: center;
1886
+ gap: 4px 12px;
1887
+ }
1888
+ .in-jira-when {
1889
+ font-size: 12px;
1890
+ color: var(--muted-foreground);
1891
+ }
1892
+ .in-jira-note {
1893
+ display: flex;
1894
+ flex-wrap: wrap;
1895
+ align-items: center;
1896
+ gap: 8px 12px;
1897
+ padding: 8px 12px;
1898
+ border: 1px solid var(--border);
1899
+ }
1900
+ .in-jira-note__text {
1901
+ flex: 1 1 200px;
1902
+ font-size: 14px;
1903
+ }
1904
+ .in-jira-list {
1905
+ display: flex;
1906
+ flex-direction: column;
1907
+ border-top: 1px solid var(--border);
1908
+ }
1909
+ .in-jira-pick-gap {
1910
+ display: inline-block;
1911
+ width: 16px;
1912
+ }
1913
+ .in-jira-where,
1914
+ .in-jira-sub {
1915
+ font-size: 12px;
1916
+ color: var(--muted-foreground);
1917
+ white-space: nowrap;
1918
+ }
1919
+ .in-jira-actions {
1920
+ display: flex;
1921
+ flex-wrap: wrap;
1922
+ align-items: center;
1923
+ gap: 8px;
1924
+ }
1925
+ .in-jira-detail-top {
1926
+ display: flex;
1927
+ flex-wrap: wrap;
1928
+ align-items: center;
1929
+ gap: 8px;
1930
+ }
1931
+ .in-jira-detail-top__gap {
1932
+ flex: 1 1 auto;
1933
+ }
1934
+ .in-jira-detail {
1935
+ display: flex;
1936
+ flex-direction: column;
1937
+ gap: 12px;
1938
+ min-width: 0;
1939
+ }
1940
+ .in-jira-detail__title {
1941
+ margin: 0;
1942
+ font-size: 20px;
1943
+ line-height: 28px;
1944
+ font-weight: 600;
1945
+ overflow-wrap: anywhere;
1946
+ }
1947
+ .in-jira-detail__meta {
1948
+ font-size: 12px;
1949
+ color: var(--muted-foreground);
1950
+ }
1951
+ .in-jira-warn {
1952
+ margin: 0;
1953
+ padding: 8px 12px;
1954
+ border: 1px solid var(--warning);
1955
+ font-size: 14px;
1956
+ }
1957
+ .in-jira-fields {
1958
+ display: grid;
1959
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1960
+ gap: 8px 12px;
1961
+ }
1962
+ .in-jira-box {
1963
+ display: flex;
1964
+ flex-wrap: wrap;
1965
+ align-items: center;
1966
+ gap: 4px 16px;
1967
+ padding: 10px 12px;
1968
+ border: 1px solid var(--border);
1969
+ }
1970
+ .in-jira-box__key {
1971
+ font-family: var(--mono);
1972
+ font-size: 14px;
1973
+ font-weight: 600;
1974
+ }
1975
+ .in-jira-box__where {
1976
+ font-size: 14px;
1977
+ }
1978
+ .in-jira-box__sub {
1979
+ font-size: 12px;
1980
+ color: var(--muted-foreground);
1981
+ }
1982
+ .in-jira-box__link {
1983
+ margin-left: auto;
1984
+ font-size: 12px;
1985
+ color: var(--foreground);
1986
+ text-underline-offset: 2px;
1987
+ }
1988
+ .in-jira-diff {
1989
+ display: flex;
1990
+ flex-direction: column;
1991
+ gap: 8px;
1992
+ padding: 10px 12px;
1993
+ border: 1px solid var(--destructive);
1994
+ }
1995
+ .in-jira-diff__title {
1996
+ margin: 0;
1997
+ font-size: 14px;
1998
+ font-weight: 600;
1999
+ }
2000
+ .in-jira-diff__sides {
2001
+ display: grid;
2002
+ grid-template-columns: 1fr 1fr;
2003
+ gap: 12px;
2004
+ }
2005
+ .in-jira-diff__side {
2006
+ display: flex;
2007
+ flex-direction: column;
2008
+ gap: 8px;
2009
+ min-width: 0;
2010
+ font-size: 14px;
2011
+ }
2012
+ .in-jira-diff__label,
2013
+ .in-jira-diff__name,
2014
+ .in-jira-body__name {
2015
+ margin: 0;
2016
+ font-size: 12px;
2017
+ font-weight: 600;
2018
+ letter-spacing: 0.1em;
2019
+ text-transform: uppercase;
2020
+ color: var(--muted-foreground);
2021
+ }
2022
+ .in-jira-diff__part,
2023
+ .in-jira-body__part {
2024
+ display: flex;
2025
+ flex-direction: column;
2026
+ gap: 4px;
2027
+ min-width: 0;
2028
+ overflow-wrap: anywhere;
2029
+ }
2030
+ .in-jira-body,
2031
+ .in-jira-edit {
2032
+ display: flex;
2033
+ flex-direction: column;
2034
+ gap: 16px;
2035
+ font-size: 14px;
2036
+ line-height: 20px;
2037
+ }
2038
+ .in-jira-body__empty {
2039
+ margin: 0;
2040
+ color: var(--muted-foreground);
2041
+ }
2042
+ .in-jira-area {
2043
+ min-height: 96px;
2044
+ }
2045
+ @media (max-width: 640px) {
2046
+ .in-jira-fields {
2047
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2048
+ }
2049
+ .in-jira-diff__sides {
2050
+ grid-template-columns: 1fr;
2051
+ }
2052
+ }
2053
+ @media (pointer: coarse) {
2054
+ .in-jira-box__link {
2055
+ display: inline-flex;
2056
+ align-items: center;
2057
+ min-height: 44px;
2058
+ }
2059
+ }
2060
+
2061
+
1856
2062
  /* -- layouts · .fr- the frame: top bar, dock, Settings (Run 3, slice 3A) -- */
1857
2063
  /* The frame's root: the top bar over the body. app.js drops the cockpit's inline geometry when the frame is on. */
1858
2064
  .fr-root {
@@ -2039,7 +2245,7 @@
2039
2245
  margin: 12px 16px 0;
2040
2246
  color: var(--muted-foreground);
2041
2247
  }
2042
- /* Settings: one column of five sections. */
2248
+ /* Settings: one column of seven sections. */
2043
2249
  .fr-set {
2044
2250
  display: flex;
2045
2251
  flex-direction: column;
@@ -2093,6 +2299,22 @@
2093
2299
  .fr-set__add {
2094
2300
  padding: 12px 0;
2095
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
+ }
2096
2318
  @media (max-width: 760px) {
2097
2319
  .fr-detail,
2098
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