create-pmos 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/mods/pmos-cockpit/README.md +13 -0
  4. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
  6. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
  7. package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/plan-items-stage.js +271 -0
  11. package/payload/scripts/pmos-log.sh +18 -7
  12. package/payload/scripts/workflow-state.js +28 -2
  13. package/payload/state/discoveries.md +1 -1
  14. package/payload/state/log.md +1 -1
  15. package/workspace/bin/pmos.js +15 -1
  16. package/workspace/cut-stub.js +40 -0
  17. package/workspace/first-paint-audit.js +145 -0
  18. package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
  19. package/workspace/fixtures/initiatives-api/list.json +176 -0
  20. package/workspace/fixtures/initiatives-api/post-403.json +5 -0
  21. package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
  22. package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
  23. package/workspace/fixtures/initiatives-api/sheet.json +112 -0
  24. package/workspace/fixtures/report-api/draft.json +177 -0
  25. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
  26. package/workspace/fixtures/today-api/provider-rows.json +204 -0
  27. package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
  28. package/workspace/guide/board.md +81 -0
  29. package/workspace/guide/breakdown.md +154 -0
  30. package/workspace/guide/command-palette.md +75 -0
  31. package/workspace/guide/index.md +27 -17
  32. package/workspace/guide/initiatives-roadmap.md +130 -0
  33. package/workspace/guide/initiatives-screen.md +89 -0
  34. package/workspace/guide/initiatives-timeline.md +57 -0
  35. package/workspace/guide/integrated-work.md +94 -0
  36. package/workspace/guide/knowledge-graph.md +3 -68
  37. package/workspace/guide/layout.md +98 -32
  38. package/workspace/guide/live-notes.md +3 -3
  39. package/workspace/guide/pm-only-acts.md +89 -0
  40. package/workspace/guide/pm-only-lines.md +61 -0
  41. package/workspace/guide/reports-page.md +80 -0
  42. package/workspace/guide/reports.md +73 -0
  43. package/workspace/guide/timeline.md +70 -0
  44. package/workspace/guide/today.md +85 -0
  45. package/workspace/guide/validate-with-typesafe.md +1 -1
  46. package/workspace/lib/actor.js +91 -0
  47. package/workspace/lib/board-cache.js +32 -2
  48. package/workspace/lib/board-cli.js +108 -0
  49. package/workspace/lib/board-fold.js +232 -0
  50. package/workspace/lib/breakdown-export.js +80 -0
  51. package/workspace/lib/capture.js +98 -0
  52. package/workspace/lib/engine/plan-items-stage.js +271 -0
  53. package/workspace/lib/engine/workflow-state.js +28 -2
  54. package/workspace/lib/factory-join.js +158 -0
  55. package/workspace/lib/grant-mint.js +113 -0
  56. package/workspace/lib/health-rules-factory.js +92 -0
  57. package/workspace/lib/health-rules-plan.js +164 -0
  58. package/workspace/lib/health.js +79 -0
  59. package/workspace/lib/initiative-cli.js +222 -0
  60. package/workspace/lib/initiative-display.js +183 -0
  61. package/workspace/lib/initiative-needs.js +127 -0
  62. package/workspace/lib/initiative-rows.js +206 -0
  63. package/workspace/lib/initiative-writer.js +141 -0
  64. package/workspace/lib/issues.js +206 -0
  65. package/workspace/lib/item-cli.js +195 -0
  66. package/workspace/lib/item-spans.js +137 -0
  67. package/workspace/lib/measure-cli.js +100 -0
  68. package/workspace/lib/measurements.js +189 -0
  69. package/workspace/lib/mobile-nav.js +57 -53
  70. package/workspace/lib/needs-providers.js +120 -0
  71. package/workspace/lib/placements.js +440 -0
  72. package/workspace/lib/plan-items.js +922 -0
  73. package/workspace/lib/plan-ledgers.js +85 -0
  74. package/workspace/lib/plan-rules.js +72 -0
  75. package/workspace/lib/plan-tree.js +367 -0
  76. package/workspace/lib/pm-guard.js +174 -0
  77. package/workspace/lib/pm-lines-needs.js +331 -0
  78. package/workspace/lib/pmos-verbs.js +66 -0
  79. package/workspace/lib/proactive.js +7 -4
  80. package/workspace/lib/report-cli.js +152 -0
  81. package/workspace/lib/report-draft.js +579 -0
  82. package/workspace/lib/report-needs.js +213 -0
  83. package/workspace/lib/route-modules.js +128 -0
  84. package/workspace/lib/routes-board.js +91 -0
  85. package/workspace/lib/routes-capture.js +52 -0
  86. package/workspace/lib/routes-initiatives.js +371 -0
  87. package/workspace/lib/routes-reports.js +163 -0
  88. package/workspace/lib/routes-timeline.js +197 -0
  89. package/workspace/lib/routes-today.js +32 -0
  90. package/workspace/lib/today-cli.js +99 -0
  91. package/workspace/lib/today.js +333 -0
  92. package/workspace/lib/triage.js +163 -0
  93. package/workspace/lib/verify-work-cli.js +81 -0
  94. package/workspace/lib/work-check-needs.js +99 -0
  95. package/workspace/package-lock.json +1 -1
  96. package/workspace/package.json +5 -5
  97. package/workspace/public/index.html +29 -19
  98. package/workspace/public/pm/app.js +299 -30
  99. package/workspace/public/pm/bd-board.js +113 -0
  100. package/workspace/public/pm/bd-drag.js +59 -0
  101. package/workspace/public/pm/bd-model.js +152 -0
  102. package/workspace/public/pm/bd-timeline.js +233 -0
  103. package/workspace/public/pm/bd-tree.js +244 -0
  104. package/workspace/public/pm/board-copy.js +57 -0
  105. package/workspace/public/pm/breakdown-api.js +58 -0
  106. package/workspace/public/pm/breakdown-copy.js +138 -0
  107. package/workspace/public/pm/components.css +3054 -0
  108. package/workspace/public/pm/cycle-review.js +125 -0
  109. package/workspace/public/pm/data.js +40 -187
  110. package/workspace/public/pm/dest-board.js +438 -0
  111. package/workspace/public/pm/dest-initiatives.js +134 -0
  112. package/workspace/public/pm/dest-reports.js +270 -0
  113. package/workspace/public/pm/dest-settings.js +217 -0
  114. package/workspace/public/pm/dest-today.js +176 -0
  115. package/workspace/public/pm/dialogs.js +88 -0
  116. package/workspace/public/pm/display-word.js +56 -0
  117. package/workspace/public/pm/dock.js +105 -0
  118. package/workspace/public/pm/fixture.js +8 -227
  119. package/workspace/public/pm/frame-copy.js +94 -0
  120. package/workspace/public/pm/handoff-box.js +82 -0
  121. package/workspace/public/pm/http.js +88 -0
  122. package/workspace/public/pm/icons.js +53 -0
  123. package/workspace/public/pm/initiative-list.js +218 -0
  124. package/workspace/public/pm/initiative-sheet.js +261 -0
  125. package/workspace/public/pm/initiatives-api.js +59 -0
  126. package/workspace/public/pm/initiatives-copy.js +215 -0
  127. package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
  128. package/workspace/public/pm/initiatives-timeline.js +410 -0
  129. package/workspace/public/pm/initiatives-ui.js +123 -0
  130. package/workspace/public/pm/item-sheet.js +251 -0
  131. package/workspace/public/pm/keep-card.js +204 -0
  132. package/workspace/public/pm/lifecycle.js +7 -1
  133. package/workspace/public/pm/mobile.css +49 -20
  134. package/workspace/public/pm/mobile.js +157 -29
  135. package/workspace/public/pm/modes-live.js +7 -883
  136. package/workspace/public/pm/modes.js +32 -1024
  137. package/workspace/public/pm/new-initiative-sheet.js +74 -0
  138. package/workspace/public/pm/next-step-ui.js +3 -2
  139. package/workspace/public/pm/palette-copy.js +51 -0
  140. package/workspace/public/pm/palette.js +312 -0
  141. package/workspace/public/pm/panel-breakdown.js +702 -0
  142. package/workspace/public/pm/panel-cockpit.js +63 -295
  143. package/workspace/public/pm/panel-detail.js +25 -172
  144. package/workspace/public/pm/panel-initiatives.js +1 -19
  145. package/workspace/public/pm/panel-repos.js +31 -81
  146. package/workspace/public/pm/panel-tabs.js +197 -110
  147. package/workspace/public/pm/panel-trees.js +5 -2
  148. package/workspace/public/pm/portfolio-report.js +61 -0
  149. package/workspace/public/pm/primitives.js +656 -0
  150. package/workspace/public/pm/print.css +33 -0
  151. package/workspace/public/pm/provider-cards.js +136 -0
  152. package/workspace/public/pm/registry.js +194 -0
  153. package/workspace/public/pm/release-card.js +110 -0
  154. package/workspace/public/pm/report-view.js +309 -0
  155. package/workspace/public/pm/reports-copy.js +114 -0
  156. package/workspace/public/pm/roadmap.js +271 -0
  157. package/workspace/public/pm/sig-watch.js +41 -0
  158. package/workspace/public/pm/state.js +8 -4
  159. package/workspace/public/pm/status-banner.js +13 -5
  160. package/workspace/public/pm/tab-doc.js +2 -4
  161. package/workspace/public/pm/tab-term.js +12 -2
  162. package/workspace/public/pm/tab-web.js +3 -2
  163. package/workspace/public/pm/timeline-copy.js +48 -0
  164. package/workspace/public/pm/today-cards.js +244 -0
  165. package/workspace/public/pm/today-copy.js +97 -0
  166. package/workspace/public/pm/tokens.css +16 -18
  167. package/workspace/public/pm/topbar.js +102 -0
  168. package/workspace/public/pm/triage-card.js +60 -0
  169. package/workspace/public/pm/undo-hold.js +103 -0
  170. package/workspace/public/pm/view-addrepo.js +2 -1
  171. package/workspace/public/pm/view-livenotes.js +3 -1
  172. package/workspace/public/pm/view-today.js +7 -162
  173. package/workspace/public/vendor/Sortable.min.js +2 -0
  174. package/workspace/public/vendor/lucide-sprite.js +79 -0
  175. package/workspace/replaced-tests.json +2864 -0
  176. package/workspace/run-tests.js +117 -6
  177. package/workspace/server.js +312 -36
  178. package/workspace/sheet-answer.js +61 -0
  179. package/workspace/skills/measure.skill +41 -0
  180. package/workspace/skills/report-biweekly.skill +23 -25
  181. package/workspace/skills/tracker-refresh.skill +50 -0
  182. package/workspace/test-files.json +20 -4
  183. package/workspace/test-lists/base.json +6 -0
  184. package/workspace/test-lists/run2-a.json +6 -0
  185. package/workspace/test-lists/run2-b.json +13 -0
  186. package/workspace/test-lists/run2-c.json +19 -0
  187. package/workspace/test-lists/run2-d.json +22 -0
  188. package/workspace/test-lists/run3-0.json +5 -0
  189. package/workspace/test-lists/run3-a.json +5 -0
  190. package/workspace/test-lists/run3-b.json +6 -0
  191. package/workspace/test-lists/run3-c.json +15 -0
  192. package/workspace/test-lists/run3-d.json +6 -0
  193. package/workspace/test-lists/run4-a.json +8 -0
  194. package/workspace/test-lists/run4-b.json +6 -0
  195. package/workspace/test-lists/run4-c.json +13 -0
  196. package/workspace/test-lists/run4-d.json +9 -0
  197. package/workspace/test-lists/run5-a.json +6 -0
  198. package/workspace/test-lists/run5-b.json +6 -0
  199. package/workspace/test-lists/run5-c.json +15 -0
  200. package/workspace/test-run.js +27 -1
  201. package/workspace/third-party/README.md +44 -0
  202. package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
  203. package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
  204. package/workspace/design-parity.js +0 -673
  205. package/workspace/public/pm/tab-task.js +0 -86
  206. package/workspace/public/pm/view-guide.js +0 -141
  207. package/workspace/public/pm/view-knowledge.js +0 -225
  208. package/workspace/public/pm/view-memory.js +0 -122
  209. package/workspace/public/pm/view-portfolio.js +0 -153
  210. package/workspace/public/pm/view-reports.js +0 -190
  211. package/workspace/public/pm/view-scheduled.js +0 -161
  212. package/workspace/public/pm/view-skills.js +0 -216
@@ -124,6 +124,13 @@ const profilesLib = require('./lib/profiles'); // readProfiles(homeDir)
124
124
  const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
125
125
  const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
126
126
  const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
127
+ const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
128
+ const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
129
+ const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
130
+ const needsProviders = require('./lib/needs-providers.js'); // …and each run's needs-you rows, folded into deriveNeeds
131
+ const pmGuard = require('./lib/pm-guard.js'); // pm-os-absorb Run 2 (R25) — Origin, nonce and writer fields on every PM press
132
+ const actorLib = require('./lib/actor.js'); // …the session-token hash persisted with each agent session
133
+ const grantMint = require('./lib/grant-mint.js'); // …one D81 grant for a bulk lifecycle press (501 until 2B fills it)
127
134
  // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
128
135
  // (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
129
136
  // — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
@@ -279,9 +286,12 @@ const SHELL = defaultShell();
279
286
  // "child session" marker and disables its own transcript saving. Strip those markers here: the
280
287
  // harness inside the PTY must run as though the PM launched it directly. Login/subscription do
281
288
  // NOT live in these vars (they're in the CLI's own config/keychain), so this never affects auth.
289
+ // PMOS_SESSION and PMOS_BIN are the same kind of marker (pm-os-absorb Run 1): a server started from inside an agent
290
+ // session holds that session's token, and a shell that inherited it would record the PM's `pmos item` as the agent's.
291
+ // spawnSession gives them to an agent session only, as its own values.
282
292
  const INHERITED_SESSION_MARKERS = [
283
293
  'CLAUDECODE', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT',
284
- 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'AI_AGENT', 'CLAUDE_EFFORT',
294
+ 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'AI_AGENT', 'CLAUDE_EFFORT', 'PMOS_SESSION', 'PMOS_BIN',
285
295
  ];
286
296
  function sessionEnv() {
287
297
  const env = { ...process.env };
@@ -1847,7 +1857,7 @@ function surfaceInitiatives(root, w) {
1847
1857
  const acc = readAcceptances(w);
1848
1858
  const life = readLifecycle(w);
1849
1859
  const runs = readRunCounts(w);
1850
- const needsRaw = readNeeds(w) || [];
1860
+ const needsRaw = (readNeeds(w) || []).filter(notPlanNeed);
1851
1861
  const needsBy = new Map();
1852
1862
  for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
1853
1863
 
@@ -2189,6 +2199,11 @@ const SEED_SETS = {
2189
2199
  { name: 'Bi-weekly report draft', skill: 'report-biweekly', cadence: 'bi-weekly', day: 'Fri', time: '16:00' },
2190
2200
  { name: 'Monthly report draft', skill: 'report-monthly', cadence: 'monthly', dayOfMonth: 1, time: '08:00' },
2191
2201
  ],
2202
+ // Run 5 (X19): the agent step that brings tracker-owned plan items up to date from local files. It is its own set, not a
2203
+ // fifth line of `reports`, so pressing `reports` still makes the four report schedules. It is weekly, Friday 15:30: the
2204
+ // bi-weekly draft (Friday 16:00) fires every other week from whichever Friday it was set up, and a weekly refresh is
2205
+ // never out of step with it. A refresh that finds no change writes no line.
2206
+ tracker: [{ name: 'Tracker refresh', skill: 'tracker-refresh', cadence: 'weekly', day: 'Fri', time: '15:30' }],
2192
2207
  };
2193
2208
  function readSchedules() {
2194
2209
  try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
@@ -2336,6 +2351,12 @@ function persist(s) {
2336
2351
  // so a session survives even if the workspace registry is lost (it re-registers its root).
2337
2352
  const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
2338
2353
  state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
2354
+ // The breakdown item a Hand to agent session is bound to, so a restart keeps the binding (pm-os-absorb Run 1).
2355
+ if (s.plan_item) meta.plan_item = s.plan_item;
2356
+ // pm-os-absorb Run 2 (R25): the SHA-256 of the agent's PMOS_SESSION token, never the token (D08). A restored session
2357
+ // has no token in memory, so its stored hash is written back; lib/actor.js sessionForToken reads the map after a restart.
2358
+ const th = s.token ? actorLib.tokenHash(s.token) : (typeof s.tokenHash === 'string' ? s.tokenHash : null);
2359
+ if (th) meta.tokenHash = th;
2339
2360
  // The profile ID ONLY — never the descriptor and never its env, which is where a credential
2340
2361
  // would live (D08). The key is written only for a session PMOS created, so its ABSENCE stays a
2341
2362
  // fact: a record persisted before profiles existed restores as "not recorded", and inventing a
@@ -2604,6 +2625,8 @@ function sessionList() {
2604
2625
  id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
2605
2626
  model: 'model' in s ? s.model : null, model_line: modelLib.line('model' in s ? s.model : null),
2606
2627
  cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
2628
+ plan_item: s.plan_item || null, // Hand to agent: the breakdown item this session is bound to (pm-os-absorb Run 1)
2629
+ // No `token` and no `tokenHash` here, ever: the page never sees a session token (F5; r25-guards.test.js checks it).
2607
2630
  // Derived presentation state, JOINED from both sources: the cadence clock above and the
2608
2631
  // transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
2609
2632
  // needs | blocked. `shell`-ness stays on `agent`, because it is orthogonal to state;
@@ -2836,10 +2859,12 @@ function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t'
2836
2859
  // not is the honest-state contract inverted, so this is a correctness fix and not a tidy-up.
2837
2860
  // `new-session -e K=V` (tmux ≥3.2) sets the environment of THAT session only; a session that
2838
2861
  // declares no profile is left genuinely unset rather than inheriting a neighbour's.
2839
- function createTmuxSession(name, cmd, workdir, cols, rows, overlay) {
2862
+ // `extra` carries what PMOS itself gives one session (PMOS_SESSION, its token for `pmos item`); the profile
2863
+ // overlay stays LAST so nothing in `extra` can unset the workspace flag the overlay carries.
2864
+ function createTmuxSession(name, cmd, workdir, cols, rows, extra, overlay) {
2840
2865
  if (tmuxHasSession(name)) return false;
2841
2866
  const eArgs = [];
2842
- for (const [k, v] of Object.entries(overlay || {})) eArgs.push('-e', `${k}=${v}`);
2867
+ for (const [k, v] of Object.entries({ ...(extra || {}), ...(overlay || {}) })) eArgs.push('-e', `${k}=${v}`);
2843
2868
  const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
2844
2869
  ...eArgs, '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
2845
2870
  return r.status === 0;
@@ -3049,8 +3074,16 @@ function resolveProfileForSpawn(id) {
3049
3074
  : `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
3050
3075
  }
3051
3076
 
3052
- function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk }) {
3077
+ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk, planItem }) {
3053
3078
  const id = nextSessionId();
3079
+ /* pm-os-absorb Run 1 — every AGENT session gets a token, `<id>.<8 hex>`, in its environment as PMOS_SESSION, and
3080
+ * PMOS_BIN, this workspace's own bin/pmos.js. `pmos item` run inside it is then attributed `agent:<token>` (the token
3081
+ * wins over a terminal, lib/plan-items.js actorFromCli). A plain shell session is the PM's own terminal, so it gets
3082
+ * neither (both set empty, never the server's own), and its `pmos item` stays the PM's. The token is an attribution,
3083
+ * not a secret, and Run 2's nonce is the guard. The ledger keeps it whole (it is committed with the repository); the
3084
+ * routes the page reads send `agent:sess-<n>` without it (lib/plan-items.js layoutForPage and needsFor), and the
3085
+ * session list does not carry it. */
3086
+ const sessionToken = `${id}.${crypto.randomBytes(4).toString('hex')}`;
3054
3087
  const w = getWs(ws);
3055
3088
  const root = w ? w.root : SELF_ROOT;
3056
3089
  const isShell = agent === 'shell';
@@ -3071,8 +3104,17 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3071
3104
  // `profileRefusal` is server-internal: callers that can refuse read it and say why.
3072
3105
  const pres = resolveProfileForSpawn(profile);
3073
3106
  const penv = sessionEnvWithProfile(pres.profile);
3107
+ // sessionEnvWithProfile returns a fresh object (sessionEnv copies process.env), so these touch this session only.
3108
+ // sessionEnv() already strips both from the server's own environment. A shell still gets them SET EMPTY: on tmux they
3109
+ // travel as `-e`, which overrides a daemon an older server seeded with its token, and an empty PMOS_SESSION fails
3110
+ // SESSION_RE, so a `pmos item` typed in the shell is the PM's.
3111
+ const itemEnv = isShell ? { PMOS_SESSION: '', PMOS_BIN: '' } : { PMOS_SESSION: sessionToken, PMOS_BIN: path.join(__dirname, 'bin', 'pmos.js') };
3112
+ Object.assign(penv, itemEnv);
3074
3113
  const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
3075
3114
  profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
3115
+ // Hand to agent: the breakdown item this session was started for, or null. Set only by /api/plan-items/handoff/go.
3116
+ base.plan_item = planItem || null;
3117
+ base.token = sessionToken;
3076
3118
 
3077
3119
  /* WHICH MODEL (D75). Resolved once, here, with the profile, so the record can say only what was applied.
3078
3120
  * Three states are kept apart — not asked, applied, and "this binary has no model control" — and a choice that
@@ -3127,7 +3169,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3127
3169
  // the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
3128
3170
  // really does spawn the process itself. See createTmuxSession for why the two differ.
3129
3171
  // The launch args are plain tokens by construction (pmos-model's strict pattern), so joining them is safe.
3130
- if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, sessionFlag.tmuxOverlay(profileOverlay(pres.profile)))) {
3172
+ if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, itemEnv, sessionFlag.tmuxOverlay(profileOverlay(pres.profile)))) {
3131
3173
  const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
3132
3174
  s.state = 'exited'; s.tmux = null; s.note = why;
3133
3175
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
@@ -3191,7 +3233,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3191
3233
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
3192
3234
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
3193
3235
  // git-then-maybe-spawn contract is unit-testable without a WS client.
3194
- function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput }) {
3236
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput, planItem }) {
3195
3237
  const w = getWs(ws);
3196
3238
  if (!w) return { ok: false, error: 'no workspace' };
3197
3239
  // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
@@ -3238,7 +3280,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
3238
3280
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
3239
3281
  }
3240
3282
  }
3241
- const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput });
3283
+ const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput, planItem });
3242
3284
  // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
3243
3285
  // block above. The isolate default already computes that other sessions would be disturbed — and
3244
3286
  // then the whole git block is skipped when no branch was picked, which is the common path and the
@@ -3341,7 +3383,7 @@ function startPreview({ initiative, repoKey, ws }) {
3341
3383
  // One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
3342
3384
  // branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
3343
3385
  // nothing) is enforced in exactly one place and is not re-implemented here.
3344
- function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput }) {
3386
+ function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput, planItem }) {
3345
3387
  const key = repoKeyFor({ repoKey, ws });
3346
3388
  if (!key) return { ok: false, error: 'no repository selected' };
3347
3389
  const trees = treesOfRepo(key);
@@ -3367,6 +3409,8 @@ function startWork({ initiative, repoKey, ws, agent, name, profile, origin, init
3367
3409
  // next-step-actions: a session started from a next step carries its origin (so the budget can count it) and its
3368
3410
  // prompt. Only the /api/next-step/go route passes either; the socket's `start` message never does.
3369
3411
  origin, initialInput,
3412
+ // Hand to agent (pm-os-absorb Run 1): only /api/plan-items/handoff/go passes it.
3413
+ planItem,
3370
3414
  });
3371
3415
  if (!r.ok) return r;
3372
3416
  return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
@@ -3434,7 +3478,7 @@ function nextStepsFor(w, id) {
3434
3478
  const acc = readAcceptances(w).get(id) || {};
3435
3479
  const lc = readLifecycle(w).get(id) || null;
3436
3480
  const ns = deriveNextSteps({ id, accepted: acc.action === 'accept', returned: acc.action === 'return',
3437
- lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
3481
+ lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id && notPlanNeed(n)),
3438
3482
  sessions: sessRows.filter((x) => x.initiative === id || liveIds.has(x.id)) });
3439
3483
  nextStepActions.annotate(ns, (it) => nextStepPathOf(root, it));
3440
3484
  attachRuns(ns, id);
@@ -3469,6 +3513,55 @@ function prepareNextStep(body) {
3469
3513
  judgment: { on: judgmentOn, outside_service: judgmentOn, needs_key: judgmentOn, note: judgmentOn ? 'The question text leaves this machine for the Typesafe service. It needs TYPESAFE_API_KEY in the session.' : 'No outside service is used.' } };
3470
3514
  }
3471
3515
 
3516
+ // ── Hand to agent (pm-os-absorb Run 1, PRD R14) ────────────────────────────────────────────────────────────────
3517
+ // A breakdown item is PREPARED (read-only: the first prompt line, where the session will run, the full prompt folded
3518
+ // away) and then STARTED by the PM's press with exactly that prompt — the next-step pattern. The session is bound to
3519
+ // the item (`plan_item`) and reports with `pmos item`. Neither step writes plan-items.jsonl or planning/work-items.jsonl:
3520
+ // materialization arrives in Run 4. Nothing else starts a bound session — no drag or drop reaches this code.
3521
+ const HANDOFF_WHERE = { 'new-worktree': 'new branch', 'reuse-worktree': 'its branch', 'main-tree-on-branch': 'its branch', 'main-tree': 'this tree' };
3522
+ // The next-step budget, with Hand to agent sessions counted beside next-step ones: both are sessions the workspace
3523
+ // starts on the PM's behalf. nsBudget() itself is unchanged, so the next-step limit reads exactly as before.
3524
+ function handoffBudget() {
3525
+ const today = new Date().toDateString();
3526
+ const all = [...sessions.values()];
3527
+ const running = all.filter((x) => (x.origin === 'next-step' || x.origin === 'plan-item') && x.state !== 'exited').length;
3528
+ const startedToday = nsRead(NS_RUNS()).filter((r) => r && r.at && new Date(r.at).toDateString() === today).length
3529
+ + all.filter((x) => x.origin === 'plan-item' && x.created && new Date(x.created).toDateString() === today).length;
3530
+ return sessionBudget.check({ running, startedToday, limits: nsLimits() });
3531
+ }
3532
+ function prepareHandoff(body) {
3533
+ const w = body && body.ws ? workspaces.get(String(body.ws)) : null;
3534
+ if (!w) return { ok: false, status: 404, error: 'no such workspace' };
3535
+ const itemId = String((body && body.item) || '');
3536
+ if (!planItems.ID_RE.test(itemId)) return { ok: false, status: 400, error: 'item must be an item id, <initiative>.<10 characters>' };
3537
+ let fold;
3538
+ try { fold = planItems.readFold(w.root); }
3539
+ catch (e) { return { ok: false, status: 500, error: 'could not read the plan-items ledger: ' + String((e && e.message) || 'error').slice(0, 140) }; }
3540
+ const it = fold.items[itemId];
3541
+ if (!it) return { ok: false, status: 404, error: `no item ${itemId} in this repository` };
3542
+ const live = [...sessions.values()].find((x) => x.plan_item === itemId && x.state !== 'exited');
3543
+ if (live) return { ok: false, status: 409, error: 'a session is already working on this item', session: live.id };
3544
+ /* Hand to agent starts an AGENT, never a shell: a prompt typed into a shell would run as commands, and an item's note
3545
+ * is text an agent can write. The page never sends `agent`; anything but claude is refused rather than swapped. */
3546
+ if (body.agent != null && body.agent !== 'claude') return { ok: false, status: 400, error: 'Hand to agent starts claude' };
3547
+ const agent = 'claude';
3548
+ let stageLabel = it.stage;
3549
+ try { const st = planTree.stagesFor(w.root, it.initiative).find((x) => x.id === it.stage); if (st) stageLabel = st.label; } catch { /* the id stands in */ }
3550
+ const gone = new Set(fold.withdrawnIds || []);
3551
+ const { first, prompt } = planItems.handoffPrompt({ ...it, after: (it.after || []).filter((x) => !gone.has(x)) }, it.initiative, stageLabel);
3552
+ const preview = startPreview({ initiative: it.initiative, ws: w.id });
3553
+ if (!preview.ok) return { ok: false, status: 409, error: preview.error || 'no branch and tree could be chosen for this item' };
3554
+ const where = HANDOFF_WHERE[preview.plan] || preview.plan;
3555
+ return {
3556
+ ok: true, item: itemId, initiative: it.initiative, agent, plan: preview.plan, where, first,
3557
+ line: `Agent will: ${first} · ${where}`, prompt,
3558
+ // Go starts exactly what was shown: the prompt, the plan, the agent, the branch and the tree it was shown for.
3559
+ prompt_sha: crypto.createHash('sha256').update([prompt, preview.plan, agent, preview.branch || '', preview.wsId || preview.dir || '', w.id].join('\n')).digest('hex'),
3560
+ details: { branch: preview.branch || null, tree: preview.dir || null, session_name: 'item: ' + it.title.slice(0, 40) },
3561
+ ws: w.id,
3562
+ };
3563
+ }
3564
+
3472
3565
  // ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
3473
3566
  // The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
3474
3567
  // `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
@@ -3560,7 +3653,7 @@ function proactiveCtx(now, settings) {
3560
3653
  const lc = life.get(b.id); const stopped = !!(lc && lc.state !== 'active');
3561
3654
  entry.initiatives.push({ id: b.id, title: b.title || b.id, active: !b.accepted && !stopped, lane: b.lane || null, lastRun: lastRun.has(b.id) ? lastRun.get(b.id) : null, hasRuns: hasRuns.has(b.id) });
3562
3655
  }
3563
- entry.needs = (readNeeds(w) || []).filter((n) => n).map((n) => ({ initiative_id: n.initiative_id || null, question: n.question || '', run_id: n.run_id || null }));
3656
+ entry.needs = (readNeeds(w) || []).filter((n) => n && notPlanNeed(n)).map((n) => ({ initiative_id: n.initiative_id || null, question: n.question || '', run_id: n.run_id || null }));
3564
3657
  for (const [id, a] of acc) if (a && a.action === 'return') entry.returned.push({ initiative_id: id, note: 'returned at the Acceptance Gate', at: a.at });
3565
3658
  entry.schedules = allSchedules.filter((x) => r.ids.has(x.ws)).map((x) => ({ id: x.id, name: x.name || x.label || x.skill || x.id, lastStatus: x.lastStatus || null, lastRun: x.lastRun || null }));
3566
3659
  } catch (e) { entry.error = 'the repository could not be read — ' + String((e && e.message) || 'error').slice(0, 120); }
@@ -3875,10 +3968,33 @@ function leadingFrontmatter(file) { return fmParse.leadingFrontmatter(file); }
3875
3968
  // The outcome rule mirrors kit/scripts/metrics.py EXACTLY, including "a missing horizon counts
3876
3969
  // as due" — a bet with no judgment date can never come due by itself (horizon-gaming-by-omission).
3877
3970
  // Derivation only ever reports; it never disposes anything (D57).
3971
+ /* The plan-items ledger belongs to the REPOSITORY (lib/plan-items.js ledgerRoot), not to a working tree, so its needs
3972
+ * rows are derived for ONE registered tree of it: the tree at the ledger's root, else the first registered. Otherwise a
3973
+ * repository with a linked worktree registered would count every Keep card twice. */
3974
+ function isPlanNeedsTree(w) {
3975
+ let mine; try { mine = planItems.ledgerRoot(w.root); } catch { return true; }
3976
+ const same = [...workspaces.values()].filter((t) => { try { return planItems.ledgerRoot(t.root) === mine; } catch { return false; } });
3977
+ if (!same.some((t) => t.id === w.id)) return true;
3978
+ const lead = same.find((t) => path.resolve(t.root) === mine) || same[0];
3979
+ return lead.id === w.id;
3980
+ }
3878
3981
  function deriveNeeds(w) {
3982
+ if (!w) return [];
3983
+ /* pm-os-absorb Run 1 — the breakdown's needs come FIRST and do not depend on a state directory: one Keep card
3984
+ * per agent batch, an agent Done with no evidence, and a Keep line that was not written from the page. A ledger
3985
+ * that cannot be read is said here, as a row, rather than turned into an empty list or a 500 for every caller. */
3986
+ let needs = [];
3987
+ if (isPlanNeedsTree(w)) {
3988
+ try { needs = planItems.needsFor(w.root).map((n) => ({ ...n, ws: w.id })); }
3989
+ catch (e) {
3990
+ needs = [{ run_id: 'plan-items:unreadable', initiative_id: null, kind: 'plan-flag', derived: true, doc: null, ws: w.id,
3991
+ question: 'The plan-items ledger could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }];
3992
+ }
3993
+ }
3994
+ // pm-os-absorb Runs 2–5 — the planning layer's other needs rows, one provider file per run (lib/needs-providers.js).
3995
+ needs = needs.concat(needsProviders.rowsFor(w, { lead: isPlanNeedsTree(w) }));
3879
3996
  const dir = stateDirFor(w);
3880
- if (!dir) return [];
3881
- const needs = [];
3997
+ if (!dir) return needs;
3882
3998
  const settled = new Set();
3883
3999
  for (const a of readLedger(w, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
3884
4000
  for (const e of readLedger(w, 'evals.jsonl')) {
@@ -3923,6 +4039,13 @@ function readNeeds(w) {
3923
4039
  const seen = new Set(queued.map((n) => n && n.run_id).filter(Boolean));
3924
4040
  return queued.concat(deriveNeeds(w).filter((n) => !seen.has(n.run_id)));
3925
4041
  }
4042
+ /* pm-os-absorb Run 1 — the breakdown's needs rows (a Keep card, an agent Done with no evidence, a forged Keep) are the
4043
+ * PM's to act on in needs-you and Today, so /api/needs and the per-tree counts carry them. They are NOT evidence for
4044
+ * an initiative's derived state, its next steps or the portfolio's needs list: those read the rows they read before
4045
+ * Run 1, so a pending Keep does not turn an initiative `blocked` with a sentence naming needs.json, and Today does not
4046
+ * show the same batch twice (once as a Keep card, once as an "Unblock" initiative card). */
4047
+ const PLAN_NEED_KINDS = new Set(['keep', 'evidence', 'plan-flag']);
4048
+ const notPlanNeed = (n) => !(n && n.derived === true && PLAN_NEED_KINDS.has(n.kind)) && !needsProviders.isProviderRow(n);
3926
4049
 
3927
4050
  // ── Read-only console surfaces over file-mode state (D64 Track B). ──────────────────────
3928
4051
  // The committed hosted snapshot is the seed/demo source when no LIVE file-mode state exists; the
@@ -3959,6 +4082,10 @@ function needsResult(w) {
3959
4082
  run_id: n.run_id || null, kind: n.kind || 'question', initiative_id: n.initiative_id || null,
3960
4083
  question: n.question || n.t || '(unstated)', derived: !!n.derived, doc: n.doc || null,
3961
4084
  deliverable: n.deliverable || null,
4085
+ // pm-os-absorb Run 1 — a breakdown row keeps what its card needs; other rows are unchanged (no new keys).
4086
+ ...(n.ws ? { ws: n.ws } : {}), ...(n.batch ? { batch: n.batch } : {}), ...(n.item ? { item: n.item } : {}), ...(n.flag ? { flag: n.flag } : {}),
4087
+ // pm-os-absorb Runs 2–5 — a provider row's Today card (lib/needs-providers.js); absent on every other row.
4088
+ ...(n.today && typeof n.today === 'object' ? { today: n.today } : {}),
3962
4089
  }));
3963
4090
  // `all` is the RAW combined queue — what readNeeds(w) computes — carried so /api/needs can serve
3964
4091
  // both the strict state and the queue from one derivation instead of deriving twice per request.
@@ -3996,10 +4123,14 @@ const needsAgg = new Map(); // ws id -> { at, sig, value }
3996
4123
  // requirement is about. `none` and `unreadable` are distinct signatures on purpose; a file that
3997
4124
  // appears, vanishes or becomes unstattable each invalidate.
3998
4125
  function needsSig(w) {
4126
+ // The plan-items ledger feeds needs too (Keep, no evidence, forged Keep), so its signature is part of this one.
4127
+ let plan = 'none';
4128
+ try { plan = planItems.ledgerSigs([{ id: w.id, root: w.root }], null)[w.id].items; } catch { plan = 'unreadable'; }
4129
+ plan += '|' + needsProviders.sigFor(w); // pm-os-absorb Runs 2–5 — and every needs provider's
3999
4130
  const f = needsFileFor(w);
4000
- if (!f) return 'none';
4001
- try { const st = fs.statSync(f); return `${st.mtimeMs}:${st.size}`; }
4002
- catch { return 'unreadable'; }
4131
+ if (!f) return `none|${plan}`;
4132
+ try { const st = fs.statSync(f); return `${st.mtimeMs}:${st.size}|${plan}`; }
4133
+ catch { return `unreadable|${plan}`; }
4003
4134
  }
4004
4135
  function needsCountCached(w) {
4005
4136
  const hit = needsAgg.get(w.id);
@@ -4793,7 +4924,18 @@ async function handleRequest(req, res) {
4793
4924
  const url = new URL(req.url, 'http://localhost');
4794
4925
  const p = url.pathname;
4795
4926
 
4796
- if (p === '/' || p === '/index.html') return serveFile(res, path.join(PUB, 'index.html'), null, true);
4927
+ if (p === '/' || p === '/index.html') {
4928
+ // pm-os-absorb Run 2 (R25, X11): the per-launch nonce goes into the base meta line, nowhere else. no-store, so a
4929
+ // restarted server never answers a press from a page that holds the previous launch's value.
4930
+ const n = pmGuard.launchNonce();
4931
+ if (!n) return serveFile(res, path.join(PUB, 'index.html'), null, true);
4932
+ return fs.readFile(path.join(PUB, 'index.html'), 'utf8', (err, html) => {
4933
+ if (err) { res.writeHead(404); res.end('not found'); return; }
4934
+ const out = html.replace('<meta name="' + pmGuard.NONCE_META + '" content="" />', '<meta name="' + pmGuard.NONCE_META + '" content="' + n + '" />');
4935
+ res.writeHead(200, { 'Content-Type': TYPES['.html'], 'Cache-Control': 'no-store' });
4936
+ res.end(out);
4937
+ });
4938
+ }
4797
4939
  // NB: there is no `/app.js` route. The single-file front end that owned it (public/app.js +
4798
4940
  // public/styles.css) was RETIRED with the four-panel design — the shell is index.html plus the
4799
4941
  // /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
@@ -4818,6 +4960,10 @@ async function handleRequest(req, res) {
4818
4960
  // client feature that depends on it fails in a way no server-side test can see.
4819
4961
  if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
4820
4962
  if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
4963
+ // pm-os-absorb — the words an initiative shows (R3), one mapping for the page and for node over every record.
4964
+ if (p === '/lib/initiative-display.js') return serveFile(res, path.join(__dirname, 'lib', 'initiative-display.js'));
4965
+ // pm-os-absorb — the dated bars of a breakdown (R20): Breakdown › Timeline, Initiatives › Timeline and the report strip.
4966
+ if (p === '/lib/item-spans.js') return serveFile(res, path.join(__dirname, 'lib', 'item-spans.js'));
4821
4967
  // …and no `/styles.css` route either: the token layer and every component rule now ship as
4822
4968
  // /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
4823
4969
  // Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
@@ -4846,6 +4992,10 @@ async function handleRequest(req, res) {
4846
4992
  // of difference the design-parity comparator reports and a human eye does not.
4847
4993
  if (p === '/vendor/ibm-plex-mono-500.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-500.woff2'));
4848
4994
  if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
4995
+ // D86 — the two vendored browser libraries of pm-os-absorb Run 1, committed under public/vendor/ (no CDN, no
4996
+ // package.json entry). SortableJS does every drag; the sprite holds the lucide icons the Breakdown uses.
4997
+ if (p === '/vendor/Sortable.min.js') return serveFile(res, path.join(PUB, 'vendor', 'Sortable.min.js'));
4998
+ if (p === '/vendor/lucide-sprite.js') return serveFile(res, path.join(PUB, 'vendor', 'lucide-sprite.js'));
4849
4999
  // ── the four-panel front end (public/pm/) ──────────────────────────────────────────────────────
4850
5000
  // A DIRECTORY route, deliberately, where /lib/ above is an allowlist. The allowlist's own comment
4851
5001
  // records why it is a trap: a file missing from it 404s silently and the feature that needed it
@@ -5060,6 +5210,9 @@ async function handleRequest(req, res) {
5060
5210
  let body = {};
5061
5211
  try { body = JSON.parse((await readBody(req)) || '{}'); }
5062
5212
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5213
+ // R25: writing parent_kr is the anchor change, a PM-only act.
5214
+ const g = pmGuard.requirePmPress(req, body, { act: 'anchor' });
5215
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
5063
5216
  // ONE item. There is no array form, no `items`, no `all` — the tedium IS the gate (R4). A
5064
5217
  // bulk confirm is how a dozen guessed anchors land on the board with every check green.
5065
5218
  if (Array.isArray(body.item) || body.items != null || body.all != null) {
@@ -5623,7 +5776,11 @@ async function handleRequest(req, res) {
5623
5776
  }
5624
5777
  // `version` lets the launcher notice a server still running OLD code after an upgrade: files on disk
5625
5778
  // advance under a live process, so the UI would otherwise call routes that process never learned.
5626
- if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION });
5779
+ // `ledgers` (pm-os-absorb Run 1) — the plan-items signature of every registered workspace, so an open Breakdown
5780
+ // knows to refetch: `items` is one stat per workspace; `evidence` is computed only for the ids in ?ledgers=
5781
+ // (at most 4). Computed on this request and never stored (D33).
5782
+ if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION,
5783
+ ledgers: planItems.ledgerSigs([...workspaces.values()].map((w) => ({ id: w.id, root: w.root })), url.searchParams.get('ledgers')) });
5627
5784
  if (p === '/api/git/branches') {
5628
5785
  // Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
5629
5786
  // branch). Fixed git args, no caller input in the read path (R1).
@@ -5788,13 +5945,16 @@ async function handleRequest(req, res) {
5788
5945
  // per-tree counts a COLLAPSED sidebar row shows, which is the whole reason a row can be collapsed.
5789
5946
  if (p === '/api/needs') {
5790
5947
  const w = getWs(url.searchParams.get('ws'));
5948
+ // The signature is taken BEFORE the read, as needsCountCached takes it: a write between the two then leaves an
5949
+ // older signature on the cached count (read again on the next call), never a newer one on an older count.
5950
+ let sig = null;
5951
+ if (w) { try { sig = needsSig(w); } catch { sig = 'unreadable'; } }
5791
5952
  const strict = needsResult(w);
5792
5953
  // One derivation per request, not three: the strict read already holds the combined queue
5793
5954
  // (`all` — identical to readNeeds when the read succeeds; on a corrupt file readNeeds keeps its
5794
5955
  // lenient derived-only fallback), and the by_ws cache is seeded from the same read so the
5795
5956
  // selected tree is not derived again — fresher than any cache hit, so R10 still holds.
5796
5957
  if (w) {
5797
- let sig; try { sig = needsSig(w); } catch { sig = 'unreadable'; }
5798
5958
  needsAgg.set(w.id, { at: Date.now(), sig, value: strict.ok
5799
5959
  ? { count: strict.empty ? 0 : strict.data.items.length, error: null }
5800
5960
  : { count: null, error: strict.error } });
@@ -5904,7 +6064,7 @@ async function handleRequest(req, res) {
5904
6064
  nextSteps = deriveNextSteps({
5905
6065
  id, accepted: acc.action === 'accept', returned: acc.action === 'return',
5906
6066
  lifecycle: lc && lc.state !== 'active' ? lc : null, ladder,
5907
- needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
6067
+ needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id && notPlanNeed(n)),
5908
6068
  sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
5909
6069
  });
5910
6070
  } catch (e) { nextSteps = { state: 'unreadable', summary: 'The next steps could not be derived — ' + String((e && e.message) || 'error').slice(0, 120), items: [] }; }
@@ -5984,6 +6144,9 @@ async function handleRequest(req, res) {
5984
6144
  let body = {};
5985
6145
  try { body = JSON.parse((await readBody(req)) || '{}'); }
5986
6146
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6147
+ // R25 / R23: binding decides which initiatives an agent session works on, so an agent cannot bind itself.
6148
+ const g = pmGuard.requirePmPress(req, body, { act: 'bind' });
6149
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
5987
6150
  const s = sessions.get(String(body.id || ''));
5988
6151
  if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
5989
6152
  const clearing = body.initiative === null || body.initiative === '';
@@ -6141,6 +6304,10 @@ async function handleRequest(req, res) {
6141
6304
  let body = {};
6142
6305
  try { body = JSON.parse((await readBody(req)) || '{}'); }
6143
6306
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6307
+ // R25: every action this route records is the PM's — acceptance, review approve and return, document approval,
6308
+ // contract approval and a D60 disposition. One guard before the action is read, so no action escapes it.
6309
+ const g = pmGuard.requirePmPress(req, body, { act: 'accept' });
6310
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
6144
6311
  if (!body.action || !body.run_id) return json(res, { ok: false, error: 'action and run_id are required' }, 400);
6145
6312
  const w = getWs(body.ws);
6146
6313
  const rec = { at: nowISO(), date: nowISO().slice(0, 10),
@@ -6158,7 +6325,7 @@ async function handleRequest(req, res) {
6158
6325
  // it, validated against the same closed set, and simply absent when they do not.
6159
6326
  accepted: body.action === 'accept' ? true : body.action === 'return' ? false : undefined,
6160
6327
  ...(body.pm_label === 'pass' || body.pm_label === 'fail' ? { pm_label: body.pm_label } : {}),
6161
- note: body.note || '', workspace: w ? w.name : null, root: w ? w.root : null, pm: true };
6328
+ note: body.note || '', workspace: w ? w.name : null, root: w ? w.root : null, pm: true, by: g.actor.by, via: g.actor.via };
6162
6329
  // A malformed label is refused rather than dropped. Silently discarding it would put the row in
6163
6330
  // the unlabeled pile while the PM believed they had labelled it — the same shape as inferring.
6164
6331
  if (body.pm_label !== undefined && body.pm_label !== 'pass' && body.pm_label !== 'fail') {
@@ -6321,6 +6488,69 @@ async function handleRequest(req, res) {
6321
6488
  const wrote = nsAppend(NS_RUNS(), rec, 'next-step run');
6322
6489
  return json(res, { ok: true, session: { id: started.session.id, name: started.session.name }, notice: started.notice || null, plan: r.plan, run: wrote.ok ? rec.id : null, recorded: wrote.ok });
6323
6490
  }
6491
+ /* ── pm-os-absorb Run 1 — an initiative's breakdown (plan-items.jsonl) ──────────────────────────────────────────
6492
+ * GET is the Breakdown layout: derived stages with their owed rows, the items, the Board columns, the Keep batches.
6493
+ * POST writes through the ONE writer as the PM (`pm:wawan`, `via: ui`). Every POST needs an Origin header: the
6494
+ * global guard already refuses a foreign one, and a request with none did not come from the page. Header values
6495
+ * are never logged (D08). `ladder=0` skips the ladder (about 800 ms here) when only the items changed. */
6496
+ if (p === '/api/plan-items' && req.method === 'GET') {
6497
+ const wsParam = url.searchParams.get('ws');
6498
+ const w = wsParam ? workspaces.get(wsParam) : homeWorkspace();
6499
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
6500
+ const id = url.searchParams.get('id') || '';
6501
+ if (!planItems.INIT_RE.test(id)) return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
6502
+ try {
6503
+ if (!planTree.recordPath(w.root, id)) return json(res, { ok: false, error: `no initiative ${id} in this repository` }, 404);
6504
+ const now = Date.now();
6505
+ // The signature is taken before the ledger and the ladder are read (lib/plan-items.js readLayout says why).
6506
+ const { sig, layout: lay } = planItems.readLayout({ id: w.id, root: w.root }, id, { ladder: url.searchParams.get('ladder') !== '0', now });
6507
+ const out = { ok: true, ws: w.id, initiative: id, profile: lay.profile, lane: lay.lane, ladderIncluded: lay.ladderIncluded };
6508
+ if (lay.ladderIncluded) out.ladder = lay.ladder;
6509
+ Object.assign(out, { sig, now: new Date(now).toISOString(), progress: lay.progress, stages: lay.stages, other: lay.other,
6510
+ items: lay.items, board: lay.board, batches: lay.batches, flags: lay.flags, withdrawnCount: lay.withdrawnCount });
6511
+ return json(res, out);
6512
+ } catch (e) {
6513
+ return json(res, { ok: false, error: 'the breakdown could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500);
6514
+ }
6515
+ }
6516
+ if ((p === '/api/plan-items' || p === '/api/plan-items/handoff/prepare' || p === '/api/plan-items/handoff/go') && req.method === 'POST') {
6517
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6518
+ // R25: pm-guard's body, writer-field, Origin and nonce checks. A call that carries a Keep is the PM-only act `keep`.
6519
+ const keeps = p === '/api/plan-items' && body && Array.isArray(body.events) && body.events.some((e) => e && e.ev === 'kept');
6520
+ const g = keeps ? pmGuard.requirePmPress(req, body, { act: 'keep' }) : pmGuard.requirePagePress(req, body);
6521
+ if (!g.ok) return json(res, { ok: false, error: g.error, ...(p === '/api/plan-items' ? { index: null } : {}) }, g.status);
6522
+ if (p === '/api/plan-items') {
6523
+ const w = body.ws ? workspaces.get(String(body.ws)) : null;
6524
+ if (!w) return json(res, { ok: false, error: 'no such workspace', index: null }, 404);
6525
+ let r;
6526
+ try { r = planItems.appendFromUi(w.root, body.events, { actor: g.actor }); }
6527
+ catch (e) { warnWriteFail('plan-items.jsonl', e); return json(res, { ok: false, error: 'could not record — nothing was saved', index: null }, 500); }
6528
+ if (!r.ok) return json(res, { ok: false, error: r.error, index: r.index }, r.status || 400);
6529
+ invalidateNeedsAgg(); // the ledger is per repository: every tree of it may show a new Keep card
6530
+ const sig = planItems.ledgerSigs([{ id: w.id, root: w.root }], null)[w.id];
6531
+ return json(res, { ok: true, written: r.written, sig });
6532
+ }
6533
+ if (p === '/api/plan-items/handoff/prepare') {
6534
+ const r = prepareHandoff(body);
6535
+ if (!r.ok) { const { status, ...rest } = r; return json(res, rest, status || 400); }
6536
+ const { ws: _ws, ...shown } = r;
6537
+ return json(res, shown);
6538
+ }
6539
+ // go: only the PM's press reaches it. It prepares again and starts one session only when the PM confirmed AND the
6540
+ // prompt and destination are the ones they were shown (the sha). It writes no ledger line.
6541
+ if (body.confirm !== true) return json(res, { ok: false, error: 'confirm is required: this starts a session' }, 400);
6542
+ const r = prepareHandoff(body);
6543
+ if (!r.ok) { const { status, ...rest } = r; return json(res, rest, status || 400); }
6544
+ if (!body.prompt_sha || body.prompt_sha !== r.prompt_sha) return json(res, { ok: false, error: 'the item changed since you reviewed it — prepare it again', prompt_sha: r.prompt_sha }, 409);
6545
+ const budget = handoffBudget();
6546
+ if (!budget.allowed) return json(res, { ok: false, error: budget.reason, budget }, 409);
6547
+ const started = startWork({ initiative: r.initiative, ws: r.ws, agent: r.agent, name: r.details.session_name,
6548
+ origin: 'plan-item', initialInput: r.prompt + '\r', planItem: r.item });
6549
+ if (!started.ok) return json(res, { ok: false, error: started.error }, 409);
6550
+ // The session's token is never projected; the page gets where it runs and the item it is bound to.
6551
+ return json(res, { ok: true, session: { id: started.session.id, name: started.session.name, ws: started.session.ws, plan_item: started.session.plan_item || null },
6552
+ notice: started.notice || null, plan: started.plan });
6553
+ }
6324
6554
  if (p === '/api/next-step/summary' && req.method === 'GET') {
6325
6555
  // What an ended (or running) next-step session did: the files git shows on its branch against trunk, the end of the
6326
6556
  // conversation, and every JUDGMENT line checked against its shape. Read-only.
@@ -6352,6 +6582,8 @@ async function handleRequest(req, res) {
6352
6582
  // The PM's verdict on one step's run. It accepts or sends back THE STEP. It never records an acceptance of the
6353
6583
  // initiative, never changes a status and never touches a lifecycle line.
6354
6584
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6585
+ const g = pmGuard.requirePmPress(req, body, { act: 'accept' }); // R25: accepting or returning a step is the PM's
6586
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
6355
6587
  const verdict = body.verdict === 'accept' ? 'accept' : body.verdict === 'send-back' ? 'send-back' : null;
6356
6588
  if (!verdict) return json(res, { ok: false, error: 'verdict must be accept or send-back' }, 400);
6357
6589
  const note = String(body.note || '').trim().slice(0, 500);
@@ -6361,7 +6593,7 @@ async function handleRequest(req, res) {
6361
6593
  if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
6362
6594
  const live = sessions.get(sid);
6363
6595
  if (live && live.state !== 'exited') return json(res, { ok: false, error: 'the session is still running — review it when it has ended' }, 409);
6364
- const rec = { at: nowISO(), initiative_id: run.initiative_id, item_id: run.item_id, key: run.key, session_id: sid, verdict, note, pm: 'wawan' };
6596
+ const rec = { at: nowISO(), initiative_id: run.initiative_id, item_id: run.item_id, key: run.key, session_id: sid, verdict, note, pm: 'wawan', by: g.actor.by, via: g.actor.via };
6365
6597
  const wrote = nsAppend(NS_REVIEWS(), rec, 'step review');
6366
6598
  if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
6367
6599
  return json(res, { ok: true, recorded: rec });
@@ -6828,17 +7060,37 @@ async function handleRequest(req, res) {
6828
7060
  let body = {};
6829
7061
  try { body = JSON.parse((await readBody(req)) || '{}'); }
6830
7062
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6831
- const id = String(body.initiative_id || '');
6832
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) return json(res, { ok: false, error: 'initiative_id is required' }, 400);
7063
+ const g = pmGuard.requirePmPress(req, body, { act: 'lifecycle' }); // R25: a lifecycle change is the PM's
7064
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
7065
+ const ID_OK = (v) => typeof v === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(v);
7066
+ // The bulk body `{ ws, ids, state }` (D86 point 2, X6): at most 20 ids and one state, under ONE lifecycle-bulk grant.
7067
+ const bulk = body.ids !== undefined;
7068
+ if (bulk && body.initiative_id !== undefined) return json(res, { ok: false, error: 'send initiative_id or ids, not both' }, 400);
7069
+ const ids = bulk ? body.ids : [String(body.initiative_id || '')];
7070
+ if (bulk && (!Array.isArray(ids) || !ids.length || ids.length > 20 || !ids.every(ID_OK) || new Set(ids).size !== ids.length)) {
7071
+ return json(res, { ok: false, error: 'ids must name 1 to 20 different initiatives' }, 400);
7072
+ }
7073
+ const id = ids[0];
7074
+ if (!ID_OK(id)) return json(res, { ok: false, error: 'initiative_id is required' }, 400);
6833
7075
  if (body.state !== 'active' && !STOPPED_STATES.includes(body.state)) {
6834
7076
  return json(res, { ok: false, error: `state must be one of ${STOPPED_STATES.concat('active').join(', ')}` }, 400);
6835
7077
  }
6836
7078
  const w = getWs(body.ws);
6837
7079
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6838
- if (!initiativeIndex(w.root).initiatives.some((g) => g.id === id)) {
6839
- return json(res, { ok: false, error: `no initiative '${id}' in ${w.name}` }, 404);
7080
+ const known = new Set(initiativeIndex(w.root).initiatives.map((x) => x.id));
7081
+ const missing = ids.find((x) => !known.has(x));
7082
+ if (missing) return json(res, { ok: false, error: `no initiative '${missing}' in ${w.name}` }, 404);
7083
+ const reason = String(body.reason || '').slice(0, 500);
7084
+ let grant = null;
7085
+ if (bulk) {
7086
+ const m = grantMint.mintFromPress(w.root, { scope: 'lifecycle-bulk', ids, state: body.state,
7087
+ instruction: `set ${ids.length} initiatives to ${body.state}`, initiative: null }, { actor: g.actor });
7088
+ if (!m.ok) return json(res, { ok: false, error: m.error }, m.status || 500); // the writer refused or is missing: nothing is written
7089
+ grant = m.grant_id;
6840
7090
  }
6841
- const rec = { initiative_id: id, state: body.state, reason: String(body.reason || '').slice(0, 500), pm: 'wawan', at: nowISO() };
7091
+ const at = nowISO();
7092
+ const recs = ids.map((x) => ({ initiative_id: x, state: body.state, reason, pm: 'wawan', by: g.actor.by, via: g.actor.via, at, ...(grant ? { grant } : {}) }));
7093
+ const rec = recs[0];
6842
7094
  // The ledger the ENGINE reads for this repository's layout (scripts/workflow-state.js#layout): a repository
6843
7095
  // with planning/initiatives/ keeps its ledgers in planning/runs/, a kit in pmos/state/. Writing anywhere else
6844
7096
  // (R6 wrote the workspace's runtime state for PMOS-self) shows on the board but never clears the gate.
@@ -6849,13 +7101,18 @@ async function handleRequest(req, res) {
6849
7101
  if (!d) return json(res, { ok: false, error: 'this repository has no state directory to record in — nothing was written' }, 500);
6850
7102
  try { fs.mkdirSync(d, { recursive: true }); } catch { /* the guard reports it */ }
6851
7103
  const sink = path.join(d, 'lifecycle.jsonl');
6852
- try { createSinkGuard({ sink, what: 'lifecycle' }).appendLine(sink, rec); }
7104
+ let wrote = 0;
7105
+ try { const sg = createSinkGuard({ sink, what: 'lifecycle' }); for (const r of recs) { sg.appendLine(sink, r); wrote++; } }
6853
7106
  catch (e) {
6854
7107
  warnWriteFail('lifecycle.jsonl', e);
6855
- return json(res, { ok: false, error: 'could not record the change — nothing was saved', sink }, 500);
7108
+ // A partial bulk write names the grant that was used and the ids still to set, so the PM can press again for them.
7109
+ const rest = ids.slice(wrote);
7110
+ return json(res, { ok: false, error: wrote
7111
+ ? `could not record the change — ${wrote} of ${recs.length} lines were saved${grant ? ` under grant ${grant}` : ''}; not saved: ${rest.join(', ')}. Set those again.`
7112
+ : 'could not record the change — nothing was saved', sink, ...(wrote ? { grant, unsaved: rest } : {}) }, 500);
6856
7113
  }
6857
7114
  boardCache.invalidate(w.root);
6858
- return json(res, { ok: true, recorded: rec, sink });
7115
+ return json(res, bulk ? { ok: true, recorded: recs, grant, sink } : { ok: true, recorded: rec, sink });
6859
7116
  }
6860
7117
  if (p === '/api/doc/save' && req.method === 'POST') {
6861
7118
  // The editor's save (workspace-doc-editor R3/R4). The ONLY write path on this surface, and it is
@@ -6865,6 +7122,9 @@ async function handleRequest(req, res) {
6865
7122
  let body = {};
6866
7123
  try { body = JSON.parse((await readBody(req)) || '{}'); }
6867
7124
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7125
+ // R25: a save can rewrite a record's parent_kr, so every save passes the anchor guard. `pm` is a writer field now.
7126
+ const g = pmGuard.requirePmPress(req, body, { act: 'anchor' });
7127
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
6868
7128
 
6869
7129
  const w = getWs(body.ws);
6870
7130
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
@@ -6902,7 +7162,7 @@ async function handleRequest(req, res) {
6902
7162
  // needs.json and acceptances.jsonl use — a tenant product repo has no workspace/ folder, and
6903
7163
  // assuming one is exactly the defect shipped in 0.7.1/0.7.2.
6904
7164
  const region = applyChange.changedRegion(body.base, body.text);
6905
- const rec = { file: String(body.path), at: nowISO(), pm: body.pm || 'wawan',
7165
+ const rec = { file: String(body.path), at: nowISO(), pm: g.actor.by.slice('pm:'.length), via: g.actor.via,
6906
7166
  note: typeof body.note === 'string' && body.note.trim() ? body.note.trim().slice(0, 400) : null,
6907
7167
  at_line: region.at_line, added: region.added, removed: region.removed,
6908
7168
  before: region.before, after: region.after };
@@ -7067,6 +7327,8 @@ async function handleRequest(req, res) {
7067
7327
  let body = {};
7068
7328
  try { body = JSON.parse((await readBody(req)) || '{}'); }
7069
7329
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7330
+ const g = pmGuard.requirePmPress(req, body, { act: 'anchor' }); // R25: an aligning edit can rewrite parent_kr too
7331
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
7070
7332
  const w = getWs(body.ws);
7071
7333
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
7072
7334
  const rel = String(body.file || '');
@@ -7087,7 +7349,7 @@ async function handleRequest(req, res) {
7087
7349
  const region = applyChange.changedRegion(body.before, body.after);
7088
7350
  const dir = stateDirFor(w, { create: true });
7089
7351
  if (dir) {
7090
- const rec = { file: rel, at: nowISO(), pm: body.pm || 'wawan',
7352
+ const rec = { file: rel, at: nowISO(), pm: g.actor.by.slice('pm:'.length), via: g.actor.via,
7091
7353
  note: 'aligned with ' + (body.source || 'an earlier change') + (body.why ? ' — ' + String(body.why).slice(0, 200) : ''),
7092
7354
  at_line: region.at_line, added: region.added, removed: region.removed,
7093
7355
  before: region.before, after: region.after, aligned_from: body.source || null };
@@ -7114,6 +7376,8 @@ async function handleRequest(req, res) {
7114
7376
  if (only) rows = rows.filter((r) => r.file === only);
7115
7377
  return json(res, { ok: true, changes: rows.slice(-200).reverse(), total: rows.length });
7116
7378
  }
7379
+ // pm-os-absorb Runs 2–5 — the planning layer's routes, one file per run (lib/route-modules.js). Last, so none shadows a route above.
7380
+ if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList })) return;
7117
7381
  res.writeHead(404); res.end('not found');
7118
7382
  }
7119
7383
 
@@ -7239,7 +7503,16 @@ function handleUpgrade(req, socket, head) {
7239
7503
  socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
7240
7504
  return;
7241
7505
  }
7242
- wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
7506
+ // R25 (bind): a session started with an initiative is bound to it, a PM-only act. Only a socket the page opened with
7507
+ // this launch's nonce (`?pmos-nonce=`) may bind; any other socket still spawns, unbound, and is told why.
7508
+ const bind = pmGuard.requirePmUpgrade(req, { act: 'bind' });
7509
+ wss.handleUpgrade(req, socket, head, (ws) => { ws._bind = bind; wss.emit('connection', ws, req); });
7510
+ }
7511
+ /** The initiative a WS spawn may bind, or null with a notice sent when this socket may not bind (R25). */
7512
+ function wsBindable(ws, initiative) {
7513
+ if (initiative == null || initiative === '' || (ws._bind && ws._bind.ok)) return initiative;
7514
+ ws.send(JSON.stringify({ t: 'notice', notice: `the session starts without an initiative: ${(ws._bind && ws._bind.error) || 'only the PM can bind a session to an initiative, from the page'}` }));
7515
+ return null;
7243
7516
  }
7244
7517
  server.on('upgrade', handleUpgrade);
7245
7518
  wss.on('connection', (ws) => {
@@ -7278,7 +7551,7 @@ wss.on('connection', (ws) => {
7278
7551
  // `initiative` is what the client has always held and never sent: `+ session` is clicked on
7279
7552
  // an initiative row, the tab carries its `slug`, and the spawn message named three other
7280
7553
  // fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
7281
- 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,
7554
+ 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: wsBindable(ws, m.initiative),
7282
7555
  // D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
7283
7556
  modelAsk: (m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null });
7284
7557
  // A refused profile is its own sentence, not "branch: …" — the composer's error line names
@@ -7292,7 +7565,7 @@ wss.on('connection', (ws) => {
7292
7565
  // repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
7293
7566
  // never a branch and never an isolate flag: those are the policy's to decide, and a client
7294
7567
  // that could still send them would be a second place the rule lives.
7295
- const r = startWork({ initiative: m.initiative, repoKey: m.repoKey, ws: m.ws,
7568
+ const r = startWork({ initiative: wsBindable(ws, m.initiative), repoKey: m.repoKey, ws: m.ws,
7296
7569
  agent: m.agent, name: m.name, profile: m.profile });
7297
7570
  if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
7298
7571
  if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
@@ -7303,7 +7576,7 @@ wss.on('connection', (ws) => {
7303
7576
  // server already holds, not re-sent by the client — a client that forgot it would otherwise
7304
7577
  // silently restart the pane on a different environment, and the pane would look identical.
7305
7578
  const carried = 'profile' in old ? old.profile : null;
7306
- 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 });
7579
+ 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, planItem: old.plan_item || null });
7307
7580
  // A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
7308
7581
  // ahead with no profile — the pane line already says so — and the reason is said out loud
7309
7582
  // rather than left for the PM to notice.
@@ -7343,6 +7616,9 @@ if ((HOME_ENABLED || workspaces.size) && [...sessions.values()].filter((s) => s.
7343
7616
  }
7344
7617
 
7345
7618
  if (require.main === module) {
7619
+ // pm-os-absorb Run 2 (R25, X11): one nonce per launch, held in memory only. Never in process.env (so no PTY inherits
7620
+ // it), never logged (D08). GET / templates it into the page; lib/pm-guard.js refuses a press without it.
7621
+ pmGuard.setLaunchNonce(crypto.randomBytes(24).toString('base64url'));
7346
7622
  // Refuse-to-start is the D62 spine: if the requested remote bind isn't loopback or a tailnet
7347
7623
  // address, resolveBinds returned {refused} and we exit rather than expose a PTY server publicly.
7348
7624
  if (BIND.refused) { console.error('✗ ' + BIND.refused); process.exit(1); }