create-pmos 0.37.0 → 0.38.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 (71) hide show
  1. package/bin/create-pmos.js +22 -3
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +4 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/frontmatter-format.md +27 -61
  6. package/payload/okf/core/templates/decision-record-template.md +34 -0
  7. package/payload/okf/core/templates/index.md +7 -2
  8. package/payload/okf/core/templates/pm-brief-template.md +34 -0
  9. package/payload/okf/core/templates/project-plan-template.md +43 -0
  10. package/payload/okf/core/templates/report-weekly-template.md +35 -0
  11. package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
  12. package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
  13. package/payload/planning/evals/eval-calibration.md +8 -0
  14. package/payload/planning/okrs/health-budgets.md +8 -0
  15. package/payload/scripts/anchor-resolve.js +56 -6
  16. package/payload/scripts/gate-initiative.sh +32 -0
  17. package/payload/scripts/okf-upgrade.js +232 -0
  18. package/payload/scripts/pmos-memory.js +375 -0
  19. package/payload/scripts/pmos-model.js +239 -0
  20. package/payload/scripts/pmos-skills.js +274 -0
  21. package/payload/scripts/workflow-state.js +178 -10
  22. package/payload/scripts/workflow-sweep.js +2 -1
  23. package/payload/state/discoveries.md +8 -0
  24. package/payload/state/index.md +7 -0
  25. package/payload/state/log.md +7 -0
  26. package/workspace/README.md +61 -204
  27. package/workspace/lib/board-cache.js +18 -8
  28. package/workspace/lib/console-state.js +2 -0
  29. package/workspace/lib/engine/anchor-resolve.js +56 -6
  30. package/workspace/lib/engine/pmos-memory.js +375 -0
  31. package/workspace/lib/engine/pmos-model.js +239 -0
  32. package/workspace/lib/engine/pmos-skills.js +274 -0
  33. package/workspace/lib/engine/workflow-state.js +178 -10
  34. package/workspace/lib/focus.js +5 -6
  35. package/workspace/lib/memory.js +54 -0
  36. package/workspace/lib/models.js +50 -0
  37. package/workspace/lib/preview-kind.js +12 -1
  38. package/workspace/lib/schedule-logic.js +31 -2
  39. package/workspace/lib/skill-store.js +5 -1
  40. package/workspace/lib/skills-registry.js +74 -0
  41. package/workspace/lib/sync.js +132 -0
  42. package/workspace/lib/tree-share.js +2 -0
  43. package/workspace/package-lock.json +1 -1
  44. package/workspace/package.json +3 -3
  45. package/workspace/public/pm/app.js +32 -2
  46. package/workspace/public/pm/data.js +160 -10
  47. package/workspace/public/pm/lifecycle.js +105 -0
  48. package/workspace/public/pm/markdown.js +10 -1
  49. package/workspace/public/pm/modes-live.js +371 -0
  50. package/workspace/public/pm/modes.js +7 -4
  51. package/workspace/public/pm/panel-detail.js +22 -2
  52. package/workspace/public/pm/panel-initiatives.js +57 -12
  53. package/workspace/public/pm/panel-newsess.js +25 -0
  54. package/workspace/public/pm/panel-repos.js +130 -4
  55. package/workspace/public/pm/panel-tabs.js +49 -2
  56. package/workspace/public/pm/state.js +11 -1
  57. package/workspace/public/pm/tab-doc.js +160 -26
  58. package/workspace/public/pm/tab-term.js +2 -0
  59. package/workspace/public/pm/tab-web.js +89 -0
  60. package/workspace/public/pm/tokens.css +4 -0
  61. package/workspace/public/pm/xlsx-read.js +117 -0
  62. package/workspace/server.js +690 -40
  63. package/workspace/skills/gather-requirements.skill +53 -0
  64. package/workspace/skills/memory-groom.skill +46 -0
  65. package/workspace/skills/report-biweekly.skill +53 -0
  66. package/workspace/skills/report-daily.skill +53 -0
  67. package/workspace/skills/report-monthly.skill +53 -0
  68. package/workspace/skills/report-special.skill +49 -0
  69. package/workspace/skills/report-weekly.skill +53 -0
  70. package/workspace/skills/skill-discover.skill +49 -0
  71. package/workspace/skills/skill-generate.skill +33 -0
@@ -71,6 +71,10 @@ const rateLimit = require('./lib/rate-limit'); // per-connection token b
71
71
  const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
72
72
  const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
73
73
  const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
74
+ const skillsReg = require('./lib/skills-registry'); // discovered and generated skills: quarantine, provenance, local approval (D76)
75
+ const modelLib = require('./lib/models'); // tier routing — one code path, shared with the agents' CLI (D75)
76
+ const memoryLib = require('./lib/memory'); // curated repository memory — one ledger code path, shared with the agents' CLI (D72)
77
+ const syncLib = require('./lib/sync'); // press-only fetch / integrate / commit records / push (D70)
74
78
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
75
79
  const startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
76
80
  const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
@@ -355,7 +359,7 @@ function loadWorkspaces() {
355
359
  if (!w || !w.root) continue;
356
360
  const root = path.resolve(w.root);
357
361
  if (!isDir(root)) continue; // a moved/deleted repo drops out quietly
358
- workspaces.set(wsId(root), { id: wsId(root), name: w.name || path.basename(root), root, addedAt: w.addedAt || nowISO() });
362
+ workspaces.set(wsId(root), { id: wsId(root), name: w.name || path.basename(root), root, addedAt: w.addedAt || nowISO(), ...(w.label ? { label: String(w.label) } : {}) });
359
363
  }
360
364
  }
361
365
  }
@@ -378,13 +382,49 @@ function registerWorkspace(root, name) {
378
382
  }
379
383
  // Forgetting a workspace never touches the repo — it only stops hosting it here. Blocked
380
384
  // while it still owns sessions, so scrollback is never orphaned by a click.
381
- function forgetWorkspace(id) {
385
+ /**
386
+ * Why a workspace cannot be forgotten, or null when it can. The three causes used to share one
387
+ * message ("unknown workspace, the home workspace, or it still owns sessions"), which told the PM
388
+ * nothing they could act on (overhaul-r1 R3.3). Sessions are NAMED, live or exited, because an
389
+ * exited session still belongs to the row and would re-register it on restart (~recovery).
390
+ */
391
+ function forgetBlocker(id) {
382
392
  const w = workspaces.get(id);
383
- if (!w || (HOME_ENABLED && w.root === SELF_ROOT)) return false;
384
- if ([...sessions.values()].some((s) => s.ws === id)) return false;
393
+ if (!w) return { reason: 'unknown', error: 'unknown repository — it may already have been removed' };
394
+ if (HOME_ENABLED && w.root === SELF_ROOT) return { reason: 'home', error: `“${w.name}” is the home workspace and cannot be removed` };
395
+ const owned = [...sessions.values()].filter((s) => s.ws === id);
396
+ if (owned.length) {
397
+ const names = owned.map((s) => s.name || s.id);
398
+ return { reason: 'sessions', sessions: names,
399
+ error: `${owned.length} session${owned.length === 1 ? '' : 's'} still use${owned.length === 1 ? 's' : ''} “${w.name}”: ${names.join(', ')} — close or remove ${owned.length === 1 ? 'it' : 'them'} first` };
400
+ }
401
+ return null;
402
+ }
403
+ function forgetWorkspace(id) {
404
+ if (forgetBlocker(id)) return false;
385
405
  workspaces.delete(id); saveWorkspaces(); pushWorkspaces();
386
406
  return true;
387
407
  }
408
+ /**
409
+ * Remove a REPOSITORY: its row and every linked-worktree row of the same repo (overhaul-r1 R3.2).
410
+ * All-or-nothing — if any row is blocked, nothing is forgotten and every blocker is reported. Never
411
+ * deletes a folder; reclaiming a worktree's folder stays with /api/git/worktrees/remove.
412
+ */
413
+ function forgetRepository(id) {
414
+ const w = workspaces.get(id);
415
+ if (!w) return { ok: false, ...forgetBlocker(id) };
416
+ const fam = treeFamily(w);
417
+ const blocked = fam.map((x) => ({ x, b: forgetBlocker(x.id) })).filter((y) => y.b);
418
+ if (blocked.length) {
419
+ const sessionsNamed = blocked.flatMap((y) => y.b.sessions || []);
420
+ const first = blocked[0].b;
421
+ return { ok: false, reason: first.reason, sessions: sessionsNamed.length ? sessionsNamed : undefined,
422
+ error: blocked.map((y) => y.b.error).join('; ') };
423
+ }
424
+ for (const x of fam) workspaces.delete(x.id);
425
+ saveWorkspaces(); pushWorkspaces();
426
+ return { ok: true, removed: fam.map((x) => x.id) };
427
+ }
388
428
  // The home is SELF_ROOT when enabled; otherwise (installed copy, no launcher env) there is no
389
429
  // privileged home, so an unresolved ws id falls back to the first registered repo rather than
390
430
  // resolving to the package dir.
@@ -501,6 +541,11 @@ function treeWriter(tree) {
501
541
  * definition looking at and which persists in the session record. Every new session, until the two
502
542
  * directories are resolved, because a notice that shows once is a notice that is missed once.
503
543
  */
544
+ // D75 — say which model was applied, in the pane's own record, when one was asked for. Silent for 'not asked'.
545
+ function announceModel(s) {
546
+ if (!s.model || s.model.state === 'not-asked') return;
547
+ appendBuf(s, `\x1b[38;5;244mpmos: ${modelLib.line(s.model)}\x1b[0m\r\n`);
548
+ }
504
549
  function announceStateHealth(s) {
505
550
  const h = stateHealthNow();
506
551
  if (h.ok) return;
@@ -542,7 +587,7 @@ function wsList() {
542
587
  return [...workspaces.values()].map((w) => {
543
588
  const rid = repoIdentity(w.root);
544
589
  return {
545
- id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
590
+ id: w.id, name: w.name, label: w.label || null, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
546
591
  shape: wsShape(w.root), addedAt: w.addedAt,
547
592
  sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
548
593
  // the repository this working tree belongs to, and the branch it is on. `branch_code`
@@ -832,7 +877,7 @@ const SEARCH_LIMIT_DEFAULT = 40;
832
877
  const SEARCH_LIMIT_MAX = 200;
833
878
  const SEARCH_INDEX_TTL_MS = 4000; // one word of typing costs one walk, not one per keystroke
834
879
  const SEARCH_SNIPPET = 160;
835
- const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html']); // what /api/doc will serve
880
+ const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html', 'pdf', 'sheet']); // what /api/doc (or /api/asset, for pdf) will serve
836
881
  const searchIndexCache = new Map(); // root -> { at, files, truncated, scanned }
837
882
 
838
883
  /** Every file under `root`, flat and repo-relative, cached for SEARCH_INDEX_TTL_MS. */
@@ -949,6 +994,7 @@ function searchWorkspace(root, query, limit) {
949
994
  * cannot click into an unknown. */
950
995
  const st = fs.statSync(path.join(root, rel));
951
996
  if (!st.isFile()) return false;
997
+ if (['pdf', 'sheet'].includes(preview.previewKind(rel))) return st.size > 0; // served by /api/asset, never read as text
952
998
  /* Past the byte budget we do not READ the file — but "we did not look" is not "the reader
953
999
  * refuses it". Returning false there swapped one false label for its opposite: a legitimate
954
1000
  * 316 KB markdown doc reported openable:false while /api/doc served it 200 with 324,017 bytes.
@@ -977,7 +1023,7 @@ function searchWorkspace(root, query, limit) {
977
1023
  if (overflow) contentCut = true;
978
1024
  if (!overflow) {
979
1025
  for (const f of idx.files) {
980
- if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
1026
+ if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind) || f.kind === 'pdf' || f.kind === 'sheet') continue; // a PDF body is not text
981
1027
  if (read >= SEARCH_CONTENT_FILES) { contentCut = true; break; }
982
1028
  const full = path.join(root, f.path);
983
1029
  let st;
@@ -1015,6 +1061,86 @@ function searchWorkspace(root, query, limit) {
1015
1061
  };
1016
1062
  }
1017
1063
 
1064
+ /**
1065
+ * Every registered tree of the repository `w` belongs to — its LINES OF WORK (overhaul-r1 R1).
1066
+ * Derived per call from `repo_key`, the same identity `wsList()` publishes and the client folds
1067
+ * by, so the registry gains no parent pointer and there is still one source of truth. The main
1068
+ * tree (the one whose root IS the repo key) comes first; the rest keep registry order. A folder
1069
+ * with no git is a family of one, which is the truth.
1070
+ */
1071
+ function treeFamily(w) {
1072
+ if (!w) return [];
1073
+ const key = repoIdentity(w.root).key;
1074
+ const fam = [...workspaces.values()].filter((x) => x.id === w.id || repoIdentity(x.root).key === key);
1075
+ fam.sort((a, b) => (b.root === key) - (a.root === key));
1076
+ return fam;
1077
+ }
1078
+
1079
+ /** Content identity for the merge — full-body sha1, only within the content budget. A file past
1080
+ * the budget is NOT compared, so it is never merged on a guess: it stays one row per tree. */
1081
+ function contentDigest(root, rel) {
1082
+ try {
1083
+ const full = path.join(root, rel);
1084
+ const st = fs.statSync(full);
1085
+ if (!st.isFile() || st.size > SEARCH_CONTENT_BYTES) return null;
1086
+ return crypto.createHash('sha1').update(fs.readFileSync(full)).digest('hex');
1087
+ } catch { return null; }
1088
+ }
1089
+
1090
+ /**
1091
+ * Search every line of work of a repository (overhaul-r1 R1.1–R1.5). Each hit carries the `ws` and
1092
+ * `branch` of the tree it came from, plus `branches` — every tree that holds the same path with
1093
+ * byte-identical content, which is then listed ONCE (PM choice, 2026-10-01). A path that differs
1094
+ * between trees, or exists on only some, is one hit per distinct tree. Walk bounds stay per tree
1095
+ * and are reported per tree in `trees[]`, so a capped walk on one branch cannot read as complete;
1096
+ * the top-level flags are the OR across trees. `only` narrows the search to one tree.
1097
+ */
1098
+ function searchFamily(w, query, limit, only) {
1099
+ let fam = treeFamily(w);
1100
+ if (only) fam = fam.filter((x) => x.id === only);
1101
+ if (!fam.length) fam = [w];
1102
+ const branchOf = (x) => repoIdentity(x.root).branch || null;
1103
+ const per = fam.map((x) => ({ x, branch: branchOf(x), r: searchWorkspace(x.root, query, limit) }));
1104
+ const first = per[0].r;
1105
+ const cap = first.limit;
1106
+ const groups = new Map(); // path -> [{hit, x, branch, digest}]
1107
+ for (const t of per) {
1108
+ for (const h of t.r.hits || []) {
1109
+ if (!groups.has(h.path)) groups.set(h.path, []);
1110
+ groups.get(h.path).push({ h, x: t.x, branch: t.branch });
1111
+ }
1112
+ }
1113
+ const hits = [];
1114
+ for (const list of groups.values()) {
1115
+ if (list.length === 1) {
1116
+ const e = list[0];
1117
+ hits.push({ ...e.h, ws: e.x.id, branch: e.branch, branches: [e.branch] });
1118
+ continue;
1119
+ }
1120
+ const buckets = new Map(); // digest (or a per-tree token when uncomparable) -> entries
1121
+ for (const e of list) {
1122
+ const d = contentDigest(e.x.root, e.h.path) || ('uncompared:' + e.x.id);
1123
+ if (!buckets.has(d)) buckets.set(d, []);
1124
+ buckets.get(d).push(e);
1125
+ }
1126
+ for (const b of buckets.values()) {
1127
+ const e = b[0];
1128
+ hits.push({ ...e.h, ws: e.x.id, branch: e.branch, branches: b.map((y) => y.branch) });
1129
+ }
1130
+ }
1131
+ const overflow = hits.length > cap;
1132
+ return {
1133
+ ok: per.every((t) => t.r.ok !== false), q: first.q, limit: cap,
1134
+ hits: hits.slice(0, cap),
1135
+ truncated: overflow || per.some((t) => t.r.truncated),
1136
+ content_truncated: per.some((t) => t.r.content_truncated),
1137
+ scanned: per.reduce((n, t) => n + (t.r.scanned || 0), 0),
1138
+ content_scanned: per.reduce((n, t) => n + (t.r.content_scanned || 0), 0),
1139
+ trees: per.map((t) => ({ ws: t.x.id, branch: t.branch, main: t.x.root === repoIdentity(w.root).key,
1140
+ truncated: !!t.r.truncated, content_truncated: !!t.r.content_truncated, scanned: t.r.scanned || 0 })),
1141
+ };
1142
+ }
1143
+
1018
1144
  // Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
1019
1145
  // per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
1020
1146
  // non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
@@ -1131,8 +1257,23 @@ function parseHead(full) {
1131
1257
  const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
1132
1258
  const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
1133
1259
  const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
1260
+ // overhaul-r2 C1 — an artifact may serve SEVERAL initiatives: `initiatives: [a, b]`,
1261
+ // `initiative: a, b`, or a YAML list under `initiatives:`. Each named id lists the artifact; the
1262
+ // first is also its primary owner when no `initiative_id:` is declared.
1263
+ const also = [];
1264
+ const multi = head.match(/^\s*initiatives?\s*:[ \t]*(.*)$/m);
1265
+ if (multi) {
1266
+ const inline = multi[1].replace(/^\[|\]$/g, '').trim();
1267
+ if (inline) inline.split(',').forEach((x) => also.push(x.trim().replace(/^["']|["']$/g, '')));
1268
+ else {
1269
+ const after = head.slice(multi.index + multi[0].length).split('\n').slice(1);
1270
+ for (const ln of after) { const li = /^\s+-\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/.exec(ln); if (!li) break; also.push(li[1]); }
1271
+ }
1272
+ }
1273
+ const alsoIds = [...new Set(also.filter((x) => /^[A-Za-z0-9][\w.-]*$/.test(x)))];
1134
1274
  return {
1135
- initiative: id ? id[1] : null,
1275
+ initiative: id ? id[1] : (alsoIds[0] || null),
1276
+ also: alsoIds,
1136
1277
  recordId: rid ? rid[1] : null,
1137
1278
  title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
1138
1279
  kr: clean(kr), lane: clean(lane),
@@ -1195,6 +1336,7 @@ function docMeta(root, full) {
1195
1336
  mtimeMs: st.mtimeMs, path: rel, name: base, role,
1196
1337
  // frontmatter wins; the filename convention is the fallback, never the override
1197
1338
  initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
1339
+ also: role === 'record' ? [] : (head.also || []),
1198
1340
  stem, title: head.title || null,
1199
1341
  kr: head.kr || null, lane: head.lane || null,
1200
1342
  dest: head.dest || null,
@@ -1288,6 +1430,13 @@ function initiativeIndex(root) {
1288
1430
  if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
1289
1431
  const g = groups.get(k);
1290
1432
  g.artifacts.push(art);
1433
+ // overhaul-r2 C1 — listed under every OTHER initiative it names too (it does not lend them a
1434
+ // title, an anchor or a lane: those stay the record's and its primary owner's)
1435
+ for (const other of m.also || []) {
1436
+ if (other === k) continue;
1437
+ if (!groups.has(other)) groups.set(other, { id: other, title: null, kr: null, lane: null, artifacts: [] });
1438
+ groups.get(other).artifacts.push({ ...art, shared: true });
1439
+ }
1291
1440
  if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
1292
1441
  else if (!g.title && m.title) g.title = m.title;
1293
1442
  // The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
@@ -1314,6 +1463,17 @@ function initiativeIndex(root) {
1314
1463
  // groups planning/ into initiatives and carries the PMOS knowledge (okf/skills/build-skills,
1315
1464
  // wherever they live) + governance. Its guarantee is that it never DROPS a planning document —
1316
1465
  // not that it mirrors every file in the repo (the folder lens is for that).
1466
+ // R11 — rewrite a single-line `generated: {by: …, at: …}` in the LEADING frontmatter block to the PM as author.
1467
+ // Returns the text unchanged when there is no such line (never adds one, never touches a nested block).
1468
+ function stampEdit(text, at) {
1469
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/.exec(text);
1470
+ if (!m) return text;
1471
+ const fm = m[1];
1472
+ const re = /^generated:[ \t]*\{[^\n}]*\}[ \t]*$/m;
1473
+ if (!re.test(fm)) return text;
1474
+ const next = fm.replace(re, `generated: {by: human:wawan, at: "${at}"}`);
1475
+ return next === fm ? text : text.replace(fm, () => next);
1476
+ }
1317
1477
  function library(root) {
1318
1478
  const shape = wsShape(root);
1319
1479
  const roots = libraryTree(root);
@@ -1323,7 +1483,15 @@ function library(root) {
1323
1483
  .map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
1324
1484
  .filter((t) => t.dirs.length || t.docs.length)
1325
1485
  : [];
1326
- return { shape, roots, initiatives, ungrouped, knowledge, governance: looseDocs(root) };
1486
+ // D74/R11 — the notes PMOS and its agents write into a repository: requirement notes, report drafts and
1487
+ // definitions, memory views (and the portfolio report at the root). Listed for ANY repository shape, because a
1488
+ // PM repository is not a 'pmos'-shaped bundle and its notes were invisible to this lens.
1489
+ const NOTE_DIRS = ['pmos/state/requirements', 'pmos/state/reports', 'pmos/state/memory',
1490
+ 'planning/requirements', 'planning/reports', 'planning/memory', 'reports'];
1491
+ const notes = NOTE_DIRS.map((d) => path.join(root, ...d.split('/'))).filter((d) => isDir(d))
1492
+ .map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
1493
+ .filter((t) => t.dirs.length || t.docs.length);
1494
+ return { shape, roots, initiatives, ungrouped, knowledge, notes, governance: looseDocs(root) };
1327
1495
  }
1328
1496
 
1329
1497
  // ── On-demand surfaces (D60 / the workspace-ondemand-surfaces PRD). ────────────────────────────
@@ -1334,7 +1502,7 @@ function library(root) {
1334
1502
  // client renders it; a surface that can't be produced returns ok:false and the client shows an
1335
1503
  // honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
1336
1504
  // ("— not tracked"), never invented (D14/D40 honesty).
1337
- const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
1505
+ const PHASE_OF = { stopped: 'plan', planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
1338
1506
 
1339
1507
  // ── Delivery evidence (board-delivery-truth R-2/R-3; shared reader, workflow-status-sweep R-1). ──
1340
1508
  // "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
@@ -1399,6 +1567,7 @@ function deliveryIndex(root, ids) {
1399
1567
  * one ordering departure, made because the alternative buries a returned initiative.)
1400
1568
  */
1401
1569
  function deriveState(ev) {
1570
+ if (ev.stopped && !ev.accepted) return { state: 'stopped', why: `${ev.stopped.state}${ev.stopped.reason ? ` — ${ev.stopped.reason}` : ''} (lifecycle.jsonl, D70)` };
1402
1571
  if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
1403
1572
  if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
1404
1573
  if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
@@ -1439,6 +1608,19 @@ function readLedger(w, basename) {
1439
1608
  }
1440
1609
  return out;
1441
1610
  }
1611
+ // D70 — the lifecycle ledger: the latest line for an initiative wins; `active` reopens. The same rule
1612
+ // as scripts/workflow-state.js#readLifecycle (a parity case in workspace/test.js keeps them in step).
1613
+ const syncing = new Set(); // repositories with a sync in flight — one at a time
1614
+ const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
1615
+ function readLifecycle(w) {
1616
+ const latest = new Map();
1617
+ for (const r of readLedger(w, 'lifecycle.jsonl')) {
1618
+ if (!r || typeof r.initiative_id !== 'string') continue;
1619
+ if (r.state !== 'active' && !STOPPED_STATES.includes(r.state)) continue;
1620
+ latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' });
1621
+ }
1622
+ return latest;
1623
+ }
1442
1624
  function readAcceptances(w) {
1443
1625
  const acc = new Map(); // initiative_id -> { action, at }
1444
1626
  for (const r of readLedger(w, 'acceptances.jsonl')) {
@@ -1490,6 +1672,7 @@ function readRunCounts(w) {
1490
1672
  function surfaceInitiatives(root, w) {
1491
1673
  const { initiatives } = initiativeIndex(root);
1492
1674
  const acc = readAcceptances(w);
1675
+ const life = readLifecycle(w);
1493
1676
  const runs = readRunCounts(w);
1494
1677
  const needsRaw = readNeeds(w) || [];
1495
1678
  const needsBy = new Map();
@@ -1502,6 +1685,7 @@ function surfaceInitiatives(root, w) {
1502
1685
  const { state, why } = deriveState({
1503
1686
  // readAcceptances already folds every accepting label (approve/merge/publish) into 'accept'.
1504
1687
  accepted: a.action === 'accept', acceptedAt: a.at,
1688
+ stopped: (life.get(g.id) && life.get(g.id).state !== 'active') ? life.get(g.id) : null,
1505
1689
  returned: a.action === 'return',
1506
1690
  needs: needsBy.get(g.id) || 0,
1507
1691
  delivery: del.ok ? 'known' : 'unknown',
@@ -1515,6 +1699,7 @@ function surfaceInitiatives(root, w) {
1515
1699
  // the client renders `statusText`, which always names the artifact the state was read from.
1516
1700
  stage: null, kr: g.kr || null, lane: g.lane || null,
1517
1701
  accepted: state === 'accepted',
1702
+ lifecycle: (life.get(g.id) && life.get(g.id).state !== 'active') ? life.get(g.id) : null,
1518
1703
  delivered: state === 'accepted' || state === 'delivered',
1519
1704
  delivery_evidence: deliveredBy,
1520
1705
  artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path, dest: a2.dest || null })),
@@ -1530,7 +1715,7 @@ function surfaceInitiatives(root, w) {
1530
1715
  initiatives: items,
1531
1716
  pulse: {
1532
1717
  total: items.length, planning: by('planning'), building: by('building'),
1533
- blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
1718
+ blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'), stopped: by('stopped'),
1534
1719
  unknown: by('unknown'),
1535
1720
  },
1536
1721
  // R-3: a failed read is reported, never absorbed. The client renders this RED above the list
@@ -1664,6 +1849,7 @@ function surfaceSpine(root) {
1664
1849
 
1665
1850
  const budgets = new Map(); // health:<id> groups — peers of objectives (D59), not a footnote
1666
1851
  const unanchored = []; // R6 — never dropped to make the tree tidy
1852
+ const domains = new Map(); // domain:<id> groups (D67) — work that serves a domain, no OKR
1667
1853
 
1668
1854
  for (const dir of initiativeDirs(root)) {
1669
1855
  let files = [];
@@ -1691,6 +1877,13 @@ function surfaceSpine(root) {
1691
1877
  budgets.get(key).initiatives.push(rec);
1692
1878
  continue;
1693
1879
  }
1880
+ // D67 — a domain is a peer group like a health budget: the standing goal it serves, no OKR
1881
+ if (r.kind === 'domain' && r.status === 'resolved') {
1882
+ const key = `domain:${r.domain}`;
1883
+ if (!domains.has(key)) domains.set(key, { id: key, name: r.domain, goal: r.domain_goal || null, initiatives: [] });
1884
+ domains.get(key).initiatives.push(rec);
1885
+ continue;
1886
+ }
1694
1887
  if (r.kind === 'okr' && r.status === 'resolved') {
1695
1888
  const kr = r.kr ? krByKey.get(`${A.objectiveId(r.objective)}::${A.krId(r.kr)}`) : null;
1696
1889
  if (kr) { kr.initiatives.push(rec); continue; }
@@ -1714,6 +1907,7 @@ function surfaceSpine(root) {
1714
1907
  return {
1715
1908
  objectives,
1716
1909
  budgets: [...budgets.values()].sort((a, b) => a.id.localeCompare(b.id)),
1910
+ domains: [...domains.values()].sort((a, b) => a.id.localeCompare(b.id)),
1717
1911
  unanchored,
1718
1912
  unreadable: null,
1719
1913
  budgets_readable: !!(budgetsDoc && budgetsDoc.ok),
@@ -1759,6 +1953,9 @@ function buildSurface(name, root, w) {
1759
1953
  // refused the move, takes effect on the next read instead of on the next restart.
1760
1954
  function schedFile() { return schedStore().path; }
1761
1955
  const DELIV_DIR = path.join(STATE, 'deliverables');
1956
+ // D76 — the PM's LOCAL approvals of discovered skills. Runtime state on this machine, never the repository: a commit or a
1957
+ // hand-edited registry must not be able to approve a skill for the PM.
1958
+ const SKILL_APPROVALS = path.join(STATE, 'skill-approvals.jsonl');
1762
1959
  // Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
1763
1960
  // user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
1764
1961
  // concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
@@ -1783,8 +1980,13 @@ function filingContract(w) {
1783
1980
  }
1784
1981
  // Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
1785
1982
  // load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
1786
- function loadTasks() {
1983
+ // D76 — `root` adds the repository's APPROVED discovered skills, loaded FIRST so the PM's own skill of the same id
1984
+ // overrides one and a built-in overrides both. Only an approved candidate whose file still hashes to what was approved
1985
+ // is ever offered; its harness is forced to an agent skill running claude.
1986
+ function loadTasks(root) {
1987
+ const discovered = root ? skillsReg.loadApproved(root, SKILL_APPROVALS, skillStore.parseSkill).skills : [];
1787
1988
  return skillStore.loadSkills([
1989
+ ...(discovered.length ? [{ skills: discovered }] : []),
1788
1990
  { dir: userSkillsDir(), source: 'user' },
1789
1991
  { dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
1790
1992
  ]).map(toTask);
@@ -1793,12 +1995,24 @@ function loadTasks() {
1793
1995
  // file's `run` and stays INTERNAL — it is never a field the PM selects.
1794
1996
  function toTask(s) {
1795
1997
  const t = { id: s.id, label: s.label, skill: s.id, kind: s.run, source: s.source,
1796
- description: s.description, output: s.output };
1998
+ description: s.description, output: s.output, tier: s.tier || null };
1797
1999
  if (s.run === 'deterministic') t.surface = s.surface;
1798
2000
  else { t.agent = s.agent || 'claude'; t.prompt = s.body || null; }
1799
2001
  return t;
1800
2002
  }
1801
- function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
2003
+ function taskById(id, root) { return loadTasks(root).find((t) => t.id === id || t.skill === id) || null; }
2004
+ // D71 — the recommended schedules, one set per purpose. Times are host-local, like every schedule.
2005
+ const SEED_SETS = {
2006
+ requirements: [{ name: 'Daily requirement gathering', skill: 'gather-requirements', cadence: 'daily', time: '07:30' }],
2007
+ memory: [{ name: 'Weekly memory grooming', skill: 'memory-groom', cadence: 'weekly', day: 'Mon', time: '08:00' }],
2008
+ skills: [{ name: 'Weekly skill discovery', skill: 'skill-discover', cadence: 'weekly', day: 'Wed', time: '09:00' }],
2009
+ reports: [
2010
+ { name: 'Daily report draft', skill: 'report-daily', cadence: 'daily', time: '17:30' },
2011
+ { name: 'Weekly report draft', skill: 'report-weekly', cadence: 'weekly', day: 'Fri', time: '16:00' },
2012
+ { name: 'Bi-weekly report draft', skill: 'report-biweekly', cadence: 'bi-weekly', day: 'Fri', time: '16:00' },
2013
+ { name: 'Monthly report draft', skill: 'report-monthly', cadence: 'monthly', dayOfMonth: 1, time: '08:00' },
2014
+ ],
2015
+ };
1802
2016
  function readSchedules() {
1803
2017
  try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
1804
2018
  }
@@ -1822,7 +2036,7 @@ function fireSchedule(sched) {
1822
2036
  const w = workspaces.get(sched.ws) || homeWorkspace();
1823
2037
  const root = w ? w.root : SELF_ROOT;
1824
2038
  // One corpus load probing both keys — taskById would re-read every .skill file per probe.
1825
- const tasks = loadTasks();
2039
+ const tasks = loadTasks(root);
1826
2040
  const byId = (id) => tasks.find((t) => t.id === id || t.skill === id) || null;
1827
2041
  const task = byId(sched.task) || byId(sched.skill);
1828
2042
  // AGENT task — fire as a real PTY session running its skill (async; the skill files its own
@@ -1836,16 +2050,21 @@ function fireSchedule(sched) {
1836
2050
  // Fixing only the SCAFFOLD fixes only skills that do not exist yet, which is no help to the
1837
2051
  // eleven that do. `needs.json` is resolved per workspace, so this cannot be a one-off rewrite
1838
2052
  // of the files either — the right answer for a given fire depends on which repo it fires in.
1839
- const prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
2053
+ let prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
2054
+ // R11 — a schedule may carry the PM's own request ("a special report for the board meeting on …"). It is
2055
+ // appended AFTER the skill body so the skill's "if they already said, do not ask again" rule applies.
2056
+ if (prompt && sched.args) prompt += `\n\nThe PM's request for this run, given when it was scheduled (do not ask again): ${String(sched.args).replace(/\s+/g, ' ').trim()}`;
1840
2057
  // The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
1841
2058
  // AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
1842
2059
  // below rather than the control simply being hidden.
1843
2060
  const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
1844
2061
  origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
1845
- profile: sched.profile == null ? null : sched.profile });
2062
+ profile: sched.profile == null ? null : sched.profile,
2063
+ // D75 — the schedule's tier, then the skill's, then the policy's route for the skill: the harness picks the model for the work.
2064
+ modelAsk: { scheduleTier: sched.tier || null, skillTier: task.tier || null, skill: task.skill } });
1846
2065
  const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
1847
2066
  const started = s.state !== 'exited';
1848
- if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
2067
+ if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; writeSchedules(list); }
1849
2068
  // A run that started WITHOUT the profile it recorded is reported as such rather than as a
1850
2069
  // clean start: the session is real, the environment is not the one the schedule declared.
1851
2070
  return { ok: started, kind: 'agent', sessionId: s.id,
@@ -1884,6 +2103,12 @@ function fireSchedule(sched) {
1884
2103
  function checkDueSchedules() {
1885
2104
  let fired = 0;
1886
2105
  for (const s of readSchedules()) { try { if (scheduleLogic.isDue(s)) { fireSchedule(s); fired++; } } catch {} }
2106
+ // R11 — a one-off whose moment passed while nothing was running is marked missed and off, never caught up.
2107
+ try {
2108
+ const list = readSchedules(); let changed = false;
2109
+ for (const s of list) if (scheduleLogic.isMissed(s)) { s.enabled = false; s.lastStatus = 'missed'; s.nextRun = null; changed = true; }
2110
+ if (changed) writeSchedules(list);
2111
+ } catch {}
1887
2112
  return fired;
1888
2113
  }
1889
2114
  // the tick — the persistent server checks on an interval; unref'd so it never blocks exit
@@ -1906,6 +2131,9 @@ function persist(s) {
1906
2131
  // fact: a record persisted before profiles existed restores as "not recorded", and inventing a
1907
2132
  // key for the past would be a lie about it.
1908
2133
  if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
2134
+ // D75 — what model was asked for and what was APPLIED. Same `in`-guard as the profile: a record from before
2135
+ // routing existed restores as 'not recorded', and writing a key for the past would be a lie about it.
2136
+ if ('model' in s) { meta.model = s.model; meta.modelAsk = s.modelAsk || null; }
1909
2137
  // WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
1910
2138
  // the same reason: every session running before this field existed has no attribution, and
1911
2139
  // writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
@@ -2164,6 +2392,7 @@ function sessionList() {
2164
2392
  const hx = transcriptHarness(s);
2165
2393
  return {
2166
2394
  id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
2395
+ model: 'model' in s ? s.model : null, model_line: modelLib.line('model' in s ? s.model : null),
2167
2396
  cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
2168
2397
  // Derived presentation state, JOINED from both sources: the cadence clock above and the
2169
2398
  // transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
@@ -2610,11 +2839,13 @@ function resolveProfileForSpawn(id) {
2610
2839
  : `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
2611
2840
  }
2612
2841
 
2613
- function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
2842
+ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk }) {
2614
2843
  const id = nextSessionId();
2615
2844
  const w = getWs(ws);
2616
2845
  const root = w ? w.root : SELF_ROOT;
2617
2846
  const isShell = agent === 'shell';
2847
+ // D72 — the session reads the memory views once, at its start; rebuild them from the ledger now.
2848
+ try { memoryLib.renderFor(root); } catch { /* memory is never a reason a session fails to start */ }
2618
2849
  // a scheduled run tags the session with its origin, and feeds the skill invocation as first input
2619
2850
  const tag = origin || null;
2620
2851
  const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(initialInput); } catch {} }, 700); } catch {} } };
@@ -2632,6 +2863,25 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2632
2863
  const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
2633
2864
  profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
2634
2865
 
2866
+ /* WHICH MODEL (D75). Resolved once, here, with the profile, so the record can say only what was applied.
2867
+ * Three states are kept apart — not asked, applied, and "this binary has no model control" — and a choice that
2868
+ * cannot be honoured (a pinned review route being lowered, a value that is not a plain token, an unreadable
2869
+ * policy) refuses BEFORE anything starts, as an unreadable profile does. */
2870
+ const mres = modelLib.resolveForSpawn(root, agent, modelAsk);
2871
+ if (!mres.ok) {
2872
+ base.model = { state: 'refused', tier: null, model: null, effort: null, decidedBy: null, why: mres.error };
2873
+ return exitedSession(base, workdir, `model choice refused — ${mres.error}`);
2874
+ }
2875
+ base.model = { state: mres.state, tier: mres.tier || null, model: mres.model || null, effort: mres.effort || null,
2876
+ decidedBy: mres.decidedBy || null, why: mres.why || null };
2877
+ base.modelAsk = modelAsk || null;
2878
+ const launchArgs = mres.state === 'applied' ? mres.args : [];
2879
+ if (mres.tier) {
2880
+ const mc = modelLib.core();
2881
+ if (mc.ok) mc.core.logDecision(root, { kind: 'spawn', session: id, agent, tier: mres.tier, model: mres.model, effort: mres.effort,
2882
+ decidedBy: mres.decidedBy, state: mres.state, skill: (modelAsk && modelAsk.skill) || null, by: 'workspace' });
2883
+ }
2884
+
2635
2885
  /* WHAT THIS SESSION IS FOR (workspace-session-attribution R1).
2636
2886
  *
2637
2887
  * A PMOS session has never recorded which initiative it is working on — not here, not on the wire,
@@ -2665,7 +2915,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2665
2915
  sessions.set(id, s);
2666
2916
  // the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
2667
2917
  // really does spawn the process itself. See createTmuxSession for why the two differ.
2668
- if (!createTmuxSession(tname, cmd, workdir, 80, 24, profileOverlay(pres.profile))) {
2918
+ // The launch args are plain tokens by construction (pmos-model's strict pattern), so joining them is safe.
2919
+ if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, profileOverlay(pres.profile))) {
2669
2920
  const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
2670
2921
  s.state = 'exited'; s.tmux = null; s.note = why;
2671
2922
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
@@ -2673,6 +2924,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2673
2924
  }
2674
2925
  announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
2675
2926
  announceTree(s); // before attach, so the advisory is the first line of the pane's record
2927
+ announceModel(s);
2676
2928
  attachPty(s);
2677
2929
  feed(s);
2678
2930
  persist(s); pushSessions(); pushWorkspaces(); return s;
@@ -2683,7 +2935,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2683
2935
  let p, usedCmd = cmd;
2684
2936
  try {
2685
2937
  if (isShell) { const r = spawnPty(cmd, workdir, penv); p = r.pty; usedCmd = r.cmd; }
2686
- else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
2938
+ else p = pty.spawn(cmd, launchArgs, { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
2687
2939
  } catch (e) {
2688
2940
  return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
2689
2941
  }
@@ -2693,6 +2945,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2693
2945
  sessions.set(id, s);
2694
2946
  announceStateHealth(s);
2695
2947
  announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
2948
+ announceModel(s);
2696
2949
  p.onData((d) => {
2697
2950
  appendBuf(s, d);
2698
2951
  for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
@@ -2727,7 +2980,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2727
2980
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
2728
2981
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
2729
2982
  // git-then-maybe-spawn contract is unit-testable without a WS client.
2730
- function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
2983
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk }) {
2731
2984
  const w = getWs(ws);
2732
2985
  if (!w) return { ok: false, error: 'no workspace' };
2733
2986
  // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
@@ -2735,6 +2988,9 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2735
2988
  // and a session that claims a profile it never got is the quietest failure this feature has.
2736
2989
  const pres = resolveProfileForSpawn(profile);
2737
2990
  if (!pres.ok) return { ok: false, error: pres.error };
2991
+ // The model choice is checked on the same terms (D75): a refused choice starts nothing, and leaves no branch moved.
2992
+ const mchk = modelLib.resolveForSpawn(w.root, agent || 'shell', modelAsk);
2993
+ if (!mchk.ok) return { ok: false, error: `model choice refused — ${mchk.error}` };
2738
2994
  let targetWs = w.id;
2739
2995
  let notice = null;
2740
2996
  // Isolation exists to protect OTHER sessions. Alone in the working TREE there are none to
@@ -2771,7 +3027,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2771
3027
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
2772
3028
  }
2773
3029
  }
2774
- const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative });
3030
+ const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk });
2775
3031
  // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
2776
3032
  // block above. The isolate default already computes that other sessions would be disturbed — and
2777
3033
  // then the whole git block is skipped when no branch was picked, which is the common path and the
@@ -3435,6 +3691,143 @@ function budgetRegistryFor(root) {
3435
3691
  // are one product with one board, and counting them separately is what put the same initiative in
3436
3692
  // the queue three times. boardCache.board() ONLY (§6.8): consoleState.workflow() is ~30s and
3437
3693
  // synchronous, and on a request path it freezes every PTY socket for the whole half minute.
3694
+ /* ── overhaul-r4 — the portfolio, across every registered repository ───────────────────────────
3695
+ * One entry per REPOSITORY (repo_key), never per worktree. Boards come from boardCache only — the
3696
+ * synchronous derive freezes every PTY socket for ~30 s (board-cache.js) — so every repository
3697
+ * carries its own as_of / stale / pending, and one that cannot be read is listed WITH its error,
3698
+ * never dropped and never shown as empty. Grouping comes from the spine (domain › objective › KR, or
3699
+ * a health budget), which is a cheap read of the records. */
3700
+ function repoLeads() {
3701
+ const groups = new Map();
3702
+ for (const t of wsList()) {
3703
+ const key = t.repo_key || t.root;
3704
+ if (!groups.has(key)) groups.set(key, { key, name: t.label || t.repo_name || t.name, trees: [] });
3705
+ groups.get(key).trees.push(t);
3706
+ }
3707
+ return [...groups.values()].map((g) => {
3708
+ const lead = g.trees.find((t) => path.resolve(t.root) === path.resolve(g.key)) || g.trees[0];
3709
+ const label = (g.trees.find((t) => t.label) || {}).label;
3710
+ return { key: g.key, name: label || g.name, ws: lead.id, root: (workspaces.get(lead.id) || lead).root, ids: new Set(g.trees.map((t) => t.id)) };
3711
+ });
3712
+ }
3713
+ /** The hub (D-3): the registered repository whose root holds portfolio.yaml. Null when there is none. */
3714
+ function portfolioHub(leads) {
3715
+ const hub = (leads || repoLeads()).find((r) => isDir(r.root) && fs.existsSync(path.join(r.root, 'portfolio.yaml')));
3716
+ if (!hub) return null;
3717
+ // `repos:` then indented `<repository name>: <domain>` lines — the only shape read, stated in the PRD
3718
+ const map = {};
3719
+ let inRepos = false;
3720
+ for (const line of fs.readFileSync(path.join(hub.root, 'portfolio.yaml'), 'utf8').split('\n')) {
3721
+ if (/^repos\s*:\s*$/.test(line)) { inRepos = true; continue; }
3722
+ if (/^\S/.test(line)) inRepos = false;
3723
+ const m = inRepos && /^\s+["']?([^:"']+?)["']?\s*:\s*["']?([A-Za-z0-9][\w-]*)["']?\s*$/.exec(line);
3724
+ if (m) map[m[1].trim()] = m[2];
3725
+ }
3726
+ return { name: hub.name, ws: hub.ws, root: hub.root, domains: map };
3727
+ }
3728
+ function portfolio() {
3729
+ const leads = repoLeads();
3730
+ const hub = portfolioHub(leads);
3731
+ const sessions = sessionList();
3732
+ const repos = [];
3733
+ const rows = [];
3734
+ for (const r of leads) {
3735
+ const board = boardCache.board(r.root);
3736
+ let spine;
3737
+ try { spine = surfaceSpine(r.root); }
3738
+ catch (e) { spine = { unreadable: 'the records could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }; }
3739
+ const repoDomain = hub ? hub.domains[r.name] || null : null;
3740
+ const entry = { name: r.name, ws: r.ws, domain: repoDomain, as_of: board.as_of || null, stale: !!board.stale,
3741
+ pending: !!board.pending, error: board.ok === false ? board.error : (spine.unreadable || null), count: 0 };
3742
+ repos.push(entry);
3743
+ if (spine.unreadable) continue;
3744
+ const group = new Map(); // initiative id -> { group, domain }
3745
+ for (const o of spine.objectives || []) {
3746
+ for (const k of o.krs || []) for (const i of k.initiatives || []) group.set(i.id, { group: `${o.name || o.key} › ${k.name || k.key}`, kind: 'kr', rec: i });
3747
+ for (const i of o.direct || []) group.set(i.id, { group: o.name || o.key, kind: 'objective', rec: i });
3748
+ }
3749
+ for (const b of spine.budgets || []) for (const i of b.initiatives || []) group.set(i.id, { group: b.id, kind: 'health', rec: i });
3750
+ for (const d of spine.domains || []) for (const i of d.initiatives || []) group.set(i.id, { group: `${d.id} (no OKR)`, kind: 'domain', domain: d.name, rec: i });
3751
+ for (const i of spine.unanchored || []) group.set(i.id, { group: 'no resolvable anchor', kind: 'unanchored', rec: i });
3752
+ const byId = new Map(((board && board.rows) || []).filter((x) => x && x.id).map((x) => [String(x.id), x]));
3753
+ for (const [id, g] of group) {
3754
+ const b = byId.get(id);
3755
+ rows.push({
3756
+ repo: r.name, ws: r.ws, id, title: (b && b.title) || g.rec.title || id, lane: (b && b.lane) || g.rec.lane || null,
3757
+ stage: b ? b.stage : null, accepted: b ? !!b.accepted : null, next: b && b.next ? b.next.action || b.next.why || null : null,
3758
+ blocked: b ? (b.blockers || []).length > 0 : null,
3759
+ group: g.group, group_kind: g.kind, domain: g.domain || repoDomain,
3760
+ sessions: sessions.filter((x) => r.ids.has(x.ws) && x.initiative === id && x.state !== 'exited').length,
3761
+ });
3762
+ entry.count++;
3763
+ }
3764
+ }
3765
+ return { ok: true, as_of: nowISO(), hub: hub ? { name: hub.name, ws: hub.ws } : null, repos, rows };
3766
+ }
3767
+
3768
+ /* overhaul-r4 C — a report from the portfolio aggregate, written into the HUB on a PM press only. It
3769
+ * creates a new file (never overwrites; a numeric suffix is added) and is NOT logged as a PM edit of
3770
+ * an existing document — that is /api/doc/save's meaning, and a generated report is not one. */
3771
+ function portfolioReport(kind, q) {
3772
+ const pf = portfolio();
3773
+ const hubInfo = pf.hub && workspaces.get(pf.hub.ws);
3774
+ if (!hubInfo) return { ok: false, reason: 'no-hub', error: 'no hub repository — add a portfolio.yaml to your PM repository and register it (D-3); a report is never written to a guessed place' };
3775
+ if (!['weekly', 'okr', 'query'].includes(kind)) return { ok: false, error: 'kind must be weekly, okr or query' };
3776
+ const needle = String(q || '').trim().toLowerCase();
3777
+ if (kind === 'query' && !needle) return { ok: false, error: 'a query report needs a q' };
3778
+ let rows = pf.rows;
3779
+ if (kind === 'query') rows = rows.filter((x) => [x.id, x.title, x.group, x.repo, x.domain].some((v) => v && String(v).toLowerCase().includes(needle)));
3780
+ const day = nowISO().slice(0, 10);
3781
+ const titles = { weekly: 'Weekly portfolio report', okr: 'OKR and domain progress', query: `Portfolio query — "${String(q).trim()}"` };
3782
+ const lines = [];
3783
+ // OKF v0.2 (D73): one key per line, nothing indented — the frontmatter readers stop at the first indented line.
3784
+ lines.push('---', 'type: Reference', `title: "${titles[kind].replace(/"/g, "'")} — ${day}"`,
3785
+ `description: "Portfolio aggregate across the registered repositories, written on the PM's request."`,
3786
+ `generated: {by: process:pmos-workspace, at: "${nowISO()}"}`, 'status: stable', '---', '');
3787
+ lines.push(`# ${titles[kind]} — ${day}`, '');
3788
+ lines.push(`Generated by the PMOS workspace from the portfolio aggregate at ${pf.as_of}, on the PM's request. It reads every registered repository's derived board; it states what it could not read rather than leaving it out.`, '');
3789
+ const unread = pf.repos.filter((r) => r.error || r.pending);
3790
+ const staleR = pf.repos.filter((r) => r.stale && !r.error);
3791
+ lines.push('## Coverage', '');
3792
+ lines.push('| repository | domain | initiatives | board as of | state |', '|---|---|---:|---|---|');
3793
+ for (const r of pf.repos) lines.push(`| ${r.name} | ${r.domain || '—'} | ${r.count} | ${r.as_of || '—'} | ${r.error ? 'NOT READ — ' + String(r.error).replace(/\|/g, '/').slice(0, 80) : r.pending ? 'board pending — stages not yet derived' : r.stale ? 'stale' : 'fresh'} |`);
3794
+ lines.push('');
3795
+ if (unread.length) lines.push(`**Not fully read:** ${unread.map((r) => r.name).join(', ')}. Their initiatives are missing or shown without a stage.`, '');
3796
+ if (staleR.length) lines.push(`**Stale boards:** ${staleR.map((r) => r.name).join(', ')} (refreshing in the background).`, '');
3797
+ const cell = (v) => String(v == null ? '—' : v).replace(/\|/g, '/').replace(/\n/g, ' ').slice(0, 140);
3798
+ const table = (list) => {
3799
+ const out = ['| initiative | repository | lane | stage | next |', '|---|---|---|---|---|'];
3800
+ for (const x of list) out.push(`| \`${x.id}\` — ${cell(x.title)} | ${x.repo} | ${x.lane || '—'} | ${x.accepted ? 'accepted' : (x.stage || 'not derived')}${x.blocked ? ' ✗' : ''} | ${cell(x.next)} |`);
3801
+ return out;
3802
+ };
3803
+ const groupBy = (list, keyFn) => { const m = new Map(); for (const x of list) { const k = keyFn(x); if (!m.has(k)) m.set(k, []); m.get(k).push(x); } return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0])); };
3804
+ if (kind === 'okr') {
3805
+ for (const [dom, list] of groupBy(rows, (x) => x.domain || '(no domain declared)')) {
3806
+ lines.push(`## Domain: ${dom}`, '');
3807
+ for (const [grp, l2] of groupBy(list, (x) => x.group)) {
3808
+ const done = l2.filter((x) => x.accepted).length;
3809
+ lines.push(`### ${grp} — ${done} of ${l2.length} accepted`, '', ...table(l2), '');
3810
+ }
3811
+ }
3812
+ } else {
3813
+ const waiting = rows.filter((x) => x.blocked && !x.accepted);
3814
+ lines.push('## Needs you', '');
3815
+ if (waiting.length) lines.push(...table(waiting), ''); else lines.push('Nothing derived as blocked across the repositories that were read.', '');
3816
+ for (const [dom, list] of groupBy(rows, (x) => x.domain || '(no domain declared)')) {
3817
+ lines.push(`## Domain: ${dom}`, '');
3818
+ for (const [repo, l2] of groupBy(list, (x) => x.repo)) lines.push(`### ${repo}`, '', ...table(l2), '');
3819
+ }
3820
+ if (!rows.length) lines.push('No initiative matched.', '');
3821
+ }
3822
+ const slug = kind === 'query' ? '-' + needle.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) : '';
3823
+ const dir = path.join(hubInfo.root, 'reports');
3824
+ fs.mkdirSync(dir, { recursive: true });
3825
+ let rel = `reports/${day}-${kind}${slug}.md`;
3826
+ for (let n = 2; fs.existsSync(path.join(hubInfo.root, rel)); n++) rel = `reports/${day}-${kind}${slug}-${n}.md`;
3827
+ fs.writeFileSync(path.join(hubInfo.root, rel), lines.join('\n') + '\n', { flag: 'wx' });
3828
+ return { ok: true, ws: hubInfo.id, path: rel, rows: rows.length, unread: unread.map((r) => r.name) };
3829
+ }
3830
+
3438
3831
  function focusProducts() {
3439
3832
  const trees = wsList();
3440
3833
  const groups = new Map(); // repo_key -> { id, name, trees[] }
@@ -3544,7 +3937,8 @@ function buildConsole(name, w, root, params) {
3544
3937
  if (s.unreadable) return { ok: false, error: s.unreadable };
3545
3938
  const placed = s.objectives.reduce((n, o) =>
3546
3939
  n + o.krs.reduce((m, k) => m + k.initiatives.length, 0) + (o.direct ? o.direct.length : 0), 0)
3547
- + s.budgets.reduce((n, b) => n + b.initiatives.length, 0);
3940
+ + s.budgets.reduce((n, b) => n + b.initiatives.length, 0)
3941
+ + (s.domains || []).reduce((n, d) => n + d.initiatives.length, 0); // D67 domain groups
3548
3942
  // `empty` is a SUCCESSFUL read of genuinely nothing — a repo with no initiative records at all.
3549
3943
  // It is a supported thing to point the workspace at, so it renders calm, not red.
3550
3944
  return { ok: true, empty: placed === 0 && s.unanchored.length === 0, ...s };
@@ -3866,10 +4260,31 @@ async function handleRequest(req, res) {
3866
4260
  return json(res, { ok: true, workspace: { ...w, home: HOME_ENABLED && w.root === SELF_ROOT, shape: wsShape(w.root) } });
3867
4261
  }
3868
4262
  if (p === '/api/workspaces/forget' && req.method === 'POST') {
4263
+ // `repository: true` removes the repository's whole family of rows (overhaul-r1 R3.2); without it,
4264
+ // one row, as before. A refusal names its cause — unknown / home / sessions (R3.3).
3869
4265
  let body = {};
3870
4266
  try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
3871
- const ok = forgetWorkspace(body.id);
3872
- return json(res, { ok, error: ok ? undefined : 'unknown workspace, the home workspace, or it still owns sessions' }, ok ? 200 : 409);
4267
+ if (body.repository) {
4268
+ const out = forgetRepository(body.id);
4269
+ return json(res, out, out.ok ? 200 : 409);
4270
+ }
4271
+ const blocker = forgetBlocker(body.id);
4272
+ if (blocker) return json(res, { ok: false, ...blocker }, 409);
4273
+ forgetWorkspace(body.id);
4274
+ return json(res, { ok: true, removed: [body.id] });
4275
+ }
4276
+ if (p === '/api/workspaces/rename' && req.method === 'POST') {
4277
+ // A display name only (overhaul-r1 R3.1): the id is a hash of the root and does not move.
4278
+ let body = {};
4279
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
4280
+ const w = workspaces.get(body.id);
4281
+ const name = String(body.name || '').trim();
4282
+ if (!w) return json(res, { ok: false, reason: 'unknown', error: 'unknown repository' }, 404);
4283
+ if (!name) return json(res, { ok: false, error: 'a name is required' }, 400);
4284
+ // `label` is the PM's name for the REPOSITORY. The client names a product after its folder
4285
+ // (`repo_name`), so changing `name` alone would never show; `label` wins over both.
4286
+ w.label = name; saveWorkspaces(); pushWorkspaces();
4287
+ return json(res, { ok: true, workspace: { id: w.id, name, label: name } });
3873
4288
  }
3874
4289
  if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
3875
4290
  // Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
@@ -4120,11 +4535,65 @@ async function handleRequest(req, res) {
4120
4535
  // ── scheduled tasks ──
4121
4536
  // The picker's source: file-driven skills. `kind` is carried for internal/back-compat consumers,
4122
4537
  // but the New Skill flow + the scheduled lens never render it — one concept, no user-facing kind.
4538
+ if (p === '/api/skills/candidates' && req.method === 'GET') {
4539
+ // D76 — the directory of discovered and generated skills for one repository, with what the PM has done about each.
4540
+ const w = getWs(url.searchParams.get('ws'));
4541
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
4542
+ const c = skillsReg.core();
4543
+ if (!c.ok) return json(res, { ok: false, error: c.error });
4544
+ const { rows, torn } = c.core.candidates(w.root);
4545
+ const reads = skillsReg.readsFor(SKILL_APPROVALS, w.root);
4546
+ const approved = new Set(skillsReg.approvedFor(SKILL_APPROVALS, w.root).map((a) => `${a.id}@${a.sha}`));
4547
+ const res2 = c.core.resolve(w.root, skillsReg.approvedFor(SKILL_APPROVALS, w.root));
4548
+ const live = new Set(res2.skills.map((x) => `${x.id}@${x.sha}`));
4549
+ const others = new Set(skillStore.loadSkills([{ dir: userSkillsDir(), source: 'user' }, { dir: BUILTIN_SKILLS_DIR, source: 'builtin' }]).map((x) => x.id));
4550
+ return json(res, { ok: true, ws: w.id, torn, blocked: res2.blocked,
4551
+ candidates: rows.map((r) => ({ ...r, read: reads.has(`${r.id}@${r.sha}`), approved: approved.has(`${r.id}@${r.sha}`), running: live.has(`${r.id}@${r.sha}`),
4552
+ needs_license_ack: c.core.licenseNeedsAck(r.license), collides: others.has(r.id) })) });
4553
+ }
4554
+ if (p === '/api/skills/decide' && req.method === 'POST') {
4555
+ // D76 — the PM's press. `read` returns the full text and records that it was opened; `approve` is refused without
4556
+ // it, and for an unknown or non-permissive licence without an acknowledgement; `reject` and `retire` go to the
4557
+ // repository's registry. Approval is recorded HERE, locally, never in the repository.
4558
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4559
+ const w = getWs(body.ws);
4560
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
4561
+ const c = skillsReg.core();
4562
+ if (!c.ok) return json(res, { ok: false, error: c.error });
4563
+ const row = c.core.candidates(w.root).rows.find((r) => r.id === String(body.id || '') && r.sha.startsWith(String(body.sha || 'x')));
4564
+ if (!row) return json(res, { ok: false, error: 'no such candidate' }, 404);
4565
+ const key = skillsReg.rootKey(w.root); const at = nowISO();
4566
+ const by = 'human:wawan'; const eid = 'a-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
4567
+ try {
4568
+ if (body.op === 'read') {
4569
+ const t = c.core.textOf(w.root, row);
4570
+ if (!t.ok) return json(res, { ok: false, error: t.error, changed: !!t.changed }, 409);
4571
+ skillsReg.appendApproval(SKILL_APPROVALS, { eid, at, by, op: 'read', root: key, id: row.id, sha: row.sha });
4572
+ return json(res, { ok: true, text: t.text, id: row.id, sha12: row.sha12, scan: row.scan, ignored_keys: row.ignored_keys });
4573
+ }
4574
+ if (body.op === 'approve') {
4575
+ if (row.state !== 'quarantined') return json(res, { ok: false, error: `that candidate is ${row.state} — it cannot be approved` }, 409);
4576
+ if (!skillsReg.readsFor(SKILL_APPROVALS, w.root).has(`${row.id}@${row.sha}`)) return json(res, { ok: false, error: 'open the full text first (read it) — approval needs the exact text to have been opened' }, 409);
4577
+ if (c.core.licenseNeedsAck(row.license) && body.ack_license !== true) return json(res, { ok: false, error: `its licence is "${row.license}": say you have read it and accept it (acknowledge the licence) to approve`, needs_license_ack: true }, 409);
4578
+ const t = c.core.textOf(w.root, row);
4579
+ if (!t.ok) return json(res, { ok: false, error: t.error }, 409);
4580
+ if (loadTasks(null).some((x) => x.id === row.id)) return json(res, { ok: false, error: `a skill named "${row.id}" already exists here and always wins over a discovered one — rename the candidate or retire it`, collides: true }, 409);
4581
+ skillsReg.appendApproval(SKILL_APPROVALS, { eid, at, by, op: 'approve', root: key, id: row.id, sha: row.sha, ack_license: body.ack_license === true, ignored_keys: row.ignored_keys || [] });
4582
+ return json(res, { ok: true, approved: row.id, sha12: row.sha12, note: `runs as an agent skill on claude; ${(row.ignored_keys || []).length ? 'its own ' + row.ignored_keys.join(', ') + ' were ignored' : 'it set no harness keys'}; approval is local to this machine` });
4583
+ }
4584
+ if (body.op === 'reject' || body.op === 'retire') {
4585
+ const r = c.core.decide(w.root, body.op, { id: row.id, sha: row.sha12, reason: body.reason, by });
4586
+ return json(res, r, r.ok ? 200 : 400);
4587
+ }
4588
+ } catch (e) { return json(res, { ok: false, error: 'could not record that — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
4589
+ return json(res, { ok: false, error: 'op must be read, approve, reject or retire' }, 400);
4590
+ }
4123
4591
  if (p === '/api/tasks') return json(res, { tasks: loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill, kind: t.kind })) });
4124
4592
  // ── skills (workspace-skill-authoring): the ONE-concept surface + the New Skill flow ──
4125
4593
  // GET lists every skill with NO kind field — from the user's view there is just a skill.
4126
4594
  if (p === '/api/skills' && req.method === 'GET') {
4127
- const skills = loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill,
4595
+ const sw = getWs(url.searchParams.get('ws'));
4596
+ const skills = loadTasks(sw ? sw.root : null).map((t) => ({ id: t.id, label: t.label, skill: t.skill,
4128
4597
  description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
4129
4598
  // NEVER a bare list. Under an unmigrated conflict this list is either short by every skill the
4130
4599
  // PM authored or is being read from the old directory, and in both cases the payload has to say
@@ -4225,7 +4694,9 @@ async function handleRequest(req, res) {
4225
4694
  return json(res, { ok: true, prefs });
4226
4695
  }
4227
4696
  if (p === '/api/schedules' && req.method === 'GET') {
4228
- const wsFilter = getWs(url.searchParams.get('ws'));
4697
+ // `all=1` (overhaul-r4 D1): every repository's schedules — the drawer lists `scheduled` under "all
4698
+ // repositories", so filtering to the home workspace there would hide the PM's other schedules
4699
+ const wsFilter = url.searchParams.get('all') ? null : getWs(url.searchParams.get('ws'));
4229
4700
  const id = wsFilter ? wsFilter.id : null;
4230
4701
  // One skills-corpus read per request, not one per schedule row — taskById re-reads every
4231
4702
  // .skill file, and the PM's forty-five schedules made that forty-five reads per GET.
@@ -4246,7 +4717,9 @@ async function handleRequest(req, res) {
4246
4717
  }
4247
4718
  if (p === '/api/schedules' && req.method === 'POST') {
4248
4719
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4249
- if (!taskById(body.task || body.skill)) return json(res, { ok: false, error: 'that task has no backing skill' }, 400);
4720
+ // D76 — an APPROVED discovered skill of this repository is a skill too, so it can be scheduled; a quarantined one is not.
4721
+ const swsch = getWs(body.ws);
4722
+ if (!taskById(body.task || body.skill, swsch ? swsch.root : null)) return json(res, { ok: false, error: 'that task has no backing skill' }, 400);
4250
4723
  const norm = scheduleLogic.normalizeSchedule(body);
4251
4724
  if (!norm.ok) return json(res, { ok: false, error: norm.error }, 400);
4252
4725
  const w = getWs(body.ws);
@@ -4259,12 +4732,35 @@ async function handleRequest(req, res) {
4259
4732
  const list = readSchedules(); list.unshift(sched); writeSchedules(list);
4260
4733
  return json(res, { ok: true, schedule: sched });
4261
4734
  }
4735
+ if (p === '/api/schedules/seed' && req.method === 'POST') {
4736
+ // D71 — one press creates the recommended schedules for a repository. Idempotent: a schedule for
4737
+ // the same skill in the same repository is never created twice. Never reached by anything but the
4738
+ // PM's press; the tick then fires them like any other schedule.
4739
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4740
+ const w = getWs(body.ws);
4741
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
4742
+ const set = SEED_SETS[body.set];
4743
+ if (!set) return json(res, { ok: false, error: `unknown set '${body.set}' — one of ${Object.keys(SEED_SETS).join(', ')}` }, 400);
4744
+ const list = readSchedules(); const made = []; const kept = [];
4745
+ for (const def of set) {
4746
+ if (!taskById(def.skill, w.root)) { kept.push({ skill: def.skill, why: 'the skill is not installed' }); continue; }
4747
+ if (list.some((x) => x.ws === w.id && x.skill === def.skill)) { kept.push({ skill: def.skill, why: 'already scheduled' }); continue; }
4748
+ const norm = scheduleLogic.normalizeSchedule({ ...def, task: def.skill });
4749
+ if (!norm.ok) { kept.push({ skill: def.skill, why: norm.error }); continue; }
4750
+ const sched = { id: 'sc-' + Date.now().toString(36) + made.length, ws: w.id, ...norm.schedule, profile: null, lastRun: null, lastStatus: null };
4751
+ sched.nextRun = scheduleLogic.nextRun(sched);
4752
+ list.unshift(sched); made.push(sched);
4753
+ }
4754
+ if (made.length) writeSchedules(list);
4755
+ return json(res, { ok: true, created: made.map((x) => ({ id: x.id, name: x.name, skill: x.skill, cadence: x.cadence, time: x.time, nextRun: x.nextRun })), kept });
4756
+ }
4262
4757
  if (p === '/api/schedules/update' && req.method === 'POST') {
4263
4758
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4264
4759
  const list = readSchedules(); const s = list.find((x) => x.id === body.id);
4265
4760
  if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
4266
4761
  if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
4267
- for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill']) if (body[k] != null) s[k] = body[k];
4762
+ for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill', 'date', 'args']) if (body[k] != null) s[k] = body[k];
4763
+ if ('tier' in body) { if (body.tier === null || body.tier === '') delete s.tier; else if (/^(fast|balanced|deep)$/.test(String(body.tier))) s.tier = String(body.tier); else return json(res, { ok: false, error: 'tier must be fast, balanced or deep' }, 400); }
4268
4764
  // `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
4269
4765
  // of the key rather than for a non-null value — the other fields above cannot be cleared.
4270
4766
  if ('profile' in body) {
@@ -4293,6 +4789,27 @@ async function handleRequest(req, res) {
4293
4789
  let md; try { md = fs.readFileSync(path.join(DELIV_DIR, id + '.md'), 'utf8'); } catch { res.writeHead(404); return res.end('not found'); }
4294
4790
  res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
4295
4791
  }
4792
+ if (p === '/api/session/model' && req.method === 'POST') {
4793
+ // D75 — switch the model of a RUNNING Claude session, on the PM's press only. It types the harness's own
4794
+ // `/model <model>` command into the pane; effort is not changed by it. Never automatic, never for a binary
4795
+ // PMOS has not checked, and a pinned review route cannot be lowered here either.
4796
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4797
+ const s = sessions.get(String(body.id || ''));
4798
+ if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
4799
+ if (!s.pty || s.state === 'exited') return json(res, { ok: false, error: 'that session is not running — there is nothing to switch' }, 400);
4800
+ if (s.agent !== 'claude') return json(res, { ok: false, error: `'${s.agent}' has no model command PMOS has checked — nothing was typed` }, 400);
4801
+ const r = modelLib.resolveForSpawn(s.root, s.agent, { explicit: { tier: body.tier, model: body.model }, skill: s.modelAsk && s.modelAsk.skill });
4802
+ if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
4803
+ if (!r.model) return json(res, { ok: false, error: 'say a tier (fast, balanced, deep) or a model' }, 400);
4804
+ try { s.pty.write(`/model ${r.model}\r`); }
4805
+ catch (e) { return json(res, { ok: false, error: 'could not write to the session — ' + String((e && e.message) || e).slice(0, 100) }, 500); }
4806
+ const at = nowISO();
4807
+ s.model = { ...(s.model || {}), state: 'applied', switched: { model: r.model, tier: r.tier, at } };
4808
+ persist(s); pushSessions();
4809
+ const mc = modelLib.core();
4810
+ if (mc.ok) mc.core.logDecision(s.root, { kind: 'switch', session: s.id, agent: s.agent, tier: r.tier, model: r.model, by: 'human:wawan' });
4811
+ return json(res, { ok: true, model: r.model, tier: r.tier, note: 'typed /model into the pane; effort is unchanged' });
4812
+ }
4296
4813
  if (p === '/api/sessions') return json(res, { sessions: sessionList() });
4297
4814
  if (p === '/api/git/branches') {
4298
4815
  // Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
@@ -4403,14 +4920,20 @@ async function handleRequest(req, res) {
4403
4920
  if (!w) { res.writeHead(404); return res.end('no workspace'); }
4404
4921
  const full = path.resolve(w.root, url.searchParams.get('path') || '');
4405
4922
  if (!insideRoot(w.root, full)) { res.writeHead(404); return res.end('outside workspace'); }
4406
- const mime = preview.imageMime(path.basename(full));
4923
+ const mime = preview.assetMime(path.basename(full));
4407
4924
  if (!mime) { res.writeHead(404); return res.end('not a servable asset'); }
4408
4925
  return fs.readFile(full, (err, buf) => {
4409
4926
  if (err) { res.writeHead(404); return res.end('not found'); }
4410
4927
  res.writeHead(200, {
4411
4928
  'Content-Type': mime,
4412
4929
  'X-Content-Type-Options': 'nosniff',
4413
- 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
4930
+ // An image keeps the script-blocking sandbox. A PDF cannot: the CSP `sandbox` directive
4931
+ // disables the browser's built-in PDF viewer, which is the whole point of serving it
4932
+ // (overhaul-r2 D1). It still gets `default-src 'none'` — nothing it references is fetched —
4933
+ // and it is only ever a file inside this workspace's root, on 127.0.0.1.
4934
+ 'Content-Security-Policy': mime === 'application/pdf'
4935
+ ? "default-src 'none'; style-src 'unsafe-inline'"
4936
+ : "default-src 'none'; style-src 'unsafe-inline'; sandbox",
4414
4937
  });
4415
4938
  res.end(buf);
4416
4939
  });
@@ -4630,6 +5153,15 @@ async function handleRequest(req, res) {
4630
5153
  // share no field (`{session, line, after}` vs `{path, kind, match, openable}`), the scrollback
4631
5154
  // search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
4632
5155
  // Collapsing them into one payload would mean one of the two lying about what it looked at.
5156
+ if (p === '/api/portfolio' && req.method === 'GET') return json(res, portfolio());
5157
+ if (p === '/api/reports' && req.method === 'POST') {
5158
+ let body = {};
5159
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5160
+ let out;
5161
+ try { out = portfolioReport(String(body.kind || ''), body.q); }
5162
+ catch (e) { out = { ok: false, error: 'the report could not be written — ' + String((e && e.message) || 'error').slice(0, 140) }; }
5163
+ return json(res, out, out.ok ? 200 : (out.reason === 'no-hub' ? 409 : 400));
5164
+ }
4633
5165
  if (p === '/api/search/files') {
4634
5166
  // `search everything…`, finally over everything: every file under THIS workspace's root, by
4635
5167
  // path and by content. There is no path parameter — the query is a needle, not a location — so
@@ -4641,7 +5173,19 @@ async function handleRequest(req, res) {
4641
5173
  return json(res, { ok: false, error: 'no workspace to search', q: url.searchParams.get('q') || '',
4642
5174
  hits: [], truncated: false, scanned: 0, content_scanned: 0, limit: SEARCH_LIMIT_DEFAULT });
4643
5175
  }
4644
- return json(res, { ws: w.id, ...searchWorkspace(w.root, url.searchParams.get('q'), url.searchParams.get('limit')) });
5176
+ // Every line of work of the repository, not only the tree the id names (overhaul-r1 R1): a file
5177
+ // that exists only on a linked worktree's branch was unfindable from its product. `tree` narrows.
5178
+ // overhaul-r4 B1 — `scope=all`: every registered repository's family of trees, each hit tagged
5179
+ // with its repository, truncation reported PER repository (a capped repo never reads as complete)
5180
+ if (url.searchParams.get('scope') === 'all') {
5181
+ const per = repoLeads().map((r) => ({ r, out: searchFamily(workspaces.get(r.ws) || w, url.searchParams.get('q'), url.searchParams.get('limit')) }));
5182
+ const hits = per.flatMap(({ r, out }) => (out.hits || []).map((h) => ({ ...h, repo: r.name })));
5183
+ return json(res, { ok: true, scope: 'all', q: url.searchParams.get('q') || '', hits,
5184
+ truncated: per.some(({ out }) => out.truncated), content_truncated: per.some(({ out }) => out.content_truncated),
5185
+ scanned: per.reduce((n, { out }) => n + (out.scanned || 0), 0), content_scanned: per.reduce((n, { out }) => n + (out.content_scanned || 0), 0),
5186
+ repos: per.map(({ r, out }) => ({ repo: r.name, ws: r.ws, truncated: !!out.truncated, scanned: out.scanned || 0, hits: (out.hits || []).length })) });
5187
+ }
5188
+ return json(res, { ws: w.id, ...searchFamily(w, url.searchParams.get('q'), url.searchParams.get('limit'), url.searchParams.get('tree')) });
4645
5189
  }
4646
5190
  if (p === '/api/search') {
4647
5191
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything
@@ -4738,6 +5282,104 @@ async function handleRequest(req, res) {
4738
5282
  pm_label_recorded: rec.pm_label === 'pass' || rec.pm_label === 'fail',
4739
5283
  });
4740
5284
  }
5285
+ if (p === '/api/memory' && req.method === 'GET') {
5286
+ const w = getWs(url.searchParams.get('ws'));
5287
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5288
+ const c = memoryLib.core();
5289
+ if (!c.ok) return json(res, { ok: false, error: c.error });
5290
+ const cur = c.core.load(w.root);
5291
+ return json(res, { ok: true, ws: w.id, name: w.name, entries: cur.state.entries, pending: cur.state.pending, blocked: cur.state.blocked,
5292
+ usage: cur.usage, torn: cur.torn, config: { write_approval: !!cur.cfg.write_approval }, wired: memoryLib.wired(w.root),
5293
+ has_state_dir: !!c.core.stateDir(w.root, false) });
5294
+ }
5295
+ if (p === '/api/memory' && req.method === 'POST') {
5296
+ // D72 — a PM write. The actor is the PM, so it is never staged, and only the PM can confirm, approve or reject.
5297
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5298
+ const w = getWs(body.ws);
5299
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5300
+ const c = memoryLib.core();
5301
+ if (!c.ok) return json(res, { ok: false, error: c.error });
5302
+ const out = c.core.write(w.root, { op: body.op, target: body.target, text: body.text, old: body.old, ref: body.ref,
5303
+ confidence: body.confidence, source: body.source, stale_after: body.stale_after, by: 'human:wawan' });
5304
+ return json(res, out, out.ok ? 200 : 400);
5305
+ }
5306
+ if (p === '/api/memory/config' && req.method === 'POST') {
5307
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5308
+ const w = getWs(body.ws);
5309
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5310
+ const c = memoryLib.core();
5311
+ if (!c.ok) return json(res, { ok: false, error: c.error });
5312
+ const dir = c.core.memDir(w.root, true);
5313
+ const cfg = c.core.readConfig(dir);
5314
+ if (typeof body.write_approval === 'boolean') cfg.write_approval = body.write_approval;
5315
+ try { fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ memory_chars: cfg.memory_chars, user_chars: cfg.user_chars, write_approval: cfg.write_approval }, null, 2) + '\n'); }
5316
+ catch (e) { return json(res, { ok: false, error: 'could not save the setting — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
5317
+ return json(res, { ok: true, config: { write_approval: !!cfg.write_approval } });
5318
+ }
5319
+ if (p === '/api/memory/wire' && req.method === 'POST') {
5320
+ // D72 — one press adds a marked block to the repository's CLAUDE.md so sessions import the snapshot.
5321
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5322
+ const w = getWs(body.ws);
5323
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5324
+ return json(res, memoryLib.wire(w.root));
5325
+ }
5326
+ if (p === '/api/sync/status') {
5327
+ // Local only — no network. Safe to poll; it is what tells the PM whether a press is needed.
5328
+ const w = getWs(url.searchParams.get('ws'));
5329
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5330
+ return json(res, { ws: w.id, name: w.name, ...(await syncLib.status(w.root)) });
5331
+ }
5332
+ if (p === '/api/sync' && req.method === 'POST') {
5333
+ // D70 — the ONE place the workspace runs fetch / integrate / commit / push. Only the PM's press
5334
+ // reaches it; nothing schedules or chains it, and it is off every other action's request path.
5335
+ let body = {};
5336
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
5337
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5338
+ const w = getWs(body.ws);
5339
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5340
+ if (syncing.has(w.root)) return json(res, { ok: false, error: 'a sync is already running for this repository' }, 409);
5341
+ syncing.add(w.root);
5342
+ try {
5343
+ const out = await syncLib.sync(w.root, { label: os.hostname() });
5344
+ boardCache.invalidate(w.root);
5345
+ return json(res, { ws: w.id, name: w.name, ...out });
5346
+ } finally { syncing.delete(w.root); }
5347
+ }
5348
+ if (p === '/api/lifecycle' && req.method === 'POST') {
5349
+ // D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
5350
+ // appends ONE line to lifecycle.jsonl and never writes an acceptance.
5351
+ let body = {};
5352
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
5353
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5354
+ const id = String(body.initiative_id || '');
5355
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) return json(res, { ok: false, error: 'initiative_id is required' }, 400);
5356
+ if (body.state !== 'active' && !STOPPED_STATES.includes(body.state)) {
5357
+ return json(res, { ok: false, error: `state must be one of ${STOPPED_STATES.concat('active').join(', ')}` }, 400);
5358
+ }
5359
+ const w = getWs(body.ws);
5360
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5361
+ if (!initiativeIndex(w.root).initiatives.some((g) => g.id === id)) {
5362
+ return json(res, { ok: false, error: `no initiative '${id}' in ${w.name}` }, 404);
5363
+ }
5364
+ const rec = { initiative_id: id, state: body.state, reason: String(body.reason || '').slice(0, 500), pm: 'wawan', at: nowISO() };
5365
+ // The ledger the ENGINE reads for this repository's layout (scripts/workflow-state.js#layout): a repository
5366
+ // with planning/initiatives/ keeps its ledgers in planning/runs/, a kit in pmos/state/. Writing anywhere else
5367
+ // (R6 wrote the workspace's runtime state for PMOS-self) shows on the board but never clears the gate.
5368
+ let d = null;
5369
+ if (isDir(path.join(w.root, 'planning', 'initiatives'))) d = path.join(w.root, 'planning', 'runs');
5370
+ else if (isDir(path.join(w.root, 'pmos', 'state', 'initiatives'))) d = path.join(w.root, 'pmos', 'state');
5371
+ else d = stateDirFor(w, { create: true });
5372
+ if (!d) return json(res, { ok: false, error: 'this repository has no state directory to record in — nothing was written' }, 500);
5373
+ try { fs.mkdirSync(d, { recursive: true }); } catch { /* the guard reports it */ }
5374
+ const sink = path.join(d, 'lifecycle.jsonl');
5375
+ try { createSinkGuard({ sink, what: 'lifecycle' }).appendLine(sink, rec); }
5376
+ catch (e) {
5377
+ warnWriteFail('lifecycle.jsonl', e);
5378
+ return json(res, { ok: false, error: 'could not record the change — nothing was saved', sink }, 500);
5379
+ }
5380
+ boardCache.invalidate(w.root);
5381
+ return json(res, { ok: true, recorded: rec, sink });
5382
+ }
4741
5383
  if (p === '/api/doc/save' && req.method === 'POST') {
4742
5384
  // The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
4743
5385
  // reached solely by the PM pressing save on a document they were editing. Nothing here consults
@@ -4768,7 +5410,12 @@ async function handleRequest(req, res) {
4768
5410
  return json(res, { ok: false, error: saved.reason, detail: saved.detail || null }, code);
4769
5411
  }
4770
5412
 
4771
- try { fs.writeFileSync(full, saved.text); }
5413
+ // R11 (D74) — a document that declares `generated: {by, at}` on one line says who last produced it. A save
5414
+ // from here is the PM's own hand, so the line is rewritten to say so. Only that one line, only in the leading
5415
+ // block, and only when it is already there: nothing is added to a document that never had one. The stamped text
5416
+ // goes back to the editor, which takes it as its new base, so the next save is not read as stale.
5417
+ const stamped = stampEdit(saved.text, nowISO());
5418
+ try { fs.writeFileSync(full, stamped); }
4772
5419
  catch (e) {
4773
5420
  warnWriteFail('document save (' + body.path + ')', e);
4774
5421
  return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
@@ -4792,7 +5439,7 @@ async function handleRequest(req, res) {
4792
5439
  }
4793
5440
  // The document IS saved at this point. A failed log is reported honestly rather than as success,
4794
5441
  // but it must not read as "your edit was lost".
4795
- return json(res, { ok: true, saved: body.path, logged, change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
5442
+ return json(res, { ok: true, saved: body.path, logged, ...(stamped !== saved.text ? { text: stamped, stamped: true } : {}), change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
4796
5443
  warning: logged ? undefined : 'the document was saved but the change could not be logged' });
4797
5444
  }
4798
5445
  if (p === '/api/align/candidates') {
@@ -5018,7 +5665,8 @@ function writeChangeContext(dir) {
5018
5665
  const out = [
5019
5666
  '---', 'type: Reference',
5020
5667
  'title: "Change context — what the PM has been editing here, and why"',
5021
- 'timestamp: ' + nowISO().slice(0, 10), '---', '',
5668
+ 'description: "A digest of the PM\'s recent edits and their reasons, regenerated on every save. For agents writing documents here."',
5669
+ `generated: {by: process:pmos-workspace, at: "${nowISO()}"}`, 'status: stable', '---', '',
5022
5670
  '# Change context',
5023
5671
  '',
5024
5672
  '**Read this before writing documents in this repository.** It is generated from the PM\'s own',
@@ -5153,10 +5801,12 @@ wss.on('connection', (ws) => {
5153
5801
  // `initiative` is what the client has always held and never sent: `+ session` is clicked on
5154
5802
  // an initiative row, the tab carries its `slug`, and the spawn message named three other
5155
5803
  // fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
5156
- 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, initiative: m.initiative });
5804
+ 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, initiative: m.initiative,
5805
+ // D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
5806
+ modelAsk: (m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null });
5157
5807
  // A refused profile is its own sentence, not "branch: …" — the composer's error line names
5158
5808
  // what PMOS could not read, and nothing was started.
5159
- if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
5809
+ if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^(profile|model choice) /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
5160
5810
  // a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
5161
5811
  // non-repo) — said out loud, never swallowed
5162
5812
  if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
@@ -5176,7 +5826,7 @@ wss.on('connection', (ws) => {
5176
5826
  // server already holds, not re-sent by the client — a client that forgot it would otherwise
5177
5827
  // silently restart the pane on a different environment, and the pane would look identical.
5178
5828
  const carried = 'profile' in old ? old.profile : null;
5179
- const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried });
5829
+ const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried, modelAsk: old.modelAsk || null });
5180
5830
  // A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
5181
5831
  // ahead with no profile — the pane line already says so — and the reason is said out loud
5182
5832
  // rather than left for the PM to notice.
@@ -5302,4 +5952,4 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
5302
5952
  // things that matter independently of a rendered page: that no declared field reaches a status,
5303
5953
  // that an unreadable history reports rather than degrades, and that a planning-only commit does
5304
5954
  // not count as delivery.
5305
- surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };
5955
+ surfaceInitiatives, readLifecycle, deliveryIndex, deriveState, isDeliveryPath };