create-pmos 0.25.0 → 0.27.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.
@@ -111,7 +111,96 @@ const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists
111
111
  const PUB = path.join(__dirname, 'public');
112
112
  const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
113
113
  const XROOT = path.join(__dirname, 'node_modules', '@xterm');
114
- const STATE = path.join(__dirname, 'state');
114
+ // ── WHERE THE PM'S RUNTIME STATE LIVES (see lib/state-root.js for the whole argument). ─────────
115
+ // It is NOT `workspace/state/` any more. That directory is inside the application directory the
116
+ // test suite runs from, which put the PM's live sessions, workspace registry and authored skills
117
+ // within reach of every test, every spawned child and every stray shell command — and three rounds
118
+ // of guarding destructive verbs failed, each to a verb nobody had guarded yet. Unreachable beats
119
+ // guarded. STATE now resolves to a per-user location (PMOS_STATE_DIR wins; else the OS default),
120
+ // and an existing in-repo state/ is MOVED there once, idempotently, on first run.
121
+ const stateRoot = require('./lib/state-root');
122
+ // LEGACY_STATE is what `workspace/state/` used to be, kept for exactly two jobs: it is the source
123
+ // the migration moves FROM, and it is D64's file-mode mirror for an EMBEDDED PMOS-self checkout
124
+ // that has no `pmos/state/` yet (see stateDirFor). The system of record stays in the repository
125
+ // that earned it — only the churny runtime moved out.
126
+ const LEGACY_STATE = path.join(__dirname, 'state');
127
+ // ── REQUIRING THIS FILE IS NOT A QUIET ACT. ───────────────────────────────────────────────────
128
+ // `require('./server.js')` — which two suites do, for the exported helpers — runs everything at
129
+ // module scope: it resolves the state root, registers the home workspace, restores persisted
130
+ // sessions and spawns a first shell. Unconfigured, all of that now lands in the PM's REAL per-user
131
+ // state, and it did exactly that once while this change was being written. So a library consumer
132
+ // must name its own state directory, and finds out by being told rather than by contaminating the
133
+ // PM's registry and discovering it a day later.
134
+ const REQUIRED_AS_LIBRARY = require.main !== module;
135
+ if (REQUIRED_AS_LIBRARY && !stateRoot.overrideDir(process.env)) {
136
+ throw new Error(
137
+ 'server.js was required as a library with no PMOS_STATE_DIR set. Requiring it runs its module ' +
138
+ 'scope — resolving the runtime state root, registering the home workspace and spawning a shell ' +
139
+ '— which would write into the PM\'s real runtime state. Point PMOS_STATE_DIR at a scratch ' +
140
+ 'directory before the require (test-run.js#configure does this).');
141
+ }
142
+ // The migration is likewise offered ONLY to a real server start. Relocating the PM's live sessions
143
+ // as a side effect of an import is not a thing an import may do, whatever its state directory.
144
+ const STATE_RESOLVED = stateRoot.resolveStateRoot({
145
+ env: process.env,
146
+ legacy: REQUIRED_AS_LIBRARY ? null : LEGACY_STATE,
147
+ });
148
+ const STATE = STATE_RESOLVED.root;
149
+ // ── AN UNSETTLED STATE ROOT MUST NOT DEGRADE INTO "YOU HAVE NOTHING". ─────────────────────────
150
+ // When both directories hold state nothing is moved (only a human can decide which sixteen
151
+ // sessions are the real sixteen) — but the stores the per-user root does NOT hold used to resolve
152
+ // to it anyway, so the PM's eleven skills and forty-five schedules rendered as two empty lists and
153
+ // forty-five scheduled runs silently stopped firing. Per store: fall back to the legacy root ONLY
154
+ // where the per-user root holds no copy at all, so there is exactly one copy and reading/writing it
155
+ // cannot fork anything. See lib/state-root.js#resolveStore for the whole argument, including why a
156
+ // test run can never reach this path.
157
+ //
158
+ // This applies to EVERY outcome that is not a completed move, not only to a conflict. A move the
159
+ // filesystem REFUSES leaves the PM's stores in the legacy directory just as a conflict does, and
160
+ // for one round it resolved them to the per-user root anyway and said nothing at all — the same
161
+ // empty list with the stderr line removed as well. state-root.js#isSettled is the single predicate.
162
+ //
163
+ // RESOLVED PER CALL, NOT ONCE AT BOOT. The previous round pinned these to constants, which meant
164
+ // the process kept whatever it decided at startup: a PM who resolved the conflict — or fixed the
165
+ // permission — went on being served the old directory until they restarted, and a PM who moved a
166
+ // store INTO the per-user root had two live copies with the server writing the stale one. When the
167
+ // root is settled these functions short-circuit before touching the filesystem (the case every test
168
+ // run and every normal boot is in), so the cost is a path.join; only an unsettled root pays for two
169
+ // existsSync calls, which is what the next boot would do anyway.
170
+ function schedStore() {
171
+ return stateRoot.resolveStore({
172
+ root: STATE, legacy: LEGACY_STATE, name: 'schedules.json', migration: STATE_RESOLVED.migration });
173
+ }
174
+ function skillsStore() {
175
+ return stateRoot.resolveStore({
176
+ root: STATE, legacy: LEGACY_STATE, name: 'skills', migration: STATE_RESOLVED.migration });
177
+ }
178
+ // The ONE fact every surface repeats. `{ok:true}` normally; otherwise a discriminated failure naming
179
+ // BOTH directories — and, for a refused move, the errno and the plain-language cause, because that
180
+ // is the one kind the PM cannot resolve from the directory names alone. Carried on /api/workspaces,
181
+ // /api/skills, /api/schedules and every /api/console payload, and written into the first line of
182
+ // every new terminal pane. stderr alone was not a surface: `pmos` detaches it into a log nobody reads.
183
+ function stateHealthNow() {
184
+ const sk = skillsStore(), sc = schedStore();
185
+ return stateRoot.stateHealth({
186
+ migration: STATE_RESOLVED.migration,
187
+ fellBack: [sk.fellBack ? 'skills' : null, sc.fellBack ? 'schedules.json' : null].filter(Boolean),
188
+ // If the state root itself could not be created there is nowhere to read from, every list
189
+ // renders empty and the migration record says nothing about it — resolveStateRoot used to
190
+ // swallow that mkdir on the promise that "the caller's first write will report it". The caller
191
+ // is a store, and what a store does is return []. So the error is carried here instead.
192
+ rootError: STATE_RESOLVED.rootError || null,
193
+ });
194
+ }
195
+ if (STATE_RESOLVED.migration.action === 'moved') {
196
+ console.log(`· moved the workspace's runtime state out of the app directory → ${STATE}`);
197
+ }
198
+ // NOT an `else if`. Chaining it off the 'moved' branch is how 'failed' came to print nothing at all:
199
+ // every non-ok health is reported here on its own terms, whatever the action was.
200
+ {
201
+ const boot = stateHealthNow();
202
+ if (!boot.ok) console.error('✗ ' + boot.error);
203
+ }
115
204
  const SESS_DIR = path.join(STATE, 'sessions');
116
205
  const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
117
206
  const LAYOUT_DIR = path.join(STATE, 'layouts'); // per-workspace arrangements (R7; local-only, gitignored)
@@ -402,6 +491,20 @@ function treePeersDescribed(tree, excludeId) {
402
491
  function treeWriter(tree) {
403
492
  return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
404
493
  }
494
+ /**
495
+ * THE UNMIGRATED STATE, SAID WHERE THE PM IS LOOKING.
496
+ *
497
+ * A conflict already printed to stderr — and `pmos` detaches stderr into a log nobody reads, so in
498
+ * practice it printed to nobody while the PM's skills and schedules quietly rendered as two empty
499
+ * lists. This puts the same sentence in the pane's own scrollback, which is a surface the PM is by
500
+ * definition looking at and which persists in the session record. Every new session, until the two
501
+ * directories are resolved, because a notice that shows once is a notice that is missed once.
502
+ */
503
+ function announceStateHealth(s) {
504
+ const h = stateHealthNow();
505
+ if (h.ok) return;
506
+ appendBuf(s, `\x1b[38;5;203m✗ ${h.error}\x1b[0m\r\n`);
507
+ }
405
508
  /**
406
509
  * A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
407
510
  * found into its own scrollback. The pane line is deliberately part of the session's record rather
@@ -1355,19 +1458,40 @@ function buildSurface(name, root, w) {
1355
1458
  // (D62). v1 tasks are DETERMINISTIC (generated from a surface + the contract = maximally consistent,
1356
1459
  // no agent needed); agent-backed tasks (reasoning work) that spawn a PTY session are the documented
1357
1460
  // extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
1358
- const SCHED_FILE = path.join(STATE, 'schedules.json');
1461
+ // Resolved per call (schedStore/skillsStore, above): normally `STATE/…`, and under any unsettled
1462
+ // state root the legacy copy where the per-user root holds none — read AND written, since it is
1463
+ // then the only copy on the machine. stateHealthNow() says so on every surface that lists them.
1464
+ // A function rather than a constant so that resolving the conflict, or fixing the permission that
1465
+ // refused the move, takes effect on the next read instead of on the next restart.
1466
+ function schedFile() { return schedStore().path; }
1359
1467
  const DELIV_DIR = path.join(STATE, 'deliverables');
1360
1468
  // Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
1361
1469
  // user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
1362
1470
  // concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
1363
1471
  // derives it instantly, or a claude PTY session reasons through it) is decided by the file, not the PM.
1364
1472
  const BUILTIN_SKILLS_DIR = path.join(__dirname, 'skills');
1365
- const USER_SKILLS_DIR = path.join(STATE, 'skills');
1473
+ function userSkillsDir() { return skillsStore().path; }
1474
+ // The two paths a scheduled agent run must file into, spelled ABSOLUTELY. They used to be written
1475
+ // into the scaffold as `workspace/state/…`, which stopped being true the moment runtime state moved
1476
+ // out of the application directory (lib/state-root.js) — and a scaffold naming a directory the
1477
+ // console never reads is a report that silently never arrives. Derived from the same constants the
1478
+ // reader uses, so the two can only agree.
1479
+ //
1480
+ // `legacy` names where the runtime state USED to live — this application directory's own `state/`.
1481
+ // The retarget needs it because the PM's nine idpf-* skills spell the old location ABSOLUTELY
1482
+ // (`/Users/…/pmos/workspace/state/deliverables/<run-id>.md`), so there is no leading quote or space
1483
+ // to anchor a match on; the only question that can be answered reliably is "is this path's parent
1484
+ // the old state root", and only the caller knows where that was. Derived from __dirname, never
1485
+ // configured, so it cannot drift from the directory the migration moved out of.
1486
+ function filingContract(w) {
1487
+ return { deliverables: DELIV_DIR, needs: needsFileFor(w) || path.join(STATE, 'needs.json'),
1488
+ legacy: LEGACY_STATE };
1489
+ }
1366
1490
  // Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
1367
1491
  // load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
1368
1492
  function loadTasks() {
1369
1493
  return skillStore.loadSkills([
1370
- { dir: USER_SKILLS_DIR, source: 'user' },
1494
+ { dir: userSkillsDir(), source: 'user' },
1371
1495
  { dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
1372
1496
  ]).map(toTask);
1373
1497
  }
@@ -1382,10 +1506,10 @@ function toTask(s) {
1382
1506
  }
1383
1507
  function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
1384
1508
  function readSchedules() {
1385
- try { const a = JSON.parse(fs.readFileSync(SCHED_FILE, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
1509
+ try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
1386
1510
  }
1387
1511
  function writeSchedules(list) {
1388
- try { fs.writeFileSync(SCHED_FILE, JSON.stringify(list)); }
1512
+ try { fs.writeFileSync(schedFile(), JSON.stringify(list)); }
1389
1513
  catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
1390
1514
  }
1391
1515
 
@@ -1407,11 +1531,20 @@ function fireSchedule(sched) {
1407
1531
  // AGENT task — fire as a real PTY session running its skill (async; the skill files its own
1408
1532
  // deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
1409
1533
  if (task && task.kind === 'agent') {
1534
+ // THE FILING CONTRACT IS RETARGETED HERE, on the prompt, at the moment it is handed to the
1535
+ // session (lib/skill-store.js#retargetFiling). The PM's eleven existing skills were authored
1536
+ // when runtime state lived at `workspace/state/`, so their bodies still say "write the report
1537
+ // to workspace/state/deliverables/". After the relocation that is a directory the console
1538
+ // never reads: the agent would succeed, report success, and the report would never arrive.
1539
+ // Fixing only the SCAFFOLD fixes only skills that do not exist yet, which is no help to the
1540
+ // eleven that do. `needs.json` is resolved per workspace, so this cannot be a one-off rewrite
1541
+ // of the files either — the right answer for a given fire depends on which repo it fires in.
1542
+ const prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
1410
1543
  // The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
1411
1544
  // AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
1412
1545
  // below rather than the control simply being hidden.
1413
1546
  const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
1414
- origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null,
1547
+ origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
1415
1548
  profile: sched.profile == null ? null : sched.profile });
1416
1549
  const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
1417
1550
  const started = s.state !== 'exited';
@@ -1476,6 +1609,14 @@ function persist(s) {
1476
1609
  // fact: a record persisted before profiles existed restores as "not recorded", and inventing a
1477
1610
  // key for the past would be a lie about it.
1478
1611
  if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
1612
+ // WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
1613
+ // the same reason: every session running before this field existed has no attribution, and
1614
+ // writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
1615
+ // move together — an attribution with no provenance could not be told apart from a reading.
1616
+ if ('initiative' in s) {
1617
+ meta.initiative = s.initiative == null ? null : s.initiative;
1618
+ meta.attributedBy = s.attributedBy || null;
1619
+ }
1479
1620
  try {
1480
1621
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
1481
1622
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
@@ -1773,6 +1914,16 @@ function sessionList() {
1773
1914
  ? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
1774
1915
  : null,
1775
1916
  tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
1917
+ // ── what this session is for (workspace-session-attribution R1) ──
1918
+ // THREE STATES, kept apart on the wire because the surface has to tell them apart:
1919
+ // initiative: 'x', attributed_by: 'spawn' PMOS was told at spawn — first-hand
1920
+ // initiative: 'x', attributed_by: 'declared' the PM bound a running session — their word
1921
+ // initiative: null, attributed_by: null never recorded — NOT "belongs to nothing"
1922
+ // The branch reading is deliberately NOT folded in here. It is the client's fallback
1923
+ // (lib/working-set.js), computed from `/api/workspaces`, and merging it into this field would
1924
+ // make an inference indistinguishable from a record — the exact confusion this field removes.
1925
+ initiative: 'initiative' in s ? (s.initiative == null ? null : s.initiative) : null,
1926
+ attributed_by: 'initiative' in s ? (s.attributedBy || null) : null,
1776
1927
  profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
1777
1928
  profile_line: profilesLib.profileLine(
1778
1929
  !('profile' in s) ? undefined
@@ -2133,6 +2284,24 @@ function nextSessionId() {
2133
2284
  return id;
2134
2285
  }
2135
2286
 
2287
+ /**
2288
+ * An initiative id, or null — the ONE gate every attribution path goes through.
2289
+ *
2290
+ * `fourPanel.validId` is the repo's existing answer to "is this a record name?" and it is reused
2291
+ * rather than re-typed: it already guards the `/api/initiative` path interpolation and the two
2292
+ * declaration stores, so a second regex here would be a second opinion about what an id is, free to
2293
+ * drift from the one that decides whether a file can be read.
2294
+ *
2295
+ * Returning null rather than throwing is deliberate. An unusable value must leave the session
2296
+ * UNATTRIBUTED — which is a real, honest state with a surface of its own — never half-attributed to
2297
+ * something that failed to parse, and never a spawn refused over a label.
2298
+ */
2299
+ function initiativeIdOrNull(v) {
2300
+ if (v == null) return null;
2301
+ const s = String(v).trim();
2302
+ return s && fourPanel.validId(s) ? s : null;
2303
+ }
2304
+
2136
2305
  // Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
2137
2306
  // feature turns on: a session must never claim a profile whose overlay was not applied. So the
2138
2307
  // three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
@@ -2150,7 +2319,7 @@ function resolveProfileForSpawn(id) {
2150
2319
  : `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
2151
2320
  }
2152
2321
 
2153
- function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2322
+ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
2154
2323
  const id = nextSessionId();
2155
2324
  const w = getWs(ws);
2156
2325
  const root = w ? w.root : SELF_ROOT;
@@ -2172,6 +2341,24 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2172
2341
  const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
2173
2342
  profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
2174
2343
 
2344
+ /* WHAT THIS SESSION IS FOR (workspace-session-attribution R1).
2345
+ *
2346
+ * A PMOS session has never recorded which initiative it is working on — not here, not on the wire,
2347
+ * not on disk — so every initiative-keyed surface had to INFER it from the `initiative/<id>` branch
2348
+ * convention, which holds in one of four ordinary situations. The board that shipped on that
2349
+ * inference could not see the session that wrote it, because that session was on a `claude/*`
2350
+ * branch.
2351
+ *
2352
+ * The key is set ONLY when the caller supplied one, exactly as `profile` is, and for the same
2353
+ * reason: its ABSENCE has to stay a fact. A session started before this field existed, or started
2354
+ * outside PMOS, restores as not-recorded — and "not recorded" must never be confused with
2355
+ * "recorded as none", which is what a `null` default would have produced. `attributedBy` says
2356
+ * where the value came from, because a value the PM declared and a value PMOS was told at spawn
2357
+ * are different claims and the surface has to be able to tell them apart.
2358
+ */
2359
+ const init = initiativeIdOrNull(initiative);
2360
+ if (init) { base.initiative = init; base.attributedBy = 'spawn'; }
2361
+
2175
2362
  // A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
2176
2363
  // otherwise silent) — the same honest exited pane on both the tmux and the direct path.
2177
2364
  if (!isShell && !commandExists(agent)) {
@@ -2193,6 +2380,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2193
2380
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
2194
2381
  persist(s); pushSessions(); pushWorkspaces(); return s;
2195
2382
  }
2383
+ announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
2196
2384
  announceTree(s); // before attach, so the advisory is the first line of the pane's record
2197
2385
  attachPty(s);
2198
2386
  feed(s);
@@ -2212,6 +2400,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2212
2400
  pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
2213
2401
  lastOutputAt: Date.now() };
2214
2402
  sessions.set(id, s);
2403
+ announceStateHealth(s);
2215
2404
  announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
2216
2405
  p.onData((d) => {
2217
2406
  appendBuf(s, d);
@@ -2247,7 +2436,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2247
2436
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
2248
2437
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
2249
2438
  // git-then-maybe-spawn contract is unit-testable without a WS client.
2250
- function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
2439
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
2251
2440
  const w = getWs(ws);
2252
2441
  if (!w) return { ok: false, error: 'no workspace' };
2253
2442
  // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
@@ -2289,7 +2478,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2289
2478
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
2290
2479
  }
2291
2480
  }
2292
- const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
2481
+ const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative });
2293
2482
  // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
2294
2483
  // block above. The isolate default already computes that other sessions would be disturbed — and
2295
2484
  // then the whole git block is skipped when no branch was picked, which is the common path and the
@@ -2377,6 +2566,10 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
2377
2566
  createBranch: plan.createBranch,
2378
2567
  isolate: plan.isolate,
2379
2568
  profile,
2569
+ // THE FIX THIS FUNCTION HAS BEEN ONE ARGUMENT AWAY FROM SINCE IT WAS WRITTEN. `initiative`
2570
+ // arrived here, decided the branch and supplied a fallback display name, and was then dropped
2571
+ // — so the one start path DESIGNED to know what it was starting work on never told the session.
2572
+ initiative,
2380
2573
  });
2381
2574
  if (!r.ok) return r;
2382
2575
  return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
@@ -2549,7 +2742,11 @@ function stateDirFor(w, { create = false } = {}) {
2549
2742
  const d = path.join(w.root, ...rel);
2550
2743
  if (isDir(d)) return d;
2551
2744
  }
2552
- if (EMBEDDED_SELF && w.root === SELF_ROOT) return STATE; // pmos-self: the server's own state/ IS its file-mode mirror (never a tenant home — that writes to its OWN repo below)
2745
+ // pmos-self with no pmos/state/ yet: the checkout's OWN state/ is the file-mode mirror. This is
2746
+ // deliberately LEGACY_STATE and not STATE — D64's system of record belongs to the repository that
2747
+ // earned it, so it must not follow the runtime state out to the per-user directory. (Never a
2748
+ // tenant home — that writes to its OWN repo below.)
2749
+ if (EMBEDDED_SELF && w.root === SELF_ROOT) return LEGACY_STATE;
2553
2750
  // A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
2554
2751
  // invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
2555
2752
  if (create && wsShape(w.root) === 'pmos') {
@@ -3350,7 +3547,9 @@ async function handleRequest(req, res) {
3350
3547
  return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
3351
3548
  note: 'a declaration, not a guarantee — nothing about either process changed' });
3352
3549
  }
3353
- if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
3550
+ // `state_health` rides on the payload EVERY client reads at boot, so the unmigrated notice reaches
3551
+ // the PM whichever lens they open first — not only the two lists it is about.
3552
+ if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList(), state_health: stateHealthNow() });
3354
3553
  if (p === '/api/workspaces' && req.method === 'POST') {
3355
3554
  // How `pmos` in another repo becomes another workspace (R1). Local-only, like every
3356
3555
  // route here — the server binds 127.0.0.1 and this only records a path.
@@ -3626,16 +3825,21 @@ async function handleRequest(req, res) {
3626
3825
  if (p === '/api/skills' && req.method === 'GET') {
3627
3826
  const skills = loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill,
3628
3827
  description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
3629
- return json(res, { skills });
3828
+ // NEVER a bare list. Under an unmigrated conflict this list is either short by every skill the
3829
+ // PM authored or is being read from the old directory, and in both cases the payload has to say
3830
+ // so — a calm list is a claim that the state is settled (see stateHealthNow).
3831
+ return json(res, { skills, state_health: stateHealthNow() });
3630
3832
  }
3631
3833
  // POST scaffolds a well-formed .skill file from name + "what it should do" + the output shape.
3632
3834
  // A GUI replaces editing server code; user skills default to running as a claude PTY session.
3633
3835
  if (p === '/api/skills' && req.method === 'POST') {
3634
3836
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
3635
- const built = skillStore.scaffoldSkill(body);
3837
+ // `filing` is built HERE, never taken from the request body: it names filesystem paths, and a
3838
+ // caller-supplied one would be a write instruction dressed up as a preference.
3839
+ const built = skillStore.scaffoldSkill({ ...body, filing: filingContract(getWs(body.ws)) });
3636
3840
  if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3637
3841
  if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
3638
- try { fs.mkdirSync(USER_SKILLS_DIR, { recursive: true }); fs.writeFileSync(path.join(USER_SKILLS_DIR, built.slug + '.skill'), built.content); }
3842
+ try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
3639
3843
  catch (e) { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
3640
3844
  return json(res, { ok: true, skill: { id: built.slug, label: built.label, skill: built.slug,
3641
3845
  description: built.description, output: built.output, source: 'user' } });
@@ -3645,13 +3849,15 @@ async function handleRequest(req, res) {
3645
3849
  // Always leaves a valid file even where claude is absent; the session is honest about not starting.
3646
3850
  if (p === '/api/skills/draft' && req.method === 'POST') {
3647
3851
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
3648
- const built = skillStore.scaffoldSkill(body);
3852
+ const w0 = getWs(body.ws);
3853
+ const filing = filingContract(w0);
3854
+ const built = skillStore.scaffoldSkill({ ...body, filing });
3649
3855
  if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3650
3856
  if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
3651
- try { fs.mkdirSync(USER_SKILLS_DIR, { recursive: true }); fs.writeFileSync(path.join(USER_SKILLS_DIR, built.slug + '.skill'), built.content); }
3857
+ try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
3652
3858
  catch { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
3653
- const w = getWs(body.ws);
3654
- const rel = path.relative(w ? w.root : SELF_ROOT, path.join(USER_SKILLS_DIR, built.slug + '.skill'));
3859
+ const w = w0;
3860
+ const rel = path.relative(w ? w.root : SELF_ROOT, path.join(userSkillsDir(), built.slug + '.skill'));
3655
3861
  const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
3656
3862
  `"${built.description || built.label}". First read skills/core/SKILL-FORMAT.md (the skill spec) and ` +
3657
3863
  `follow build-skills/skill-creator.skill (the authoring procedure). Keep the runtime frontmatter shape ` +
@@ -3659,7 +3865,7 @@ async function handleRequest(req, res) {
3659
3865
  `ALWAYS produce). Sharpen the BODY into a procedure-first, repeatable procedure grounded in THIS repository's ` +
3660
3866
  `own files and tools only (no network): a Goal line, then numbered steps each ending on a checkable "Done when:" ` +
3661
3867
  `condition. Keep the FILING CONTRACT the scaffold wrote — the run must write its deliverable to ` +
3662
- `workspace/state/deliverables/<run-id>.md and append the structured object to workspace/state/needs.json ` +
3868
+ `${filing.deliverables}/<run-id>.md and append the structured object to ${filing.needs} ` +
3663
3869
  `(a valid JSON array), never a prose note. Then run the SKILL-FORMAT conformance checklist (items 6-9: ` +
3664
3870
  `predictability, per-step completion criteria, pruning, leading words) over the body — put no self-check block ` +
3665
3871
  `in the file itself. Then stop.`;
@@ -3729,7 +3935,9 @@ async function handleRequest(req, res) {
3729
3935
  profile: s.profile == null ? null : s.profile,
3730
3936
  profile_applies: !!(t && t.kind === 'agent') };
3731
3937
  });
3732
- return json(res, { schedules: list, now: nowISO() });
3938
+ // Same rule as /api/skills: an empty (or fallen-back) schedule list under a conflict must never
3939
+ // read as "you have no scheduled tasks". A schedule that never loads never fires.
3940
+ return json(res, { schedules: list, now: nowISO(), state_health: stateHealthNow() });
3733
3941
  }
3734
3942
  if (p === '/api/schedules' && req.method === 'POST') {
3735
3943
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
@@ -3930,7 +4138,7 @@ async function handleRequest(req, res) {
3930
4138
  // Stripped by spreading rather than deleting — some surfaces return frozen objects.
3931
4139
  const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
3932
4140
  if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
3933
- return json(res, { name, ...out }, code);
4141
+ return json(res, { name, ...out, state_health: stateHealthNow() }, code);
3934
4142
  }
3935
4143
  // ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
3936
4144
  // Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
@@ -3994,6 +4202,45 @@ async function handleRequest(req, res) {
3994
4202
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3995
4203
  return json(res, { ws: w ? w.id : null, ...out });
3996
4204
  }
4205
+ if (p === '/api/session/initiative' && req.method === 'POST') {
4206
+ /* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
4207
+ * (workspace-session-attribution R3/R8).
4208
+ *
4209
+ * Every session running when this shipped has no attribution, and so does every session started
4210
+ * outside PMOS. Without this they would stay unattributed forever, which would make the whole
4211
+ * field a promise about the future and no help with the work on screen today.
4212
+ *
4213
+ * Three properties this endpoint holds:
4214
+ * 1. It is a DECLARATION. `attributedBy: 'declared'` is written, never 'spawn', so the surface
4215
+ * can keep the PM's word apart from what PMOS was told first-hand. Same distinction
4216
+ * `initiative-links.jsonl` already draws with `origin`/`state`.
4217
+ * 2. It is SINGLE-VALUED, and a replacement says what it replaced. `previous` comes back on
4218
+ * the response rather than being overwritten with no trace (R8) — a re-bind the PM did not
4219
+ * mean to do is otherwise unrecoverable and unnoticeable.
4220
+ * 3. It touches NOTHING else. No checkout, no branch, no respawn. The session keeps running
4221
+ * exactly as it was; the only thing that changes is what PMOS says it is for.
4222
+ *
4223
+ * `initiative: null` CLEARS the binding, which is the honest way back from a wrong one — and it
4224
+ * still writes the key, because a session the PM has explicitly unbound is a different state
4225
+ * from one that was never recorded.
4226
+ */
4227
+ let body = {};
4228
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
4229
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4230
+ const s = sessions.get(String(body.id || ''));
4231
+ if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
4232
+ const clearing = body.initiative === null || body.initiative === '';
4233
+ const next = clearing ? null : initiativeIdOrNull(body.initiative);
4234
+ if (!clearing && !next) {
4235
+ return json(res, { ok: false, error: 'that initiative id is not a record name' }, 400);
4236
+ }
4237
+ const previous = 'initiative' in s ? (s.initiative || null) : null;
4238
+ s.initiative = next;
4239
+ s.attributedBy = next ? 'declared' : null;
4240
+ persist(s);
4241
+ pushSessions();
4242
+ return json(res, { ok: true, id: s.id, initiative: next, attributed_by: s.attributedBy, previous });
4243
+ }
3997
4244
  if (p === '/api/initiative/link' && req.method === 'POST') {
3998
4245
  // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
3999
4246
  // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
@@ -4095,12 +4342,16 @@ async function handleRequest(req, res) {
4095
4342
  // response so the client can be honest about where the decision landed.
4096
4343
  let sink = acceptancesFileFor(w, { create: true });
4097
4344
  const fallback = !sink;
4098
- if (fallback) sink = path.join(STATE, 'acceptances.jsonl');
4345
+ // LEGACY_STATE, not STATE: an acceptance is a D64 record, and the last-resort sink for a repo
4346
+ // whose state directory could not be resolved stays with the install rather than following the
4347
+ // runtime state out to the per-user directory, where nobody would think to look for a record.
4348
+ if (fallback) sink = path.join(LEGACY_STATE, 'acceptances.jsonl');
4099
4349
  // FAIL LOUD (D64 Track A2): under D64 the file-mode acceptances log is the SYSTEM OF RECORD.
4100
4350
  // A swallowed append + {ok:true} would light the gate green over a record that never landed —
4101
4351
  // exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
4102
4352
  // {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
4103
4353
  try {
4354
+ if (fallback) fs.mkdirSync(LEGACY_STATE, { recursive: true }); // the install's state/ may not exist yet
4104
4355
  fs.appendFileSync(sink, JSON.stringify(rec) + '\n');
4105
4356
  } catch (e) {
4106
4357
  warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);
@@ -4522,7 +4773,10 @@ wss.on('connection', (ws) => {
4522
4773
  } else if (m.t === 'resize') {
4523
4774
  const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
4524
4775
  } else if (m.t === 'new') {
4525
- 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 });
4776
+ // `initiative` is what the client has always held and never sent: `+ session` is clicked on
4777
+ // an initiative row, the tab carries its `slug`, and the spawn message named three other
4778
+ // fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
4779
+ 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 });
4526
4780
  // A refused profile is its own sentence, not "branch: …" — the composer's error line names
4527
4781
  // what PMOS could not read, and nothing was started.
4528
4782
  if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+ /* test-run.js — THE ISOLATION A SUITE GETS BY CALLING ONE FUNCTION.
3
+ *
4
+ * Every suite in this workspace boots real servers, opens real PTYs and writes real state. The
5
+ * PM's live runtime state used to live at `workspace/state/`, INSIDE the directory the suites run
6
+ * from, so it was reachable BY CONSTRUCTION — by any verb, by any subprocess, by any stray shell
7
+ * command in the tree. `test.js#resetState()` removed sessions/, scratchpad/, workspaces.json,
8
+ * schedules.json, deliverables/ and skills/ from it five times a run, and the suite was safe in CI
9
+ * only because a fresh checkout has nothing there.
10
+ *
11
+ * The state has moved out (lib/state-root.js). What remains is to make sure a suite never resolves
12
+ * to the NEW per-user location either — an installed copy's state IS the PM's own, so "the app's
13
+ * own directory" was never the isolation boundary anybody thought it was. `configure()` points
14
+ * PMOS_STATE_DIR at a fresh scratch directory before `server.js` is ever required and before any
15
+ * child is spawned, so a run writes only into a directory it made.
16
+ *
17
+ * It also hands out a PRIVATE TMUX SOCKET (never the PM's own, `pmos`) and an EPHEMERAL PORT
18
+ * claimed from the kernel rather than a bet on 7599/7601-7606. The port is about collisions, not
19
+ * about state, and it is worth being exact about what it buys: it makes a collision RARE. It does
20
+ * not DETECT one — a suite that does collide still drives whatever answers, and proving whose
21
+ * server is on a port is a different defect with its own repair. Nothing here claims otherwise.
22
+ *
23
+ * It lives at the workspace root rather than under lib/ on purpose: lib/ is the SHIPPED server
24
+ * process, and no-network.test.js holds every file there to a standard this scaffolding does not
25
+ * need to meet. It belongs beside the suites.
26
+ */
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const { execFileSync } = require('child_process');
31
+
32
+ const HOST = '127.0.0.1';
33
+
34
+ let state = null; // set by configure(); this module is a no-op until then
35
+
36
+ /** Bind :0, read the port back, release it. Synchronous because callers need PORT as a const. */
37
+ function claimEphemeralPort() {
38
+ const src = "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close()});";
39
+ const out = execFileSync(process.execPath, ['-e', src], { encoding: 'utf8' });
40
+ const n = Number(String(out).trim());
41
+ if (!Number.isInteger(n) || n <= 0) throw new Error('could not claim an ephemeral port');
42
+ return n;
43
+ }
44
+
45
+ /**
46
+ * Arm a suite. MUST be called before `require('./server')` and before any child is spawned —
47
+ * server.js resolves its state root at require time, and a child inherits the environment as it
48
+ * stood when it was spawned.
49
+ *
50
+ * Returns, and also exposes on the module: PORT, HOST, BASE, STATE_DIR.
51
+ */
52
+ function configure({ name = 'run', port = null } = {}) {
53
+ if (state) return state;
54
+ const stateDir = process.env.PMOS_STATE_DIR
55
+ ? path.resolve(process.env.PMOS_STATE_DIR)
56
+ : fs.mkdtempSync(path.join(os.tmpdir(), `pmos-state-${name}-`));
57
+ fs.mkdirSync(stateDir, { recursive: true });
58
+ // In the environment BEFORE server.js is required here and before any child inherits it. Every
59
+ // boot site in every suite goes through process.env, so this is a chokepoint by construction
60
+ // rather than a convention each call site has to remember — and a convention that must be
61
+ // remembered at hundreds of call sites is a convention that will be forgotten at one of them,
62
+ // which is exactly how this class of defect got in.
63
+ process.env.PMOS_STATE_DIR = stateDir;
64
+ // A private tmux socket, never the PM's own ('pmos'), so a suite cannot reach their sessions.
65
+ process.env.PMOS_TMUX_SOCK = process.env.PMOS_TMUX_SOCK || `pmos-${name}-${process.pid}`;
66
+
67
+ const chosen = Number(port) > 0 ? Number(port) : claimEphemeralPort();
68
+
69
+ state = {
70
+ PORT: chosen, HOST, BASE: `http://${HOST}:${chosen}`, STATE_DIR: stateDir,
71
+ /** Environment for a spawned server/launcher: this run's state dir and tmux socket. */
72
+ env(extra = {}) {
73
+ return { ...process.env, PMOS_STATE_DIR: stateDir, ...extra };
74
+ },
75
+ /** A port nobody else holds, for a suite that needs a second server. */
76
+ port: claimEphemeralPort,
77
+ /** Remove the scratch state directory. Safe by construction: this run made it. */
78
+ cleanup() { try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {} },
79
+ };
80
+ Object.assign(module.exports, state);
81
+ return state;
82
+ }
83
+
84
+ module.exports = { configure, claimEphemeralPort, HOST };