create-pmos 0.14.0 → 0.16.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 (39) hide show
  1. package/README.md +57 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/console-state.js +48 -1
  7. package/workspace/lib/engine/workflow-state.js +13 -3
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/focus.js +967 -0
  10. package/workspace/lib/four-panel.js +345 -0
  11. package/workspace/lib/git-info.js +28 -1
  12. package/workspace/lib/handoff.js +280 -0
  13. package/workspace/lib/layout.js +68 -46
  14. package/workspace/lib/mobile-nav.js +157 -0
  15. package/workspace/lib/profiles.js +285 -0
  16. package/workspace/lib/repo-scan.js +469 -0
  17. package/workspace/package-lock.json +29 -2
  18. package/workspace/package.json +14 -4
  19. package/workspace/public/index.html +79 -279
  20. package/workspace/public/pm/app.js +242 -0
  21. package/workspace/public/pm/data.js +877 -0
  22. package/workspace/public/pm/dom.js +116 -0
  23. package/workspace/public/pm/fixture.js +979 -0
  24. package/workspace/public/pm/mobile.css +200 -0
  25. package/workspace/public/pm/mobile.js +403 -0
  26. package/workspace/public/pm/modes.js +1076 -0
  27. package/workspace/public/pm/panel-detail.js +648 -0
  28. package/workspace/public/pm/panel-initiatives.js +426 -0
  29. package/workspace/public/pm/panel-repos.js +460 -0
  30. package/workspace/public/pm/panel-tabs.js +487 -0
  31. package/workspace/public/pm/state.js +164 -0
  32. package/workspace/public/pm/tab-doc.js +279 -0
  33. package/workspace/public/pm/tab-task.js +83 -0
  34. package/workspace/public/pm/tab-term.js +560 -0
  35. package/workspace/public/pm/tokens.css +73 -0
  36. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  37. package/workspace/server.js +1072 -31
  38. package/workspace/public/app.js +0 -3450
  39. package/workspace/public/styles.css +0 -1444
@@ -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,157 @@
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
+ // The second block is the four-panel surface's `detail.kind` vocabulary (pm/app.js's DETAIL_TITLE).
56
+ // It is ADDED rather than substituted: the first block is what the retired surface pushes and the
57
+ // two overlap on every key they share, so one map titles a detail screen coming from either. A
58
+ // missing key here is not a crash — `titleOf` falls back to 'detail' — it is a phone header that
59
+ // says nothing, which is why the set is written down rather than left to the fallback.
60
+ const DETAIL_TITLE = {
61
+ initiative: 'initiative', repo: 'repository', flow: 'workflow', sched: 'scheduled task',
62
+ skill: 'skill', know: 'knowledge', agent: 'agent', mem: 'memory',
63
+ ingest: 'ingest proposal', profiles: 'profiles', connections: 'connections',
64
+ init: 'initiative', prof: 'claude profile', intg: 'connection',
65
+ addprod: 'add repository', new: 'file new',
66
+ };
67
+
68
+ const MOBILE_DESTS = ['board', 'scheduled', 'agents', 'memory', 'more'];
69
+ // Every MODES key has an entry. The suite ITERATES `MODES` against this map rather than a literal
70
+ // list, so adding a mode without giving it a phone path fails the suite instead of going silently
71
+ // unreachable — which is the failure P2 exists to catch.
72
+ const MOBILE_DEST = { product: 'board', scheduled: 'scheduled', agent: 'agents', memory: 'memory',
73
+ workflow: 'more', skills: 'more', knowledge: 'more' };
74
+ const MOBILE_ROOT = {
75
+ board: { kind: 'list', mode: 'product' },
76
+ scheduled: { kind: 'list', mode: 'scheduled' },
77
+ agents: { kind: 'list', mode: 'agent' },
78
+ memory: { kind: 'list', mode: 'memory' },
79
+ more: { kind: 'more', mode: null },
80
+ };
81
+ // kind → column. There is no second input, and no screen may carry a column its kind does not give.
82
+ const MOBILE_COLUMN = { list: 'side', more: 'side', detail: 'rail', terminal: 'term', doc: 'term' };
83
+ function mobColumnOf(kind) { return MOBILE_COLUMN[kind] || 'side'; }
84
+
85
+ /* kind → PANEL INDEX, for the four-panel surface (public/pm/).
86
+ *
87
+ * WHY THIS EXISTS ALONGSIDE `column`, RATHER THAN REPLACING IT. `column` names one of the three
88
+ * columns of the retired `public/app.js` layout, and MOBNAV-P1 pins it to exactly those three
89
+ * values. The surface that ships now has FOUR panels — repositories · mode list · detail · tabs —
90
+ * and two of the old `side` screens belong to DIFFERENT panels there: a `list` screen is panel 2
91
+ * (the mode list) while a `more` screen is panel 1, which is the structure/settings panel and is
92
+ * therefore the phone's "more" destination in the four-panel design.
93
+ *
94
+ * So this is an ADDITIVE widening, not an edit: `columnOf` is untouched and still describes the
95
+ * three-column layout truthfully for the surface that owns it, and `panelOf` describes the
96
+ * four-panel one. Both are pure functions of `kind` alone, so the exclusivity guarantee — one
97
+ * visible surface per screen — is the same property stated against a different layout. Putting it
98
+ * HERE rather than in pm/mobile.js keeps a single node-walkable source of truth for "which surface
99
+ * does this screen show", which is the whole reason the reducer was pulled out of app.js.
100
+ */
101
+ const MOBILE_PANEL = { more: 0, list: 1, detail: 2, terminal: 3, doc: 3 };
102
+ function mobPanelOf(kind) {
103
+ return Object.prototype.hasOwnProperty.call(MOBILE_PANEL, kind) ? MOBILE_PANEL[kind] : 1;
104
+ }
105
+ function mobDestForMode(k) { return MOBILE_DEST[k] || null; }
106
+ // Complete a partial screen. The column is derived, never passed in, so a caller cannot put a
107
+ // detail on the terminal column by writing it down.
108
+ function mobScreen(s) {
109
+ const kind = (s && s.kind) || 'list';
110
+ return { dest: (s && s.dest) || 'board', kind, mode: (s && s.mode) || null,
111
+ id: (s && s.id) || null, dk: (s && s.dk) || null, title: (s && s.title) || null,
112
+ column: mobColumnOf(kind) };
113
+ }
114
+ function mobRootOf(dest) {
115
+ const r = MOBILE_ROOT[dest] || MOBILE_ROOT.board;
116
+ return mobScreen({ dest: MOBILE_ROOT[dest] ? dest : 'board', kind: r.kind, mode: r.mode });
117
+ }
118
+ /* The title map. `list` and `more` derive; `detail` reads DETAIL_TITLE (declared far below — this
119
+ * is only ever called at runtime, long after that const has evaluated); `terminal` and `doc` carry
120
+ * a title because a session's name and a document's name are not derivable from a screen. */
121
+ function mobTitleOf(s) {
122
+ if (!s) return '';
123
+ if (s.kind === 'list') return MODE_TITLE[s.mode] || String(s.mode || '');
124
+ if (s.kind === 'more') return 'more';
125
+ if (s.kind === 'detail') return DETAIL_TITLE[s.dk] || 'detail';
126
+ return s.title || String(s.id || '');
127
+ }
128
+ function mobNavInit(dest) { return { dest: MOBILE_ROOT[dest] ? dest : 'board', stack: [] }; }
129
+ function mobPush(nav, screen) {
130
+ const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
131
+ return { dest: n.dest, stack: n.stack.concat([mobScreen(Object.assign({ dest: n.dest }, screen))]) };
132
+ }
133
+ // Removes EXACTLY one. Back from a destination root is a no-op — it never exits the app and never
134
+ // unwinds into another destination, which is the second half of P3.
135
+ function mobPop(nav) {
136
+ const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
137
+ return n.stack.length ? { dest: n.dest, stack: n.stack.slice(0, -1) } : { dest: n.dest, stack: [] };
138
+ }
139
+ // Choosing a destination RESETS it to its root with an empty stack — the prototype's {tab, stack:[]}.
140
+ function mobSwitchDest(nav, dest) { return mobNavInit(dest); }
141
+ function mobCurrentOf(nav) {
142
+ const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
143
+ return n.stack.length ? n.stack[n.stack.length - 1] : mobRootOf(n.dest);
144
+ }
145
+ /* R11 — the legacy key is read ONCE, for migration, and never written again. Every value the old
146
+ * three-way switcher could hold, plus anything unrecognised and nothing at all, lands on `board`:
147
+ * the destination root is the only screen that is safe to draw without re-reading anything. */
148
+ function mobMigrateView(v) { return MOBILE_ROOT[v] ? v : 'board'; }
149
+
150
+ return { push: mobPush, pop: mobPop, switchDest: mobSwitchDest, init: mobNavInit,
151
+ current: mobCurrentOf, titleOf: mobTitleOf, columnOf: mobColumnOf, panelOf: mobPanelOf,
152
+ destForMode: mobDestForMode,
153
+ MODE_TITLE,
154
+ migrateView: mobMigrateView, screen: mobScreen, root: mobRootOf,
155
+ DESTS: MOBILE_DESTS, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT,
156
+ PANEL_OF_KIND: MOBILE_PANEL, DETAIL_TITLE };
157
+ });
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+ /*
3
+ * Claude profile descriptors — a PURE reader (initiative `workspace-profiles`, PRD R1/R2/R4/R6/R7).
4
+ *
5
+ * The workspace lets the PM name a profile when a session's process is created. This module is the
6
+ * half of that feature which decides WHAT IS TRUE: it reads `<home>/.claude/profiles/*.json` and
7
+ * reports what it found. It never spawns anything, never writes, and never touches the network.
8
+ *
9
+ * WHAT THIS MODULE IS NOT ALLOWED TO IMPLY. PMOS holds no credential, runs no MCP client, and
10
+ * cannot ask a running `claude` what configuration it is really using. So every value here is
11
+ * "what a file on this machine declares", never "what is true of the world". The naming below is
12
+ * deliberately clumsy in places (`declared`, `recorded`) because the shorter words all read as
13
+ * verified facts.
14
+ *
15
+ * HONESTY BY CONSTRUCTION — readProfiles() returns a FOUR-way discrimination, not a boolean.
16
+ * These are four different facts about the world and collapsing any pair of them produces a
17
+ * specific, known lie:
18
+ *
19
+ * { ok:true, absent:true, profiles:[], broken:[] } no ~/.claude/profiles directory.
20
+ * A calm, stated absence. Nothing was read because there was nothing to read. The picker
21
+ * renders the path and says so. It must NOT render `work`/`personal` chips: those are the
22
+ * names the PM's machine is expected to carry, not names PMOS ships, and drawing them here
23
+ * is the PRD's stated FAIL — a picker appears, a choice is made, and nothing was applied.
24
+ *
25
+ * { ok:true, absent:false, profiles:[], broken:[] } the directory exists and is empty.
26
+ * A SUCCESSFUL READ OF NOTHING. Same consequence for the PM, different fact: the directory
27
+ * was created and left empty rather than never created. Calm, not red.
28
+ *
29
+ * { ok:true, absent:false, profiles:[…], broken:[…] } some descriptors read, some did not.
30
+ * R4: one bad file must not take the others down. The good ones are still offered; each bad
31
+ * one is named with its filename and its reason so it renders RED. Note that `profiles` can
32
+ * be EMPTY while `broken` is not — every descriptor on the machine is malformed. A consumer
33
+ * that tests only `profiles.length` renders that as the calm empty state above, which
34
+ * reports a failure as an emptiness. Check `broken.length` before drawing calm prose.
35
+ *
36
+ * { ok:false, absent:false, profiles:[], error:'…' } the directory could not be read.
37
+ * Permissions, or a FILE sitting where the directory should be. This is the distinction the
38
+ * module exists to protect: a missing directory and an unreadable one are different facts.
39
+ * Defaulting an unreadable directory to "no profiles configured" would state a capability
40
+ * claim the data does not support — it would tell the PM their machine has no profiles when
41
+ * what actually happened is that PMOS could not look.
42
+ *
43
+ * `dir` is returned in every case, including the failures, because R2 and R10 both require the
44
+ * surface to NAME the path it read or failed to read. An absence with no path is an assertion the
45
+ * PM cannot check.
46
+ */
47
+ const fs = require('fs');
48
+ const path = require('path');
49
+
50
+ // Where the agent CLI keeps them. PMOS does not define this format and never writes here (PRD
51
+ // non-goal 3) — it is read-only to us, so an unexpected key is ignored rather than migrated.
52
+ const PROFILE_SUBDIR = ['.claude', 'profiles'];
53
+
54
+ function profilesDir(homeDir) { return path.join(homeDir, ...PROFILE_SUBDIR); }
55
+
56
+ // Errors are shown to a human who has to go fix a file, so carry the code AND the message. Both
57
+ // halves earn their place: the code says what kind of problem it is, the message says which path.
58
+ // Node already prefixes most fs messages with the code, so only prepend it when it is missing —
59
+ // "EISDIR: EISDIR: illegal operation" is noise in a line a PM is meant to act on.
60
+ function describeErr(e) {
61
+ const msg = String((e && e.message) || e);
62
+ const code = e && e.code ? String(e.code) : '';
63
+ return (code && !msg.startsWith(code) ? `${code}: ${msg}` : msg).slice(0, 200);
64
+ }
65
+
66
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
67
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
68
+
69
+ /*
70
+ * Validate one parsed descriptor against R1's table. Returns `{ profile }` or `{ error }`.
71
+ *
72
+ * The rule running through every branch: A KEY THAT IS PRESENT BUT WRONG-TYPED MAKES THE
73
+ * DESCRIPTOR UNREADABLE. It is never coerced and never dropped. The reason is that both softer
74
+ * options lie in the PM's favour. Coercing `mcp: "jira"` into `["jira"]` invents a declaration the
75
+ * file did not make; dropping it renders "no mcp servers declared in this profile" over a file
76
+ * that plainly declared one. Rejecting is loud, names the file, and leaves the other descriptors
77
+ * alone (R4) — the PM gets a fixable error instead of a quiet wrong answer.
78
+ *
79
+ * A key that is ABSENT is a different matter: R1 forbids substituting a default, so absence is
80
+ * carried through as `null` (account) or `[]` (mcp) for the consumer to state, not to fill in.
81
+ */
82
+ function validate(raw, stem) {
83
+ if (!isPlainObject(raw)) {
84
+ const found = raw === null ? 'null' : Array.isArray(raw) ? 'an array' : typeof raw;
85
+ return { error: `is not a JSON object — found ${found}` };
86
+ }
87
+
88
+ // `id` is the identity the pane line, the session record and the descriptor path all cite, so it
89
+ // is the one key with no tolerated absence. Falling back to the filename stem would look
90
+ // harmless and be exactly the wrong thing: PMOS would be manufacturing the identity it then
91
+ // displays as read from the file, and a later rename would silently change which profile a
92
+ // session claims to have run under.
93
+ if (!has(raw, 'id')) {
94
+ return { error: 'declares no `id` — PMOS will not substitute the filename, because the id is the identity the pane and the session record cite' };
95
+ }
96
+ if (typeof raw.id !== 'string' || !raw.id) return { error: '`id` is present but is not a non-empty string' };
97
+ if (raw.id !== stem) {
98
+ return { error: `declares id "${raw.id}" but is filed as ${stem}.json — the descriptor's identity disagrees with its filename` };
99
+ }
100
+
101
+ let label = raw.id; // R1: display name, falls back to `id` when absent
102
+ if (has(raw, 'label')) {
103
+ if (typeof raw.label !== 'string' || !raw.label.trim()) return { error: '`label` is present but is not a non-empty string' };
104
+ label = raw.label;
105
+ }
106
+
107
+ // Displayed VERBATIM and always attributed to the file (R1). PMOS cannot verify an account
108
+ // string and does not try; if it is wrong on disk it is wrong on screen, which is why every
109
+ // surface that shows it also shows where it came from. Absent stays null — "not declared" is a
110
+ // thing the consumer can say, and is not the same as an empty account name.
111
+ let account = null;
112
+ if (has(raw, 'account')) {
113
+ if (typeof raw.account !== 'string') return { error: '`account` is present but is not a string' };
114
+ account = raw.account;
115
+ }
116
+
117
+ // R7: these are names a file declares, never servers PMOS reached. Absent and `[]` collapse to
118
+ // the same array on purpose — both mean "this descriptor declares no MCP servers", which is one
119
+ // fact with one honest phrasing. What must NOT collapse into it is a malformed `mcp`, above.
120
+ let mcp = [];
121
+ if (has(raw, 'mcp')) {
122
+ if (!Array.isArray(raw.mcp) || raw.mcp.some((x) => typeof x !== 'string')) {
123
+ return { error: '`mcp` is present but is not an array of server names' };
124
+ }
125
+ mcp = raw.mcp.slice();
126
+ }
127
+
128
+ // The only key with a mechanical effect: it becomes the environment overlay at process creation
129
+ // (R3). `null` (key absent) and `{}` (declared empty) are kept DISTINCT here even though both
130
+ // apply nothing, because the reader's job is to report the file faithfully; profileLine() is
131
+ // where they converge, and it converges them on the sentence that says nothing was applied.
132
+ let env = null;
133
+ if (has(raw, 'env')) {
134
+ if (!isPlainObject(raw.env)) return { error: '`env` is present but is not an object of environment variables' };
135
+ for (const k of Object.keys(raw.env)) {
136
+ if (typeof raw.env[k] !== 'string') {
137
+ return { error: `\`env.${k}\` is not a string — PMOS will not coerce a value into an environment variable it was not given` };
138
+ }
139
+ }
140
+ env = { ...raw.env };
141
+ }
142
+
143
+ return { profile: { id: raw.id, label, account, mcp, env } };
144
+ }
145
+
146
+ /*
147
+ * Read every descriptor under `<homeDir>/.claude/profiles`.
148
+ *
149
+ * `homeDir` is a parameter rather than `os.homedir()` read inside, which is what makes this
150
+ * testable against real fixture directories instead of against a mock of the filesystem. A caller
151
+ * with no home directory gets a stated failure; guessing one would be the same class of invention
152
+ * this whole module refuses.
153
+ */
154
+ function readProfiles(homeDir) {
155
+ if (typeof homeDir !== 'string' || !homeDir) {
156
+ return {
157
+ ok: false, absent: false, dir: null, profiles: [], broken: [],
158
+ error: 'no home directory was given — PMOS will not guess where .claude/profiles lives',
159
+ };
160
+ }
161
+ const dir = profilesDir(homeDir);
162
+
163
+ let names;
164
+ try {
165
+ names = fs.readdirSync(dir);
166
+ } catch (e) {
167
+ // THE DISTINCTION THIS MODULE EXISTS FOR. ENOENT is "there is nothing here", which is calm and
168
+ // true. Everything else — EACCES, ENOTDIR, EIO — is "PMOS could not look", which is a failure
169
+ // and must read red. Reporting the second as the first would tell the PM their machine has no
170
+ // profiles configured when PMOS never got far enough to know.
171
+ if (e && e.code === 'ENOENT') return { ok: true, absent: true, dir, profiles: [], broken: [], error: null };
172
+ if (e && e.code === 'ENOTDIR') {
173
+ return { ok: false, absent: false, dir, profiles: [], broken: [], error: `${dir} is not a directory — a file is sitting where the profiles directory should be` };
174
+ }
175
+ return { ok: false, absent: false, dir, profiles: [], broken: [], error: `${dir} could not be read — ${describeErr(e)}` };
176
+ }
177
+
178
+ const profiles = [];
179
+ const broken = [];
180
+ // Sorted so the picker's chip order is stable across reads. Directory order is not.
181
+ for (const name of names.slice().sort()) {
182
+ if (!name.endsWith('.json')) continue; // not a descriptor at all — not a failed one either
183
+ const file = path.join(dir, name);
184
+ const stem = name.slice(0, -'.json'.length);
185
+
186
+ let text;
187
+ try {
188
+ text = fs.readFileSync(file, 'utf8');
189
+ } catch (e) {
190
+ // Includes EISDIR: a DIRECTORY named `something.json`. Reported rather than skipped — it
191
+ // occupies the name a descriptor would have, so silently passing over it would leave the PM
192
+ // looking at a picker missing a chip with nothing anywhere saying why.
193
+ broken.push({ file: name, path: file, error: `could not be read — ${describeErr(e)}` });
194
+ continue;
195
+ }
196
+
197
+ let raw;
198
+ try {
199
+ raw = JSON.parse(text);
200
+ } catch (e) {
201
+ broken.push({ file: name, path: file, error: `is not valid JSON — ${String((e && e.message) || e).slice(0, 140)}` });
202
+ continue;
203
+ }
204
+
205
+ const v = validate(raw, stem);
206
+ if (v.error) { broken.push({ file: name, path: file, error: v.error }); continue; }
207
+ profiles.push({ ...v.profile, path: file });
208
+ }
209
+
210
+ // ok:true means THE DIRECTORY was read. It does not mean every file in it was. R4 requires those
211
+ // to be separable, so a malformed descriptor lands in `broken` and leaves its neighbours usable.
212
+ return { ok: true, absent: false, dir, profiles, broken, error: null };
213
+ }
214
+
215
+ /*
216
+ * The one line naming what a pane is running under — PRD R6, verbatim sentences.
217
+ *
218
+ * DIVERGENCE FROM THE DESIGN, recorded rather than glossed. `HANDOFF.md` §3 asks for
219
+ * `profile: claude-work · mcp: jira confluence slack gworkspace`, and `mcp: none` for the other
220
+ * case. This returns the PRD's R6/R7 wording instead, for two reasons the PRD states and the
221
+ * design does not address:
222
+ * - `mcp: none` reads as a checked fact about the world. PMOS has no MCP client and never
223
+ * contacted anything; the only thing it knows is what the descriptor declared. R7 forbids the
224
+ * phrase outright, and the honest replacement is "no mcp servers declared in this profile".
225
+ * - the design's line has no way to say "the profile declares no environment overlay", which is
226
+ * the case where the PM is most likely to believe a choice took effect when nothing did.
227
+ * If the literal design string is wanted it is a PM ruling, not an implementation detail.
228
+ *
229
+ * "applied N environment variables" IS A CLAIM ABOUT THE SPAWN, not about the file. This function
230
+ * is a formatter and cannot verify it. The caller must therefore only pass a descriptor for a
231
+ * session PMOS actually created with that descriptor's overlay (R3); rendering this line from a
232
+ * descriptor that was merely selected would state an application that never happened.
233
+ *
234
+ * The three no-descriptor cases are distinguished by the ARGUMENT'S OWN TYPE, because they survive
235
+ * a JSON round-trip differently and a session record is JSON:
236
+ * undefined / key absent → "not recorded" (an orphan tmux pane, or a record persisted before
237
+ * this feature existed — inventing a profile for it
238
+ * would be a lie about the past)
239
+ * null → "none was chosen" (PMOS started it and recorded that nothing was
240
+ * picked; `null` is written explicitly so it does
241
+ * not decay into the case above)
242
+ * A bare id STRING is rejected on purpose: what was applied is a fact about the descriptor, so a
243
+ * caller holding only an id must resolve it through readProfiles() first (R9 — a respawn whose
244
+ * descriptor has since been deleted must not substitute another).
245
+ */
246
+ function profileLine(profile, read) {
247
+ if (profile === undefined) return 'profile: not recorded — PMOS did not start this pane';
248
+
249
+ if (profile === null) {
250
+ // Only claim the machine has none configured when the read in hand actually says so. A read
251
+ // that failed, or one that found broken descriptors, does not support that sentence — the
252
+ // profiles may well be there and unreadable.
253
+ const noneConfigured = !!read && read.ok === true &&
254
+ (read.absent === true ||
255
+ ((read.profiles || []).length === 0 && (read.broken || []).length === 0));
256
+ return noneConfigured
257
+ ? 'no profile — none was chosen (no profiles are configured on this machine)'
258
+ : 'no profile — none was chosen for this session';
259
+ }
260
+
261
+ if (!isPlainObject(profile)) return 'profile: unreadable — the recorded value is not a profile descriptor';
262
+
263
+ // `label` is the declared display name (R1) but `id` is the traceable one: it is the descriptor's
264
+ // filename stem, so a reader can go check the file. Label wins where present because the spec
265
+ // says so; where it is absent the id carries the line.
266
+ const name = (typeof profile.label === 'string' && profile.label) ? profile.label
267
+ : (typeof profile.id === 'string' && profile.id) ? profile.id : null;
268
+ if (!name) return 'profile: unreadable — the recorded descriptor names no profile';
269
+
270
+ const n = isPlainObject(profile.env) ? Object.keys(profile.env).length : 0;
271
+ if (n === 0) {
272
+ // Covers both "no `env` key" and "`env: {}`". They differ on disk and are identical to the PM:
273
+ // nothing was applied. Saying "applied 0 environment variables" would be the precise phrasing
274
+ // most likely to be read as success, so R6 replaces it with a sentence about consequence.
275
+ return `profile: ${name} — the profile declares no environment overlay; this session runs with the workspace's default environment`;
276
+ }
277
+
278
+ const vars = `applied ${n} environment variable${n === 1 ? '' : 's'}`;
279
+ const mcp = (Array.isArray(profile.mcp) && profile.mcp.length)
280
+ ? `mcp declared by the profile: ${profile.mcp.join(' ')}`
281
+ : 'no mcp servers declared in this profile';
282
+ return `profile: ${name} — ${vars} · ${mcp}`;
283
+ }
284
+
285
+ module.exports = { readProfiles, profileLine, profilesDir, PROFILE_SUBDIR };