create-pmos 0.10.0 → 0.11.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/README.md +60 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/lib/board-cache.js +116 -0
- package/workspace/lib/console-state.js +20 -0
- package/workspace/lib/layout.js +44 -9
- package/workspace/package.json +7 -3
- package/workspace/public/app.js +570 -80
- package/workspace/public/index.html +39 -35
- package/workspace/public/styles.css +121 -2
- package/workspace/server.js +216 -23
package/README.md
CHANGED
|
@@ -333,6 +333,66 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.11.0
|
|
337
|
+
|
|
338
|
+
**The workspace navigates by initiative instead of by folder.** If you upgrade and the left column
|
|
339
|
+
looks unfamiliar, this is why.
|
|
340
|
+
|
|
341
|
+
- **One tree instead of two panes.** The sidebar was a list of working trees named after
|
|
342
|
+
directories, plus a separate list of running agents. It is now a single tree: your repository,
|
|
343
|
+
the working trees under it, and each tree's sessions beneath that. A branch called
|
|
344
|
+
`initiative/<id>` is labelled with the initiative, not the folder — and the folder name stays on
|
|
345
|
+
the row, because switching a branch moves every session in that tree and a row that renamed
|
|
346
|
+
itself would look like a row that vanished.
|
|
347
|
+
|
|
348
|
+
- **Each row says where the work is and what is next** — its lane, its derived stage, and either
|
|
349
|
+
the next step or what is blocking it. Nothing here is a status anyone types; it is derived from
|
|
350
|
+
your records and git, by the same engine the gate runs.
|
|
351
|
+
|
|
352
|
+
- **Every stage names the branch it was derived from.** This one matters more than it sounds.
|
|
353
|
+
Stage is computed over `merge-base(main, HEAD)..HEAD` — *this branch's* commits — so an
|
|
354
|
+
initiative you shipped months ago legitimately reads "spec" on a branch that did not build it.
|
|
355
|
+
The surface now says which branch produced the answer instead of implying it is absolute.
|
|
356
|
+
|
|
357
|
+
- **The rail follows your selection.** The four lenses (initiatives · folders · scheduled ·
|
|
358
|
+
history) are replaced by one detail: pick a repository and you get its folder map, schedules and
|
|
359
|
+
history; pick an initiative and you get its dossier — where it is, what it still owes, and every
|
|
360
|
+
artifact it owns, grouped by what the *lane* asks for. A chore is not shown as a feature missing
|
|
361
|
+
a PRD. Opening a dossier is a read: it never switches your working tree.
|
|
362
|
+
|
|
363
|
+
- **The workflow panel no longer freezes the app.** Deriving the board shells `git` once per
|
|
364
|
+
initiative — around 30 seconds on a large repo — and it used to run on the request path, locking
|
|
365
|
+
the server and every terminal session with it. It now runs in a separate process and every answer
|
|
366
|
+
is stamped with when it was derived. On a big repository the first read says "deriving" rather
|
|
367
|
+
than showing nothing.
|
|
368
|
+
|
|
369
|
+
- **Prototypes, user stories and copywriting are visible to the library.** Specimens in
|
|
370
|
+
`prototype/<id>/` were outside the scanned tree, so a D60 prototype never appeared on the
|
|
371
|
+
initiative that produced it. They do now, and `-user-stories`, `-copy-brief` and `-copy` are
|
|
372
|
+
recognised artifact roles.
|
|
373
|
+
|
|
374
|
+
Honest note on what this release did *not* get: the 30-second derivation is **cached, not fixed** —
|
|
375
|
+
the underlying per-file git lookups are still unbatched. And the Review Gate for this work was never
|
|
376
|
+
scored; it was accepted on green CI and PM judgement, without an independent adversarial pass.
|
|
377
|
+
|
|
378
|
+
## What's new in 0.10.1
|
|
379
|
+
|
|
380
|
+
**Two fixes found by installing 0.10.0 and using it, rather than by reading the code.**
|
|
381
|
+
|
|
382
|
+
- **Upgrades will keep reaching you.** The launcher decides whether to reinstall the workspace by
|
|
383
|
+
comparing the app version it bundles against the one you have. 0.10.0 shipped the *kit* at 0.10.0
|
|
384
|
+
but the bundled app still labelled **0.9.1** — so the next release would have compared equal,
|
|
385
|
+
decided you were current, and **silently skipped the upgrade**. Which is precisely the failure
|
|
386
|
+
0.9.1 existed to fix. The two versions are now checked against each other by the test suite, so it
|
|
387
|
+
cannot drift again unnoticed.
|
|
388
|
+
- **Alignment no longer offers to edit its own notes.** The change-context digest quotes every change
|
|
389
|
+
you make, so it matched the "quotes text this change altered" rule on the very first save in any
|
|
390
|
+
repository — and topped the list of documents to realign. A generated file proposing to realign
|
|
391
|
+
itself. Machine-written state is now excluded from alignment entirely.
|
|
392
|
+
|
|
393
|
+
If you installed 0.10.0, run `npx create-pmos@latest workspace` once. Nothing was damaged by either
|
|
394
|
+
bug; the first would have cost you a future upgrade, and the second a confusing first suggestion.
|
|
395
|
+
|
|
336
396
|
## What's new in 0.10.0
|
|
337
397
|
|
|
338
398
|
**Editing a document now does something for the rest of them.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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. `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
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-07-
|
|
1
|
+
kernel-source: pmos@cb9565d
|
|
2
|
+
assembled: 2026-07-29
|
|
3
3
|
mode: vendor
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* board-cache.js — the workflow board, cached and derived OFF the event loop.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `console-state.workflow()` runs `scripts/workflow-state.js` over every
|
|
6
|
+
* initiative record, and the engine resolves each artifact's FIRST APPEARANCE by shelling `git`.
|
|
7
|
+
* Measured on the PMOS repo, 2026-07-29: ~260 ms per initiative × 109 records = **~30 seconds**, and
|
|
8
|
+
* every millisecond of it is SYNCHRONOUS. Today that cost is paid only when the PM opens the
|
|
9
|
+
* `workflow` lens, where it freezes the server — and with it every PTY WebSocket — for half a minute.
|
|
10
|
+
*
|
|
11
|
+
* PRD R4 puts the same board behind EVERY sidebar row, so an uncached in-process derivation would
|
|
12
|
+
* turn a 30-second freeze into a freeze on every render. Two rules follow, and they are the whole
|
|
13
|
+
* design:
|
|
14
|
+
*
|
|
15
|
+
* 1. NEVER derive on the request path. A miss spawns a CHILD PROCESS and returns immediately.
|
|
16
|
+
* The event loop is never blocked, however cold the cache is.
|
|
17
|
+
* 2. NEVER serve a stale board as if it were fresh. Every hit carries `as_of` and `stale`, and a
|
|
18
|
+
* cold cache returns `pending: true` rather than an empty board. A surface that cannot say how
|
|
19
|
+
* old its answer is has no business showing a stage — that is R5's rule applied to time
|
|
20
|
+
* instead of to branch.
|
|
21
|
+
*
|
|
22
|
+
* The signature is HEAD + the initiative records' own mtimes, so editing a record refreshes the
|
|
23
|
+
* board without a commit, and switching branch refreshes it without an edit.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { spawnSync, spawn } = require('child_process');
|
|
28
|
+
|
|
29
|
+
const MAX_AGE_MS = 10 * 60 * 1000; // a board older than this is refreshed even if the signature holds
|
|
30
|
+
const REFRESH_TIMEOUT_MS = 120000; // a derivation that cannot finish in 2 minutes is reported, not awaited
|
|
31
|
+
|
|
32
|
+
const cache = new Map(); // root -> { sig, at, value, error }
|
|
33
|
+
const inflight = new Map(); // root -> true
|
|
34
|
+
|
|
35
|
+
function headSha(root) {
|
|
36
|
+
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
37
|
+
return r.status === 0 && r.stdout ? r.stdout.trim() : 'no-head';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The records' own state, so an UNCOMMITTED edit still invalidates. Cheap: one readdir + one stat
|
|
41
|
+
// per record, no git, no file contents.
|
|
42
|
+
function recordsSig(root) {
|
|
43
|
+
const dir = path.join(root, 'planning', 'initiatives');
|
|
44
|
+
let names = [];
|
|
45
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort(); } catch { return 'none'; }
|
|
46
|
+
let newest = 0;
|
|
47
|
+
for (const n of names) {
|
|
48
|
+
try { const st = fs.statSync(path.join(dir, n)); if (st.mtimeMs > newest) newest = st.mtimeMs; }
|
|
49
|
+
catch { /* a record that vanished mid-scan just does not raise the watermark */ }
|
|
50
|
+
}
|
|
51
|
+
return `${names.length}:${Math.round(newest)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function signature(root) { return `${headSha(root)}|${recordsSig(root)}`; }
|
|
55
|
+
|
|
56
|
+
// The child runs the SAME reader the lens uses — no second implementation of the board to drift.
|
|
57
|
+
function refresh(root, sig) {
|
|
58
|
+
if (inflight.get(root)) return;
|
|
59
|
+
inflight.set(root, true);
|
|
60
|
+
const script = `
|
|
61
|
+
const cs = require(${JSON.stringify(path.join(__dirname, 'console-state.js'))});
|
|
62
|
+
process.stdout.write(JSON.stringify(cs.workflow({ root: ${JSON.stringify(root)} })));
|
|
63
|
+
`;
|
|
64
|
+
let out = '';
|
|
65
|
+
let child;
|
|
66
|
+
try { child = spawn(process.execPath, ['-e', script], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }); }
|
|
67
|
+
catch (e) {
|
|
68
|
+
inflight.delete(root);
|
|
69
|
+
cache.set(root, { sig, at: Date.now(), value: null, error: 'could not start the board derivation: ' + (e.code || e.message) });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, REFRESH_TIMEOUT_MS);
|
|
73
|
+
if (timer.unref) timer.unref();
|
|
74
|
+
child.stdout.on('data', (c) => { out += c; });
|
|
75
|
+
child.on('error', (e) => {
|
|
76
|
+
clearTimeout(timer); inflight.delete(root);
|
|
77
|
+
cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation failed to run: ' + (e.code || e.message) });
|
|
78
|
+
});
|
|
79
|
+
child.on('close', (code, signal) => {
|
|
80
|
+
clearTimeout(timer); inflight.delete(root);
|
|
81
|
+
if (signal === 'SIGKILL') {
|
|
82
|
+
cache.set(root, { sig, at: Date.now(), value: null, error: `the board took longer than ${Math.round(REFRESH_TIMEOUT_MS / 1000)}s to derive and was stopped` });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (code !== 0 || !out) {
|
|
86
|
+
cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation exited without a result' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try { cache.set(root, { sig, at: Date.now(), value: JSON.parse(out), error: null }); }
|
|
90
|
+
catch { cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation returned unreadable output' }); }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
* The read the request path uses. Always returns immediately.
|
|
96
|
+
* { pending: true } nothing cached yet; a derivation was started
|
|
97
|
+
* { ok:false, error, as_of, stale } the derivation failed — render RED, never a calm empty
|
|
98
|
+
* { ...workflowPayload, as_of, stale } a board; `stale` means a refresh is running behind it
|
|
99
|
+
*/
|
|
100
|
+
function board(root, { refreshNow = false } = {}) {
|
|
101
|
+
if (!root) return { ok: false, error: 'no workspace root resolved for the workflow board', as_of: null, stale: false };
|
|
102
|
+
let sig;
|
|
103
|
+
try { sig = signature(root); } catch { sig = 'unreadable'; }
|
|
104
|
+
const hit = cache.get(root);
|
|
105
|
+
const fresh = hit && hit.sig === sig && (Date.now() - hit.at) < MAX_AGE_MS;
|
|
106
|
+
if (!fresh || refreshNow) refresh(root, sig);
|
|
107
|
+
if (!hit) return { pending: true, refreshing: true, as_of: null, stale: false };
|
|
108
|
+
const envelope = { as_of: new Date(hit.at).toISOString(), stale: !fresh, refreshing: !!inflight.get(root) };
|
|
109
|
+
if (hit.error) return { ok: false, error: hit.error, ...envelope };
|
|
110
|
+
return { ...hit.value, ...envelope };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Test/maintenance seam: drop everything, or one root.
|
|
114
|
+
function invalidate(root) { if (root) { cache.delete(root); inflight.delete(root); } else { cache.clear(); inflight.clear(); } }
|
|
115
|
+
|
|
116
|
+
module.exports = { board, invalidate, signature, MAX_AGE_MS, REFRESH_TIMEOUT_MS };
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
20
|
const reviewGate = require('./review-gate');
|
|
21
|
+
const gitInfo = require('./git-info'); // R5: the ladder is branch-scoped — name the branch it came from
|
|
21
22
|
|
|
22
23
|
function loadJson(file) {
|
|
23
24
|
let text;
|
|
@@ -140,6 +141,24 @@ function workflow({ root }) {
|
|
|
140
141
|
|
|
141
142
|
// One commit range for the whole board — resolving it per initiative would re-shell git for each.
|
|
142
143
|
const range = engine.commitRange(root);
|
|
144
|
+
// WHERE the ladder was derived from. `commitRange` resolves merge-base(origin/main, HEAD)..HEAD —
|
|
145
|
+
// this BRANCH's commits, and an EMPTY range on main. So `build`/`quality`, and the stage that
|
|
146
|
+
// follows from them, are branch-scoped facts. A consumer that prints a stage without saying which
|
|
147
|
+
// branch produced it states something the data does not support: pmos-terminal-workspace, shipped
|
|
148
|
+
// under D62, derives to `spec` with "no implementation commits in this range" on every branch that
|
|
149
|
+
// did not build it. Two worktrees of one repo legitimately print two different ladders.
|
|
150
|
+
// This block is what lets a caller say so; it is not decoration (PRD R5, rubric dimension 1).
|
|
151
|
+
const bi = gitInfo.branchInfo(root);
|
|
152
|
+
const scope = {
|
|
153
|
+
branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
|
|
154
|
+
branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
|
|
155
|
+
commits: range.ok ? range.shas.length : null,
|
|
156
|
+
whole_history: range.ok ? !!range.wholeHistory : null,
|
|
157
|
+
// The empty range is the trap: range.ok is TRUE with zero commits (you are on main, or level
|
|
158
|
+
// with it), so nothing is "degraded" — every build step legitimately reads `pending` and a naive
|
|
159
|
+
// reader calls that "never built". Named separately because it needs different words.
|
|
160
|
+
empty_range: range.ok ? range.shas.length === 0 : null,
|
|
161
|
+
};
|
|
143
162
|
const rows = ids.map((id) => {
|
|
144
163
|
try { return engine.computeWorkflow(id, { root, range }); }
|
|
145
164
|
catch (e) { return { ok: false, id, error: String((e && e.message) || 'derivation failed').slice(0, 160) }; }
|
|
@@ -164,6 +183,7 @@ function workflow({ root }) {
|
|
|
164
183
|
unreadable: rows.filter((r) => !r.ok).map(shape),
|
|
165
184
|
blocked: shown.filter((r) => r.ok && r.blockers.length).length,
|
|
166
185
|
degraded: range.ok ? null : range.reason,
|
|
186
|
+
scope, // R5 — the branch this ladder was derived from; never omit it
|
|
167
187
|
enforced_from: engine.ENFORCED_FROM,
|
|
168
188
|
},
|
|
169
189
|
};
|
package/workspace/lib/layout.js
CHANGED
|
@@ -31,15 +31,17 @@
|
|
|
31
31
|
|
|
32
32
|
const SIDE_MIN = 160, SIDE_MAX = 520;
|
|
33
33
|
const READ_MIN = 280, READ_MAX = 1000;
|
|
34
|
+
const LIB_MIN = 15, LIB_MAX = 80; // the library/document divider, percent
|
|
34
35
|
const BOTTOM_H = 300; // the reader's height when docked bottom
|
|
36
|
+
const LIB_DEFAULT = 46;
|
|
35
37
|
|
|
36
38
|
// `review` is byte-for-byte the arrangement that shipped before this feature (R2): a PM who
|
|
37
39
|
// never opens the layout control must see no change at all.
|
|
38
40
|
const STOCK = [
|
|
39
|
-
{ id: 'focus', name: 'focus', key: 1, side: 'off', reader: 'off', split: 'off', sideW: 262, readW: 400 },
|
|
40
|
-
{ id: 'read', name: 'read', key: 2, side: 'left', reader: 'left', split: 'off', sideW: 214, readW: 760 },
|
|
41
|
-
{ id: 'compare', name: 'compare', key: 3, side: 'left', reader: 'off', split: 'on', sideW: 214, readW: 400 },
|
|
42
|
-
{ id: 'review', name: 'review', key: 4, side: 'left', reader: 'right', split: 'off', sideW: 262, readW: 400 },
|
|
41
|
+
{ id: 'focus', name: 'focus', key: 1, side: 'off', reader: 'off', split: 'off', sideW: 262, readW: 400, libSplit: LIB_DEFAULT },
|
|
42
|
+
{ id: 'read', name: 'read', key: 2, side: 'left', reader: 'left', split: 'off', sideW: 214, readW: 760, libSplit: LIB_DEFAULT },
|
|
43
|
+
{ id: 'compare', name: 'compare', key: 3, side: 'left', reader: 'off', split: 'on', sideW: 214, readW: 400, libSplit: LIB_DEFAULT },
|
|
44
|
+
{ id: 'review', name: 'review', key: 4, side: 'left', reader: 'right', split: 'off', sideW: 262, readW: 400, libSplit: LIB_DEFAULT },
|
|
43
45
|
];
|
|
44
46
|
const DEFAULT_ID = 'review';
|
|
45
47
|
const STOCK_IDS = STOCK.map((l) => l.id);
|
|
@@ -86,6 +88,11 @@
|
|
|
86
88
|
split: oneOf(r.split, SPLIT, base ? base.split : 'off'),
|
|
87
89
|
sideW: Math.round(clamp(num(r.sideW, base ? base.sideW : 262), SIDE_MIN, SIDE_MAX)),
|
|
88
90
|
readW: Math.round(clamp(num(r.readW, base ? base.readW : 400), READ_MIN, READ_MAX)),
|
|
91
|
+
// the library/document divider. It lives IN the tuple rather than in localStorage because R8
|
|
92
|
+
// promises to carry it across the upgrade and dimension 1 counts it as "nothing lost" — a
|
|
93
|
+
// global client key can satisfy neither: it is not per-workspace, and it goes on being read
|
|
94
|
+
// from the pre-feature store on every boot, which R8 explicitly forbids.
|
|
95
|
+
libSplit: Math.round(clamp(num(r.libSplit, base ? base.libSplit : LIB_DEFAULT), LIB_MIN, LIB_MAX) * 10) / 10,
|
|
89
96
|
folds: folds(r.folds),
|
|
90
97
|
lastUsedAt: Math.max(0, Math.round(num(r.lastUsedAt, 0))),
|
|
91
98
|
};
|
|
@@ -140,16 +147,26 @@
|
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
/* A layout that hides every pane is legal (`focus` is exactly that) — but a layout the PM
|
|
143
|
-
cannot get OUT of is not.
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
cannot get OUT of is not.
|
|
151
|
+
|
|
152
|
+
HONEST SCOPE, and read this before trusting it: every branch of gridFor() unconditionally
|
|
153
|
+
emits a `term` cell, so this can only fail if someone edits gridFor. It is a change-detector
|
|
154
|
+
for that one function — NOT evidence for rubric dimension 1's checklist item 4. A Review-Gate
|
|
155
|
+
evaluator fuzzed 38,416 layouts through it for zero failures and correctly called it vacuous.
|
|
156
|
+
The property item 4 actually names — the control that un-hides a pane is itself always
|
|
157
|
+
reachable — is a fact about the DOM (the chip lives in `.bar`, a grid sibling of `#bodyGrid`,
|
|
158
|
+
and the position controls in `.lay-pos` can always restore a pane), and it is asserted where
|
|
159
|
+
it can genuinely fail: ui-test.js walks all twelve side × reader states in a real engine. */
|
|
146
160
|
function hasEscape(l) {
|
|
147
161
|
return gridFor(l).areas.indexOf('term') !== -1;
|
|
148
162
|
}
|
|
149
163
|
|
|
150
164
|
/* R8 — carry the pre-feature client keys into this workspace's `review` so nobody's tuning is
|
|
151
165
|
lost on upgrade. Reads the five localStorage values the shipped app wrote:
|
|
152
|
-
rail(on|off) railw(<n>px) libsplit(<n>%) fold-lib(on|off) fold-doc(on|off).
|
|
166
|
+
rail(on|off) railw(<n>px) libsplit(<n>%) fold-lib(on|off) fold-doc(on|off).
|
|
167
|
+
It formerly said "five" and read four — `libsplit` was collected by the caller and dropped
|
|
168
|
+
here, so R8's promise was false for it and the pre-feature key went on being read at every
|
|
169
|
+
boot. Review-Gate finding: the comment was the spec, and the code disagreed with it. */
|
|
153
170
|
function migrateLegacy(legacy) {
|
|
154
171
|
const g = (k) => (legacy && typeof legacy === 'object' ? legacy[k] : null);
|
|
155
172
|
const review = stockLayout(DEFAULT_ID);
|
|
@@ -157,15 +174,33 @@
|
|
|
157
174
|
if (railOff) review.reader = 'off';
|
|
158
175
|
const w = num(String(g('railw') || '').replace('px', ''), null);
|
|
159
176
|
if (w !== null) review.readW = Math.round(clamp(w, READ_MIN, READ_MAX));
|
|
177
|
+
const ls = num(String(g('libsplit') || '').replace('%', ''), null);
|
|
178
|
+
if (ls !== null) review.libSplit = Math.round(clamp(ls, LIB_MIN, LIB_MAX) * 10) / 10;
|
|
160
179
|
if (g('fold-lib') === 'off') review.folds.lib = false;
|
|
161
180
|
if (g('fold-doc') === 'off') review.folds.doc = false;
|
|
162
181
|
const layouts = stockLayouts().map((l) => (l.id === DEFAULT_ID ? review : l));
|
|
163
182
|
return { layouts, currentId: DEFAULT_ID };
|
|
164
183
|
}
|
|
165
184
|
|
|
185
|
+
/* What a GET /api/layouts response entitles the client to DO. Split out as a pure function
|
|
186
|
+
because the original inline condition conflated two very different states — "this workspace
|
|
187
|
+
has never stored a layout" and "I could not read what it stored" — and answered both by
|
|
188
|
+
migrating and then WRITING, silently replacing a real stored document whenever a read failed.
|
|
189
|
+
A read failure must never author a write.
|
|
190
|
+
|
|
191
|
+
'migrate' — the server affirmatively says nothing is stored (first run here): R8 applies.
|
|
192
|
+
'use' — a stored document came back: adopt it.
|
|
193
|
+
'keep' — the answer is unusable (transport error, non-JSON, !ok, or no `stored` flag).
|
|
194
|
+
Hold the boot defaults in memory and WRITE NOTHING. */
|
|
195
|
+
function loadAction(doc) {
|
|
196
|
+
if (!doc || doc.ok !== true || typeof doc.stored !== 'boolean') return 'keep';
|
|
197
|
+
return doc.stored ? 'use' : 'migrate';
|
|
198
|
+
}
|
|
199
|
+
|
|
166
200
|
return {
|
|
167
201
|
SIDE, READER, SPLIT, FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
|
|
168
|
-
SIDE_MIN, SIDE_MAX, READ_MIN, READ_MAX, BOTTOM_H,
|
|
202
|
+
SIDE_MIN, SIDE_MAX, READ_MIN, READ_MAX, LIB_MIN, LIB_MAX, LIB_DEFAULT, BOTTOM_H,
|
|
169
203
|
isStock, stockLayout, stockLayouts, layout, sanitize, gridFor, hasEscape, migrateLegacy,
|
|
204
|
+
loadAction,
|
|
170
205
|
};
|
|
171
206
|
});
|
package/workspace/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
7
|
-
"bin": {
|
|
7
|
+
"bin": {
|
|
8
|
+
"pmos": "bin/pmos.js"
|
|
9
|
+
},
|
|
8
10
|
"scripts": {
|
|
9
11
|
"start": "node server.js",
|
|
10
12
|
"test": "node test.js",
|
|
11
13
|
"test:ui": "node ui-test.js",
|
|
12
14
|
"doctor": "node doctor.js"
|
|
13
15
|
},
|
|
14
|
-
"engines": {
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
15
19
|
"dependencies": {
|
|
16
20
|
"@xterm/addon-fit": "^0.11.0",
|
|
17
21
|
"@xterm/addon-web-links": "^0.12.0",
|