create-pmos 0.26.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';
@@ -2247,6 +2380,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2247
2380
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
2248
2381
  persist(s); pushSessions(); pushWorkspaces(); return s;
2249
2382
  }
2383
+ announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
2250
2384
  announceTree(s); // before attach, so the advisory is the first line of the pane's record
2251
2385
  attachPty(s);
2252
2386
  feed(s);
@@ -2266,6 +2400,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2266
2400
  pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
2267
2401
  lastOutputAt: Date.now() };
2268
2402
  sessions.set(id, s);
2403
+ announceStateHealth(s);
2269
2404
  announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
2270
2405
  p.onData((d) => {
2271
2406
  appendBuf(s, d);
@@ -2607,7 +2742,11 @@ function stateDirFor(w, { create = false } = {}) {
2607
2742
  const d = path.join(w.root, ...rel);
2608
2743
  if (isDir(d)) return d;
2609
2744
  }
2610
- 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;
2611
2750
  // A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
2612
2751
  // invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
2613
2752
  if (create && wsShape(w.root) === 'pmos') {
@@ -3408,7 +3547,9 @@ async function handleRequest(req, res) {
3408
3547
  return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
3409
3548
  note: 'a declaration, not a guarantee — nothing about either process changed' });
3410
3549
  }
3411
- 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() });
3412
3553
  if (p === '/api/workspaces' && req.method === 'POST') {
3413
3554
  // How `pmos` in another repo becomes another workspace (R1). Local-only, like every
3414
3555
  // route here — the server binds 127.0.0.1 and this only records a path.
@@ -3684,16 +3825,21 @@ async function handleRequest(req, res) {
3684
3825
  if (p === '/api/skills' && req.method === 'GET') {
3685
3826
  const skills = loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill,
3686
3827
  description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
3687
- 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() });
3688
3832
  }
3689
3833
  // POST scaffolds a well-formed .skill file from name + "what it should do" + the output shape.
3690
3834
  // A GUI replaces editing server code; user skills default to running as a claude PTY session.
3691
3835
  if (p === '/api/skills' && req.method === 'POST') {
3692
3836
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
3693
- 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)) });
3694
3840
  if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3695
3841
  if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
3696
- 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); }
3697
3843
  catch (e) { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
3698
3844
  return json(res, { ok: true, skill: { id: built.slug, label: built.label, skill: built.slug,
3699
3845
  description: built.description, output: built.output, source: 'user' } });
@@ -3703,13 +3849,15 @@ async function handleRequest(req, res) {
3703
3849
  // Always leaves a valid file even where claude is absent; the session is honest about not starting.
3704
3850
  if (p === '/api/skills/draft' && req.method === 'POST') {
3705
3851
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
3706
- const built = skillStore.scaffoldSkill(body);
3852
+ const w0 = getWs(body.ws);
3853
+ const filing = filingContract(w0);
3854
+ const built = skillStore.scaffoldSkill({ ...body, filing });
3707
3855
  if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3708
3856
  if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
3709
- 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); }
3710
3858
  catch { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
3711
- const w = getWs(body.ws);
3712
- 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'));
3713
3861
  const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
3714
3862
  `"${built.description || built.label}". First read skills/core/SKILL-FORMAT.md (the skill spec) and ` +
3715
3863
  `follow build-skills/skill-creator.skill (the authoring procedure). Keep the runtime frontmatter shape ` +
@@ -3717,7 +3865,7 @@ async function handleRequest(req, res) {
3717
3865
  `ALWAYS produce). Sharpen the BODY into a procedure-first, repeatable procedure grounded in THIS repository's ` +
3718
3866
  `own files and tools only (no network): a Goal line, then numbered steps each ending on a checkable "Done when:" ` +
3719
3867
  `condition. Keep the FILING CONTRACT the scaffold wrote — the run must write its deliverable to ` +
3720
- `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} ` +
3721
3869
  `(a valid JSON array), never a prose note. Then run the SKILL-FORMAT conformance checklist (items 6-9: ` +
3722
3870
  `predictability, per-step completion criteria, pruning, leading words) over the body — put no self-check block ` +
3723
3871
  `in the file itself. Then stop.`;
@@ -3787,7 +3935,9 @@ async function handleRequest(req, res) {
3787
3935
  profile: s.profile == null ? null : s.profile,
3788
3936
  profile_applies: !!(t && t.kind === 'agent') };
3789
3937
  });
3790
- 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() });
3791
3941
  }
3792
3942
  if (p === '/api/schedules' && req.method === 'POST') {
3793
3943
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
@@ -3988,7 +4138,7 @@ async function handleRequest(req, res) {
3988
4138
  // Stripped by spreading rather than deleting — some surfaces return frozen objects.
3989
4139
  const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
3990
4140
  if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
3991
- return json(res, { name, ...out }, code);
4141
+ return json(res, { name, ...out, state_health: stateHealthNow() }, code);
3992
4142
  }
3993
4143
  // ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
3994
4144
  // Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
@@ -4192,12 +4342,16 @@ async function handleRequest(req, res) {
4192
4342
  // response so the client can be honest about where the decision landed.
4193
4343
  let sink = acceptancesFileFor(w, { create: true });
4194
4344
  const fallback = !sink;
4195
- 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');
4196
4349
  // FAIL LOUD (D64 Track A2): under D64 the file-mode acceptances log is the SYSTEM OF RECORD.
4197
4350
  // A swallowed append + {ok:true} would light the gate green over a record that never landed —
4198
4351
  // exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
4199
4352
  // {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
4200
4353
  try {
4354
+ if (fallback) fs.mkdirSync(LEGACY_STATE, { recursive: true }); // the install's state/ may not exist yet
4201
4355
  fs.appendFileSync(sink, JSON.stringify(rec) + '\n');
4202
4356
  } catch (e) {
4203
4357
  warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);
@@ -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 };