create-pmos 0.13.0 → 0.15.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.
@@ -0,0 +1,280 @@
1
+ 'use strict';
2
+ /*
3
+ * The handoff links model (workspace-jira-handoff · PRD R1/R4/R5/R6/R7).
4
+ *
5
+ * A real PMOS workflow can end outside PMOS: the PM owns intake through spec, and the build happens
6
+ * on another team's board. This module turns what an initiative RECORD declares about that handoff
7
+ * into a model the panel can render. It reads the record's frontmatter and nothing else, because
8
+ * there is nothing else to read.
9
+ *
10
+ * THE DISTINCTION THE WHOLE MODULE RESTS ON:
11
+ * A DECLARATION is something the PM typed into `planning/initiatives/<id>.md`.
12
+ * A READING is something PMOS went and looked at.
13
+ * This module returns declarations, labels them as declarations, and returns no readings at all.
14
+ *
15
+ * Even the epic key is a declaration. PMOS does not know that `ATL-482` exists, that it is an epic,
16
+ * that it belongs to this initiative, or that anyone has touched it. It knows that a human wrote
17
+ * that string in a file in this repository. Every rule below follows from taking that seriously.
18
+ *
19
+ * WHY THERE IS NO STORY COUNT AND NO SYNC TIME, AND WHY THAT IS NOT A "NOT YET".
20
+ * There is no Jira client, no Confluence client, no credential store, no host allow-list and no
21
+ * outbound call anywhere in the workspace. That absence is a RULING, not a gap:
22
+ * `planning/prd/jira-sync-prd.md` records that a PMOS-side Jira REST client and a stored credential
23
+ * were built in a first draft and then REJECTED by the PM at build review, under D01 (orchestrate,
24
+ * don't execute) and D51 (no standing credential). So `3 of 7 stories done` here would not be an
25
+ * optimistic estimate or a stale cache — it would be a fabricated reading of a system PMOS cannot
26
+ * see, wearing the same visual grammar the panel uses for facts it actually derived. A PM who
27
+ * believes PMOS is watching Jira stops watching Jira, and that is the failure this module exists to
28
+ * make impossible.
29
+ *
30
+ * HONEST BY CONSTRUCTION, NOT BY DISCIPLINE. The defence is structural, so that it survives an
31
+ * author who has not read this comment. handoff.test.js asserts each of these as a property:
32
+ *
33
+ * · this file `require`s NOTHING — no fs, no net, no clock. There is no path by which an external
34
+ * reading could enter it, and no I/O to mock, stub or accidentally re-enable;
35
+ * · the model contains NO NUMBER anywhere — every value is a string, a boolean, null, or an array
36
+ * of those. A story count, an issue tally and a progress percentage have no field to live in;
37
+ * · the field sets are closed. A link is exactly LINK_FIELDS, the result is exactly RESULT_FIELDS,
38
+ * and the test compares those key lists for equality — so `synced_at` cannot be added quietly;
39
+ * · `connected` is the literal `false`, and the whole result is deep-frozen, so a consumer can
40
+ * neither flip it nor bolt a fabricated field onto it after the fact;
41
+ * · every string this module emits is either copied VERBATIM out of the record or is fixed prose
42
+ * enumerated in the test — so a value that is computed, estimated, or formatted relative to now
43
+ * fails the moment it is invented, whatever it is and however plausible it looks.
44
+ *
45
+ * The not-connected state is not decoration on this feature — it IS the feature. The failure it
46
+ * prevents is a screen on which every individual sentence is true and the composition still reads
47
+ * as a live connection.
48
+ *
49
+ * WHAT THIS MODULE DOES NOT DO — deliberately, and the renderer must:
50
+ * · it does not escape. Values come straight from frontmatter and have NOT passed through `esc()`
51
+ * (unlike `mdLink`, which is safe only because `inline()` escaped the whole line first). They
52
+ * are returned verbatim so the whitelist property test can compare them against the record file
53
+ * byte for byte; the renderer MUST escape at every interpolation site, including the `href`;
54
+ * · it does not compose the R8 ladder annotation or the R10 sessions line. It returns the pieces
55
+ * (`to`, `at`, `systems`) and leaves the sentence to the surface that shows it.
56
+ */
57
+
58
+ // ── the record fields (PRD R1) ──────────────────────────────────────────────────────────────────
59
+ // All five optional, all flat, all single-line — `parseLeadingFrontmatter` (lib/file-mode-parse.js)
60
+ // admits `^[a-z_]+: value$` and nothing else. A YAML list under a `links:` key does not partially
61
+ // parse: it TERMINATES the frontmatter block at that line and silently drops every field below it,
62
+ // including `lane` and `parent_kr`. That is why there is no list form here and never will be; the
63
+ // truncation is pinned by a test in handoff.test.js so the next author who reaches for a list finds
64
+ // a failing test explaining it rather than an initiative that reads as unanchored.
65
+ const HANDOFF_KEYS = ['flow', 'handoff_to', 'handoff_epic', 'handoff_page', 'handoff_at'];
66
+
67
+ // The two keys that name something in another system. Iteration order here — not frontmatter order —
68
+ // fixes the order of `links`, so the section renders the same way whatever order the PM typed.
69
+ const REFERENCE_KEYS = ['handoff_epic', 'handoff_page'];
70
+ const KIND = { handoff_epic: 'jira', handoff_page: 'confluence' };
71
+ const SYSTEM = { handoff_epic: 'Jira', handoff_page: 'Confluence' };
72
+
73
+ // The closed field sets. These exist to be asserted against: a new field is a test failure, which is
74
+ // the point — "no story count" has to be a property of the shape, not a habit of the author.
75
+ const LINK_FIELDS = ['kind', 'id', 'declared', 'state', 'why', 'state_text', 'href', 'inert_why'];
76
+ const PROBLEM_FIELDS = ['id', 'declared', 'why'];
77
+ const RESULT_FIELDS = ['connected', 'handoff', 'flow', 'to', 'at', 'systems', 'links', 'problems', 'empty'];
78
+
79
+ // The one state a link can be in. There is no second value and no code path toward one.
80
+ const NOT_CONNECTED = 'not-connected';
81
+
82
+ // R5 — why a bare reference is inert. PMOS was handed a key, not a location; guessing the location
83
+ // (a base URL, an issue-browse path) would be PMOS inventing the one thing it was not told.
84
+ const INERT_WHY = 'PMOS was given a reference, not a URL, and will not construct one it was not given';
85
+
86
+ // R4 — the state, per row and per system. `why` is the short clause; `state_text` is the full
87
+ // sentence for the row's accessible name. BOTH are per-link on purpose: a single banner at the top
88
+ // of the section is the FAIL shape the PRD names, because a PM scanning a row sees the row.
89
+ function whyFor(system) { return `no ${system} credential is configured`; }
90
+ function stateTextFor(system) {
91
+ return `not connected — ${whyFor(system)}; this is what the record declares, not a reading`;
92
+ }
93
+
94
+ // ── validation ──────────────────────────────────────────────────────────────────────────────────
95
+ // This surface has no read that can fail, so a malformed declaration is the only failure it CAN
96
+ // have — which makes it the only thing that may render red, and it must (R7).
97
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
98
+ const ISO_DATE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
99
+ const SINGLE_TOKEN = /^\S+$/;
100
+ // `flow` names the file `planning/workflows/<flow>.md`, so it is a filename and is checked as one.
101
+ // A value carrying `/` or a leading `.` would send the "open the template" affordance somewhere the
102
+ // PM did not point it.
103
+ const TEMPLATE_NAME = /^[a-z0-9][a-z0-9._-]*$/;
104
+ // R5's allow-list, deliberately STRICTER than `mdLink`'s (`app.js:2999`), which also admits
105
+ // protocol-relative `//host` and `mailto:`. Neither belongs on a handoff reference: `//evil` inherits
106
+ // whatever scheme the page happens to be on, and a mail address is not an epic. A host character is
107
+ // required after the slashes, so a bare `https://` is refused rather than rendered as a dead link.
108
+ const ABSOLUTE_URL = /^https?:\/\/[^\s/?#]+/i;
109
+
110
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
111
+
112
+ // Names a JS type for a problem message. Used ONLY when the declared value is not text at all, in
113
+ // which case there is no "value verbatim" to print and naming what arrived is the honest substitute.
114
+ function typeName(v) {
115
+ if (v === null) return 'null';
116
+ if (Array.isArray(v)) return 'an array';
117
+ return typeof v === 'object' ? 'an object' : `a ${typeof v}`;
118
+ }
119
+
120
+ // Per-key shape rules. Each returns a reason string when the value is malformed, else null.
121
+ // NOTE: a reason NEVER embeds the declared value — the `declared` field prints it verbatim, and
122
+ // keeping the prose value-free is what lets the whitelist property test enumerate the fixed
123
+ // vocabulary this module can emit.
124
+ const CHECK = {
125
+ flow: (v) => (TEMPLATE_NAME.test(v) ? null
126
+ : '`flow:` names a template file under planning/workflows/ — lower-case letters, digits, dot, underscore and hyphen only. This value cannot name a file, so there is nothing to open'),
127
+ // `handoff_to` is "who received it, in plain language" (R1), so whitespace is EXPECTED here and
128
+ // the token rule below must not be applied to it. The asymmetry is deliberate, not an oversight.
129
+ handoff_to: () => null,
130
+ handoff_epic: (v) => (SINGLE_TOKEN.test(v) ? null
131
+ : '`handoff_epic:` holds a reference or an absolute URL — a single token. This value contains whitespace, so it is prose in a field meant for an identifier, and PMOS will not dress prose as a reference'),
132
+ handoff_page: (v) => (SINGLE_TOKEN.test(v) ? null
133
+ : '`handoff_page:` holds a reference or an absolute URL — a single token. This value contains whitespace, so it is prose in a field meant for an identifier, and PMOS will not dress prose as a reference'),
134
+ handoff_at: (v) => (ISO_DATE.test(v) ? null
135
+ : '`handoff_at:` is the ISO date the PM declared the handoff (YYYY-MM-DD). This value is not one, and PMOS will not guess what date was meant'),
136
+ };
137
+
138
+ /* Read ONE declared key. Returns { present, value, why }:
139
+ * present:false — the key is not in the record. Nothing is claimed about it.
140
+ * why:null — a well-formed declaration; `value` is the verbatim (trimmed) string.
141
+ * why:<reason> — malformed; `value` is the offending string verbatim, or null when the
142
+ * declared value was never text.
143
+ * ABSENT and MALFORMED are answered separately because they are different facts: one is an
144
+ * initiative that was not handed off, the other is a record with a mistake in it, and rendering the
145
+ * second as the first is how a typo becomes silence.
146
+ */
147
+ function readDeclared(fm, key) {
148
+ if (!has(fm, key)) return { present: false, value: null, why: null };
149
+ const raw = fm[key];
150
+ if (typeof raw !== 'string') {
151
+ return { present: true, value: null,
152
+ why: `\`${key}:\` is ${typeName(raw)}, not the single-line string the record format admits — PMOS prints what the record declares as text, and there is no text here to print` };
153
+ }
154
+ const v = raw.trim();
155
+ if (!v) {
156
+ return { present: true, value: null,
157
+ why: `\`${key}:\` is present but empty — the key is declared and no value is, so there is nothing to state` };
158
+ }
159
+ if (CONTROL_CHARS.test(v)) {
160
+ return { present: true, value: null,
161
+ why: `\`${key}:\` contains a control character — refused rather than rendered, because what it would print is not what the record shows` };
162
+ }
163
+ return { present: true, value: v, why: CHECK[key](v) };
164
+ }
165
+
166
+ // Freezing is part of the contract, not tidiness: a frozen result cannot acquire a `synced_at` or a
167
+ // `story_count` downstream, and `connected` cannot be flipped by a consumer that would rather it
168
+ // were true.
169
+ function deepFreeze(o) {
170
+ if (o && typeof o === 'object' && !Object.isFrozen(o)) {
171
+ Object.freeze(o);
172
+ for (const k of Object.keys(o)) deepFreeze(o[k]);
173
+ }
174
+ return o;
175
+ }
176
+
177
+ /* readLinks(record) — the handoff model for ONE initiative.
178
+ *
179
+ * `record` is the initiative's parsed leading frontmatter: the plain object
180
+ * `parseLeadingFrontmatter(text)` returns (lib/file-mode-parse.js). Not a path, not the file text —
181
+ * this module does no I/O by design, and a caller that hands it text gets a stated problem rather
182
+ * than a silent empty, because "no handoff declared" and "you gave me the wrong thing" are
183
+ * different facts.
184
+ *
185
+ * Returns a deep-frozen object with exactly RESULT_FIELDS:
186
+ *
187
+ * connected false — ALWAYS, a literal. There is no argument, no record field and no code path
188
+ * that produces any other value.
189
+ * handoff boolean — the record declares `flow:` or any `handoff_*` key. False for every
190
+ * initiative that was not handed off, so the panel is unchanged for them.
191
+ * flow string|null — the verbatim `flow:` value, or null (absent or malformed).
192
+ * to string|null — the verbatim `handoff_to:` value.
193
+ * at string|null — the verbatim `handoff_at:` value, only when it is an ISO date.
194
+ * systems string[] — 'Jira' and/or 'Confluence', from which reference keys are DECLARED
195
+ * (including malformed ones — a broken epic still names what PMOS cannot read).
196
+ * links link[] — one per well-formed reference, in REFERENCE_KEYS order.
197
+ * problems problem[] — one per malformed declaration (R7). Render red, value verbatim.
198
+ * empty boolean — a handoff is declared and there is nothing to link and nothing wrong: the
199
+ * verified empty of R6. Absence is STATED, never implied by a missing section.
200
+ *
201
+ * A link is exactly LINK_FIELDS:
202
+ * kind 'jira' | 'confluence' — which system the reference points AT. A kind marker, never a
203
+ * claim about it, and never to be styled like the panel's derived `d2-pill`.
204
+ * id 'handoff_epic' | 'handoff_page' — the RECORD KEY this row came from. This is the
205
+ * row's identity for rendering; it is NOT the epic's identifier. The thing the
206
+ * PM typed is `declared`.
207
+ * declared string — the reference, verbatim and UNESCAPED. The renderer escapes.
208
+ * state 'not-connected' — always, for every link, for every record.
209
+ * why string — the short clause, per system ('no Jira credential is configured').
210
+ * state_text string — the full R4 sentence for the row's accessible name.
211
+ * href string|null — the value IFF it is an absolute http(s) URL (R5); else null. Byte-
212
+ * identical to `declared` when set — never normalised, never constructed.
213
+ * inert_why string|null — why this row is not a link, when href is null.
214
+ *
215
+ * A problem is exactly PROBLEM_FIELDS: { id, declared, why } — the record key (or 'record' for a
216
+ * caller that passed the wrong shape), the offending value verbatim (null when it was never text),
217
+ * and the reason, which never embeds the value.
218
+ */
219
+ function readLinks(record) {
220
+ const problems = [];
221
+ const links = [];
222
+ let fm = {};
223
+
224
+ if (record !== null && record !== undefined) {
225
+ if (typeof record !== 'object' || Array.isArray(record)) {
226
+ problems.push({ id: 'record', declared: null,
227
+ why: `readLinks was given ${typeName(record)}, not the parsed frontmatter object it reads — no handoff is being claimed for this record, and that is a fault rather than an absence` });
228
+ } else {
229
+ fm = record;
230
+ }
231
+ }
232
+
233
+ const declared = HANDOFF_KEYS.filter((k) => has(fm, k));
234
+ const value = {};
235
+ for (const key of HANDOFF_KEYS) {
236
+ const r = readDeclared(fm, key);
237
+ if (!r.present) continue;
238
+ if (r.why) { problems.push({ id: key, declared: r.value, why: r.why }); continue; }
239
+ value[key] = r.value;
240
+ }
241
+
242
+ for (const key of REFERENCE_KEYS) {
243
+ const v = value[key];
244
+ if (v === undefined) continue;
245
+ const isUrl = ABSOLUTE_URL.test(v);
246
+ links.push({
247
+ kind: KIND[key],
248
+ id: key,
249
+ declared: v,
250
+ state: NOT_CONNECTED,
251
+ why: whyFor(SYSTEM[key]),
252
+ state_text: stateTextFor(SYSTEM[key]),
253
+ href: isUrl ? v : null,
254
+ inert_why: isUrl ? null : INERT_WHY,
255
+ });
256
+ }
257
+
258
+ return deepFreeze({
259
+ connected: false,
260
+ handoff: declared.length > 0,
261
+ flow: value.flow === undefined ? null : value.flow,
262
+ to: value.handoff_to === undefined ? null : value.handoff_to,
263
+ at: value.handoff_at === undefined ? null : value.handoff_at,
264
+ systems: REFERENCE_KEYS.filter((k) => declared.indexOf(k) !== -1).map((k) => SYSTEM[k]),
265
+ links,
266
+ problems,
267
+ // A `flow:` with no references is a VERIFIED EMPTY, in the panel's existing vocabulary — not a
268
+ // hidden section. A malformed declaration is not empty; it is red, and red wins.
269
+ empty: declared.length > 0 && links.length === 0 && problems.length === 0,
270
+ });
271
+ }
272
+
273
+ module.exports = deepFreeze({
274
+ readLinks,
275
+ HANDOFF_KEYS, REFERENCE_KEYS, LINK_FIELDS, PROBLEM_FIELDS, RESULT_FIELDS,
276
+ NOT_CONNECTED, INERT_WHY,
277
+ // Exported so the renderer states EXACTLY what this module states — a surface that rewords the
278
+ // not-connected sentence is a surface that can soften it.
279
+ whyFor, stateTextFor,
280
+ });
@@ -6,7 +6,7 @@
6
6
  * so a layout the client can produce and a layout the server will store can never disagree.
7
7
  *
8
8
  * A layout is a NAMED STATE TUPLE. The grid template is COMPUTED from it (gridFor) rather than
9
- * hand-written per layout, because the twelve side×reader combinations are exactly where a
9
+ * hand-written per layout, because that is exactly where a
10
10
  * hand-written template drifts: a column count that no longer matches its area row produces an
11
11
  * unusable window, and the PM's live agent sessions are inside it.
12
12
  *
@@ -24,24 +24,41 @@
24
24
  else root.PMOSLayout = api;
25
25
  })(typeof self !== 'undefined' ? self : this, function () {
26
26
 
27
+ /* workspace-viewer-column — `reader` is RETIRED from the tuple.
28
+ *
29
+ * It used to hold four positions and decided where a document rendered. Documents, images and
30
+ * the summoned surfaces now open as tabs in the terminal column, so there is no longer a pane to
31
+ * position: `#paneDoc`, `#railSplit` and `libSplit` went with it, and `.rail` is the detail panel
32
+ * alone. Keeping the slot and merely emptying it was the alternative and is the worse one — it
33
+ * would leave document-shaped content two possible homes and no rule for which.
34
+ *
35
+ * What survives is the DETAIL PANEL's column, which still needs a width and keeps its drag
36
+ * handle — `readW` is renamed `panelW` and old values fall back to it, since it sized the same
37
+ * column. What does not survive is its POSITION: the panel now always sits between the structure
38
+ * column and the terminal, because the four positions existed to place a document.
39
+ *
40
+ * So the tuple is `{side, split, sideW, panelW, folds}` rather than seven fields.
41
+ */
27
42
  const SIDE = ['left', 'right', 'off'];
28
- const READER = ['left', 'right', 'bottom', 'off']; // FOUR positions (R1) — every position
29
- const SPLIT = ['off', 'on']; // the state can hold has a control
30
- const FOLD_KEYS = ['ws', 'sess', 'lib', 'doc'];
43
+ const SPLIT = ['off', 'on'];
44
+ // `doc` retired with the pane it folded. `sess` was already retired (R13) and is likewise ignored
45
+ // rather than migrated — a stored value for either resolves harmlessly through sanitize.
46
+ const FOLD_KEYS = ['ws', 'lib'];
47
+ const RETIRED_FOLD_KEYS = ['sess', 'doc'];
31
48
 
32
49
  const SIDE_MIN = 160, SIDE_MAX = 520;
33
- const READ_MIN = 280, READ_MAX = 1000;
34
- const LIB_MIN = 15, LIB_MAX = 80; // the library/document divider, percent
35
- const BOTTOM_H = 300; // the reader's height when docked bottom
36
- const LIB_DEFAULT = 46;
50
+ const PANEL_MIN = 280, PANEL_MAX = 1000; // the detail panel's width, still draggable
37
51
 
38
- // `review` is byte-for-byte the arrangement that shipped before this feature (R2): a PM who
39
- // never opens the layout control must see no change at all.
52
+ // `review` is the DEFAULT — the arrangement a PM who never opens the layout control sees.
53
+ // With the reader retired, the four stock layouts differ in the two axes that remain: whether the
54
+ // structure column is present, which side it sits on, and whether the terminal is split. That is
55
+ // fewer axes than before and they are kept genuinely distinct rather than padded back out to four
56
+ // by inventing differences the PM would not feel.
40
57
  const STOCK = [
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 },
58
+ { id: 'focus', name: 'focus', key: 1, side: 'off', split: 'off', sideW: 262, panelW: 460 },
59
+ { id: 'read', name: 'read', key: 2, side: 'left', split: 'off', sideW: 214, panelW: 760 },
60
+ { id: 'compare', name: 'compare', key: 3, side: 'left', split: 'on', sideW: 214, panelW: 420 },
61
+ { id: 'review', name: 'review', key: 4, side: 'left', split: 'off', sideW: 280, panelW: 560 },
45
62
  ];
46
63
  const DEFAULT_ID = 'review';
47
64
  const STOCK_IDS = STOCK.map((l) => l.id);
@@ -54,7 +71,7 @@
54
71
  return Number.isFinite(n) ? n : fallback;
55
72
  };
56
73
 
57
- function defaultFolds() { return { ws: true, sess: true, lib: true, doc: true }; }
74
+ function defaultFolds() { return { ws: true, lib: true }; }
58
75
  function folds(v) {
59
76
  const out = defaultFolds();
60
77
  if (v && typeof v === 'object') for (const k of FOLD_KEYS) if (typeof v[k] === 'boolean') out[k] = v[k];
@@ -84,15 +101,19 @@
84
101
  name: stock ? base.name : name, // stock names are not editable (R5)
85
102
  key: stock ? base.key : null, // ⌃1–⌃4 belong to the stock four
86
103
  side: oneOf(r.side, SIDE, base ? base.side : 'left'),
87
- reader: oneOf(r.reader, READER, base ? base.reader : 'right'),
88
104
  split: oneOf(r.split, SPLIT, base ? base.split : 'off'),
89
105
  sideW: Math.round(clamp(num(r.sideW, base ? base.sideW : 262), SIDE_MIN, SIDE_MAX)),
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,
106
+ // The detail panel keeps a width and keeps its drag handle. A layout stored before this
107
+ // change has no `panelW`, but it does have the `readW` that used to size the same column —
108
+ // so that is what it falls back to, and the PM's tuning survives the rename instead of
109
+ // snapping to a default. This is the one legacy field genuinely worth carrying.
110
+ panelW: Math.round(clamp(num(r.panelW, num(r.readW, base ? base.panelW : 560)), PANEL_MIN, PANEL_MAX)),
111
+ // `reader`, `readW` and `libSplit` are DROPPED rather than carried. Every layout persisted
112
+ // before workspace-viewer-column still holds all three, and they arrive here on every boot in
113
+ // a live workspace — so the contract that matters is that reading them is harmless, which it
114
+ // is: unknown keys on `r` are simply not copied out. Dropping is deliberate and not silent —
115
+ // a value for a pane that no longer exists cannot be honoured, and carrying it forward would
116
+ // let a later reader resurrect a pane the app can no longer render.
96
117
  folds: folds(r.folds),
97
118
  lastUsedAt: Math.max(0, Math.round(num(r.lastUsedAt, 0))),
98
119
  };
@@ -130,19 +151,17 @@
130
151
  /* The computed grid. Returns {columns, rows, areas} as CSS strings.
131
152
  INVARIANT: every area row names exactly as many cells as `columns` has tracks. */
132
153
  function gridFor(l) {
133
- const S = l.sideW + 'px', R = l.readW + 'px';
134
- if (l.reader === 'bottom') {
135
- const rows = 'minmax(0,1fr) ' + BOTTOM_H + 'px';
136
- if (l.side === 'left') return { columns: S + ' minmax(0,1fr)', rows, areas: '"side term" "side read"' };
137
- if (l.side === 'right') return { columns: 'minmax(0,1fr) ' + S, rows, areas: '"term side" "read side"' };
138
- return { columns: 'minmax(0,1fr)', rows, areas: '"term" "read"' };
139
- }
154
+ const S = l.sideW + 'px', P = l.panelW + 'px';
155
+ // The detail panel is no longer independently positionable: it always sits between the
156
+ // structure column and the terminal, because that is the order the work flows in — pick the
157
+ // initiative, read its state, act on it. The four positions it used to have existed to place a
158
+ // DOCUMENT, and documents are tabs in the terminal column now. Hiding the panel is the column
159
+ // collapse (a 36px labelled rail), which is client state rather than part of the arrangement.
140
160
  const cols = [], names = [];
141
- if (l.side === 'left') { cols.push(S); names.push('side'); }
142
- if (l.reader === 'left') { cols.push(R); names.push('read'); }
161
+ if (l.side === 'left') { cols.push(S); names.push('side'); }
162
+ cols.push(P); names.push('read');
143
163
  cols.push('minmax(0,1fr)'); names.push('term');
144
- if (l.reader === 'right'){ cols.push(R); names.push('read'); }
145
- if (l.side === 'right') { cols.push(S); names.push('side'); }
164
+ if (l.side === 'right') { cols.push(S); names.push('side'); }
146
165
  return { columns: cols.join(' '), rows: 'minmax(0,1fr)', areas: '"' + names.join(' ') + '"' };
147
166
  }
148
167
 
@@ -156,28 +175,31 @@
156
175
  The property item 4 actually names — the control that un-hides a pane is itself always
157
176
  reachable — is a fact about the DOM (the chip lives in `.bar`, a grid sibling of `#bodyGrid`,
158
177
  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. */
178
+ it can genuinely fail: ui-test.js walks every remaining arrangement in a real engine. */
160
179
  function hasEscape(l) {
161
180
  return gridFor(l).areas.indexOf('term') !== -1;
162
181
  }
163
182
 
164
183
  /* R8 — carry the pre-feature client keys into this workspace's `review` so nobody's tuning is
165
- lost on upgrade. Reads the five localStorage values the shipped app wrote:
184
+ lost on upgrade. It read five localStorage values the shipped app wrote:
166
185
  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. */
186
+
187
+ THREE OF THE FIVE NO LONGER HAVE ANYWHERE TO GO, and that is stated rather than quietly
188
+ dropped: `rail: off` positioned a pane that is now always a column (hiding it is the column
189
+ collapse, which is client state and not part of an arrangement); `libsplit` divided a rail that
190
+ no longer has two halves; `fold-doc` folded `#paneDoc`, which no longer exists. Carrying any of
191
+ them would mean writing a value no reader can honour.
192
+
193
+ `railw` still has a real destination — it sized the same column `panelW` now sizes — so it is
194
+ the one width that survives, and `fold-lib` still folds the pane it always folded. A PM
195
+ upgrading from the pre-feature build keeps their column width and their fold; they lose a
196
+ reader position for a reader that is gone. */
170
197
  function migrateLegacy(legacy) {
171
198
  const g = (k) => (legacy && typeof legacy === 'object' ? legacy[k] : null);
172
199
  const review = stockLayout(DEFAULT_ID);
173
- const railOff = g('rail') === 'off';
174
- if (railOff) review.reader = 'off';
175
200
  const w = num(String(g('railw') || '').replace('px', ''), null);
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;
201
+ if (w !== null) review.panelW = Math.round(clamp(w, PANEL_MIN, PANEL_MAX));
179
202
  if (g('fold-lib') === 'off') review.folds.lib = false;
180
- if (g('fold-doc') === 'off') review.folds.doc = false;
181
203
  const layouts = stockLayouts().map((l) => (l.id === DEFAULT_ID ? review : l));
182
204
  return { layouts, currentId: DEFAULT_ID };
183
205
  }
@@ -198,8 +220,8 @@
198
220
  }
199
221
 
200
222
  return {
201
- SIDE, READER, SPLIT, FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
202
- SIDE_MIN, SIDE_MAX, READ_MIN, READ_MAX, LIB_MIN, LIB_MAX, LIB_DEFAULT, BOTTOM_H,
223
+ SIDE, SPLIT, FOLD_KEYS, RETIRED_FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
224
+ SIDE_MIN, SIDE_MAX, PANEL_MIN, PANEL_MAX,
203
225
  isStock, stockLayout, stockLayouts, layout, sanitize, gridFor, hasEscape, migrateLegacy,
204
226
  loadAction,
205
227
  };
@@ -0,0 +1,127 @@
1
+ /* mobile-nav — the phone surface's navigation reducer (workspace-mobile-surface).
2
+ *
3
+ * One source of truth for "which screen am I on", used on BOTH sides:
4
+ * - the browser loads it as /lib/mobile-nav.js before app.js (window.PMOSMobileNav)
5
+ * - test.js requires it, so the whole domain is walkable in node without a browser
6
+ * exactly as lib/layout.js is. It was written inline in app.js and moved here because the rubric
7
+ * asks for a pure module and because a reducer that cannot be unit-tested is one whose exclusivity
8
+ * guarantee is only ever checked by eye.
9
+ *
10
+ * DOM-FREE ON PURPOSE. Every function takes a nav state and returns a new one; none touches an
11
+ * element, a media query, or the app's `state`. That is what makes the four properties below
12
+ * decidable without a browser:
13
+ * P1 exactly one column is shown for any screen — `column` is a function of `kind` alone
14
+ * P2 every MODES key has a phone path — the map is iterated, never a literal list
15
+ * P3 back never exits the app and never unwinds into another destination
16
+ * P4 choosing a destination resets it to its root
17
+ *
18
+ * The screen model:
19
+ * { dest, kind, mode, id, dk, title, column }
20
+ * dest one of DESTS — which destination this screen belongs to
21
+ * kind 'list' | 'detail' | 'terminal' | 'doc' | 'more'
22
+ * mode a MODES key, for kind 'list'
23
+ * id the detail id / session id / viewer id
24
+ * dk the DETAIL kind, for kind 'detail'
25
+ * title only for 'terminal' and 'doc', where the name is not derivable
26
+ * column 'side' | 'term' | 'rail' — determined by `kind` and by nothing else
27
+ *
28
+ * `column` is the whole of the exclusivity guarantee: the stylesheet shows exactly one of the three
29
+ * column nodes per value, so P1 is a property of the CSS rather than something this reducer has to
30
+ * remember to enforce. The columns are never re-parented and never rebuilt, which is what makes the
31
+ * xterm instance and its PTY socket survive a push, a pop and a destination switch structurally.
32
+ */
33
+ 'use strict';
34
+ (function (root, factory) {
35
+ const api = factory();
36
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
37
+ else root.PMOSMobileNav = api;
38
+ })(typeof self !== 'undefined' ? self : this, function () {
39
+
40
+ // What a LIST screen is called, keyed by mode. This mirrors `MODES[].title` in app.js, and the
41
+ // mirror is deliberate rather than lazy: `titleOf` used to read app.js's `MODE_BY_K` directly,
42
+ // which works in a browser (one global scope) and throws `ReferenceError` the moment the module is
43
+ // required in node — for `kind:'list'`, the root of four of the five destinations. Nothing was red,
44
+ // because nothing called it outside a browser; the header two lines below claimed the whole domain
45
+ // was walkable in node, and it was not. A module that reaches back into its caller for a lookup
46
+ // table is not pure, and the fix for that is to hold the table, not to keep reaching.
47
+ // The drift risk a mirror creates is paid for in the suite: MOBNAV-P6 iterates app.js's own MODES
48
+ // against this map, so a mode added or retitled there and forgotten here fails rather than
49
+ // silently renders a key where a title belongs.
50
+ const MODE_TITLE = {
51
+ product: 'initiatives', scheduled: 'scheduled tasks', workflow: 'workflows', skills: 'skills',
52
+ knowledge: 'knowledge', agent: 'agents', memory: 'memory',
53
+ };
54
+ // What a DETAIL screen is called. It moved here with the reducer, for the same reason.
55
+ const DETAIL_TITLE = {
56
+ initiative: 'initiative', repo: 'repository', flow: 'workflow', sched: 'scheduled task',
57
+ skill: 'skill', know: 'knowledge', agent: 'agent', mem: 'memory',
58
+ ingest: 'ingest proposal', profiles: 'profiles', connections: 'connections',
59
+ };
60
+
61
+ const MOBILE_DESTS = ['board', 'scheduled', 'agents', 'memory', 'more'];
62
+ // Every MODES key has an entry. The suite ITERATES `MODES` against this map rather than a literal
63
+ // list, so adding a mode without giving it a phone path fails the suite instead of going silently
64
+ // unreachable — which is the failure P2 exists to catch.
65
+ const MOBILE_DEST = { product: 'board', scheduled: 'scheduled', agent: 'agents', memory: 'memory',
66
+ workflow: 'more', skills: 'more', knowledge: 'more' };
67
+ const MOBILE_ROOT = {
68
+ board: { kind: 'list', mode: 'product' },
69
+ scheduled: { kind: 'list', mode: 'scheduled' },
70
+ agents: { kind: 'list', mode: 'agent' },
71
+ memory: { kind: 'list', mode: 'memory' },
72
+ more: { kind: 'more', mode: null },
73
+ };
74
+ // kind → column. There is no second input, and no screen may carry a column its kind does not give.
75
+ const MOBILE_COLUMN = { list: 'side', more: 'side', detail: 'rail', terminal: 'term', doc: 'term' };
76
+ function mobColumnOf(kind) { return MOBILE_COLUMN[kind] || 'side'; }
77
+ function mobDestForMode(k) { return MOBILE_DEST[k] || null; }
78
+ // Complete a partial screen. The column is derived, never passed in, so a caller cannot put a
79
+ // detail on the terminal column by writing it down.
80
+ function mobScreen(s) {
81
+ const kind = (s && s.kind) || 'list';
82
+ return { dest: (s && s.dest) || 'board', kind, mode: (s && s.mode) || null,
83
+ id: (s && s.id) || null, dk: (s && s.dk) || null, title: (s && s.title) || null,
84
+ column: mobColumnOf(kind) };
85
+ }
86
+ function mobRootOf(dest) {
87
+ const r = MOBILE_ROOT[dest] || MOBILE_ROOT.board;
88
+ return mobScreen({ dest: MOBILE_ROOT[dest] ? dest : 'board', kind: r.kind, mode: r.mode });
89
+ }
90
+ /* The title map. `list` and `more` derive; `detail` reads DETAIL_TITLE (declared far below — this
91
+ * is only ever called at runtime, long after that const has evaluated); `terminal` and `doc` carry
92
+ * a title because a session's name and a document's name are not derivable from a screen. */
93
+ function mobTitleOf(s) {
94
+ if (!s) return '';
95
+ if (s.kind === 'list') return MODE_TITLE[s.mode] || String(s.mode || '');
96
+ if (s.kind === 'more') return 'more';
97
+ if (s.kind === 'detail') return DETAIL_TITLE[s.dk] || 'detail';
98
+ return s.title || String(s.id || '');
99
+ }
100
+ function mobNavInit(dest) { return { dest: MOBILE_ROOT[dest] ? dest : 'board', stack: [] }; }
101
+ function mobPush(nav, screen) {
102
+ const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
103
+ return { dest: n.dest, stack: n.stack.concat([mobScreen(Object.assign({ dest: n.dest }, screen))]) };
104
+ }
105
+ // Removes EXACTLY one. Back from a destination root is a no-op — it never exits the app and never
106
+ // unwinds into another destination, which is the second half of P3.
107
+ function mobPop(nav) {
108
+ const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
109
+ return n.stack.length ? { dest: n.dest, stack: n.stack.slice(0, -1) } : { dest: n.dest, stack: [] };
110
+ }
111
+ // Choosing a destination RESETS it to its root with an empty stack — the prototype's {tab, stack:[]}.
112
+ function mobSwitchDest(nav, dest) { return mobNavInit(dest); }
113
+ function mobCurrentOf(nav) {
114
+ const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
115
+ return n.stack.length ? n.stack[n.stack.length - 1] : mobRootOf(n.dest);
116
+ }
117
+ /* R11 — the legacy key is read ONCE, for migration, and never written again. Every value the old
118
+ * three-way switcher could hold, plus anything unrecognised and nothing at all, lands on `board`:
119
+ * the destination root is the only screen that is safe to draw without re-reading anything. */
120
+ function mobMigrateView(v) { return MOBILE_ROOT[v] ? v : 'board'; }
121
+
122
+ return { push: mobPush, pop: mobPop, switchDest: mobSwitchDest, init: mobNavInit,
123
+ current: mobCurrentOf, titleOf: mobTitleOf, columnOf: mobColumnOf, destForMode: mobDestForMode,
124
+ MODE_TITLE,
125
+ migrateView: mobMigrateView, screen: mobScreen, root: mobRootOf,
126
+ DESTS: MOBILE_DESTS, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT, DETAIL_TITLE };
127
+ });