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.
- package/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +6 -1
- package/workspace/bin/pmos.js +204 -51
- package/workspace/doctor.js +54 -0
- package/workspace/lib/engine/workflow-state.js +129 -22
- package/workspace/lib/server-discovery.js +127 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/public/pm/data.js +28 -0
- package/workspace/public/pm/panel-repos.js +94 -1
- package/workspace/public/pm/state.js +4 -0
- package/workspace/server.js +258 -47
package/workspace/server.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
576
|
-
//
|
|
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
|
-
|
|
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 ||
|
|
608
|
-
|
|
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,
|
|
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
|
-
//
|
|
655
|
-
|
|
656
|
-
if (m.
|
|
657
|
-
if (m.
|
|
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',
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
704
|
-
|
|
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 ->
|
|
708
|
-
const f
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
for (const line of
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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:
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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 =
|
|
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: {
|
|
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', ['
|
|
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
|
-
|
|
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: '
|
|
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.
|
|
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
|
|
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
|
-
|
|
3626
|
-
|
|
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(`
|
|
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
|
|
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 };
|