create-pmos 0.16.0 → 0.17.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.
@@ -121,7 +121,11 @@ const CLONE_DIR = path.join(STATE, 'clones');
121
121
  // PMOS's OWN memory of which profile to preselect per agent binary (profiles R8.2). It is a
122
122
  // workspace preference, not an agent attribute — the file lives here for exactly that reason.
123
123
  const PREFS_FILE = path.join(STATE, 'prefs.json');
124
- const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
124
+ const discovery = require('./lib/server-discovery'); // how a launcher FINDS a running server
125
+ const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : discovery.DEFAULT_PORT;
126
+ // Written on listen, removed on shutdown. This is the server's answer to "where am I?" — see
127
+ // lib/server-discovery.js for why a launcher must ask rather than assume.
128
+ const PID_FILE = path.join(STATE, discovery.pidFileName(PORT));
125
129
  const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
126
130
  const layoutModel = require('./lib/layout'); // the arrangement model — validated on BOTH sides
127
131
  // Bind + token policy (D62). DEFAULT = loopback only, no token (unchanged). The opt-in remote flag
@@ -519,6 +523,10 @@ const ROLE_BY_DIR = {
519
523
  prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
520
524
  okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
521
525
  'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
526
+ // The initiative RECORD (planning/initiatives/<id>.md) is the canonical anchor+lane carrier and
527
+ // the only artifact that exists for work backfilled from a merge. Before it was a role, a record
528
+ // with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
529
+ initiatives: 'record',
522
530
  };
523
531
  // ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
524
532
  // The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
@@ -571,17 +579,23 @@ function parseHead(full) {
571
579
  head = buf.slice(0, n).toString('utf8');
572
580
  } catch { return {}; }
573
581
  const id = head.match(/^\s*[-*]?\s*initiative_id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
582
+ // An initiative RECORD names itself with `id:`, not `initiative_id:`. Only the record role reads
583
+ // this (see docMeta) — a bare `id:` on any other document must not invent an initiative.
584
+ const rid = head.match(/^\s*id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
574
585
  const h1 = head.match(/^#\s+(.+)$/m);
575
- // status/stage/anchor ride the PRD's identity block — the only initiative signal that lives on
576
- // disk today. RICE/effort/milestones/progress do NOT (net-new state) and are never invented.
586
+ // The anchor and the lane are DECLARED state and are read as such — they say what the work is
587
+ // pointed at, never how far along it is. `status:`/`stage:` are deliberately NOT read: they were
588
+ // hand-typed free text (23 distinct values in this repo, 33 absent, several truncated mid-YAML)
589
+ // funnelled through a regex, so `build complete, PR #138` rendered as `building`. Position is
590
+ // derived from evidence in deliveryIndex/deriveState instead — see the surface explainer.
577
591
  const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
578
- const status = head.match(/^\s*[-*]?\s*status\s*:\s*(.+?)\s*$/mi);
579
- const stage = head.match(/^\s*[-*]?\s*stage\s*:\s*(.+?)\s*$/mi);
580
592
  const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
593
+ const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
581
594
  return {
582
595
  initiative: id ? id[1] : null,
596
+ recordId: rid ? rid[1] : null,
583
597
  title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
584
- status: clean(status), stage: clean(stage), kr: clean(kr),
598
+ kr: clean(kr), lane: clean(lane),
585
599
  };
586
600
  }
587
601
  function roleAndStem(rel, base) {
@@ -604,8 +618,9 @@ function docMeta(root, full) {
604
618
  const meta = {
605
619
  mtimeMs: st.mtimeMs, path: rel, name: base, role,
606
620
  // frontmatter wins; the filename convention is the fallback, never the override
607
- initiative: head.initiative || null, stem, title: head.title || null,
608
- status: head.status || null, stage: head.stage || null, kr: head.kr || null,
621
+ initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
622
+ stem, title: head.title || null,
623
+ kr: head.kr || null, lane: head.lane || null,
609
624
  };
610
625
  metaCache.set(full, meta);
611
626
  return meta;
@@ -646,17 +661,17 @@ function initiativeIndex(root) {
646
661
  const k = keyFor(m);
647
662
  const art = { name: m.name, path: m.path, role: m.role, title: m.title };
648
663
  if (!k) { ungrouped.push(art); continue; }
649
- if (!groups.has(k)) groups.set(k, { id: k, title: null, status: null, stage: null, kr: null, artifacts: [] });
664
+ if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
650
665
  const g = groups.get(k);
651
666
  g.artifacts.push(art);
652
667
  if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
653
668
  else if (!g.title && m.title) g.title = m.title;
654
- // status/stage/anchor: the PRD is authoritative; any artifact fills a gap, never overrides it
655
- if (m.status && (m.role === 'PRD' || !g.status)) g.status = m.status;
656
- if (m.stage && (m.role === 'PRD' || !g.stage)) g.stage = m.stage;
657
- if (m.kr && (m.role === 'PRD' || !g.kr)) g.kr = m.kr;
669
+ // The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
670
+ // PRD fills a gap for legacy initiatives that predate repo-native records, never overrides it.
671
+ if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
672
+ if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
658
673
  }
659
- const ROLE_ORDER = ['PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
674
+ const ROLE_ORDER = ['record', 'PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
660
675
  const initiatives = [...groups.values()].map((g) => {
661
676
  g.artifacts.sort((a, b) => {
662
677
  const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
@@ -691,57 +706,207 @@ function library(root) {
691
706
  // client renders it; a surface that can't be produced returns ok:false and the client shows an
692
707
  // honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
693
708
  // ("— not tracked"), never invented (D14/D40 honesty).
694
- const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', landed: 'report' };
695
- function statusKey(raw) {
696
- const s = String(raw || '').toLowerCase();
697
- if (/block/.test(s)) return 'blocked';
698
- if (/ship|merg|accept|closed|done|live/.test(s)) return 'landed';
699
- if (/build|progress|in build|dogfood/.test(s)) return 'building';
700
- if (/intake|spec|plan|draft|propos/.test(s)) return 'planning';
709
+ const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
710
+
711
+ // ── Delivery evidence (board-delivery-truth R-2/R-3). ──────────────────────────────────────────
712
+ // "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
713
+ //
714
+ // ONE `git log` per trunk head, memoised on that sha — ~0.3s and ~800 KB on this repo's 1,214
715
+ // commits, paid once and reused by every surface until the trunk moves. Local only: git reads the
716
+ // object store on disk, so the suite's "no network" guarantee (K. Surfaces) is untouched.
717
+ //
718
+ // TWO signals, because one is not enough. The branch convention (`initiative/<id>`) is precise but
719
+ // incomplete: 31% of this repo's merged PRs shipped on `claude/*` branches, which match no id. So a
720
+ // commit that MENTIONS the id and TOUCHES a build path counts too. The build-path requirement is
721
+ // what keeps the mention signal honest — `feat(planning): add <id> PRD` mentions the id while
722
+ // delivering nothing, and without the path test every specified initiative would read as shipped.
723
+ const BUILD_EXCLUDE = /^(planning|okf|prototype)\//;
724
+ function isDeliveryPath(f) {
725
+ if (!f) return false;
726
+ if (BUILD_EXCLUDE.test(f)) return false;
727
+ return !/^[^/]+\.md$/.test(f); // root governance prose (DECISIONS.md, CLAUDE.md…) is not a build
728
+ }
729
+ const deliveryCache = new Map(); // root -> { head, index }
730
+ function trunkRef(root) {
731
+ for (const c of ['origin/main', 'main', 'origin/master', 'master']) {
732
+ const r = gitCap(root, ['rev-parse', '--verify', '--quiet', c]);
733
+ if (r.ok && r.out.trim()) return { ref: c, head: r.out.trim() };
734
+ }
701
735
  return null;
702
736
  }
703
- function statusFrom(g) {
704
- return statusKey(g.status) || statusKey(g.stage) || 'planning';
737
+ function gitCap(root, args) {
738
+ try {
739
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
740
+ if (r.error || r.status !== 0) return { ok: false, out: '', err: (r.stderr || (r.error && r.error.message) || '').trim() };
741
+ return { ok: true, out: r.stdout || '' };
742
+ } catch (e) { return { ok: false, out: '', err: String((e && e.message) || e) }; }
743
+ }
744
+ /**
745
+ * → { ok, reason?, byBranch:Set<id>, byMention:Set<id> }
746
+ * `ok:false` is a REAL answer and never degrades into "nothing was delivered" — the caller renders
747
+ * it red and marks every initiative's delivery `unknown` (R-3). A board that cannot read the
748
+ * history must say so; silently reporting `planning` for 110 shipped initiatives is the failure
749
+ * this whole initiative exists to remove.
750
+ */
751
+ function deliveryIndex(root, ids) {
752
+ const t = trunkRef(root);
753
+ if (!t) return { ok: false, reason: 'no trunk branch (main/master) is reachable from this repository — delivery cannot be derived', byBranch: new Set(), byMention: new Set() };
754
+ const hit = deliveryCache.get(root);
755
+ if (hit && hit.head === t.head) return hit.index;
756
+
757
+ // %x1e separates commits, %x1f separates the message from --name-only's file list.
758
+ const r = gitCap(root, ['log', '--format=%x1e%s %b%x1f', '--name-only', t.head]);
759
+ if (!r.ok) {
760
+ return { ok: false, reason: `git could not read the history at ${t.ref}${r.err ? ` — ${r.err}` : ''}`, byBranch: new Set(), byMention: new Set() };
761
+ }
762
+ const byBranch = new Set(), byMention = new Set();
763
+ const known = Array.isArray(ids) ? ids : [...ids];
764
+ for (const chunk of r.out.split('\x1e')) {
765
+ if (!chunk.trim()) continue;
766
+ const cut = chunk.indexOf('\x1f');
767
+ const msg = cut < 0 ? chunk : chunk.slice(0, cut);
768
+ const files = cut < 0 ? [] : chunk.slice(cut + 1).split('\n').map((s) => s.trim()).filter(Boolean);
769
+ for (const m of msg.matchAll(/initiative\/([A-Za-z0-9][\w.-]*)/g)) byBranch.add(m[1]);
770
+ if (!files.some(isDeliveryPath)) continue;
771
+ for (const id of known) if (id.length > 3 && msg.includes(id)) byMention.add(id);
772
+ }
773
+ const index = { ok: true, byBranch, byMention };
774
+ deliveryCache.set(root, { head: t.head, index });
775
+ return index;
776
+ }
777
+ /**
778
+ * The five states, in precedence order. Every one of them names the artifact it was read from, so
779
+ * the surface can show its own evidence instead of asking to be trusted.
780
+ *
781
+ * `blocked` deliberately outranks `delivered`: work the PM RETURNED is work that needs them, and
782
+ * hiding that behind a delivery glyph would reproduce, in a new place, the exact failure of a board
783
+ * that reports green over a red underneath. (The PRD's R-1 table lists delivered first; this is the
784
+ * one ordering departure, made because the alternative buries a returned initiative.)
785
+ */
786
+ function deriveState(ev) {
787
+ if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
788
+ if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
789
+ if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
790
+ // Not `planning`. An unreadable history means the evidence for the delivery states is missing,
791
+ // and `planning` is itself a claim ("nothing has happened") that the missing evidence cannot
792
+ // support. `unknown` is the only honest answer, and it is visibly not a resting state.
793
+ if (ev.delivery === 'unknown') return { state: 'unknown', why: 'the trunk history could not be read — delivery cannot be derived here' };
794
+ if (ev.deliveredBy) return { state: 'delivered', why: ev.deliveredBy === 'branch' ? 'an initiative/<id> branch merged to the trunk' : 'a commit on the trunk names this initiative and changes build files' };
795
+ if (ev.runs) return { state: 'building', why: `${ev.runs} run${ev.runs > 1 ? 's' : ''} logged, nothing merged yet` };
796
+ return { state: 'planning', why: 'no run, no merge, no acceptance — nothing has happened yet' };
797
+ }
798
+ // BOTH acceptance ledgers, oldest-wins-never: the workspace writes to its state dir, while the
799
+ // ladder and every hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the
800
+ // state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
801
+ // "one field, several readers, none agreeing" defect the anchor resolver was built to end.
802
+ function acceptanceFilesFor(w) {
803
+ const out = [];
804
+ const d = stateDirFor(w);
805
+ if (d) out.push(path.join(d, 'acceptances.jsonl'));
806
+ if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', 'acceptances.jsonl'));
807
+ return out;
705
808
  }
706
809
  function readAcceptances(w) {
707
- const acc = new Map(); // initiative_id -> last action (accept | approve | return)
708
- const f = acceptancesFileFor(w);
709
- if (!f) return acc;
710
- try {
711
- for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
810
+ const acc = new Map(); // initiative_id -> { action, at }
811
+ for (const f of acceptanceFilesFor(w)) {
812
+ let text = '';
813
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
814
+ for (const line of text.split('\n')) {
712
815
  if (!line.trim()) continue;
713
816
  // Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
817
+ // THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
818
+ // — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
819
+ // itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
820
+ // `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
821
+ // missing, and the surface reported those initiatives as merely `building`.
714
822
  try {
715
823
  const r = JSON.parse(line);
716
824
  if (!r.initiative_id) continue;
717
- acc.set(r.initiative_id, r.action || (r.accepted === true ? 'accept' : r.accepted === false ? 'return' : null));
825
+ const label = String(r.action || '').toLowerCase();
826
+ const action = r.accepted === true ? 'accept'
827
+ : r.accepted === false ? 'return'
828
+ : label === 'return' ? 'return'
829
+ : ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
830
+ : null;
831
+ const at = r.at || r.date || null;
832
+ // Last write wins, but a row with no timestamp never displaces a dated one.
833
+ const prev = acc.get(r.initiative_id);
834
+ if (prev && prev.at && !at) continue;
835
+ acc.set(r.initiative_id, { action, at });
718
836
  } catch {}
719
837
  }
720
- } catch {}
838
+ }
721
839
  return acc;
722
840
  }
841
+ // How many runs each initiative has logged — evidence that work STARTED, which is the only thing
842
+ // separating `building` from `planning`. The file is the same one the ladder reads.
843
+ function readRunCounts(w) {
844
+ const counts = new Map();
845
+ const d = stateDirFor(w);
846
+ const files = [];
847
+ if (d) files.push(path.join(d, 'agent-runs.jsonl'));
848
+ if (w && w.root) files.push(path.join(w.root, 'planning', 'runs', 'agent-runs.jsonl'));
849
+ for (const f of files) {
850
+ let text = '';
851
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
852
+ for (const line of text.split('\n')) {
853
+ if (!line.trim()) continue;
854
+ try {
855
+ const r = JSON.parse(line);
856
+ if (r && r.initiative_id) counts.set(r.initiative_id, (counts.get(r.initiative_id) || 0) + 1);
857
+ } catch {}
858
+ }
859
+ }
860
+ return counts;
861
+ }
723
862
  function surfaceInitiatives(root, w) {
724
863
  const { initiatives } = initiativeIndex(root);
725
864
  const acc = readAcceptances(w);
865
+ const runs = readRunCounts(w);
866
+ const needsRaw = readNeeds(w) || [];
867
+ const needsBy = new Map();
868
+ for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
869
+
870
+ const del = deliveryIndex(root, initiatives.map((g) => g.id));
726
871
  const items = initiatives.map((g) => {
727
- const sk = statusFrom(g);
872
+ const a = acc.get(g.id) || {};
873
+ const deliveredBy = !del.ok ? null : del.byBranch.has(g.id) ? 'branch' : del.byMention.has(g.id) ? 'mention' : null;
874
+ const { state, why } = deriveState({
875
+ accepted: a.action === 'accept' || a.action === 'approve', acceptedAt: a.at,
876
+ returned: a.action === 'return',
877
+ needs: needsBy.get(g.id) || 0,
878
+ delivery: del.ok ? 'known' : 'unknown',
879
+ deliveredBy,
880
+ runs: runs.get(g.id) || 0,
881
+ });
728
882
  return {
729
883
  id: g.id, title: g.title || null,
730
- status: sk, statusText: g.status || g.stage || sk, phase: PHASE_OF[sk] || 'plan',
731
- stage: g.stage || null, kr: g.kr || null,
732
- accepted: acc.get(g.id) === 'accept',
733
- artifacts: g.artifacts.map((a) => ({ role: a.role, name: a.name, path: a.path })),
884
+ status: state, statusText: why, phase: PHASE_OF[state] || 'plan',
885
+ // `stage` was the declared field this surface used to trust. It is gone, not renamed —
886
+ // the client renders `statusText`, which always names the artifact the state was read from.
887
+ stage: null, kr: g.kr || null, lane: g.lane || null,
888
+ accepted: state === 'accepted',
889
+ delivered: state === 'accepted' || state === 'delivered',
890
+ delivery_evidence: deliveredBy,
891
+ artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path })),
734
892
  // NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
735
893
  rice: null, effort: null, milestones: null, progress: null, tracked: false,
736
894
  };
737
895
  });
738
896
  const by = (k) => items.filter((i) => i.status === k).length;
739
- const needs = (readNeeds(w) || []).map((n) => ({
897
+ const needs = needsRaw.map((n) => ({
740
898
  t: n.question || '(unstated)', kind: n.kind || 'question', initiative_id: n.initiative_id || null,
741
899
  }));
742
900
  return {
743
901
  initiatives: items,
744
- pulse: { total: items.length, planning: by('planning'), building: by('building'), blocked: by('blocked'), landed: by('landed') },
902
+ pulse: {
903
+ total: items.length, planning: by('planning'), building: by('building'),
904
+ blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
905
+ unknown: by('unknown'),
906
+ },
907
+ // R-3: a failed read is reported, never absorbed. The client renders this RED above the list
908
+ // rather than letting 110 shipped initiatives quietly read `planning`.
909
+ delivery_read: del.ok ? null : (del.reason || 'the trunk history could not be read'),
745
910
  needs,
746
911
  };
747
912
  }
@@ -751,7 +916,7 @@ function surfaceRoadmap(root, w) {
751
916
  label, dates_tracked: false,
752
917
  items: items.filter((i) => keys.includes(i.status)).map((i) => ({ id: i.id, status: i.status, phase: i.phase, progress: i.progress })),
753
918
  });
754
- const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['landed'])]
919
+ const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['delivered', 'accepted'])]
755
920
  .filter((g) => g.items.length);
756
921
  return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
757
922
  }
@@ -928,12 +1093,16 @@ function surfaceReport(root, w) {
928
1093
  const s = surfaceInitiatives(root, w);
929
1094
  const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
930
1095
  const inflight = s.initiatives.filter((i) => i.status === 'building' || i.status === 'blocked');
931
- const landed = s.initiatives.filter((i) => i.status === 'landed');
1096
+ // Delivered and accepted are reported SEPARATELY, not summed into one "landed" heading. The gap
1097
+ // between them is the D12 Acceptance Gate's backlog — the number this report exists to surface.
1098
+ const delivered = s.initiatives.filter((i) => i.status === 'delivered');
1099
+ const accepted = s.initiatives.filter((i) => i.status === 'accepted');
932
1100
  const sections = [
933
1101
  { heading: 'In flight', items: inflight.map(line) },
934
- { heading: 'Landed', items: landed.map(line) },
1102
+ { heading: 'Delivered — acceptance not recorded', items: delivered.map(line) },
1103
+ { heading: 'Accepted', items: accepted.map(line) },
935
1104
  { heading: 'Needs you', items: s.needs.map((n) => n.t) },
936
- { heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.landed} landed`] },
1105
+ { heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.delivered} delivered · ${s.pulse.accepted} accepted`] },
937
1106
  ];
938
1107
  return { cadence: 'weekly', sections };
939
1108
  }
@@ -3604,10 +3773,27 @@ async function handleRequest(req, res) {
3604
3773
  'and useful answer.',
3605
3774
  ].filter((l) => l !== '').join('\n');
3606
3775
 
3776
+ // NOT typed blind into the PTY. An agent CLI opening a folder it has never seen shows a
3777
+ // trust prompt first — and a fresh tenant repo is by definition such a folder, so the case this
3778
+ // feature exists for is the exact case where blind input is swallowed by a security dialog and
3779
+ // the drafting silently never happens. Found by installing the published kit and watching a real
3780
+ // session sit at "Is this a project you trust?" with the prompt already gone.
3781
+ //
3782
+ // Auto-answering that dialog is not on the table: it is the PM's decision, not ours. So the
3783
+ // prompt is PERSISTED and STAGED instead — it cannot be lost, and the PM sends it once the agent
3784
+ // is actually at its input.
3785
+ const promptFile = path.join(adir, 'prompt-' + Date.now() + '.md');
3786
+ try { fs.writeFileSync(promptFile, prompt + '\n'); }
3787
+ catch (e) { warnWriteFail('alignment prompt', e); return json(res, { ok: false, error: 'could not stage the drafting prompt' }, 500); }
3788
+
3607
3789
  const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
3608
- origin: 'align:' + rel, initialInput: prompt + '\r' });
3790
+ origin: 'align:' + rel });
3609
3791
  const started = !!(s && s.pty);
3792
+ let staged = false;
3793
+ if (started) { try { writeScratchpad(w, s.id, prompt); staged = true; } catch {} }
3610
3794
  return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
3795
+ prompt_file: promptFile, staged,
3796
+ next: started ? 'the drafting prompt is staged in this session\'s scratchpad — answer any trust prompt the agent shows, then send it' : undefined,
3611
3797
  error: started ? undefined : ((s && s.note) || 'could not start the session') });
3612
3798
  }
3613
3799
  if (p === '/api/align/drafts') {
@@ -3622,8 +3808,19 @@ async function handleRequest(req, res) {
3622
3808
  let doc;
3623
3809
  try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
3624
3810
  catch { return json(res, { ok: false, error: `the draft file '${pick}' is unreadable or not valid JSON` }, 500); }
3625
- const drafts = Array.isArray(doc.drafts) ? doc.drafts.filter((d) => d && typeof d.file === 'string') : [];
3626
- return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts });
3811
+ // A malformed draft is REFUSED at read, not half-rendered (contract crit 4). Silently dropping a
3812
+ // bad draft would let the agent's mistake reach the PM as a shorter, unexplained list — the same
3813
+ // "looks fine, is wrong" failure the anchored write guard exists to stop. An empty `drafts` array
3814
+ // is valid and calm (crit 3): the agent looked and found nothing to realign.
3815
+ if (!Array.isArray(doc.drafts)) {
3816
+ return json(res, { ok: false, error: `the draft file '${pick}' has no drafts array — the agent did not produce the documented shape` }, 422);
3817
+ }
3818
+ for (const d of doc.drafts) {
3819
+ if (!d || typeof d.file !== 'string' || typeof d.after !== 'string' || (d.before != null && typeof d.before !== 'string')) {
3820
+ return json(res, { ok: false, error: `a draft in '${pick}' is missing a file or an after — refusing to render a partial set (crit 4)` }, 422);
3821
+ }
3822
+ }
3823
+ return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts: doc.drafts });
3627
3824
  }
3628
3825
  if (p === '/api/align/apply' && req.method === 'POST') {
3629
3826
  // Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
@@ -3909,6 +4106,7 @@ if (require.main === module) {
3909
4106
  if (shuttingDown) return; shuttingDown = true;
3910
4107
  for (const s of sessions.values()) if (s.pty) { try { persist(s); } catch {} }
3911
4108
  for (const l of listeners) { try { l.close(); } catch {} }
4109
+ try { fs.unlinkSync(PID_FILE); } catch {} // stop being discoverable the moment we stop serving
3912
4110
  process.exit(0);
3913
4111
  };
3914
4112
  process.on('SIGTERM', shutdown);
@@ -3916,7 +4114,9 @@ if (require.main === module) {
3916
4114
  const onError = (e) => {
3917
4115
  if (e.code === 'EADDRINUSE') {
3918
4116
  console.error(`✗ port ${PORT} is already in use (another workspace server running?).`);
3919
- console.error(` Stop it, or start on another port: PMOS_WS_PORT=7500 npm start`);
4117
+ console.error(` \`pmos\` finds a running workspace on any port and reuses it, and picks a free`);
4118
+ console.error(` port when something else holds this one. \`pmos stop\` shuts running ones down.`);
4119
+ console.error(` To pin a port by hand: PMOS_WS_PORT=7500 npm start`);
3920
4120
  process.exit(1);
3921
4121
  }
3922
4122
  if (e.code === 'EADDRNOTAVAIL') {
@@ -3931,7 +4131,13 @@ if (require.main === module) {
3931
4131
  l.on('error', onError);
3932
4132
  l.listen(PORT, addr, () => {
3933
4133
  // stdout only — no request/header logging (D08). Banner (incl. any generated token) printed once.
3934
- if (++up === BIND.binds.length) for (const line of remoteAccess.startupLines(BIND, PORT)) console.log(line);
4134
+ if (++up !== BIND.binds.length) return;
4135
+ // Record the port WE are serving, whoever started us. The launcher discovers a running
4136
+ // workspace from these files, so a server started by `npm start` (which is exactly what the
4137
+ // old EADDRINUSE advice told the PM to do, on another port) has to leave the same trace as
4138
+ // one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
4139
+ try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
4140
+ for (const line of remoteAccess.startupLines(BIND, PORT)) console.log(line);
3935
4141
  });
3936
4142
  });
3937
4143
  }
@@ -3956,4 +4162,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
3956
4162
  // where the anchor resolver is looked for — exported so the suite can assert the INSTALL
3957
4163
  // location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
3958
4164
  // exactly as it hid the workflow engine's absence for four releases (PKG4).
3959
- ANCHOR_LIB_PATHS };
4165
+ ANCHOR_LIB_PATHS,
4166
+ // the delivery derivation (board-delivery-truth), exported so the suite can assert the three
4167
+ // things that matter independently of a rendered page: that no declared field reaches a status,
4168
+ // that an unreadable history reports rather than degrades, and that a planning-only commit does
4169
+ // not count as delivery.
4170
+ surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };