create-pmos 0.37.0 → 0.38.1

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