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
@@ -25,7 +25,7 @@ const fs = require('fs');
25
25
  const os = require('os');
26
26
  const path = require('path');
27
27
  const crypto = require('crypto');
28
- const { execFile, spawnSync } = require('child_process');
28
+ const { execFile, spawnSync, spawn } = require('child_process');
29
29
  const { WebSocketServer } = require('ws');
30
30
 
31
31
  // node-pty is a NATIVE module; a broken/mismatched build is the #1 field failure
@@ -75,6 +75,15 @@ const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the
75
75
  const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
76
76
  const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
77
77
  const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
78
+ // Bundle 2 (planning/design/bundle-2-wiring-contract.md). Four already-built pure modules. The
79
+ // server's whole job for these four features is to READ the filesystem and hand the result over —
80
+ // none of their logic is re-derived here, because a second implementation of "what does this
81
+ // record declare" is how two surfaces come to disagree about the same file.
82
+ const repoScan = require('./lib/repo-scan'); // scanRepo(root) — pure, cannot write (ingest R3)
83
+ const profilesLib = require('./lib/profiles'); // readProfiles(homeDir) / profileLine(profile, read)
84
+ const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
85
+ const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
86
+ const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
78
87
  // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
79
88
  // (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
80
89
  // — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
@@ -98,12 +107,20 @@ const EMBEDDED_SELF = !INSTALLED_COPY && SELF_ROOT === PARENT_DIR &&
98
107
  // (unsupported direct run) has no home — the client must open a repo to do anything.
99
108
  const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists(SELF_ROOT));
100
109
  const PUB = path.join(__dirname, 'public');
110
+ const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
101
111
  const XROOT = path.join(__dirname, 'node_modules', '@xterm');
102
112
  const STATE = path.join(__dirname, 'state');
103
113
  const SESS_DIR = path.join(STATE, 'sessions');
104
114
  const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
105
115
  const LAYOUT_DIR = path.join(STATE, 'layouts'); // per-workspace arrangements (R7; local-only, gitignored)
106
116
  const WS_FILE = path.join(STATE, 'workspaces.json');
117
+ // Ingest proposals live BESIDE workspaces.json, never inside the repo being scanned (ingest R3):
118
+ // a proposal that is never accepted must leave the clone byte-identical.
119
+ const INGEST_DIR = path.join(STATE, 'ingest');
120
+ const CLONE_DIR = path.join(STATE, 'clones');
121
+ // PMOS's OWN memory of which profile to preselect per agent binary (profiles R8.2). It is a
122
+ // workspace preference, not an agent attribute — the file lives here for exactly that reason.
123
+ const PREFS_FILE = path.join(STATE, 'prefs.json');
107
124
  const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
108
125
  const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
109
126
  const layoutModel = require('./lib/layout'); // the arrangement model — validated on BOTH sides
@@ -139,6 +156,67 @@ function sessionEnv() {
139
156
  for (const k of Object.keys(env)) if (/^CLAUDE_CODE_/.test(k)) delete env[k];
140
157
  return env;
141
158
  }
159
+
160
+ // ── Agent profiles (workspace-profiles). ──────────────────────────────────────────────────────
161
+ // A profile is a DESCRIPTOR the PM already keeps at ~/.claude/profiles/*.json. PMOS reads it and
162
+ // applies its `env` overlay when it creates a process — that is the whole mechanism. It runs no
163
+ // MCP client, checks no reachability and holds no credential, so nothing here may say "connected".
164
+ //
165
+ // The read is not cached. A descriptor added while the workspace is open must appear on the next
166
+ // look, and a stale copy of a file whose whole purpose is provenance is worse than a re-read.
167
+ function readProfilesNow() { return profilesLib.readProfiles(os.homedir()); }
168
+ /** One descriptor by id, or null. Returns the READ too, because profileLine(null, read) needs it
169
+ * to tell "no profiles on this machine" from "the read failed and we cannot say". */
170
+ function profileById(id) {
171
+ const read = readProfilesNow();
172
+ if (id == null || id === '') return { read, profile: null };
173
+ const profile = (read.profiles || []).find((p) => p.id === id) || null;
174
+ return { read, profile };
175
+ }
176
+ // `env` VALUES NEVER CROSS THE WIRE (contract §1.3). An environment overlay is exactly where a
177
+ // credential lives, and D08's never-log-headers discipline covers anything the browser receives.
178
+ // The names are shipped so the pane can say WHAT was applied; the values are not.
179
+ function profileWire(p, read) {
180
+ const env = p && p.env && typeof p.env === 'object' ? p.env : {};
181
+ const keys = Object.keys(env);
182
+ return {
183
+ id: p.id, label: p.label == null ? null : p.label, account: p.account == null ? null : p.account,
184
+ mcp: Array.isArray(p.mcp) ? p.mcp.slice() : [],
185
+ env_count: keys.length, env_keys: keys,
186
+ // Computed HERE, once, so no surface can re-word R6's five sentences.
187
+ line: profilesLib.profileLine(p, read),
188
+ };
189
+ }
190
+ // PMOS's own memory: agent binary name → preferred profile id. It preselects the composer and
191
+ // starts nothing (R8.2). Read fresh; a corrupt file is treated as no memory rather than crashing
192
+ // a spawn — the preference is a convenience, and losing it costs one click.
193
+ function readPrefs() {
194
+ try { const o = JSON.parse(fs.readFileSync(PREFS_FILE, 'utf8')); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; }
195
+ catch { return {}; }
196
+ }
197
+ function writePrefs(prefs) {
198
+ try { fs.writeFileSync(PREFS_FILE, JSON.stringify(prefs)); return null; }
199
+ catch (e) { warnWriteFail('prefs.json (profile preselection)', e); return `the preference could not be saved — ${String((e && e.code) || 'write failed')}`; }
200
+ }
201
+ // The merged environment a session actually runs under. The overlay goes ON TOP of sessionEnv(),
202
+ // AFTER the marker-stripping (R3) — never instead of it, or a profile would silently re-enable the
203
+ // nested-session markers sessionEnv exists to remove.
204
+ function sessionEnvWithProfile(profile) {
205
+ const env = sessionEnv();
206
+ for (const [k, v] of Object.entries(profileOverlay(profile))) env[k] = v;
207
+ return env;
208
+ }
209
+ // Just the DELTA a profile introduces, stringified — never merged with the base. The tmux path
210
+ // needs these separately from sessionEnv() because a pane's environment does not come from the
211
+ // process that ran `new-session`: see createTmuxSession.
212
+ function profileOverlay(profile) {
213
+ const out = {};
214
+ if (profile && profile.env && typeof profile.env === 'object') {
215
+ for (const k of Object.keys(profile.env)) out[k] = String(profile.env[k]);
216
+ }
217
+ return out;
218
+ }
219
+
142
220
  const SCROLLBACK = 200_000; // bytes of ring buffer per session
143
221
  // How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
144
222
  // 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
@@ -230,10 +308,15 @@ function repoIdentity(root) {
230
308
  const now = Date.now();
231
309
  if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
232
310
  let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
311
+ let remote = null;
233
312
  try {
234
313
  const trees = gitInfo.worktrees(root);
235
314
  if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
236
315
  } catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
316
+ // Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
317
+ // another `git` shell-out on a call that runs for every workspace on every push. A failure is not
318
+ // a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
319
+ try { remote = gitInfo.originUrl(root); } catch { remote = null; }
237
320
  try {
238
321
  const bi = gitInfo.branchInfo(root);
239
322
  if (bi.ok) branch = bi.detached ? null : bi.current;
@@ -246,7 +329,7 @@ function repoIdentity(root) {
246
329
  // one drifts.
247
330
  else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
248
331
  } catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
249
- const val = { at: now, key, name, branch, branchError, branchCode };
332
+ const val = { at: now, key, name, branch, branchError, branchCode, remote };
250
333
  repoKeyCache.set(root, val);
251
334
  return val;
252
335
  }
@@ -262,6 +345,14 @@ function wsList() {
262
345
  // the client renders the first quietly and the second as a fault (R1/R2).
263
346
  repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
264
347
  branch_error: rid.branchError, branch_code: rid.branchCode,
348
+ // Where the tree came from, and the ONE label the repository list can honestly put on a row.
349
+ // `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
350
+ // answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
351
+ // cloned from GitHub, or a folder on this machine. A remote that is not GitHub's reads as
352
+ // `local`, because `github` would be a claim about a host nobody contacted. Deriving `github`
353
+ // from the mere presence of a `.git` directory is what this replaces.
354
+ remote: rid.remote,
355
+ kind: gitInfo.isGithubRemote(rid.remote) ? 'github' : 'local',
265
356
  };
266
357
  });
267
358
  }
@@ -669,9 +760,34 @@ function surfaceRoadmap(root, w) {
669
760
  // (KR\d+) — so `## O-INTEGRITY` and `KR-C` were invisible here while the ladder happily accepted
670
761
  // anchors naming them. One field with two opinions is the defect this initiative removes; a second
671
762
  // parser would have been the same bug wearing a different regex.
763
+ /* WHERE THE RESOLVER ACTUALLY LIVES — the same two-location lookup `lib/console-state.js` already
764
+ * does for the workflow engine, and for the same reason (incident kit-workflow-engine-missing).
765
+ * This function looked ONLY at `../scripts/anchor-resolve.js`, which exists in a PMOS checkout and
766
+ * nowhere else: the published package ships bin/ payload/ workspace/, so in an adopter's install
767
+ * there is no `scripts/` beside the app and every OKR and spine read answered "the anchor resolver
768
+ * is not present in this install". Panel 1 showed no count and panel 2 no groups, in every release
769
+ * that had the surface.
770
+ *
771
+ * It was invisible to every existing check because a dev checkout always has the file one directory
772
+ * up — the identical blind spot that hid the engine. It was found by launching the INSTALLED app
773
+ * with egress blocked (.github/scripts/offline-workspace-smoke.js), which is the shape adopters get
774
+ * and the shape nothing used to run.
775
+ *
776
+ * 1. `../scripts/anchor-resolve.js` — a repo checkout, preferred so a dev panel and CI cannot
777
+ * disagree about what resolves.
778
+ * 2. `lib/engine/anchor-resolve.js` — the copy `scripts/vendor-workspace-engine.js` vendors into
779
+ * the bundle (it is in the engine's own require closure, so it is already shipped). This is
780
+ * what an adopter runs. */
781
+ const ANCHOR_LIB_PATHS = [
782
+ path.join(__dirname, '..', 'scripts', 'anchor-resolve.js'),
783
+ path.join(__dirname, 'lib', 'engine', 'anchor-resolve.js'),
784
+ ];
672
785
  function anchorLib() {
673
- try { return require(path.join(__dirname, '..', 'scripts', 'anchor-resolve.js')); }
674
- catch { return null; }
786
+ for (const p of ANCHOR_LIB_PATHS) {
787
+ if (!fs.existsSync(p)) continue;
788
+ try { return require(p); } catch { return null; }
789
+ }
790
+ return null;
675
791
  }
676
792
  function surfaceOkr(root) {
677
793
  const A = anchorLib();
@@ -717,6 +833,97 @@ function surfaceOkr(root) {
717
833
  note: 'KR current/target and the lever tree are net-new state — shown as "— not tracked"',
718
834
  };
719
835
  }
836
+ // ── The PM spine (workspace-pm-spine): Objective → Key Result → Initiative. ────────────────────
837
+ // surfaceOkr() above reads the SAME objectives and KRs and deliberately stops short — its comment
838
+ // calls the initiative↔KR join "an explicit non-goal". That left the workspace able to say an
839
+ // anchor is VALID while unable to say what a KR is MADE OF, and left the UI rooted on the delivery
840
+ // substrate (repo → tree → session) with the initiative reduced to a string parsed out of a branch
841
+ // name. This function is that join, and nothing more: it re-uses anchor-resolve.js rather than
842
+ // re-parsing anything, so there is exactly one definition of what an anchor means (R1).
843
+ //
844
+ // It deliberately returns NO runtime state. Which working trees and sessions are on an initiative
845
+ // is a client-side attachment (the client already holds both, live over the socket) — keeping the
846
+ // server's answer purely the PM structure means a spine that is correct whether or not anything is
847
+ // currently checked out, which is the case R2 exists to make possible.
848
+ function surfaceSpine(root) {
849
+ const A = anchorLib();
850
+ if (!A) {
851
+ return { objectives: [], budgets: [], unanchored: [], unreadable: 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install — the spine cannot be derived here' };
852
+ }
853
+ const index = A.parseOkrIndex(root);
854
+ // R5 — a read that FAILED is not a read that found nothing. Unreadable renders RED; the calm
855
+ // empty state is reserved for a successful read of genuinely nothing.
856
+ if (!index.ok) {
857
+ return { objectives: [], budgets: [], unanchored: [], unreadable: index.error || 'the OKR documents could not be read' };
858
+ }
859
+ const budgetsDoc = A.readBudgets(root);
860
+
861
+ // the empty spine, keyed for O(1) attachment below
862
+ const objectives = index.objectives.map((o) => ({
863
+ id: o.token, key: o.id, name: (o.name || '').slice(0, 140), file: o.file,
864
+ krs: o.krs.map((k) => ({ id: k.token, key: k.id, name: (k.name || '').slice(0, 160), initiatives: [] })),
865
+ }));
866
+ const krByKey = new Map();
867
+ for (const o of objectives) for (const k of o.krs) krByKey.set(`${o.key}::${k.key}`, k);
868
+ const objByKey = new Map(objectives.map((o) => [o.key, o]));
869
+
870
+ const budgets = new Map(); // health:<id> groups — peers of objectives (D59), not a footnote
871
+ const unanchored = []; // R6 — never dropped to make the tree tidy
872
+
873
+ for (const dir of findDirsNamed(root, 'initiatives', BUNDLE_MAX_DEPTH)) {
874
+ let files = [];
875
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort(); } catch { continue; }
876
+ for (const f of files) {
877
+ if (f === 'README.md' || f === 'index.md') continue;
878
+ const fm = leadingFrontmatter(path.join(dir, f)) || {};
879
+ const id = String(fm.id || f.replace(/\.md$/, ''));
880
+ const rec = {
881
+ id,
882
+ title: String(fm.title || '').slice(0, 160),
883
+ lane: String(fm.lane || 'feature'),
884
+ file: path.relative(root, path.join(dir, f)),
885
+ // R7 — position is DERIVED by the workflow engine. No `stage:`/`status:` is read for it;
886
+ // `status` is carried only because the chore lane's intent lives there as prose.
887
+ has_spec: !!String(fm.prd_path || '').trim(),
888
+ has_rubric: !!String(fm.rubric_path || '').trim(),
889
+ };
890
+ const raw = fm.parent_kr == null ? '' : String(fm.parent_kr);
891
+ const r = A.resolveAnchor(raw, index, budgetsDoc);
892
+
893
+ if (r.kind === 'health' && r.status === 'resolved') {
894
+ const key = `health:${r.budget}`;
895
+ if (!budgets.has(key)) budgets.set(key, { id: key, name: r.budget, initiatives: [] });
896
+ budgets.get(key).initiatives.push(rec);
897
+ continue;
898
+ }
899
+ if (r.kind === 'okr' && r.status === 'resolved') {
900
+ const kr = r.kr ? krByKey.get(`${A.objectiveId(r.objective)}::${A.krId(r.kr)}`) : null;
901
+ if (kr) { kr.initiatives.push(rec); continue; }
902
+ // resolved to an OBJECTIVE with no KR named — real and legitimate, but it has no KR bucket
903
+ const obj = objByKey.get(A.objectiveId(r.objective));
904
+ if (obj) {
905
+ if (!obj.direct) obj.direct = [];
906
+ obj.direct.push(rec);
907
+ continue;
908
+ }
909
+ }
910
+ // R6 — anything that did not resolve is LISTED, with the resolver's own reason. This is the
911
+ // set surfaceOkr() only ever counted.
912
+ unanchored.push(Object.assign({}, rec, {
913
+ raw: raw.trim().slice(0, 100),
914
+ status: r.status,
915
+ reason: r.reason || 'this anchor does not resolve against the OKR documents or the health-budget registry',
916
+ }));
917
+ }
918
+ }
919
+ return {
920
+ objectives,
921
+ budgets: [...budgets.values()].sort((a, b) => a.id.localeCompare(b.id)),
922
+ unanchored,
923
+ unreadable: null,
924
+ budgets_readable: !!(budgetsDoc && budgetsDoc.ok),
925
+ };
926
+ }
720
927
  function surfaceReport(root, w) {
721
928
  const s = surfaceInitiatives(root, w);
722
929
  const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
@@ -798,12 +1005,19 @@ function fireSchedule(sched) {
798
1005
  // AGENT task — fire as a real PTY session running its skill (async; the skill files its own
799
1006
  // deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
800
1007
  if (task && task.kind === 'agent') {
1008
+ // The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
1009
+ // AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
1010
+ // below rather than the control simply being hidden.
801
1011
  const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
802
- origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null });
1012
+ origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null,
1013
+ profile: sched.profile == null ? null : sched.profile });
803
1014
  const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
804
1015
  const started = s.state !== 'exited';
805
1016
  if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
806
- return { ok: started, kind: 'agent', sessionId: s.id, reason: started ? '' : (s.note || 'could not start the session') };
1017
+ // A run that started WITHOUT the profile it recorded is reported as such rather than as a
1018
+ // clean start: the session is real, the environment is not the one the schedule declared.
1019
+ return { ok: started, kind: 'agent', sessionId: s.id,
1020
+ reason: started ? (s.profileRefusal || '') : (s.note || 'could not start the session') };
807
1021
  }
808
1022
  const runId = `sched-${sched.id}-${Date.now()}`;
809
1023
  let ok = false, deliverable = null, reason = '';
@@ -855,6 +1069,11 @@ function persist(s) {
855
1069
  // so a session survives even if the workspace registry is lost (it re-registers its root).
856
1070
  const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
857
1071
  state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
1072
+ // The profile ID ONLY — never the descriptor and never its env, which is where a credential
1073
+ // would live (D08). The key is written only for a session PMOS created, so its ABSENCE stays a
1074
+ // fact: a record persisted before profiles existed restores as "not recorded", and inventing a
1075
+ // key for the past would be a lie about it.
1076
+ if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
858
1077
  try {
859
1078
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
860
1079
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
@@ -866,7 +1085,7 @@ function unpersist(id) {
866
1085
  }
867
1086
  }
868
1087
  // Scrollback written by the PRE-control-mode architecture carries the tmux CLIENT's own screen
869
- // setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/app.js) — putting
1088
+ // setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/pm/tab-term.js) — putting
870
1089
  // the terminal straight back into the alternate buffer, where there is no scrollback. Strip it.
871
1090
  //
872
1091
  // The two signatures below are the client's, not an application's: the alt-screen switch IMMEDIATELY
@@ -1078,6 +1297,12 @@ function harnessOf(s) {
1078
1297
  }
1079
1298
  function sessionList() {
1080
1299
  const now = Date.now();
1300
+ // ONE profiles read per broadcast, not one per session: the line is a formatter over the
1301
+ // descriptor, and re-reading the directory per row would turn a 2s heartbeat into a directory
1302
+ // scan per pane. A read that FAILED is passed through as-is — profileLine() needs to see the
1303
+ // failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
1304
+ const pread = readProfilesNow();
1305
+ const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
1081
1306
  return [...sessions.values()].map((s) => {
1082
1307
  const d = harness.deriveState(s.state, s.lastOutputAt, now);
1083
1308
  const h = harnessOf(s);
@@ -1098,6 +1323,24 @@ function sessionList() {
1098
1323
  // double the transcript work the TX_REREAD_MS floor exists to bound.
1099
1324
  hx,
1100
1325
  missing: h.missing,
1326
+ // ── profiles (workspace-profiles R6). Two fields, and the second is a SENTENCE. ──
1327
+ // `profile_line` is always a string, computed here rather than on the client, because the
1328
+ // three no-descriptor cases are distinguished by the ARGUMENT'S OWN TYPE and `undefined`
1329
+ // cannot survive JSON.stringify: a session PMOS never started (no `profile` key at all →
1330
+ // "not recorded") and one PMOS started with nothing picked (`null` → "none was chosen") would
1331
+ // arrive on the wire identical. Computing the sentence server-side is what preserves them.
1332
+ // A recorded id whose descriptor has since been deleted resolves to no descriptor, and the
1333
+ // line says so rather than substituting another profile (R9).
1334
+ // A recorded id whose descriptor has since been deleted is handed to profileLine() AS THE
1335
+ // BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
1336
+ // profile descriptor"), which is the honest answer. Resolving it to `null` instead would
1337
+ // print "none was chosen" over a session that did choose one (R9).
1338
+ profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
1339
+ profile_line: profilesLib.profileLine(
1340
+ !('profile' in s) ? undefined
1341
+ : s.profile == null ? null
1342
+ : (byId.get(s.profile) || s.profile),
1343
+ pread),
1101
1344
  };
1102
1345
  });
1103
1346
  }
@@ -1241,7 +1484,10 @@ function writeTmuxConf() {
1241
1484
  }
1242
1485
  // A tmux CONTROL command (not the attach PTY). Uses the CLEANED session env so that the cold
1243
1486
  // start this may trigger seeds the tmux server's own environment marker-free (D62 / sessionEnv).
1244
- function tmuxCtl(args) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: sessionEnv(), encoding: 'utf8' }); }
1487
+ // `env` is an OPT-IN override and only `new-session` ever passes one (profiles R3). has-session /
1488
+ // kill-session / list-sessions / display-message have no session context, so handing them a
1489
+ // profile's overlay would apply it to whichever pane the command happened to touch — a silent bug.
1490
+ function tmuxCtl(args, env) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: env || sessionEnv(), encoding: 'utf8' }); }
1245
1491
  function tmuxHasSession(name) { return !!TMUX && tmuxCtl(['has-session', '-t', name]).status === 0; }
1246
1492
  function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t', name]); }
1247
1493
  // Create the tmux session a FRESH spawn runs in. It REFUSES a name that already exists rather
@@ -1249,10 +1495,25 @@ function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t'
1249
1495
  // name is someone else's process, in someone else's directory. Silently reusing it is how a new
1250
1496
  // session came up in the wrong repo. freshTmuxName makes the collision practically impossible;
1251
1497
  // this is the floor that keeps it impossible even if it were not.
1252
- function createTmuxSession(name, cmd, workdir, cols, rows) {
1498
+ //
1499
+ // THE PROFILE OVERLAY TRAVELS AS `-e`, NEVER AS THIS PROCESS'S ENV. A pane does not inherit the
1500
+ // environment of the process that ran `new-session` — it inherits the tmux SERVER's, which was
1501
+ // fixed at whatever cold-started the daemon. Handing the overlay to the client therefore did two
1502
+ // wrong things at once, both measured on tmux 3.4 before this line changed:
1503
+ // · the FIRST profiled session cold-started the server and seeded ITS overlay into the daemon,
1504
+ // so every later pane on the socket inherited it — a `personal` session and a session with no
1505
+ // profile at all both came up carrying the `work` overlay;
1506
+ // · while the pane's own profile line went on naming the profile that session had asked for.
1507
+ // A workspace that runs a personal session under work credentials and then TELLS the PM it did
1508
+ // not is the honest-state contract inverted, so this is a correctness fix and not a tidy-up.
1509
+ // `new-session -e K=V` (tmux ≥3.2) sets the environment of THAT session only; a session that
1510
+ // declares no profile is left genuinely unset rather than inheriting a neighbour's.
1511
+ function createTmuxSession(name, cmd, workdir, cols, rows, overlay) {
1253
1512
  if (tmuxHasSession(name)) return false;
1513
+ const eArgs = [];
1514
+ for (const [k, v] of Object.entries(overlay || {})) eArgs.push('-e', `${k}=${v}`);
1254
1515
  const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
1255
- '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
1516
+ ...eArgs, '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
1256
1517
  return r.status === 0;
1257
1518
  }
1258
1519
  // Every session alive on our private socket, with the directory its pane is actually sitting in.
@@ -1396,8 +1657,8 @@ function attachPty(s, { seed = false } = {}) {
1396
1657
  }
1397
1658
 
1398
1659
  // Try a command, falling back to `sh` on posix if the preferred shell can't spawn.
1399
- function spawnPty(cmd, workdir) {
1400
- const opts = { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: sessionEnv() };
1660
+ function spawnPty(cmd, workdir, env) {
1661
+ const opts = { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: env || sessionEnv() };
1401
1662
  const chain = process.platform === 'win32' ? [cmd] : [...new Set([cmd, 'sh', '/bin/sh'])];
1402
1663
  let lastErr;
1403
1664
  for (const c of chain) {
@@ -1432,7 +1693,24 @@ function nextSessionId() {
1432
1693
  return id;
1433
1694
  }
1434
1695
 
1435
- function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1696
+ // Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
1697
+ // feature turns on: a session must never claim a profile whose overlay was not applied. So the
1698
+ // three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
1699
+ // and callers that can refuse (the composer) refuse rather than start something mislabelled.
1700
+ function resolveProfileForSpawn(id) {
1701
+ if (id == null || id === '') return { ok: true, id: null, profile: null };
1702
+ const { read, profile } = profileById(String(id));
1703
+ if (profile) return { ok: true, id: profile.id, profile };
1704
+ if (!read.ok) return { ok: false, id: null, profile: null, error: `profile "${id}" could not be checked — ${read.error}` };
1705
+ const bad = (read.broken || []).find((b) => b.file === `${id}.json`);
1706
+ if (bad) return { ok: false, id: null, profile: null, error: `profile "${id}" is not usable — ${bad.file} ${bad.error}` };
1707
+ return { ok: false, id: null, profile: null,
1708
+ error: read.absent
1709
+ ? `profile "${id}" was asked for, but ${read.dir} does not exist — no descriptor was read and nothing would be applied`
1710
+ : `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
1711
+ }
1712
+
1713
+ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
1436
1714
  const id = nextSessionId();
1437
1715
  const w = getWs(ws);
1438
1716
  const root = w ? w.root : SELF_ROOT;
@@ -1445,7 +1723,14 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1445
1723
  const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
1446
1724
  // a session's working directory is confined to its OWN workspace root (R3)
1447
1725
  const workdir = cwd && insideRoot(root, path.resolve(root, cwd)) ? path.resolve(root, cwd) : root;
1448
- const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag };
1726
+ // Resolved once, here, so every path below spawns under the SAME environment the record claims.
1727
+ // `pres.id` is null whenever the overlay was not applied — including when a profile was asked for
1728
+ // and could not be read — so the pane line can never name a profile that did nothing (R3/R9).
1729
+ // `profileRefusal` is server-internal: callers that can refuse read it and say why.
1730
+ const pres = resolveProfileForSpawn(profile);
1731
+ const penv = sessionEnvWithProfile(pres.profile);
1732
+ const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
1733
+ profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
1449
1734
 
1450
1735
  // A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
1451
1736
  // otherwise silent) — the same honest exited pane on both the tmux and the direct path.
@@ -1460,7 +1745,9 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1460
1745
  const s = { ...base, cwd: workdir, state: 'working', pty: null, tmux: tname,
1461
1746
  note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO() };
1462
1747
  sessions.set(id, s);
1463
- if (!createTmuxSession(tname, cmd, workdir, 80, 24)) {
1748
+ // the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
1749
+ // really does spawn the process itself. See createTmuxSession for why the two differ.
1750
+ if (!createTmuxSession(tname, cmd, workdir, 80, 24, profileOverlay(pres.profile))) {
1464
1751
  const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
1465
1752
  s.state = 'exited'; s.tmux = null; s.note = why;
1466
1753
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
@@ -1475,8 +1762,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1475
1762
  const startedMs = Date.now();
1476
1763
  let p, usedCmd = cmd;
1477
1764
  try {
1478
- if (isShell) { const r = spawnPty(cmd, workdir); p = r.pty; usedCmd = r.cmd; }
1479
- else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: sessionEnv() });
1765
+ if (isShell) { const r = spawnPty(cmd, workdir, penv); p = r.pty; usedCmd = r.cmd; }
1766
+ else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
1480
1767
  } catch (e) {
1481
1768
  return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
1482
1769
  }
@@ -1518,9 +1805,14 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1518
1805
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
1519
1806
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
1520
1807
  // git-then-maybe-spawn contract is unit-testable without a WS client.
1521
- function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }) {
1808
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
1522
1809
  const w = getWs(ws);
1523
1810
  if (!w) return { ok: false, error: 'no workspace' };
1811
+ // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
1812
+ // read must not leave a branch checked out and a session running under the wrong environment —
1813
+ // and a session that claims a profile it never got is the quietest failure this feature has.
1814
+ const pres = resolveProfileForSpawn(profile);
1815
+ if (!pres.ok) return { ok: false, error: pres.error };
1524
1816
  let targetWs = w.id;
1525
1817
  let notice = null;
1526
1818
  // Isolation exists to protect OTHER sessions. Alone in a workspace there are none to protect, so
@@ -1555,7 +1847,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }
1555
1847
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
1556
1848
  }
1557
1849
  }
1558
- const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs });
1850
+ const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
1559
1851
  return { ok: true, session: s, notice };
1560
1852
  }
1561
1853
 
@@ -1891,7 +2183,299 @@ function needsByWorkspace() {
1891
2183
  }
1892
2184
  // Anything that resolves or files a need makes the memo stale; drop it rather than wait out the TTL.
1893
2185
  function invalidateNeedsAgg(id) { if (id) needsAgg.delete(id); else needsAgg.clear(); }
1894
- function buildConsole(name, w, root) {
2186
+ // ── repo ingest (workspace-repo-ingest): clone → scan → review → accept. ──────────────────────
2187
+ // The whole design is the boundary between stages 2 and 3. `lib/repo-scan.js` is PURE — it opens
2188
+ // files and returns a PROPOSAL; it cannot write, shell, or decide. Every write in this pipeline
2189
+ // lives below, and there is exactly one of them that touches a record: the confirm handler.
2190
+ //
2191
+ // Proposals persist BESIDE state/workspaces.json, never inside the repository being scanned (R3):
2192
+ // a proposal that is never accepted must leave the clone byte-identical.
2193
+ const INGEST_CLONE_TIMEOUT_MS = Number(process.env.PMOS_INGEST_TIMEOUT_MS || 120_000);
2194
+ const INGEST_CREDENTIAL_NOTE = 'PMOS holds no GitHub credential and will not ask for one — the clone used the git credential helper this machine already has.';
2195
+ const GITHUB_REF = /^(?:https?:\/\/(?:www\.)?github\.com\/)?([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*?)(?:\.git)?\/?$/;
2196
+
2197
+ function ingestPath(id) { return path.join(INGEST_DIR, id + '.json'); }
2198
+ function readProposal(id) {
2199
+ if (!/^ing-\d+$/.test(String(id || ''))) return null;
2200
+ try { return JSON.parse(fs.readFileSync(ingestPath(id), 'utf8')); } catch { return null; }
2201
+ }
2202
+ function writeProposal(pr) {
2203
+ try { fs.mkdirSync(INGEST_DIR, { recursive: true }); fs.writeFileSync(ingestPath(pr.id), JSON.stringify(pr)); return null; }
2204
+ catch (e) { warnWriteFail('state/ingest (the ingest proposal store)', e); return `the proposal could not be saved — ${String((e && e.code) || 'write failed')}`; }
2205
+ }
2206
+ function listProposals() {
2207
+ let names = [];
2208
+ try { names = fs.readdirSync(INGEST_DIR).filter((f) => /^ing-\d+\.json$/.test(f)); } catch { return []; }
2209
+ return names.map((f) => readProposal(f.replace(/\.json$/, ''))).filter(Boolean)
2210
+ .sort((a, b) => Number(a.id.slice(4)) - Number(b.id.slice(4)));
2211
+ }
2212
+ function nextIngestId() {
2213
+ let max = 0;
2214
+ try { for (const f of fs.readdirSync(INGEST_DIR)) { const m = /^ing-(\d+)\.json$/.exec(f); if (m && Number(m[1]) > max) max = Number(m[1]); } } catch {}
2215
+ return 'ing-' + (max + 1);
2216
+ }
2217
+ // The three DERIVED fields the UI prints, computed from the module's own field names. The PRD
2218
+ // sketches `anchor:{source,value,evidence}`; the module returns declaredAnchor/guessedAnchor/why,
2219
+ // and the wire uses the module's names PLUS these — translating between the two shapes in four
2220
+ // places independently is exactly the drift this pipeline cannot afford.
2221
+ function ingestItemWire(row) {
2222
+ return {
2223
+ ...row,
2224
+ anchor_state: row.declaredAnchor ? 'declared' : row.guessedAnchor ? 'guessed' : 'none',
2225
+ anchor_value: row.declaredAnchor || row.guessedAnchor || null,
2226
+ evidence: row.why == null ? null : row.why,
2227
+ disposition: row.disposition || 'open',
2228
+ };
2229
+ }
2230
+ function pushIngest(pr) { broadcast({ t: 'ingest', id: pr.id, stage: pr.stage, proposal: pr }); }
2231
+
2232
+ // The clone. Asynchronous and OFF the request path (R1) — the POST returns an id immediately and
2233
+ // the result arrives over the socket. GIT_TERMINAL_PROMPT=0 is what turns a private repository
2234
+ // from a hang into a stated failure: git's own credential helper still runs, but nothing waits on
2235
+ // a username nobody is there to type.
2236
+ function startIngestClone(pr) {
2237
+ const dir = path.join(CLONE_DIR, pr.id);
2238
+ // 'error' and 'close' BOTH fire when the binary is missing, and `close` arrives with code null —
2239
+ // which would overwrite "git is not on PATH" with "git exited with code null" and collapse two
2240
+ // of the three failures R9 requires kept apart. First outcome wins; the rest are dropped.
2241
+ // The guard is on the child's LIFECYCLE, not on fail() itself — the scan below runs after the
2242
+ // clone has settled and must still be able to report its own failure.
2243
+ let settled = false;
2244
+ const fail = (error, extra) => {
2245
+ const cur = readProposal(pr.id) || pr;
2246
+ Object.assign(cur, { stage: 'review', ok: false, error, scanned_at: nowISO() }, extra || {});
2247
+ cur.log.push({ level: 'error', path: null, message: error });
2248
+ writeProposal(cur); pushIngest(cur);
2249
+ };
2250
+ try { fs.mkdirSync(CLONE_DIR, { recursive: true }); }
2251
+ catch (e) { return fail(`the clone directory could not be created — ${String((e && e.code) || 'write failed')}`); }
2252
+ // A re-run of the same id must not clone into a directory that already holds a tree.
2253
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
2254
+
2255
+ const env = { ...sessionEnv(), GIT_TERMINAL_PROMPT: '0' };
2256
+ let child;
2257
+ try {
2258
+ child = spawn('git', ['clone', '--depth', '1', '--', pr.source.url, dir],
2259
+ { env, stdio: ['ignore', 'pipe', 'pipe'] });
2260
+ } catch (e) {
2261
+ // Thrown synchronously on some platforms; the same fact as the ENOENT below.
2262
+ return fail('git is not on PATH — PMOS did not run a clone');
2263
+ }
2264
+ let stderr = '';
2265
+ let timedOut = false;
2266
+ child.stderr.on('data', (d) => { stderr = (stderr + String(d)).slice(-8000); });
2267
+ const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, INGEST_CLONE_TIMEOUT_MS);
2268
+ if (timer.unref) timer.unref();
2269
+ child.on('error', (e) => {
2270
+ clearTimeout(timer);
2271
+ if (settled) return;
2272
+ settled = true;
2273
+ // THREE distinct failures, and they must not collapse (R9): git absent is not a clone that
2274
+ // failed, and neither is a clone that timed out.
2275
+ if (e && e.code === 'ENOENT') return fail('git is not on PATH — PMOS did not run a clone');
2276
+ fail(`git clone failed: ${String((e && e.message) || 'the clone could not be started')}`);
2277
+ });
2278
+ child.on('close', (code) => {
2279
+ clearTimeout(timer);
2280
+ if (settled) return; // 'error' already reported the real reason — do not restate it worse
2281
+ settled = true;
2282
+ if (timedOut) {
2283
+ return fail(`git clone timed out after ${Math.round(INGEST_CLONE_TIMEOUT_MS / 1000)}s — the clone did not complete and nothing was scanned`);
2284
+ }
2285
+ if (code !== 0) {
2286
+ // git's OWN stderr line, verbatim. A rewritten error is a fact the PM cannot check.
2287
+ const line = stderr.split('\n').map((l) => l.trim()).filter(Boolean).pop() || `git exited with code ${code}`;
2288
+ const auth = /authentication|could not read Username|Permission denied|terminal prompts disabled|not found|403/i.test(stderr);
2289
+ return fail(`git clone failed: ${line}`, auth ? { credential_note: INGEST_CREDENTIAL_NOTE } : null);
2290
+ }
2291
+ // The commit is read, never assumed. A proposal that cannot name the commit it scanned says
2292
+ // null rather than a branch name standing in for one.
2293
+ let commit = null;
2294
+ try {
2295
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, env, encoding: 'utf8' });
2296
+ if (r.status === 0) commit = String(r.stdout || '').trim() || null;
2297
+ } catch {}
2298
+
2299
+ const cur = readProposal(pr.id) || pr;
2300
+ cur.stage = 'scan'; cur.source.commit = commit; cur.root = dir;
2301
+ writeProposal(cur); pushIngest(cur);
2302
+
2303
+ let scan;
2304
+ try { scan = repoScan.scanRepo(dir); }
2305
+ catch (e) { return fail(`the scan failed — ${String((e && e.message) || 'error').slice(0, 160)}`); }
2306
+ Object.assign(cur, {
2307
+ stage: 'review', ok: scan.ok, error: scan.error, root: scan.root,
2308
+ scanned_at: nowISO(),
2309
+ log: cur.log.concat(scan.log),
2310
+ unreadable: scan.unreadable,
2311
+ initiatives: scan.initiatives.map(ingestItemWire),
2312
+ adrs: scan.adrs.map((a) => ({ ...a, disposition: 'open' })),
2313
+ });
2314
+ writeProposal(cur); pushIngest(cur);
2315
+ });
2316
+ }
2317
+
2318
+ // The ONE record write in this pipeline. `parent_kr` is set into the LEADING frontmatter through
2319
+ // applyChange, whose contract is REFUSE ON MISMATCH — if the record moved between the scan and the
2320
+ // confirm, the write is rejected loudly rather than merged. Grep this file for `parent_kr`: this
2321
+ // function and the confirm handler are the only write site, which is R4 held structurally.
2322
+ function anchorEdit(text, anchor) {
2323
+ const lines = text.split('\n');
2324
+ if (lines[0].trim() !== '---') {
2325
+ return { error: 'this record has no leading frontmatter block — PMOS will not add one during an import' };
2326
+ }
2327
+ let close = -1;
2328
+ for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { close = i; break; } }
2329
+ if (close === -1) return { error: 'this record\'s leading frontmatter block is never closed — it is malformed, and PMOS will not guess where it ends' };
2330
+ const head = lines.slice(0, close);
2331
+ const at = head.findIndex((l) => /^parent_kr:/.test(l));
2332
+ if (at !== -1) return { proposal: { before: head[at], after: `parent_kr: ${anchor}` } };
2333
+ // No key at all: insert after `type:`, which D63 requires on every tracked record and which is
2334
+ // therefore the one line guaranteed to exist exactly once in this block.
2335
+ const t = head.findIndex((l) => /^type:/.test(l));
2336
+ if (t === -1) return { error: 'this record declares no `type:` — PMOS has no anchor line to insert after and will not guess a position' };
2337
+ return { proposal: { after: `\nparent_kr: ${anchor}` }, opts: { anchor: head[t] } };
2338
+ }
2339
+
2340
+ // ── focus (workspace-focus): the three reads lib/focus.js cannot perform itself. ───────────────
2341
+ // focus.js is pure — it stats nothing and opens nothing. Two of its outputs are therefore SILENTLY
2342
+ // degraded when the caller does not supply an input, and both degradations are correct-looking:
2343
+ // without `skills` no ▶ delegate button ever appears anywhere, and without `budgetRegistry` every
2344
+ // budget cap reads `null`. They are supplied here, and their failures are passed through as
2345
+ // failures rather than as absences.
2346
+
2347
+ // Every `.skill` file that EXISTS ON DISK under this repo's skills/ and build-skills/, as the
2348
+ // repo-relative paths focus.js matches against (`(?:build-)?skills/….skill`). One readdir per
2349
+ // directory per call — an offer PMOS cannot honour is worse than no offer, so the index is the
2350
+ // filesystem's answer and never a list of names we expect to be there.
2351
+ const SKILL_INDEX_MAX = 2000;
2352
+ function skillIndexFor(root) {
2353
+ const out = [];
2354
+ const walk = (dir, rel, depth) => {
2355
+ if (depth > 4 || out.length >= SKILL_INDEX_MAX) return;
2356
+ let ents = [];
2357
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
2358
+ for (const e of ents) {
2359
+ if (out.length >= SKILL_INDEX_MAX) return;
2360
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), `${rel}/${e.name}`, depth + 1); }
2361
+ else if (e.name.endsWith('.skill')) out.push(`${rel}/${e.name}`);
2362
+ }
2363
+ };
2364
+ for (const base of ['skills', 'build-skills']) walk(path.join(root, base), base, 0);
2365
+ return out;
2366
+ }
2367
+
2368
+ // The health-budget registry (D59), parsed out of planning/okrs/health-budgets.md's own table.
2369
+ // {ok:false,error} and "not supplied" are DIFFERENT to focus.js — the first renders
2370
+ // budgets.state:'error', the second 'not-read' — so a failed read is never returned as an absence.
2371
+ const BUDGET_ROW = /^\|\s*`?health:([a-z0-9][\w-]*)`?\s*\|([^|]*)\|([^|]*)\|/i;
2372
+ function budgetRegistryFor(root) {
2373
+ const rel = 'planning/okrs/health-budgets.md';
2374
+ let text;
2375
+ try { text = fs.readFileSync(path.join(root, rel), 'utf8'); }
2376
+ catch (e) {
2377
+ if (e && e.code === 'ENOENT') return { ok: false, error: `${rel} does not exist in this repository — no health budget declares a cap here` };
2378
+ return { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` };
2379
+ }
2380
+ const budgets = [];
2381
+ for (const line of text.split('\n')) {
2382
+ const m = BUDGET_ROW.exec(line.trim());
2383
+ if (!m) continue;
2384
+ const cap = m[3].trim();
2385
+ budgets.push({ id: `health:${m[1].toLowerCase()}`, covers: m[2].trim() || null, cap: cap || null });
2386
+ }
2387
+ // A file that parsed to nothing is reported as read-and-empty, not as an error: a repo may
2388
+ // legitimately declare no budgets, and the caps column is the only thing being claimed here.
2389
+ return { ok: true, budgets, path: rel };
2390
+ }
2391
+
2392
+ // One entry per DISTINCT repo_key — a PRODUCT, not a working tree. Several worktrees of one repo
2393
+ // are one product with one board, and counting them separately is what put the same initiative in
2394
+ // the queue three times. boardCache.board() ONLY (§6.8): consoleState.workflow() is ~30s and
2395
+ // synchronous, and on a request path it freezes every PTY socket for the whole half minute.
2396
+ function focusProducts() {
2397
+ const trees = wsList();
2398
+ const groups = new Map(); // repo_key -> { id, name, trees[] }
2399
+ for (const t of trees) {
2400
+ const key = t.repo_key || t.root;
2401
+ if (!groups.has(key)) groups.set(key, { id: key, name: t.repo_name || t.name, trees: [] });
2402
+ groups.get(key).trees.push(t);
2403
+ }
2404
+ const rows = sessionList();
2405
+ return [...groups.values()].map((g) => {
2406
+ // The tree whose root IS the repo — the main worktree — is the one whose planning/ and okf/ are
2407
+ // canonical. Absent it (only linked worktrees registered), the first registered tree stands in.
2408
+ const lead = g.trees.find((t) => path.resolve(t.root) === path.resolve(g.id)) || g.trees[0];
2409
+ const w = workspaces.get(lead.id) || null;
2410
+ const root = w ? w.root : lead.root;
2411
+ const ids = new Set(g.trees.map((t) => t.id));
2412
+ let needs;
2413
+ try { needs = needsResult(w); }
2414
+ catch (e) { needs = { ok: false, error: 'the needs-you queue could not be read — ' + String((e && e.code) || 'error').slice(0, 60) }; }
2415
+ let spine;
2416
+ try { spine = surfaceSpine(root); }
2417
+ catch (e) { spine = { unreadable: 'the spine could not be derived — ' + String((e && e.message) || 'error').slice(0, 120) }; }
2418
+ return {
2419
+ id: g.id, name: g.name,
2420
+ board: boardCache.board(root),
2421
+ needs,
2422
+ spine,
2423
+ // Partitioned by each row's OWN `ws`, because focus.js matches sessions to products by id and
2424
+ // a session's `ws` is a working-tree id, not a repo key — the join has to happen here.
2425
+ sessions: rows.filter((s) => ids.has(s.ws)),
2426
+ };
2427
+ });
2428
+ }
2429
+
2430
+ // ── workflow templates (jira-handoff R13). ────────────────────────────────────────────────────
2431
+ // A DECLARED template is a file a human wrote. Nothing derives from it — the engine
2432
+ // (scripts/workflow-state.js) builds one fixed ladder per lane and never reads `flow:` — so the
2433
+ // stage list must come out of the document's own body and `declared:true` is shipped so the client
2434
+ // can label the group without inventing the word.
2435
+ const STAGE_CHAIN = /([a-z][a-z0-9_-]{1,24})(?:\s*(?:→|->)\s*([a-z][a-z0-9_-]{1,24}))+/i;
2436
+ function templateStages(text, fm) {
2437
+ // A declared `stages:` list in frontmatter wins — it is the document saying so directly.
2438
+ const declared = fm && typeof fm.stages === 'string' ? fm.stages : null;
2439
+ const line = declared || (STAGE_CHAIN.test(text) ? STAGE_CHAIN.exec(text)[0] : null);
2440
+ if (!line) return []; // never a default six-stage list: that would be this server declaring it
2441
+ return line.split(/\s*(?:→|->|,)\s*/).map((s) => s.trim().replace(/^[`'"]|[`'"]$/g, ''))
2442
+ .filter((s) => /^[a-z][a-z0-9_-]*$/i.test(s));
2443
+ }
2444
+ function workflowTemplates(root) {
2445
+ const rel = 'planning/workflows';
2446
+ const dir = path.join(root, rel);
2447
+ let ents;
2448
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); }
2449
+ catch (e) {
2450
+ // ABSENT is calm and stated — most repositories have none — and is not the same answer as
2451
+ // "the directory is there and PMOS could not look inside it".
2452
+ if (e && e.code === 'ENOENT') return { ok: true, empty: true, dir: rel, templates: [], absent: true };
2453
+ return { ok: false, error: `${rel} could not be listed — ${String((e && e.code) || 'read failed')}` };
2454
+ }
2455
+ const files = ents.filter((e) => !e.isDirectory() && /\.md$/i.test(e.name)).map((e) => e.name).sort();
2456
+ const templates = [];
2457
+ for (const nm of files) {
2458
+ if (nm === 'index.md' || nm === 'log.md') continue; // OKF-reserved, never a concept document
2459
+ let text;
2460
+ try { text = fs.readFileSync(path.join(dir, nm), 'utf8'); }
2461
+ catch (e) { return { ok: false, error: `${rel}/${nm} could not be read — ${String((e && e.code) || 'read failed')}` }; }
2462
+ const fm = fmParse.parseLeadingFrontmatter(text) || {};
2463
+ // D63 titles are quoted in frontmatter; the quotes are syntax, not part of the title. `null`
2464
+ // rather than the filename when there is none — a filename standing in for a title is a title
2465
+ // the document does not have.
2466
+ const title = String(fm.title == null ? '' : fm.title).trim().replace(/^["']|["']$/g, '').trim();
2467
+ templates.push({
2468
+ id: nm.replace(/\.md$/i, ''), path: `${rel}/${nm}`,
2469
+ title: title || null,
2470
+ stages: templateStages(text, fm),
2471
+ declared: true,
2472
+ });
2473
+ }
2474
+ if (!templates.length) return { ok: true, empty: true, dir: rel, templates: [] };
2475
+ return { ok: true, empty: false, dir: rel, templates };
2476
+ }
2477
+
2478
+ function buildConsole(name, w, root, params) {
1895
2479
  const stateDir = stateDirFor(w);
1896
2480
  const snapshotDir = latestSnapshotDir(root);
1897
2481
  if (name === 'needs') return needsResult(w);
@@ -1905,6 +2489,75 @@ function buildConsole(name, w, root) {
1905
2489
  // child; the payload carries `as_of` / `stale` / `pending` so the client can say how old the
1906
2490
  // answer is instead of implying it is current.
1907
2491
  if (name === 'workflow') return boardCache.board(w ? w.root : root);
2492
+ // ONE initiative's ladder. The board caps itself at what is in flight and reports the rest as
2493
+ // `hidden`; a panel asked about a named initiative must not read that cap as "no record", so it
2494
+ // derives the one it was asked for. Single engine run — safe on the request path, unlike a board.
2495
+ if (name === 'ladder') return consoleState.ladder({ root: w ? w.root : root, id: params && params.get('id') });
2496
+ // The PM spine belongs to the console family, not the deliverable family: it is read-only
2497
+ // file-mode state and it owes the same honesty contract — a failed read renders RED, never a
2498
+ // green empty tree (R5). It reads the OPENED workspace's repo for the same reason the ladder
2499
+ // does — objectives, budgets and initiative records are properties of that repo.
2500
+ if (name === 'spine') {
2501
+ const s = surfaceSpine(w ? w.root : root);
2502
+ if (s.unreadable) return { ok: false, error: s.unreadable };
2503
+ const placed = s.objectives.reduce((n, o) =>
2504
+ n + o.krs.reduce((m, k) => m + k.initiatives.length, 0) + (o.direct ? o.direct.length : 0), 0)
2505
+ + s.budgets.reduce((n, b) => n + b.initiatives.length, 0);
2506
+ // `empty` is a SUCCESSFUL read of genuinely nothing — a repo with no initiative records at all.
2507
+ // It is a supported thing to point the workspace at, so it renders calm, not red.
2508
+ return { ok: true, empty: placed === 0 && s.unanchored.length === 0, ...s };
2509
+ }
2510
+ // ── focus (workspace-focus). CROSS-PRODUCT: `ws` is accepted and IGNORED. ──
2511
+ // Focus joins every registered workspace, so scoping it to the selected tree would hide the one
2512
+ // item that is actually blocking the PM behind whichever repo they happened to be standing in.
2513
+ if (name === 'focus') {
2514
+ const products = focusProducts();
2515
+ // The skill index is per-product and unioned: focus.js matches a step's `next` against
2516
+ // repo-relative paths, and a skill that exists in one repo is a real offer for that repo's
2517
+ // ladder. Supplying `[]` (rather than nothing) when no repo has any is deliberate — it means
2518
+ // "we looked and found none", which correctly yields no offers, not "we did not look".
2519
+ // `product.id` IS the repo's main-worktree directory (repoIdentity's key), so it is the root to
2520
+ // read — no second lookup, and no chance of indexing a different tree's skills.
2521
+ const skills = new Set();
2522
+ for (const pr of products) for (const s of skillIndexFor(pr.id)) skills.add(s);
2523
+ // ONE registry, read from the home repo when there is one — health budgets are a PMOS-wide
2524
+ // declaration (D59), and every product's spine is matched against the same caps.
2525
+ const regRoot = HOME_ENABLED ? SELF_ROOT : (products.length ? products[0].id : SELF_ROOT);
2526
+ const view = focus.buildFocus({
2527
+ products,
2528
+ sessions: sessionList(),
2529
+ skills: [...skills],
2530
+ budgetRegistry: budgetRegistryFor(regRoot),
2531
+ });
2532
+ return { ok: true, ...view };
2533
+ }
2534
+ // ── handoff (workspace-jira-handoff R1). ──
2535
+ // The record's LEADING frontmatter, which no existing payload carries, parsed by the real
2536
+ // parser — a hand-rolled regex here would be a second opinion about what a record declares.
2537
+ // `links.connected` is false on every code path readLinks has; there is no argument that
2538
+ // changes it and none is passed.
2539
+ if (name === 'handoff') {
2540
+ const id = params && params.get('id');
2541
+ if (!id) return { ok: false, error: 'an initiative id is required', http_status: 400 };
2542
+ // Same path guard as every other read on this surface — an id is a filename, not a path.
2543
+ if (!/^[A-Za-z0-9][\w.-]*$/.test(id)) return { ok: false, error: 'that initiative id is not a record name', http_status: 400 };
2544
+ const rel = `planning/initiatives/${id}.md`;
2545
+ const file = path.join(w ? w.root : root, 'planning', 'initiatives', `${id}.md`);
2546
+ let text;
2547
+ try { text = fs.readFileSync(file, 'utf8'); }
2548
+ catch (e) {
2549
+ // A record that does not exist is NOT a failed read — the panel already has a "no record"
2550
+ // state for it. Anything else (EACCES, EISDIR, EIO) is PMOS failing to look, and is red.
2551
+ if (e && e.code === 'ENOENT') return { ok: true, id, record: rel, links: null, absent: true };
2552
+ return { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` };
2553
+ }
2554
+ let links;
2555
+ try { links = handoff.readLinks(fmParse.parseLeadingFrontmatter(text)); }
2556
+ catch (e) { return { ok: false, error: `${rel} — ${String((e && e.message) || 'the frontmatter could not be parsed').slice(0, 160)}` }; }
2557
+ return { ok: true, id, record: rel, links };
2558
+ }
2559
+ // ── workflow templates (jira-handoff R13). ──
2560
+ if (name === 'workflows') return workflowTemplates(w ? w.root : root);
1908
2561
  return { ok: false, error: 'unknown console surface: ' + name };
1909
2562
  }
1910
2563
 
@@ -2044,7 +2697,7 @@ function serveFile(res, file, type, noCache) {
2044
2697
  fs.readFile(file, (err, buf) => {
2045
2698
  if (err) { res.writeHead(404); res.end('not found'); return; }
2046
2699
  const headers = { 'Content-Type': type || TYPES[path.extname(file)] || 'application/octet-stream' };
2047
- // The app SHELL (index.html + app.js) must revalidate every load: without this a tab left open
2700
+ // The app SHELL (index.html + the /pm/ module graph) must revalidate every load: without this a tab left open
2048
2701
  // across a `git merge` + server restart keeps running the pre-merge JS, so a fix that IS being
2049
2702
  // served never reaches the screen. `no-cache` = "always revalidate", not "never store".
2050
2703
  if (noCache) headers['Cache-Control'] = 'no-cache';
@@ -2065,9 +2718,13 @@ async function handleRequest(req, res) {
2065
2718
  const p = url.pathname;
2066
2719
 
2067
2720
  if (p === '/' || p === '/index.html') return serveFile(res, path.join(PUB, 'index.html'), null, true);
2068
- if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
2721
+ // NB: there is no `/app.js` route. The single-file front end that owned it (public/app.js +
2722
+ // public/styles.css) was RETIRED with the four-panel design — the shell is index.html plus the
2723
+ // /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
2724
+ // 404s loudly rather than silently resurrecting a second, diverging surface.
2069
2725
  if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
2070
2726
  if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
2727
+ if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
2071
2728
  if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
2072
2729
  // The editor computes its live diff with the SAME module the server saves through, so what the PM
2073
2730
  // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
@@ -2075,7 +2732,19 @@ async function handleRequest(req, res) {
2075
2732
  // client feature that depends on it fails in a way no server-side test can see.
2076
2733
  if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
2077
2734
  if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
2078
- if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
2735
+ // …and no `/styles.css` route either: the token layer and every component rule now ship as
2736
+ // /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
2737
+ // Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
2738
+ // puts a permanent red line in the console and in this suite's `page errors seen` list — noise
2739
+ // that trains you to ignore the one place a real client-side failure would show up. An inline SVG
2740
+ // answers it with no asset to ship: the PMOS mark, sized to work at 16px.
2741
+ if (p === '/favicon.ico' || p === '/favicon.svg') {
2742
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
2743
+ `<rect width="16" height="16" rx="3" fill="#111"/>` +
2744
+ `<path d="M4 12V4h3.2a2.4 2.4 0 010 4.8H6" stroke="#fff" stroke-width="1.6" fill="none"/></svg>`;
2745
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' });
2746
+ return res.end(svg);
2747
+ }
2079
2748
  // xterm assets straight from node_modules (offline; no CDN)
2080
2749
  if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
2081
2750
  if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
@@ -2086,7 +2755,28 @@ async function handleRequest(req, res) {
2086
2755
  // because the terminal renders bold — with only the regular face the browser synthesises it.
2087
2756
  if (p === '/vendor/space-grotesk-var.woff2') return serveFile(res, path.join(PUB, 'vendor', 'space-grotesk-var.woff2'));
2088
2757
  if (p === '/vendor/ibm-plex-mono-400.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-400.woff2'));
2758
+ // 500 joined 400/700 with the four-panel design, whose section labels and pills are medium-weight
2759
+ // mono. Without the face the browser synthesises it, and a synthesised weight is exactly the kind
2760
+ // of difference the design-parity comparator reports and a human eye does not.
2761
+ if (p === '/vendor/ibm-plex-mono-500.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-500.woff2'));
2089
2762
  if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
2763
+ // ── the four-panel front end (public/pm/) ──────────────────────────────────────────────────────
2764
+ // A DIRECTORY route, deliberately, where /lib/ above is an allowlist. The allowlist's own comment
2765
+ // records why it is a trap: a file missing from it 404s silently and the feature that needed it
2766
+ // fails somewhere no server-side test looks. That cost is tolerable for six stable modules and is
2767
+ // not tolerable for a front end split into fifteen, where every new module would be one forgotten
2768
+ // line away from a blank panel. The traversal guard is what makes the directory form safe: the
2769
+ // resolved path must still sit inside PM_DIR, so '..' segments and absolute-looking names cannot
2770
+ // reach a file outside it. Extension-restricted as well — this route serves code and styles, not
2771
+ // whatever else happens to land in the folder.
2772
+ if (p.startsWith('/pm/')) {
2773
+ const rel = p.slice('/pm/'.length);
2774
+ const file = path.resolve(PM_DIR, rel);
2775
+ if (!file.startsWith(PM_DIR + path.sep) || !['.js', '.css', '.json'].includes(path.extname(file))) {
2776
+ res.writeHead(404); return res.end('not found');
2777
+ }
2778
+ return serveFile(res, file, null, true);
2779
+ }
2090
2780
 
2091
2781
  if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
2092
2782
  if (p === '/api/workspaces' && req.method === 'POST') {
@@ -2145,6 +2835,191 @@ async function handleRequest(req, res) {
2145
2835
  if (!isDir(root)) return json(res, { ok: false, error: `not a directory: ${root}` }, 400);
2146
2836
  return json(res, { ok: true, root });
2147
2837
  }
2838
+ // ── repo ingest (workspace-repo-ingest R1/R2/R9). ────────────────────────────────────────────
2839
+ if (p === '/api/ingest' && req.method === 'POST') {
2840
+ let body = {};
2841
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2842
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2843
+ // R2 enforced in CODE, not only in copy: a local folder is REGISTERED (POST /api/workspaces)
2844
+ // and nothing is imported from it. Accepting `folder` here and quietly registering it would
2845
+ // make the two paths indistinguishable to anything but the copy under the radio button.
2846
+ if (body.source === 'folder' || body.source === 'local') {
2847
+ return json(res, { ok: false, error: 'a local folder is registered, never ingested — nothing is imported from a bare folder' }, 400);
2848
+ }
2849
+ if (body.source !== 'github') return json(res, { ok: false, error: 'source must be "github"' }, 400);
2850
+ const ref = String(body.ref == null ? '' : body.ref).trim();
2851
+ if (!ref) return json(res, { ok: false, error: 'a repository reference is required' }, 400);
2852
+ const m = GITHUB_REF.exec(ref);
2853
+ if (!m) return json(res, { ok: false, error: `"${ref.slice(0, 80)}" is not a GitHub repository reference — give owner/name or https://github.com/owner/name` }, 400);
2854
+ const slug = `${m[1]}/${m[2]}`;
2855
+ const pr = {
2856
+ id: nextIngestId(), stage: 'acquire',
2857
+ source: { kind: 'github', ref: slug, url: `https://github.com/${slug}.git`, commit: null },
2858
+ clone_dir: `state/clones/`, scanned_at: null,
2859
+ ok: true, error: null, root: null,
2860
+ log: [{ level: 'info', path: null, message: `cloning ${slug} — read-only; nothing is written into this repository or into PMOS until an item is confirmed` }],
2861
+ unreadable: [], initiatives: [], adrs: [],
2862
+ };
2863
+ pr.clone_dir = `state/clones/${pr.id}`;
2864
+ const err = writeProposal(pr);
2865
+ if (err) return json(res, { ok: false, error: err }, 500);
2866
+ pushIngest(pr);
2867
+ // Returns IMMEDIATELY. The clone runs off the request path and reports over the socket.
2868
+ startIngestClone(pr);
2869
+ return json(res, { ok: true, id: pr.id, stage: pr.stage });
2870
+ }
2871
+ if (p === '/api/ingest' && req.method === 'GET') {
2872
+ const id = url.searchParams.get('id');
2873
+ if (id) {
2874
+ const pr = readProposal(id);
2875
+ if (!pr) return json(res, { ok: false, error: 'no such ingest' });
2876
+ return json(res, { ok: true, proposal: pr });
2877
+ }
2878
+ return json(res, { ok: true, proposals: listProposals().filter((x) => x.stage !== 'closed') });
2879
+ }
2880
+ if (p === '/api/ingest/confirm' && req.method === 'POST') {
2881
+ let body = {};
2882
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2883
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2884
+ // ONE item. There is no array form, no `items`, no `all` — the tedium IS the gate (R4). A
2885
+ // bulk confirm is how a dozen guessed anchors land on the board with every check green.
2886
+ if (Array.isArray(body.item) || body.items != null || body.all != null) {
2887
+ return json(res, { ok: false, error: 'confirmation is per item' }, 400);
2888
+ }
2889
+ const pr = readProposal(body.id);
2890
+ if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
2891
+ const item = String(body.item == null ? '' : body.item);
2892
+ const row = (pr.initiatives || []).find((r) => r.id === item);
2893
+ if (!row) return json(res, { ok: false, error: `this proposal has no item "${item.slice(0, 60)}"` }, 400);
2894
+ // The anchor comes from the PM's action and nowhere else. There is no code path in which a
2895
+ // `guessedAnchor` reaches `parent_kr` without an explicit request carrying it.
2896
+ const anchor = String(body.anchor == null ? '' : body.anchor).trim();
2897
+ if (!anchor) return json(res, { ok: false, error: 'an anchor is required — a guess is not a declaration and D59 rejects unanchored work' }, 400);
2898
+ if (!pr.root) return json(res, { ok: false, error: 'this proposal has no scanned repository — nothing was cloned, so there is nothing to write to' }, 409);
2899
+ const full = path.resolve(pr.root, row.path);
2900
+ if (!insideRoot(pr.root, full)) return json(res, { ok: false, error: 'that record is outside the scanned repository' }, 400);
2901
+
2902
+ // The resolver's verdict is computed and reported BEFORE the write, so the PM is told whether
2903
+ // the anchor they chose names anything this repository declares — not after the fact.
2904
+ let resolves = false, resolverNote = 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install — whether this anchor resolves was not checked';
2905
+ const A = anchorLib();
2906
+ if (A) {
2907
+ try {
2908
+ const r = A.resolveAnchor(anchor, A.parseOkrIndex(pr.root), A.readBudgets(pr.root));
2909
+ resolves = r.status === 'resolved';
2910
+ resolverNote = r.reason || `anchor status: ${r.status}`;
2911
+ } catch (e) { resolverNote = `the anchor could not be resolved — ${String((e && e.message) || 'error').slice(0, 120)}`; }
2912
+ }
2913
+
2914
+ let current;
2915
+ try { current = fs.readFileSync(full, 'utf8'); }
2916
+ catch (e) { return json(res, { ok: false, error: `${row.path} could not be read — ${String((e && e.code) || 'read failed')}` }, 409); }
2917
+ const edit = anchorEdit(current, anchor);
2918
+ if (edit.error) return json(res, { ok: false, error: `${row.path} — ${edit.error}` }, 409);
2919
+ const applied = applyChange.applyChange(current, edit.proposal, edit.opts);
2920
+ if (!applied.ok) {
2921
+ // applyChange's OWN refusal reason — the record moved between the scan and the confirm, and
2922
+ // a merged-anyway write is a diff the PM never saw.
2923
+ return json(res, { ok: false, error: `${row.path} — ${applied.detail || applied.reason}` }, 409);
2924
+ }
2925
+ try { fs.writeFileSync(full, applied.text); }
2926
+ catch (e) { return json(res, { ok: false, error: `${row.path} could not be written — ${String((e && e.code) || 'write failed')}` }, 409); }
2927
+ row.disposition = 'confirmed';
2928
+ row.declaredAnchor = anchor; row.guessedAnchor = null;
2929
+ row.anchor_state = 'declared'; row.anchor_value = anchor;
2930
+ row.why = `confirmed by the PM on ${nowISO()} — written into ${row.path} as \`parent_kr: ${anchor}\``;
2931
+ row.evidence = row.why;
2932
+ const werr = writeProposal(pr);
2933
+ pushIngest(pr);
2934
+ if (werr) return json(res, { ok: false, error: `${anchor} was written into ${row.path}, but ${werr} — the proposal on screen is now behind the file` }, 500);
2935
+ return json(res, { ok: true, path: row.path, anchor, resolves, resolver_note: resolverNote });
2936
+ }
2937
+ if (p === '/api/ingest/dismiss' && req.method === 'POST') {
2938
+ let body = {};
2939
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2940
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2941
+ const pr = readProposal(body.id);
2942
+ if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
2943
+ const row = (pr.initiatives || []).find((r) => r.id === String(body.item == null ? '' : body.item));
2944
+ if (!row) return json(res, { ok: false, error: `this proposal has no item "${String(body.item || '').slice(0, 60)}"` }, 400);
2945
+ row.disposition = 'dismissed';
2946
+ const err = writeProposal(pr);
2947
+ if (err) return json(res, { ok: false, error: err }, 500);
2948
+ pushIngest(pr);
2949
+ return json(res, { ok: true });
2950
+ }
2951
+ if (p === '/api/ingest/adr' && req.method === 'POST') {
2952
+ let body = {};
2953
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
2954
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
2955
+ const pr = readProposal(body.id);
2956
+ if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
2957
+ const disposition = body.disposition;
2958
+ if (disposition !== 'accepted' && disposition !== 'superseded') {
2959
+ return json(res, { ok: false, error: 'disposition must be "accepted" or "superseded"' }, 400);
2960
+ }
2961
+ const rel = String(body.path == null ? '' : body.path);
2962
+ const adr = (pr.adrs || []).find((a) => a.path === rel);
2963
+ if (!adr) return json(res, { ok: false, error: `this proposal has no ADR at "${rel.slice(0, 80)}"` }, 400);
2964
+ const w = getWs(body.ws);
2965
+ if (!w) return json(res, { ok: false, error: 'no workspace to accept this ADR into' }, 400);
2966
+
2967
+ let wrote = null;
2968
+ if (disposition === 'accepted') {
2969
+ if (!pr.root) return json(res, { ok: false, error: 'this proposal has no scanned repository — there is no file to copy' }, 409);
2970
+ const src = path.resolve(pr.root, rel);
2971
+ if (!insideRoot(pr.root, src)) return json(res, { ok: false, error: 'that ADR is outside the scanned repository' }, 400);
2972
+ // The RECEIVING repo's own adr/ directory. PMOS does not invent one: a repository with no
2973
+ // OKF bundle has nowhere an ADR belongs, and creating a tree during an import would be this
2974
+ // server deciding where another repo's knowledge lives.
2975
+ const dirs = findDirsNamed(path.join(w.root, 'okf'), 'adr', 3);
2976
+ if (!dirs.length) {
2977
+ return json(res, { ok: false, error: `“${w.name}” has no \`adr/\` directory under okf/ — PMOS will not create one during an import; make the directory and accept again` }, 409);
2978
+ }
2979
+ let text;
2980
+ try { text = fs.readFileSync(src, 'utf8'); }
2981
+ catch (e) { return json(res, { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` }, 409); }
2982
+ const name = path.basename(rel);
2983
+ const dest = path.join(dirs[0], name);
2984
+ // Never overwrite. An ADR of that name already there is a collision only a human can settle.
2985
+ if (fs.existsSync(dest)) {
2986
+ return json(res, { ok: false, error: `${path.relative(w.root, dest)} already exists — PMOS will not overwrite an ADR during an import` }, 409);
2987
+ }
2988
+ // The PROVENANCE goes into the FILE. The `ingested` badge is derived from `ingested_from`
2989
+ // being present in the document — never from a stored flag — so a badge cannot outlive the
2990
+ // fact it claims, and a copy someone moves by hand carries its own origin with it.
2991
+ // The scan only proposes a file that already parsed with a `type: ADR` frontmatter block, so
2992
+ // the two lines go inside the block that is known to be there rather than in a new one.
2993
+ const stamp = `ingested_from: ${pr.source.ref}@${pr.source.commit || 'unknown-commit'}\ningested_at: ${nowISO().slice(0, 10)}`;
2994
+ const lines = text.split('\n');
2995
+ if (lines[0].trim() !== '---') {
2996
+ return json(res, { ok: false, error: `${rel} has no leading frontmatter block — PMOS cannot record where it came from, and will not import a document it cannot stamp` }, 409);
2997
+ }
2998
+ lines.splice(1, 0, stamp);
2999
+ try { fs.writeFileSync(dest, lines.join('\n')); }
3000
+ catch (e) { return json(res, { ok: false, error: `${path.relative(w.root, dest)} could not be written — ${String((e && e.code) || 'write failed')}` }, 409); }
3001
+ wrote = path.relative(w.root, dest);
3002
+ }
3003
+ // The disposition itself is a decision record, appended to the workspace's own file-mode
3004
+ // state, keyed by ADR path. It is NOT written to acceptances.jsonl: that file is the PM
3005
+ // Acceptance Gate's log and metrics.py counts its rows for the north star — filing an import
3006
+ // decision there would inflate a metric with something that was never an agent run.
3007
+ const d = stateDirFor(w, { create: true });
3008
+ if (!d) return json(res, { ok: false, error: `the disposition could not be recorded — “${w.name}” has no file-mode state directory and PMOS will not create one in a repository of unknown shape`, wrote }, 500);
3009
+ const rec = { at: nowISO(), date: nowISO().slice(0, 10), kind: 'adr', adr_path: rel,
3010
+ disposition, ingest: pr.id, source: `${pr.source.ref}@${pr.source.commit || 'unknown-commit'}`,
3011
+ wrote, workspace: w.name, root: w.root, pm: true };
3012
+ try { fs.appendFileSync(path.join(d, 'ingest.jsonl'), JSON.stringify(rec) + '\n'); }
3013
+ catch (e) {
3014
+ warnWriteFail('ingest.jsonl (ADR dispositions)', e);
3015
+ return json(res, { ok: false, error: 'the disposition could not be recorded — the file-mode state directory is unwritable; the decision was NOT saved', wrote }, 500);
3016
+ }
3017
+ adr.disposition = disposition;
3018
+ const err = writeProposal(pr);
3019
+ if (err) return json(res, { ok: false, error: err, wrote }, 500);
3020
+ pushIngest(pr);
3021
+ return json(res, { ok: true, wrote });
3022
+ }
2148
3023
  if (p === '/api/fs/list') {
2149
3024
  const listing = fsList(url.searchParams.get('path'));
2150
3025
  if (!listing) return json(res, { ok: false, error: 'not a directory' }, 404);
@@ -2224,10 +3099,64 @@ async function handleRequest(req, res) {
2224
3099
  skill: { id: built.slug, label: built.label, skill: built.slug,
2225
3100
  description: built.description, output: built.output, source: 'user' } });
2226
3101
  }
3102
+ // ── profiles (workspace-profiles R1/R2/R4). ──────────────────────────────────────────────────
3103
+ // Four states, mapped straight from readProfiles() and NEVER collapsed: the directory does not
3104
+ // exist (calm, path named) · it exists and holds nothing (a successful read of nothing, also
3105
+ // calm, different words) · it holds descriptors, some of which are broken (both lists ship —
3106
+ // one bad file must not turn the surface into a stated absence) · it could not be read (RED).
3107
+ //
3108
+ // `env` values are absent from this payload BY CONSTRUCTION (profileWire). An overlay is where a
3109
+ // credential lives, and D08's discipline covers anything the browser receives; `env_keys` exists
3110
+ // so the pane can say WHAT was applied without shipping what it was.
3111
+ if (p === '/api/profiles' && req.method === 'GET') {
3112
+ const read = readProfilesNow();
3113
+ return json(res, {
3114
+ ok: read.ok !== false,
3115
+ absent: !!read.absent,
3116
+ dir: read.dir,
3117
+ profiles: (read.profiles || []).map((x) => profileWire(x, read)),
3118
+ broken: (read.broken || []).map((b) => ({ file: b.file, error: b.error })),
3119
+ prefs: readPrefs(),
3120
+ error: read.error || null,
3121
+ });
3122
+ }
3123
+ if (p === '/api/profiles/prefer' && req.method === 'POST') {
3124
+ let body = {};
3125
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3126
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3127
+ const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
3128
+ if (!agent) return json(res, { ok: false, error: 'an agent name is required' }, 400);
3129
+ // "Unknown agent" has to mean something CHECKABLE, and PMOS keeps no agent registry — the
3130
+ // roster is derived from live sessions, which is a client-side fact. What the server can check
3131
+ // is the only thing that matters here: whether it could ever start that agent. A preference
3132
+ // for a CLI that is not on this machine would preselect the composer for something that
3133
+ // cannot spawn. `shell` is the one built-in kind and has no binary of its own.
3134
+ if (agent !== 'shell' && !commandExists(agent)) {
3135
+ return json(res, { ok: false, error: `“${agent.slice(0, 40)}” is not on PATH — PMOS will not remember a preference for an agent it cannot start` }, 400);
3136
+ }
3137
+ // PMOS does not remember a preference for a descriptor it cannot read. The preference is its
3138
+ // OWN memory (it preselects the composer and starts nothing), so a stored id that resolves to
3139
+ // nothing would silently preselect a chip that is not on screen.
3140
+ const pres = resolveProfileForSpawn(body.profile);
3141
+ if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
3142
+ const prefs = readPrefs();
3143
+ if (pres.id == null) delete prefs[agent]; else prefs[agent] = pres.id;
3144
+ const err = writePrefs(prefs);
3145
+ if (err) return json(res, { ok: false, error: err }, 500);
3146
+ return json(res, { ok: true, prefs });
3147
+ }
2227
3148
  if (p === '/api/schedules' && req.method === 'GET') {
2228
3149
  const wsFilter = getWs(url.searchParams.get('ws'));
2229
3150
  const id = wsFilter ? wsFilter.id : null;
2230
- const list = readSchedules().filter((s) => !id || s.ws === id).map((s) => ({ ...s, next: scheduleLogic.nextRun(s) }));
3151
+ const list = readSchedules().filter((s) => !id || s.ws === id).map((s) => {
3152
+ // `profile_applies` is the difference between "no profile was chosen" and "a profile could
3153
+ // not apply here at all": a skill-kind task runs IN THIS PROCESS and never creates a PTY, so
3154
+ // the detail row states that instead of offering a control that would do nothing (R8.3).
3155
+ const t = taskById(s.task || s.skill);
3156
+ return { ...s, next: scheduleLogic.nextRun(s),
3157
+ profile: s.profile == null ? null : s.profile,
3158
+ profile_applies: !!(t && t.kind === 'agent') };
3159
+ });
2231
3160
  return json(res, { schedules: list, now: nowISO() });
2232
3161
  }
2233
3162
  if (p === '/api/schedules' && req.method === 'POST') {
@@ -2236,7 +3165,11 @@ async function handleRequest(req, res) {
2236
3165
  const norm = scheduleLogic.normalizeSchedule(body);
2237
3166
  if (!norm.ok) return json(res, { ok: false, error: norm.error }, 400);
2238
3167
  const w = getWs(body.ws);
2239
- const sched = { id: 'sc-' + Date.now().toString(36), ws: w ? w.id : wsId(SELF_ROOT), ...norm.schedule, lastRun: null, lastStatus: null };
3168
+ // A profile named on a schedule is checked NOW, not at fire time — the PM is here to be told.
3169
+ const pres = resolveProfileForSpawn(body.profile);
3170
+ if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
3171
+ const sched = { id: 'sc-' + Date.now().toString(36), ws: w ? w.id : wsId(SELF_ROOT), ...norm.schedule,
3172
+ profile: pres.id, lastRun: null, lastStatus: null };
2240
3173
  sched.nextRun = scheduleLogic.nextRun(sched);
2241
3174
  const list = readSchedules(); list.unshift(sched); writeSchedules(list);
2242
3175
  return json(res, { ok: true, schedule: sched });
@@ -2247,6 +3180,13 @@ async function handleRequest(req, res) {
2247
3180
  if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
2248
3181
  if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
2249
3182
  for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill']) if (body[k] != null) s[k] = body[k];
3183
+ // `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
3184
+ // of the key rather than for a non-null value — the other fields above cannot be cleared.
3185
+ if ('profile' in body) {
3186
+ const pres = resolveProfileForSpawn(body.profile);
3187
+ if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
3188
+ s.profile = pres.id;
3189
+ }
2250
3190
  s.nextRun = scheduleLogic.nextRun(s); writeSchedules(list);
2251
3191
  return json(res, { ok: true, schedule: s });
2252
3192
  }
@@ -2375,9 +3315,96 @@ async function handleRequest(req, res) {
2375
3315
  const w = getWs(url.searchParams.get('ws'));
2376
3316
  const root = w ? w.root : SELF_ROOT;
2377
3317
  let out;
2378
- try { out = buildConsole(name, w, root); }
3318
+ try { out = buildConsole(name, w, root, url.searchParams); }
2379
3319
  catch (e) { out = { ok: false, error: 'console read failed: ' + String((e && e.message) || 'error').slice(0, 120) }; }
2380
- return json(res, { name, ...out });
3320
+ // A surface may name an HTTP status for a MALFORMED REQUEST only (400 — a missing required
3321
+ // param). Every discriminated READ result stays 200, including {ok:false}: the client's
3322
+ // honest-state rendering is driven by the body, and a transport code cannot carry the reason.
3323
+ // Stripped by spreading rather than deleting — some surfaces return frozen objects.
3324
+ const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
3325
+ if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
3326
+ return json(res, { name, ...out }, code);
3327
+ }
3328
+ // ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
3329
+ // Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
3330
+ // its sessions to answer a single question about a single initiative. Every one of those is
3331
+ // already produced somewhere here; what did not exist was the JOIN, so each client had to make
3332
+ // it — four round-trips per selection, and four chances for two surfaces to disagree about one
3333
+ // record. This route calls the SAME producers the existing endpoints call and joins them once.
3334
+ // The existing endpoints are deliberately unchanged: other surfaces still read them.
3335
+ //
3336
+ // GET → the assembled payload.
3337
+ // POST → append one declaration to a file-mode store (see fourPanel's header for why a link is
3338
+ // a declaration and never an outbound call).
3339
+ if (p === '/api/initiative' && req.method === 'GET') {
3340
+ const w = getWs(url.searchParams.get('ws'));
3341
+ const root = w ? w.root : SELF_ROOT;
3342
+ const id = url.searchParams.get('id') || '';
3343
+ // An id is a filename, not a path — the same guard the handoff surface applies, and the reason
3344
+ // this route can join a path without a traversal check at every use site.
3345
+ if (!fourPanel.validId(id)) {
3346
+ return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
3347
+ }
3348
+ const engine = consoleState.loadEngine();
3349
+ if (!engine.ok) return json(res, { ok: false, id, error: engine.error });
3350
+ const L = engine.engine.layout(root);
3351
+ // The ENGINE's frontmatter parser, not the workspace's — see lib/four-panel.js on why the two
3352
+ // disagree and why this endpoint's `frontmatter` has to agree with its own `ladder`.
3353
+ const rec = fourPanel.readRecord({ root, dir: L.initiatives, id, parse: engine.engine.parseLeadingFrontmatter });
3354
+ // A record that would not READ is the one fact the panel must show, and it is the whole
3355
+ // answer: deriving a ladder over a file that could not be parsed would be deriving over
3356
+ // nothing, and reporting the result beside the failure would invite reading it as data.
3357
+ let ladder = null, linksOut = null;
3358
+ if (rec.ok) {
3359
+ try { ladder = consoleState.ladder({ root, id }); }
3360
+ catch (e) { ladder = { ok: false, error: 'the ladder could not be derived: ' + String((e && e.message) || 'error').slice(0, 140) }; }
3361
+ // An ABSENT record declares nothing, so there is nothing for the handoff reader to read —
3362
+ // distinct from a record that declares no handoff, which readLinks reports as empty.
3363
+ if (!rec.absent) {
3364
+ try { linksOut = handoff.readLinks(rec.frontmatter || {}); }
3365
+ catch (e) { linksOut = null; void e; }
3366
+ }
3367
+ }
3368
+ let index = { initiatives: [] };
3369
+ try { if (wsShape(root) === 'pmos') index = initiativeIndex(root); }
3370
+ catch (e) { void e; }
3371
+ const stateDir = fourPanel.storeDir(engine.engine, root);
3372
+ const out = fourPanel.assemble({
3373
+ id, rec, ladder, links: linksOut, index,
3374
+ sessions: sessionList(), workspaces: wsList(),
3375
+ anchor: rec.ok && !rec.absent
3376
+ ? fourPanel.anchorFor({ anchors: anchorLib(), root, raw: (rec.frontmatter || {}).parent_kr })
3377
+ : null,
3378
+ artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
3379
+ initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
3380
+ });
3381
+ // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3382
+ return json(res, { ws: w ? w.id : null, ...out });
3383
+ }
3384
+ if (p === '/api/initiative/link' && req.method === 'POST') {
3385
+ // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
3386
+ // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
3387
+ // and it cannot state anything about the destination's contents.
3388
+ const w = getWs(url.searchParams.get('ws'));
3389
+ let body = {};
3390
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3391
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3392
+ const w2 = body.ws ? getWs(body.ws) : w;
3393
+ const kind = String(body.store || 'artifact');
3394
+ const built = kind === 'initiative'
3395
+ ? fourPanel.initiativeLinkRow(body)
3396
+ : fourPanel.artifactLinkRow(body);
3397
+ if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3398
+ const engine = consoleState.loadEngine();
3399
+ if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
3400
+ const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
3401
+ const wrote = fourPanel.appendDeclaration(
3402
+ stateDir,
3403
+ kind === 'initiative' ? fourPanel.INITIATIVE_LINKS : fourPanel.ARTIFACT_LINKS,
3404
+ built.row,
3405
+ );
3406
+ // A write that failed returns 500 and says so — never a false green (the /api/gate rule).
3407
+ return json(res, { ...wrote, connectors: fourPanel.connectors() }, wrote.ok ? 200 : 500);
2381
3408
  }
2382
3409
  if (p === '/api/search') {
2383
3410
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything
@@ -2811,15 +3838,25 @@ wss.on('connection', (ws) => {
2811
3838
  } else if (m.t === 'resize') {
2812
3839
  const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
2813
3840
  } else if (m.t === 'new') {
2814
- const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate });
2815
- if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: 'branch: ' + r.error })); return; }
3841
+ const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile });
3842
+ // A refused profile is its own sentence, not "branch: …" — the composer's error line names
3843
+ // what PMOS could not read, and nothing was started.
3844
+ if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
2816
3845
  // a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
2817
3846
  // non-repo) — said out loud, never swallowed
2818
3847
  if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
2819
3848
  ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
2820
3849
  } else if (m.t === 'respawn') {
2821
3850
  const old = sessions.get(m.id); if (!old || old.pty) return;
2822
- const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws });
3851
+ // `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the
3852
+ // server already holds, not re-sent by the client — a client that forgot it would otherwise
3853
+ // silently restart the pane on a different environment, and the pane would look identical.
3854
+ const carried = 'profile' in old ? old.profile : null;
3855
+ const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried });
3856
+ // A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
3857
+ // ahead with no profile — the pane line already says so — and the reason is said out loud
3858
+ // rather than left for the PM to notice.
3859
+ if (s.profileRefusal) ws.send(JSON.stringify({ t: 'notice', notice: `respawned without a profile — ${s.profileRefusal}` }));
2823
3860
  removeSession(old.id); // the corpse doesn't linger — respawn replaces, not accumulates
2824
3861
  ws.send(JSON.stringify({ t: 'created', id: s.id }));
2825
3862
  } else if (m.t === 'kill') {
@@ -2915,4 +3952,8 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
2915
3952
  trimBuf, SCROLLBACK,
2916
3953
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
2917
3954
  // asserted without a server that has no clients (the suite always has both).
2918
- shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS };
3955
+ shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,
3956
+ // where the anchor resolver is looked for — exported so the suite can assert the INSTALL
3957
+ // location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
3958
+ // exactly as it hid the workflow engine's absence for four releases (PKG4).
3959
+ ANCHOR_LIB_PATHS };