create-pmos 0.26.0 → 0.28.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
@@ -679,6 +782,153 @@ function looseDocs(root) {
679
782
  }
680
783
  return out;
681
784
  }
785
+ /* ── SEARCH: the corpus `search everything…` is finally allowed to be ─────────────────────────
786
+ *
787
+ * The box shipped searching three DERIVED lists — initiative slugs, paths declared on an initiative
788
+ * record, and session names — so a file the board was never told about could not be found by typing
789
+ * its exact name. Worse, a miss rendered "nothing matches — a successful read of nothing", this
790
+ * repo's phrase for *we looked and there was nothing*, after a search that never opened the folder.
791
+ *
792
+ * Four rules hold this honest, and each one is asserted in the suite:
793
+ *
794
+ * 1. THE WALK IS THE FOLDER LENS'S WALK. Same `SKIP_DIRS`, same dotted-name exclusion, so search
795
+ * and the lens can never disagree about what exists in this folder. A third answer to "what is
796
+ * in here" would be worse than the missing feature.
797
+ * 2. NOTHING NEW IS REACHABLE. Every emitted path is `path.relative(root, …)` from a walk that
798
+ * STARTS at the root and only descends through `e.isDirectory()`. `readdirSync(…,
799
+ * {withFileTypes:true})` reports a symlink as `isSymbolicLink()` — neither a file nor a
800
+ * directory — so a link pointing out of the workspace is never followed and never emitted.
801
+ * There is no user-supplied path parameter here at all: the query is a needle, not a location.
802
+ * 3. IT IS BOUNDED, AND THE BOUND IS IN THE PAYLOAD. A capped list rendered as a complete one is
803
+ * the same lie the box already told, with a longer walk behind it — so every cap reports
804
+ * itself, and it must be FALSE when nothing was cut. The suite asserts both directions,
805
+ * because a flag that is always true is not a flag.
806
+ *
807
+ * TWO caps, TWO flags, deliberately not one. `truncated` means *files you searched for were
808
+ * never looked at* (the walk budget, the depth, or the result limit). `content_truncated`
809
+ * means *every file was named, but not every body was read* — which is the ordinary state of
810
+ * any repository carrying a lock file, and collapsing it into `truncated` would light the
811
+ * serious flag permanently and thereby retire it.
812
+ * 4. `kind` IS preview-kind's ANSWER, NOT A SECOND OPINION. The same classifier the read endpoints
813
+ * and the tree flag already use decides whether the reader can show a hit, so search can say
814
+ * "found, but not openable" instead of handing the PM a tab that prints a failure.
815
+ */
816
+ const SEARCH_MAX_FILES = 20000; // walk budget — a giant repo (or a Drive mount) never hangs the box
817
+ const SEARCH_MAX_DEPTH = 12; // deeper than the lens's 6: search renders no tree, so depth is cheap
818
+ const SEARCH_CONTENT_FILES = 4000; // how many readable files get their BODIES scanned
819
+ const SEARCH_CONTENT_BYTES = 256 * 1024; // per file; a bigger one is path-matched only, never read whole
820
+ const SEARCH_LIMIT_DEFAULT = 40;
821
+ const SEARCH_LIMIT_MAX = 200;
822
+ const SEARCH_INDEX_TTL_MS = 4000; // one word of typing costs one walk, not one per keystroke
823
+ const SEARCH_SNIPPET = 160;
824
+ const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html']); // what /api/doc will serve
825
+ const searchIndexCache = new Map(); // root -> { at, files, truncated, scanned }
826
+
827
+ /** Every file under `root`, flat and repo-relative, cached for SEARCH_INDEX_TTL_MS. */
828
+ function searchIndex(root) {
829
+ const now = Date.now();
830
+ const hit = searchIndexCache.get(root);
831
+ if (hit && now - hit.at < SEARCH_INDEX_TTL_MS) return hit;
832
+ const files = [];
833
+ let truncated = false;
834
+ (function walk(dir, rel, depth) {
835
+ if (truncated) return;
836
+ let ents = [];
837
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
838
+ for (const e of ents) {
839
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
840
+ const full = path.join(dir, e.name);
841
+ const r = rel ? rel + '/' + e.name : e.name;
842
+ if (e.isDirectory()) {
843
+ // Depth is a bound like any other: reaching it means files below were NOT looked at.
844
+ if (depth < SEARCH_MAX_DEPTH) walk(full, r, depth + 1);
845
+ else truncated = true;
846
+ } else if (e.isFile()) {
847
+ if (files.length >= SEARCH_MAX_FILES) { truncated = true; return; }
848
+ files.push({ path: r, name: e.name, dir: rel, kind: preview.previewKind(e.name) });
849
+ }
850
+ }
851
+ })(root, '', 0);
852
+ const entry = { at: now, files, truncated, scanned: files.length };
853
+ searchIndexCache.set(root, entry);
854
+ return entry;
855
+ }
856
+
857
+ /** One matched line, trimmed and centred on the needle, so a minified file cannot flood the row. */
858
+ function searchSnippet(line, at) {
859
+ const s = String(line || '').replace(/\t/g, ' ');
860
+ if (s.length <= SEARCH_SNIPPET) return s.trim();
861
+ const start = Math.max(0, at - Math.floor(SEARCH_SNIPPET / 3));
862
+ return (start > 0 ? '…' : '') + s.slice(start, start + SEARCH_SNIPPET).trim()
863
+ + (start + SEARCH_SNIPPET < s.length ? '…' : '');
864
+ }
865
+
866
+ /**
867
+ * Search one workspace. Path matches first (they are what the PM typed a name for), then content
868
+ * matches over the readable text files that did not already match by path.
869
+ * `{ ok, q, hits, truncated, content_truncated, scanned, content_scanned, limit }` — never throws,
870
+ * never 404s: an empty `hits` with `truncated:false` IS the honest "looked, found nothing".
871
+ */
872
+ function searchWorkspace(root, query, limit) {
873
+ const q = String(query == null ? '' : query).trim();
874
+ const cap = Math.max(1, Math.min(SEARCH_LIMIT_MAX, parseInt(limit, 10) || SEARCH_LIMIT_DEFAULT));
875
+ if (!q) {
876
+ return { ok: true, q: '', hits: [], truncated: false, content_truncated: false,
877
+ scanned: 0, content_scanned: 0, limit: cap };
878
+ }
879
+ const needle = q.toLowerCase();
880
+ const idx = searchIndex(root);
881
+ const hits = [];
882
+ const seen = new Set();
883
+ let overflow = false; // more matched than `cap` — a REAL cut, not a coincidence
884
+
885
+ for (const f of idx.files) {
886
+ if (!f.path.toLowerCase().includes(needle)) continue;
887
+ seen.add(f.path);
888
+ if (hits.length >= cap) { overflow = true; break; }
889
+ hits.push({ ...f, match: 'path', line: null, snippet: null, openable: SEARCH_OPENABLE.has(f.kind) });
890
+ }
891
+
892
+ let read = 0;
893
+ let contentCut = false;
894
+ if (!overflow) {
895
+ for (const f of idx.files) {
896
+ if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
897
+ if (read >= SEARCH_CONTENT_FILES) { contentCut = true; break; }
898
+ const full = path.join(root, f.path);
899
+ let st;
900
+ try { st = fs.statSync(full); } catch { continue; }
901
+ if (!st.isFile()) continue;
902
+ if (st.size > SEARCH_CONTENT_BYTES) { contentCut = true; continue; }
903
+ read += 1;
904
+ let text;
905
+ try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
906
+ if (text.indexOf(String.fromCharCode(0)) !== -1) continue; // binary masquerading as text — never scanned
907
+ const at = text.toLowerCase().indexOf(needle);
908
+ if (at === -1) continue;
909
+ if (hits.length >= cap) { overflow = true; break; }
910
+ const before = text.slice(0, at);
911
+ const lineNo = before.split('\n').length;
912
+ const lineStart = before.lastIndexOf('\n') + 1;
913
+ const lineEnd = text.indexOf('\n', at);
914
+ hits.push({
915
+ ...f, match: 'content', line: lineNo,
916
+ snippet: searchSnippet(text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd), at - lineStart),
917
+ openable: true,
918
+ });
919
+ }
920
+ }
921
+
922
+ return {
923
+ ok: true, q, hits, limit: cap,
924
+ // "files you searched for were never looked at" — the serious one.
925
+ truncated: idx.truncated || overflow,
926
+ // "every file was named; not every body was read" — the ordinary one.
927
+ content_truncated: contentCut,
928
+ scanned: idx.scanned, content_scanned: read,
929
+ };
930
+ }
931
+
682
932
  // Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
683
933
  // per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
684
934
  // non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
@@ -1033,10 +1283,32 @@ function deriveState(ev) {
1033
1283
  // state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
1034
1284
  // "one field, several readers, none agreeing" defect the anchor resolver was built to end.
1035
1285
  function acceptanceFilesFor(w) {
1286
+ return ledgerFilesFor(w, 'acceptances.jsonl');
1287
+ }
1288
+ // The general form of the comment above, because the defect it describes was never specific to
1289
+ // acceptances. `deriveNeeds` read evals.jsonl, acceptances.jsonl AND outcome_checks.jsonl from the
1290
+ // state dir alone, so the supervision surface under-reported everything pmos-self records in
1291
+ // planning/runs/ — the same "one field, several readers, none agreeing" shape, three ledgers wide.
1292
+ // One helper with three call sites, so the fourth ledger added later cannot reintroduce it.
1293
+ function ledgerFilesFor(w, basename) {
1036
1294
  const out = [];
1037
1295
  const d = stateDirFor(w);
1038
- if (d) out.push(path.join(d, 'acceptances.jsonl'));
1039
- if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', 'acceptances.jsonl'));
1296
+ if (d) out.push(path.join(d, basename));
1297
+ if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', basename));
1298
+ return out;
1299
+ }
1300
+ // readJsonl over BOTH ledger locations, deduped by nothing: callers fold, and a row present in both
1301
+ // is the same row recorded twice, which every existing fold already tolerates.
1302
+ function readLedger(w, basename) {
1303
+ const out = [];
1304
+ for (const f of ledgerFilesFor(w, basename)) {
1305
+ let text = '';
1306
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
1307
+ for (const line of text.split('\n')) {
1308
+ if (!line.trim()) continue;
1309
+ try { out.push(JSON.parse(line)); } catch { /* a torn line is reported by the checkers, not here */ }
1310
+ }
1311
+ }
1040
1312
  return out;
1041
1313
  }
1042
1314
  function readAcceptances(w) {
@@ -1355,19 +1627,40 @@ function buildSurface(name, root, w) {
1355
1627
  // (D62). v1 tasks are DETERMINISTIC (generated from a surface + the contract = maximally consistent,
1356
1628
  // no agent needed); agent-backed tasks (reasoning work) that spawn a PTY session are the documented
1357
1629
  // 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');
1630
+ // Resolved per call (schedStore/skillsStore, above): normally `STATE/…`, and under any unsettled
1631
+ // state root the legacy copy where the per-user root holds none — read AND written, since it is
1632
+ // then the only copy on the machine. stateHealthNow() says so on every surface that lists them.
1633
+ // A function rather than a constant so that resolving the conflict, or fixing the permission that
1634
+ // refused the move, takes effect on the next read instead of on the next restart.
1635
+ function schedFile() { return schedStore().path; }
1359
1636
  const DELIV_DIR = path.join(STATE, 'deliverables');
1360
1637
  // Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
1361
1638
  // user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
1362
1639
  // concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
1363
1640
  // derives it instantly, or a claude PTY session reasons through it) is decided by the file, not the PM.
1364
1641
  const BUILTIN_SKILLS_DIR = path.join(__dirname, 'skills');
1365
- const USER_SKILLS_DIR = path.join(STATE, 'skills');
1642
+ function userSkillsDir() { return skillsStore().path; }
1643
+ // The two paths a scheduled agent run must file into, spelled ABSOLUTELY. They used to be written
1644
+ // into the scaffold as `workspace/state/…`, which stopped being true the moment runtime state moved
1645
+ // out of the application directory (lib/state-root.js) — and a scaffold naming a directory the
1646
+ // console never reads is a report that silently never arrives. Derived from the same constants the
1647
+ // reader uses, so the two can only agree.
1648
+ //
1649
+ // `legacy` names where the runtime state USED to live — this application directory's own `state/`.
1650
+ // The retarget needs it because the PM's nine idpf-* skills spell the old location ABSOLUTELY
1651
+ // (`/Users/…/pmos/workspace/state/deliverables/<run-id>.md`), so there is no leading quote or space
1652
+ // to anchor a match on; the only question that can be answered reliably is "is this path's parent
1653
+ // the old state root", and only the caller knows where that was. Derived from __dirname, never
1654
+ // configured, so it cannot drift from the directory the migration moved out of.
1655
+ function filingContract(w) {
1656
+ return { deliverables: DELIV_DIR, needs: needsFileFor(w) || path.join(STATE, 'needs.json'),
1657
+ legacy: LEGACY_STATE };
1658
+ }
1366
1659
  // Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
1367
1660
  // load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
1368
1661
  function loadTasks() {
1369
1662
  return skillStore.loadSkills([
1370
- { dir: USER_SKILLS_DIR, source: 'user' },
1663
+ { dir: userSkillsDir(), source: 'user' },
1371
1664
  { dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
1372
1665
  ]).map(toTask);
1373
1666
  }
@@ -1382,10 +1675,10 @@ function toTask(s) {
1382
1675
  }
1383
1676
  function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
1384
1677
  function readSchedules() {
1385
- try { const a = JSON.parse(fs.readFileSync(SCHED_FILE, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
1678
+ try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
1386
1679
  }
1387
1680
  function writeSchedules(list) {
1388
- try { fs.writeFileSync(SCHED_FILE, JSON.stringify(list)); }
1681
+ try { fs.writeFileSync(schedFile(), JSON.stringify(list)); }
1389
1682
  catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
1390
1683
  }
1391
1684
 
@@ -1407,11 +1700,20 @@ function fireSchedule(sched) {
1407
1700
  // AGENT task — fire as a real PTY session running its skill (async; the skill files its own
1408
1701
  // deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
1409
1702
  if (task && task.kind === 'agent') {
1703
+ // THE FILING CONTRACT IS RETARGETED HERE, on the prompt, at the moment it is handed to the
1704
+ // session (lib/skill-store.js#retargetFiling). The PM's eleven existing skills were authored
1705
+ // when runtime state lived at `workspace/state/`, so their bodies still say "write the report
1706
+ // to workspace/state/deliverables/". After the relocation that is a directory the console
1707
+ // never reads: the agent would succeed, report success, and the report would never arrive.
1708
+ // Fixing only the SCAFFOLD fixes only skills that do not exist yet, which is no help to the
1709
+ // eleven that do. `needs.json` is resolved per workspace, so this cannot be a one-off rewrite
1710
+ // of the files either — the right answer for a given fire depends on which repo it fires in.
1711
+ const prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
1410
1712
  // The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
1411
1713
  // AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
1412
1714
  // below rather than the control simply being hidden.
1413
1715
  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,
1716
+ origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
1415
1717
  profile: sched.profile == null ? null : sched.profile });
1416
1718
  const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
1417
1719
  const started = s.state !== 'exited';
@@ -2247,6 +2549,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2247
2549
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
2248
2550
  persist(s); pushSessions(); pushWorkspaces(); return s;
2249
2551
  }
2552
+ announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
2250
2553
  announceTree(s); // before attach, so the advisory is the first line of the pane's record
2251
2554
  attachPty(s);
2252
2555
  feed(s);
@@ -2266,6 +2569,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2266
2569
  pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
2267
2570
  lastOutputAt: Date.now() };
2268
2571
  sessions.set(id, s);
2572
+ announceStateHealth(s);
2269
2573
  announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
2270
2574
  p.onData((d) => {
2271
2575
  appendBuf(s, d);
@@ -2607,7 +2911,11 @@ function stateDirFor(w, { create = false } = {}) {
2607
2911
  const d = path.join(w.root, ...rel);
2608
2912
  if (isDir(d)) return d;
2609
2913
  }
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)
2914
+ // pmos-self with no pmos/state/ yet: the checkout's OWN state/ is the file-mode mirror. This is
2915
+ // deliberately LEGACY_STATE and not STATE — D64's system of record belongs to the repository that
2916
+ // earned it, so it must not follow the runtime state out to the per-user directory. (Never a
2917
+ // tenant home — that writes to its OWN repo below.)
2918
+ if (EMBEDDED_SELF && w.root === SELF_ROOT) return LEGACY_STATE;
2611
2919
  // A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
2612
2920
  // invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
2613
2921
  if (create && wsShape(w.root) === 'pmos') {
@@ -2655,18 +2963,20 @@ function deriveNeeds(w) {
2655
2963
  if (!dir) return [];
2656
2964
  const needs = [];
2657
2965
  const settled = new Set();
2658
- for (const a of readJsonl(dir, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
2659
- for (const e of readJsonl(dir, 'evals.jsonl')) {
2966
+ for (const a of readLedger(w, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
2967
+ for (const e of readLedger(w, 'evals.jsonl')) {
2660
2968
  if (!e.run_id || settled.has(e.run_id)) continue;
2661
2969
  const verdict = e.verdict ? String(e.verdict) : 'no verdict';
2662
2970
  needs.push({
2663
2971
  run_id: e.run_id, initiative_id: e.initiative_id || null, kind: 'accept', derived: true,
2664
2972
  question: `Acceptance Gate open — evaluator says ${verdict}${e.score != null ? ` (${e.score})` : ''}`,
2665
- doc: e.rubric_path || null,
2973
+ // `rubric` is the deprecated alias every one of the 26 legacy rows uses; scripts/record-eval.js
2974
+ // requires `rubric_path` going forward. Read both, write only the new one.
2975
+ doc: e.rubric_path || e.rubric || null,
2666
2976
  });
2667
2977
  }
2668
2978
  const disposed = new Set();
2669
- for (const c of readJsonl(dir, 'outcome_checks.jsonl')) if (c.initiative_id) disposed.add(c.initiative_id);
2979
+ for (const c of readLedger(w, 'outcome_checks.jsonl')) if (c.initiative_id) disposed.add(c.initiative_id);
2670
2980
  const today = nowISO().slice(0, 10);
2671
2981
  let files = [];
2672
2982
  try { files = fs.readdirSync(path.join(dir, 'initiatives')).filter((f) => f.endsWith('.md')).sort(); } catch {}
@@ -3408,7 +3718,9 @@ async function handleRequest(req, res) {
3408
3718
  return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
3409
3719
  note: 'a declaration, not a guarantee — nothing about either process changed' });
3410
3720
  }
3411
- if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
3721
+ // `state_health` rides on the payload EVERY client reads at boot, so the unmigrated notice reaches
3722
+ // the PM whichever lens they open first — not only the two lists it is about.
3723
+ if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList(), state_health: stateHealthNow() });
3412
3724
  if (p === '/api/workspaces' && req.method === 'POST') {
3413
3725
  // How `pmos` in another repo becomes another workspace (R1). Local-only, like every
3414
3726
  // route here — the server binds 127.0.0.1 and this only records a path.
@@ -3684,16 +3996,21 @@ async function handleRequest(req, res) {
3684
3996
  if (p === '/api/skills' && req.method === 'GET') {
3685
3997
  const skills = loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill,
3686
3998
  description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
3687
- return json(res, { skills });
3999
+ // NEVER a bare list. Under an unmigrated conflict this list is either short by every skill the
4000
+ // PM authored or is being read from the old directory, and in both cases the payload has to say
4001
+ // so — a calm list is a claim that the state is settled (see stateHealthNow).
4002
+ return json(res, { skills, state_health: stateHealthNow() });
3688
4003
  }
3689
4004
  // POST scaffolds a well-formed .skill file from name + "what it should do" + the output shape.
3690
4005
  // A GUI replaces editing server code; user skills default to running as a claude PTY session.
3691
4006
  if (p === '/api/skills' && req.method === 'POST') {
3692
4007
  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);
4008
+ // `filing` is built HERE, never taken from the request body: it names filesystem paths, and a
4009
+ // caller-supplied one would be a write instruction dressed up as a preference.
4010
+ const built = skillStore.scaffoldSkill({ ...body, filing: filingContract(getWs(body.ws)) });
3694
4011
  if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3695
4012
  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); }
4013
+ try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
3697
4014
  catch (e) { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
3698
4015
  return json(res, { ok: true, skill: { id: built.slug, label: built.label, skill: built.slug,
3699
4016
  description: built.description, output: built.output, source: 'user' } });
@@ -3703,13 +4020,15 @@ async function handleRequest(req, res) {
3703
4020
  // Always leaves a valid file even where claude is absent; the session is honest about not starting.
3704
4021
  if (p === '/api/skills/draft' && req.method === 'POST') {
3705
4022
  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);
4023
+ const w0 = getWs(body.ws);
4024
+ const filing = filingContract(w0);
4025
+ const built = skillStore.scaffoldSkill({ ...body, filing });
3707
4026
  if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3708
4027
  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); }
4028
+ try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
3710
4029
  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'));
4030
+ const w = w0;
4031
+ const rel = path.relative(w ? w.root : SELF_ROOT, path.join(userSkillsDir(), built.slug + '.skill'));
3713
4032
  const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
3714
4033
  `"${built.description || built.label}". First read skills/core/SKILL-FORMAT.md (the skill spec) and ` +
3715
4034
  `follow build-skills/skill-creator.skill (the authoring procedure). Keep the runtime frontmatter shape ` +
@@ -3717,7 +4036,7 @@ async function handleRequest(req, res) {
3717
4036
  `ALWAYS produce). Sharpen the BODY into a procedure-first, repeatable procedure grounded in THIS repository's ` +
3718
4037
  `own files and tools only (no network): a Goal line, then numbered steps each ending on a checkable "Done when:" ` +
3719
4038
  `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 ` +
4039
+ `${filing.deliverables}/<run-id>.md and append the structured object to ${filing.needs} ` +
3721
4040
  `(a valid JSON array), never a prose note. Then run the SKILL-FORMAT conformance checklist (items 6-9: ` +
3722
4041
  `predictability, per-step completion criteria, pruning, leading words) over the body — put no self-check block ` +
3723
4042
  `in the file itself. Then stop.`;
@@ -3787,7 +4106,9 @@ async function handleRequest(req, res) {
3787
4106
  profile: s.profile == null ? null : s.profile,
3788
4107
  profile_applies: !!(t && t.kind === 'agent') };
3789
4108
  });
3790
- return json(res, { schedules: list, now: nowISO() });
4109
+ // Same rule as /api/skills: an empty (or fallen-back) schedule list under a conflict must never
4110
+ // read as "you have no scheduled tasks". A schedule that never loads never fires.
4111
+ return json(res, { schedules: list, now: nowISO(), state_health: stateHealthNow() });
3791
4112
  }
3792
4113
  if (p === '/api/schedules' && req.method === 'POST') {
3793
4114
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
@@ -3988,7 +4309,7 @@ async function handleRequest(req, res) {
3988
4309
  // Stripped by spreading rather than deleting — some surfaces return frozen objects.
3989
4310
  const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
3990
4311
  if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
3991
- return json(res, { name, ...out }, code);
4312
+ return json(res, { name, ...out, state_health: stateHealthNow() }, code);
3992
4313
  }
3993
4314
  // ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
3994
4315
  // Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
@@ -4159,6 +4480,25 @@ async function handleRequest(req, res) {
4159
4480
  });
4160
4481
  return json(res, built, built.ok ? 200 : 400);
4161
4482
  }
4483
+ // ── TWO CORPORA, TWO ENDPOINTS, ONE BOX ────────────────────────────────────────────────────
4484
+ // `/api/search` is EPISODIC — what a past session printed. `/api/search/files` is the FOLDER —
4485
+ // what is on disk under this workspace's root. They are deliberately not merged: the hit shapes
4486
+ // share no field (`{session, line, after}` vs `{path, kind, match, openable}`), the scrollback
4487
+ // search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
4488
+ // Collapsing them into one payload would mean one of the two lying about what it looked at.
4489
+ if (p === '/api/search/files') {
4490
+ // `search everything…`, finally over everything: every file under THIS workspace's root, by
4491
+ // path and by content. There is no path parameter — the query is a needle, not a location — so
4492
+ // this adds no reachable path; `openable` reports whether /api/doc (or /api/asset) could show a
4493
+ // hit. `truncated` + `scanned` are the contract that keeps a bounded answer from reading as a
4494
+ // complete one, which is the defect this endpoint exists to end rather than to relocate.
4495
+ const w = getWs(url.searchParams.get('ws'));
4496
+ if (!w) {
4497
+ return json(res, { ok: false, error: 'no workspace to search', q: url.searchParams.get('q') || '',
4498
+ hits: [], truncated: false, scanned: 0, content_scanned: 0, limit: SEARCH_LIMIT_DEFAULT });
4499
+ }
4500
+ return json(res, { ws: w.id, ...searchWorkspace(w.root, url.searchParams.get('q'), url.searchParams.get('limit')) });
4501
+ }
4162
4502
  if (p === '/api/search') {
4163
4503
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything
4164
4504
  // else here — an agent running in a pane can curl it to check what an earlier run tried.
@@ -4192,12 +4532,16 @@ async function handleRequest(req, res) {
4192
4532
  // response so the client can be honest about where the decision landed.
4193
4533
  let sink = acceptancesFileFor(w, { create: true });
4194
4534
  const fallback = !sink;
4195
- if (fallback) sink = path.join(STATE, 'acceptances.jsonl');
4535
+ // LEGACY_STATE, not STATE: an acceptance is a D64 record, and the last-resort sink for a repo
4536
+ // whose state directory could not be resolved stays with the install rather than following the
4537
+ // runtime state out to the per-user directory, where nobody would think to look for a record.
4538
+ if (fallback) sink = path.join(LEGACY_STATE, 'acceptances.jsonl');
4196
4539
  // FAIL LOUD (D64 Track A2): under D64 the file-mode acceptances log is the SYSTEM OF RECORD.
4197
4540
  // A swallowed append + {ok:true} would light the gate green over a record that never landed —
4198
4541
  // exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
4199
4542
  // {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
4200
4543
  try {
4544
+ if (fallback) fs.mkdirSync(LEGACY_STATE, { recursive: true }); // the install's state/ may not exist yet
4201
4545
  fs.appendFileSync(sink, JSON.stringify(rec) + '\n');
4202
4546
  } catch (e) {
4203
4547
  warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);