create-pmos 0.38.7 → 0.39.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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +64 -1
  7. package/workspace/bin/pmos.js +10 -1
  8. package/workspace/guide/index.md +38 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +59 -0
  11. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  12. package/workspace/guide/statuses.md +53 -0
  13. package/workspace/guide/validate-with-typesafe.md +47 -0
  14. package/workspace/lib/initiative-files.js +134 -0
  15. package/workspace/lib/judgment.js +65 -0
  16. package/workspace/lib/mobile-nav.js +1 -1
  17. package/workspace/lib/next-step-actions.js +139 -0
  18. package/workspace/lib/next-steps.js +133 -0
  19. package/workspace/lib/prompt-input.js +18 -0
  20. package/workspace/lib/schedule-cli.js +259 -0
  21. package/workspace/lib/schedule-logic.js +23 -1
  22. package/workspace/lib/secret-scan.js +28 -0
  23. package/workspace/lib/session-budget.js +35 -0
  24. package/workspace/lib/skill-store.js +11 -3
  25. package/workspace/lib/status-groups.js +109 -0
  26. package/workspace/lib/transcripts.js +73 -0
  27. package/workspace/lib/typesafe-settings.js +119 -0
  28. package/workspace/lib/typesafe.js +124 -0
  29. package/workspace/lib/validate.js +304 -0
  30. package/workspace/package-lock.json +1 -1
  31. package/workspace/package.json +3 -3
  32. package/workspace/public/index.html +2 -0
  33. package/workspace/public/pm/app.js +53 -18
  34. package/workspace/public/pm/data.js +41 -5
  35. package/workspace/public/pm/mobile.js +1 -1
  36. package/workspace/public/pm/modes-live.js +171 -0
  37. package/workspace/public/pm/modes.js +25 -0
  38. package/workspace/public/pm/next-step-ui.js +185 -0
  39. package/workspace/public/pm/panel-detail.js +121 -1
  40. package/workspace/public/pm/panel-initiatives.js +61 -3
  41. package/workspace/public/pm/panel-repos.js +2 -2
  42. package/workspace/public/pm/panel-tabs.js +38 -2
  43. package/workspace/public/pm/panel-trees.js +9 -2
  44. package/workspace/public/pm/state.js +9 -4
  45. package/workspace/public/pm/status-banner.js +87 -0
  46. package/workspace/public/pm/tab-doc.js +11 -6
  47. package/workspace/server.js +434 -8
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+ /* validate.js — judge a repository's documents for reliability, coherence and freshness (D79).
3
+ *
4
+ * Order matters, and it is the cost and egress control:
5
+ * 1. LOCAL FIRST. Links that point nowhere, a `stale_after` date that has passed, and the workflow sweep
6
+ * run on this machine and send nothing.
7
+ * 2. WITHHOLD. Every text is scanned for credential shapes. A text that matches is never sent and is
8
+ * listed by file and rule name.
9
+ * 3. JUDGE. Only what is left goes to Jev, as typed questions, one request per document or per pair.
10
+ *
11
+ * Nothing here edits a repository file, a decision or an acceptance. The only write is one append-only
12
+ * line per finding in a ledger the caller names, through the sink guard.
13
+ *
14
+ * What a number means (Jev's own semantics, see lib/typesafe.js): a Noul answer is P(yes) and carries no
15
+ * separate confidence, so `confidence` here is DERIVED as |2p - 1| and says so. A Score answer reports its
16
+ * own confidence. Every Noul question is phrased so that a high value means a problem.
17
+ */
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { execFileSync } = require('child_process');
21
+ const crypto = require('crypto');
22
+ const scanner = require('./secret-scan');
23
+ const settings = require('./typesafe-settings');
24
+ const typesafe = require('./typesafe');
25
+ const { createSinkGuard } = require('./sink');
26
+
27
+ const MAX_DOCS = 40; // single-document requests per run
28
+ const MAX_PAIRS = 10; // two-document requests per run
29
+ const MAX_DOC_BYTES = 200 * 1024; // a larger document is listed, not sent
30
+ const SNIPPET_DOC = 24000; // characters of one document in a request
31
+ const SNIPPET_PAIR = 6000; // characters of each document in a pair request
32
+ const AGING_DAYS = 120; // git age after which a document is asked whether it is out of date
33
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.next', 'dist', 'build', 'vendor']);
34
+
35
+ /* ── reading the repository ────────────────────────────────────────────────────────────────── */
36
+ function listMarkdown(root) {
37
+ try {
38
+ const out = execFileSync('git', ['ls-files', '-z', '--', '*.md'], { cwd: root, maxBuffer: 32 << 20, stdio: ['ignore', 'pipe', 'ignore'] });
39
+ const files = out.toString('utf8').split('\0').filter(Boolean);
40
+ if (files.length) return files.sort();
41
+ } catch { /* not a git checkout, or git is absent */ }
42
+ const found = [];
43
+ (function walk(dir, rel) {
44
+ let entries = [];
45
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
46
+ for (const e of entries) {
47
+ if (e.isSymbolicLink()) continue;
48
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), rel ? rel + '/' + e.name : e.name); }
49
+ else if (e.name.endsWith('.md')) found.push(rel ? rel + '/' + e.name : e.name);
50
+ }
51
+ })(root, '');
52
+ return found.sort();
53
+ }
54
+
55
+ function frontmatter(text) {
56
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
57
+ const fm = {};
58
+ if (!m) return fm;
59
+ for (const line of m[1].split(/\r?\n/)) {
60
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
61
+ if (kv) fm[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
62
+ }
63
+ return fm;
64
+ }
65
+
66
+ function gitAgeDays(root, rel, now) {
67
+ try {
68
+ const out = execFileSync('git', ['log', '-1', '--format=%ct', '--', rel], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
69
+ const t = Number(out);
70
+ return t > 0 ? Math.floor((now - t * 1000) / 86400000) : null;
71
+ } catch { return null; }
72
+ }
73
+
74
+ function linksOf(text) {
75
+ const body = text.replace(/```[\s\S]*?```/g, '');
76
+ const out = [];
77
+ const re = /\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
78
+ let m;
79
+ while ((m = re.exec(body))) out.push(m[1]);
80
+ return out;
81
+ }
82
+
83
+ function resolveLink(root, docRel, target) {
84
+ if (!target || /^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(target)) return null;
85
+ const clean = decodeURI(target.split('#')[0].split('?')[0]);
86
+ if (!clean) return null;
87
+ const abs = clean.startsWith('/') ? path.join(root, clean) : path.join(root, path.dirname(docRel), clean);
88
+ const rel = path.relative(root, abs).split(path.sep).join('/');
89
+ return rel.startsWith('..') ? null : rel;
90
+ }
91
+
92
+ /* ── the survey: what exists, what is excluded, what is withheld, what is flagged locally ──────── */
93
+ function survey(root, exclude, now) {
94
+ const all = listMarkdown(root);
95
+ const docs = [], withheld = [], tooLarge = [];
96
+ let excluded = 0;
97
+ for (const rel of all) {
98
+ if (settings.isExcluded(exclude, rel)) { excluded++; continue; }
99
+ const full = path.join(root, rel);
100
+ let st;
101
+ try { st = fs.lstatSync(full); } catch { continue; }
102
+ if (!st.isFile()) continue;
103
+ if (st.size > MAX_DOC_BYTES) { tooLarge.push(rel); continue; }
104
+ let text;
105
+ try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
106
+ const hits = scanner.scan(text);
107
+ if (hits.length) { withheld.push({ file: rel, rules: hits }); continue; }
108
+ docs.push({ rel, text, fm: frontmatter(text) });
109
+ }
110
+ const have = new Set(docs.map((d) => d.rel));
111
+ const deterministic = [];
112
+ const today = new Date(now).toISOString().slice(0, 10);
113
+ for (const d of docs) {
114
+ const broken = [];
115
+ for (const t of linksOf(d.text)) {
116
+ const r = resolveLink(root, d.rel, t);
117
+ if (r && !fs.existsSync(path.join(root, r))) broken.push(t);
118
+ }
119
+ for (const t of broken.slice(0, 5)) {
120
+ deterministic.push({ file: d.rel, rule: 'broken-link', severity: 'medium', detail: `links to ${t}, which does not exist` });
121
+ }
122
+ d.flagged = broken.length > 0;
123
+ if (/^\d{4}-\d{2}-\d{2}/.test(d.fm.stale_after || '') && d.fm.stale_after.slice(0, 10) < today) {
124
+ deterministic.push({ file: d.rel, rule: 'stale-after-passed', severity: 'medium', detail: `stale_after ${d.fm.stale_after.slice(0, 10)} has passed` });
125
+ d.flagged = true; d.aging = true;
126
+ }
127
+ }
128
+ return { docs, have, withheld, tooLarge, excluded, deterministic, total: all.length };
129
+ }
130
+
131
+ /* ── the questions. Every Noul is phrased so that a high value means a problem. ──────────────── */
132
+ const Q = {
133
+ self_contradiction: { type: 'noul', rule: 'self-contradiction', kind: 'coherence',
134
+ instructions: 'The document contradicts itself: two of its statements cannot both be true.' },
135
+ unclear_purpose: { type: 'noul', rule: 'unclear-purpose', kind: 'structure',
136
+ instructions: 'A reader cannot tell from the opening of this document what it is for or who should read it.' },
137
+ padding: { type: 'score', rule: 'padding', kind: 'tone',
138
+ instructions: 'How succinct, specific and direct the writing is.',
139
+ criteria: ['Rambling, vague, hedged or padded; much could be cut', 'Mostly clear, with some padding or hedging', 'Succinct, specific and direct'] },
140
+ outdated: { type: 'noul', rule: 'possibly-outdated', kind: 'freshness',
141
+ instructions: 'The document describes things that are probably out of date: it treats past dates, versions or plans as current.' },
142
+ conflict: { type: 'noul', rule: 'conflicting-documents', kind: 'coherence',
143
+ instructions: 'The two documents state conflicting facts about the same thing.' },
144
+ unsupported: { type: 'noul', rule: 'unsupported-claim', kind: 'reliability',
145
+ instructions: 'The first document states claims that the second document, which it cites as its source, does not support or contradicts.' },
146
+ };
147
+ const wire = (names) => Object.fromEntries(names.map((n) => {
148
+ const q = Q[n]; const o = { type: q.type, instructions: q.instructions };
149
+ if (q.criteria) o.criteria = q.criteria;
150
+ return [n, o];
151
+ }));
152
+
153
+ /* ── the plan: what a run WOULD send. No network, no write. ───────────────────────────────────── */
154
+ function buildRequests(root, sv, now) {
155
+ const docs = sv.docs.slice().sort((a, b) => (b.flagged ? 1 : 0) - (a.flagged ? 1 : 0) || a.rel.localeCompare(b.rel)).slice(0, MAX_DOCS);
156
+ const requests = [];
157
+ for (const d of docs) {
158
+ const age = gitAgeDays(root, d.rel, now);
159
+ const aging = d.aging || (age != null && age > AGING_DAYS);
160
+ const names = ['self_contradiction', 'unclear_purpose', 'padding'].concat(aging ? ['outdated'] : []);
161
+ requests.push({ kind: 'doc', files: [d.rel], names, state: d.text.slice(0, SNIPPET_DOC) });
162
+ }
163
+ const pairs = new Map();
164
+ const add = (a, b, name) => {
165
+ if (a === b || !sv.have.has(a) || !sv.have.has(b)) return;
166
+ const key = name === 'unsupported' ? a + '\0' + b : [a, b].sort().join('\0');
167
+ const p = pairs.get(key) || { a: name === 'unsupported' ? a : key.split('\0')[0], b: name === 'unsupported' ? b : key.split('\0')[1], names: new Set() };
168
+ p.names.add(name); pairs.set(key, p);
169
+ };
170
+ for (const d of sv.docs) {
171
+ for (const t of linksOf(d.text)) { const r = resolveLink(root, d.rel, t); if (r) add(d.rel, r, 'conflict'); }
172
+ for (const s of String(d.fm.sources || '').replace(/^\[|\]$/g, '').split(',')) {
173
+ const r = resolveLink(root, d.rel, s.trim()) || (s.trim() ? s.trim().replace(/^\/+/, '') : null);
174
+ if (r) add(d.rel, r, 'unsupported');
175
+ }
176
+ }
177
+ const byRel = new Map(sv.docs.map((d) => [d.rel, d]));
178
+ for (const p of Array.from(pairs.values()).slice(0, MAX_PAIRS)) {
179
+ const A = byRel.get(p.a), B = byRel.get(p.b);
180
+ const state = `DOCUMENT 1 (${p.a}):\n${A.text.slice(0, SNIPPET_PAIR)}\n\nDOCUMENT 2 (${p.b}):\n${B.text.slice(0, SNIPPET_PAIR)}`;
181
+ requests.push({ kind: 'pair', files: [p.a, p.b], names: Array.from(p.names), state });
182
+ }
183
+ return requests;
184
+ }
185
+
186
+ function plan({ root, exclude = [], now = Date.now() }) {
187
+ const sv = survey(root, exclude, now);
188
+ const reqs = buildRequests(root, sv, now);
189
+ return {
190
+ documents: sv.total, excluded: sv.excluded, eligible: sv.docs.length,
191
+ withheld: sv.withheld, too_large: sv.tooLarge,
192
+ local_findings: sv.deterministic.length + sweepFindings(root).length,
193
+ requests: reqs.length, characters: reqs.reduce((n, r) => n + r.state.length, 0),
194
+ host: typesafe.HOST,
195
+ };
196
+ }
197
+
198
+ /* ── judging the answers ──────────────────────────────────────────────────────────────────────── */
199
+ function judge(name, ans) {
200
+ const q = Q[name];
201
+ if (!ans || ans.type !== q.type) return null;
202
+ if (q.type === 'noul') {
203
+ const p = Number(ans.noul);
204
+ if (!Number.isFinite(p) || p < 0.5) return null;
205
+ const certainty = Math.abs(2 * p - 1);
206
+ return { value: p, confidence: certainty, confidence_source: 'derived from the noul value',
207
+ severity: p >= 0.85 ? 'high' : p >= 0.65 ? 'medium' : 'low', probabilities: null };
208
+ }
209
+ const s = Number(ans.score), c = Number(ans.confidence);
210
+ if (!Number.isFinite(s) || s >= 0.7) return null;
211
+ return { value: s, confidence: Number.isFinite(c) ? c : null, confidence_source: 'reported by the service',
212
+ severity: s < 0.4 ? 'medium' : 'low', probabilities: ans.probabilities || null };
213
+ }
214
+
215
+ const nowISO = () => new Date().toISOString();
216
+
217
+ /* The workflow sweep (D77) is deterministic and read-only; its defects are findings that send nothing. A
218
+ * repository the sweep cannot read contributes none — that is not an error here. */
219
+ function sweepFindings(root) {
220
+ try {
221
+ const a = require('./audit').run(root);
222
+ if (!a || !a.ok) return [];
223
+ return (a.defects || []).slice(0, 20).map((d) => ({
224
+ file: d.file || 'planning/', rule: 'workflow-sweep', severity: 'high', detail: String(d.what || 'a workflow record is inconsistent').slice(0, 300),
225
+ }));
226
+ } catch { return []; }
227
+ }
228
+
229
+ /**
230
+ * Run one validation. The caller has already checked consent and the key; this refuses if the request
231
+ * count differs from what the PM confirmed, so the PM never confirms one plan and runs another.
232
+ *
233
+ * @returns {Promise<object>} { ok, run_id, asked, raised, failed, withheld, findings_file, ... } or { ok:false, error }
234
+ */
235
+ async function run({ root, exclude = [], key, findingsFile, expectRequests, ask = typesafe.ask, now = Date.now(), askOpts = {} }) {
236
+ if (!typesafe.validKey(key)) return { ok: false, error: 'no TypeSafe key is set' };
237
+ const sv = survey(root, exclude, now);
238
+ const reqs = buildRequests(root, sv, now);
239
+ if (!Number.isInteger(expectRequests) || expectRequests !== reqs.length) {
240
+ return { ok: false, conflict: true, error: `the plan changed — it now has ${reqs.length} request(s), not ${expectRequests}. Review it again.` };
241
+ }
242
+ const runId = 'val-' + crypto.randomBytes(6).toString('hex');
243
+ const at = nowISO();
244
+ const guard = createSinkGuard({ sink: findingsFile, what: 'validation findings' });
245
+ const lines = [];
246
+ const emit = (rec) => lines.push({ type: 'finding', run_id: runId, at: nowISO(), ...rec });
247
+
248
+ for (const f of sv.deterministic) emit({ file: f.file, files: [f.file], rule: f.rule, kind: 'deterministic', severity: f.severity, label: 'finding', sent: false, detail: f.detail, model: null });
249
+ for (const f of sweepFindings(root)) emit({ file: f.file, files: [f.file], rule: f.rule, kind: 'deterministic', severity: f.severity, label: 'finding', sent: false, detail: f.detail, model: null });
250
+ for (const w of sv.withheld) emit({ file: w.file, files: [w.file], rule: 'withheld-by-secret-scan', kind: 'withheld', severity: 'medium', label: 'finding', sent: false, detail: 'not sent; matched ' + w.rules.join(', '), model: null });
251
+
252
+ let asked = 0, raised = 0, failed = 0, aborted = null, model = null, consecutive401 = 0;
253
+ for (const r of reqs) {
254
+ const res = await ask({ key, state: r.state, questions: wire(r.names), ...askOpts });
255
+ if (!res.ok) {
256
+ failed++;
257
+ if (res.status === 401) consecutive401++; else consecutive401 = 0;
258
+ emit({ file: r.files[0], files: r.files, rule: 'request-failed', kind: 'error', severity: 'low', label: 'finding', sent: !!res.requested, detail: res.reason, model: null });
259
+ if (consecutive401 >= 2) { aborted = 'the service rejected the key twice; the run stopped'; break; }
260
+ continue;
261
+ }
262
+ consecutive401 = 0; asked++; model = res.model || model;
263
+ for (const n of r.names) {
264
+ const j = judge(n, res.answers[n]);
265
+ if (!j) continue;
266
+ raised++;
267
+ emit({
268
+ file: r.files[0], files: r.files, rule: Q[n].rule, kind: 'judged', facet: Q[n].kind,
269
+ question: Q[n].instructions, answer: { type: Q[n].type, value: j.value, probabilities: j.probabilities },
270
+ confidence: j.confidence, confidence_source: j.confidence_source, model: res.model || null,
271
+ severity: j.severity, label: j.confidence != null && j.confidence >= 0.6 ? 'finding' : 'for-your-judgement', sent: true,
272
+ });
273
+ }
274
+ }
275
+
276
+ const summary = { type: 'run', run_id: runId, at, host: typesafe.HOST, model, requests: reqs.length, asked, failed, raised,
277
+ local_findings: lines.filter((l) => l.kind === 'deterministic').length, withheld: sv.withheld.length, too_large: sv.tooLarge.length, excluded: sv.excluded,
278
+ aborted };
279
+ try {
280
+ guard.appendLine(findingsFile, summary);
281
+ for (const l of lines) guard.appendLine(findingsFile, l);
282
+ } catch (e) {
283
+ return { ok: false, error: 'the findings could not be recorded — ' + String((e && e.message) || e).slice(0, 200) };
284
+ }
285
+ return { ok: true, run_id: runId, findings_file: findingsFile, ...summary, recorded: lines.length };
286
+ }
287
+
288
+ /** The latest run's findings (and its summary), newest run only, sorted by severity then confidence. */
289
+ function readFindings(findingsFile, limit = 200) {
290
+ let raw = '';
291
+ try { raw = fs.readFileSync(findingsFile, 'utf8'); } catch { return { run: null, findings: [] }; }
292
+ const recs = [];
293
+ for (const line of raw.split('\n')) { if (!line) continue; try { recs.push(JSON.parse(line)); } catch { /* a torn line is skipped, not fatal */ } }
294
+ let lastRun = null;
295
+ for (const r of recs) if (r.type === 'run') lastRun = r;
296
+ if (!lastRun) return { run: null, findings: [] };
297
+ const rank = { high: 0, medium: 1, low: 2 };
298
+ const findings = recs.filter((r) => r.type === 'finding' && r.run_id === lastRun.run_id)
299
+ .sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3) || (b.confidence || 0) - (a.confidence || 0))
300
+ .slice(0, limit);
301
+ return { run: lastRun, findings };
302
+ }
303
+
304
+ module.exports = { plan, run, readFindings, survey, judge, Q, MAX_DOCS, MAX_PAIRS };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.7",
3
+ "version": "0.39.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.7",
3
+ "version": "0.39.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -10,12 +10,12 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
17
  "doctor": "node doctor.js",
18
- "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js",
18
+ "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js",
19
19
  "test:offline": "node offline-tarball.test.js"
20
20
  },
21
21
  "engines": {
@@ -42,6 +42,8 @@
42
42
  — test.js requires it and walks its whole input domain. It reads window.PMOSSessionState for
43
43
  the attention order, so it must come AFTER that script. -->
44
44
  <script src="/lib/working-set.js"></script>
45
+ <!-- Group-by-status for the initiative list: the order and the mapping from the DERIVED status, walkable in node. -->
46
+ <script src="/lib/status-groups.js"></script>
45
47
  <!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
46
48
  window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
47
49
  rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
@@ -33,7 +33,7 @@ import { isMobile, mountMobile, applyMobile } from './mobile.js';
33
33
  const MODE_TITLE = {
34
34
  product: 'initiatives', scheduled: 'scheduled tasks', workflow: 'workflows', skills: 'skills',
35
35
  knowledge: 'knowledge', agent: 'agents', memory: 'memory', connect: 'connections', focus: 'focus',
36
- portfolio: 'portfolio',
36
+ portfolio: 'portfolio', guide: 'guide', validate: 'validate',
37
37
  };
38
38
  const DETAIL_TITLE = {
39
39
  init: 'initiative', flow: 'workflow', sched: 'scheduled task', skill: 'skill', know: 'decision',
@@ -47,15 +47,17 @@ const detailTitle = () => DETAIL_TITLE[(state.detail && state.detail.kind) || ''
47
47
  /* Panels 1 and 2 are resizable: a drag handle on each right edge. The width is clamped, remembered
48
48
  * (state.js PERSIST) and reset by a double-click. Before this the columns were a fixed 210px / 280px and a
49
49
  * long repository or branch name was simply cut off. */
50
- const W0 = { min: 180, max: 520, def: 210 };
51
- const W1 = { min: 220, max: 560, def: 280 };
50
+ const W0 = { min: 220, max: 560, def: 280 };
52
51
  const clampW = (v, r) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(r.max, Math.max(r.min, Math.round(v))) : r.def);
53
52
  const col0w = () => clampW(state.col0W, W0);
54
- const col1w = () => clampW(state.col1W, W1);
55
53
 
54
+ /* THREE columns (column-layout): navigation, detail, tabs. Navigation is the repository list with the list of the
55
+ * current mode UNDER it (initiatives, or memory, skills, knowledge …). The detail opens in the second column, the
56
+ * tabs fill the rest. The DOM still holds four hosts in the same order — repos, list, detail, tabs — and the grid
57
+ * puts the list host under the repositories host (see placeHosts). Folded away, the navigation column is a 36px
58
+ * rail; the list alone folds to a one-line bar under the repositories. */
56
59
  const gridCols = () =>
57
60
  (state.col0 ? col0w() + 'px' : '36px') + ' ' +
58
- (state.col1 ? col1w() + 'px' : '36px') + ' ' +
59
61
  (state.col2 ? 'minmax(420px,560px)' : '36px') + ' minmax(0,1fr)';
60
62
 
61
63
  /** The right-hand 7px of a column is its drag handle. It is a hit-test on the host, not an extra element:
@@ -172,10 +174,11 @@ const statusBar = el('div', {
172
174
  * disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
173
175
  * middle track is: an auto row is floored at its items' min-content height, and one tall panel
174
176
  * would push the grid taller than the box it was given. */
175
- const BODY_GRID_STYLE = () =>
176
- 'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:minmax(0,1fr);min-height:0;overflow:hidden';
177
+ // Two rows on a desktop (repositories, then the list under them), one on a phone, where the hosts are shown one at a time.
178
+ const BODY_GRID_STYLE = (phone) =>
179
+ 'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:' + (phone ? 'minmax(0,1fr)' : 'auto minmax(0,1fr)') + ';min-height:0;overflow:hidden';
177
180
 
178
- const bodyGrid = el('div', { style: BODY_GRID_STYLE() });
181
+ const bodyGrid = el('div', { style: BODY_GRID_STYLE(false) });
179
182
 
180
183
  /** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
181
184
  function makeRail(flag) {
@@ -189,8 +192,14 @@ function makeRail(flag) {
189
192
  }
190
193
 
191
194
  const rail0 = makeRail('col0');
192
- const rail1 = makeRail('col1');
193
195
  const rail2 = makeRail('col2');
196
+ /* The list under the repositories, folded: one line, not a column. */
197
+ const listBar = el('button', {
198
+ onClick: () => setState((s) => ({ col1: !s.col1 })),
199
+ title: 'show the list',
200
+ style: 'background:var(--sidebar);border-right:1px solid var(--border);border-top:1px solid var(--border);display:flex;align-items:center;align-self:start;gap:6px;padding:8px 14px;min-height:34px;font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--muted-foreground);text-align:left',
201
+ hover: 'background:var(--accent)',
202
+ });
194
203
  rail0.label.textContent = 'repositories ⟩';
195
204
  /* overhaul-r1 R4: in live mode the collapsed repositories rail also carries one glyph per repository
196
205
  * (panel-repos.js#railGlyphs), so a repository is selectable without expanding the panel. The
@@ -208,9 +217,9 @@ const rail0Host = el('div', {
208
217
  * two that already carry `overflow-y:auto` get it for free under the spec — stated anyway, because
209
218
  * "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
210
219
  const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
211
- const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
220
+ const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);border-top:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
212
221
  makeEdgeDrag(hostRepos, 'col0W', W0, col0w);
213
- makeEdgeDrag(hostInits, 'col1W', W1, col1w);
222
+ makeEdgeDrag(hostInits, 'col0W', W0, col0w);
214
223
  const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
215
224
  const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
216
225
 
@@ -256,6 +265,27 @@ function railRepos() {
256
265
  return rail0Host;
257
266
  }
258
267
 
268
+ /* Where each host sits in the grid. Desktop: column 1 holds the repositories (row 1) and the list under them (row 2); the
269
+ * detail and the tabs span both rows. The repositories block is capped so a long list of repositories cannot push the
270
+ * list off the screen; it scrolls inside its cap. A folded column is a rail spanning both rows. On a phone the placement
271
+ * is cleared and mobile.css decides which single host is shown. */
272
+ function placeHosts(phone, kids) {
273
+ const cell = (el0, col, row) => { if (el0) { el0.style.gridColumn = col; el0.style.gridRow = row; } };
274
+ const clear0 = (el0) => { el0.style.gridColumn = ''; el0.style.gridRow = ''; el0.style.maxHeight = ''; };
275
+ for (const h of [hostRepos, hostInits, hostDetail, hostTabs, listBar, rail0.btn, rail0Host, rail2.btn]) clear0(h);
276
+ if (phone) return;
277
+ if (state.col0) {
278
+ cell(hostRepos, '1', '1');
279
+ hostRepos.style.maxHeight = 'calc((100vh - 70px) * .42)';
280
+ cell(state.col1 ? hostInits : listBar, '1', '2');
281
+ } else {
282
+ const rail = kids.includes(rail0Host) ? rail0Host : rail0.btn;
283
+ cell(rail, '1', '1 / 3');
284
+ }
285
+ cell(state.col2 ? hostDetail : rail2.btn, '2', '1 / 3');
286
+ cell(hostTabs, '3', '1 / 3');
287
+ }
288
+
259
289
  function render() {
260
290
  root.setAttribute('data-pm-theme', state.theme);
261
291
  themeBtn.textContent = state.theme === 'dark' ? 'theme · dark' : 'theme · light';
@@ -266,26 +296,31 @@ function render() {
266
296
 
267
297
  // Panel 1's rail is a constant; panels 2 and 3 name whatever is behind them, so a PM who folded
268
298
  // a panel away can still see what they folded.
269
- rail1.label.textContent = modeTitle() + ' ⟩';
299
+ listBar.textContent = '▸ ' + modeTitle();
270
300
  rail2.label.textContent = detailTitle() + ' ⟩';
271
301
 
272
- bodyGrid.setAttribute('style', BODY_GRID_STYLE());
302
+ const phone = isMobile();
303
+ bodyGrid.setAttribute('style', BODY_GRID_STYLE(phone));
273
304
 
274
305
  // On a phone there are no rails to collapse into and no second column to fold away: the four
275
306
  // hosts all stay mounted, one is DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the
276
307
  // PM's desktop arrangement — col0/col1/col2 — is left exactly as they set it. Mounting all four
277
308
  // is also what keeps a live terminal alive: a host dropped from this list would be removed from
278
309
  // the document, and panel 4's xterm does not survive that.
279
- const phone = isMobile();
280
- setChildren(bodyGrid, phone ? [hostRepos, hostInits, hostDetail, hostTabs] : [
310
+ const listShown = phone || (state.col0 && state.col1);
311
+ const listHost = phone || state.col1 ? hostInits : listBar;
312
+ const kids = phone ? [hostRepos, hostInits, hostDetail, hostTabs] : [
281
313
  state.col0 ? hostRepos : railRepos(),
282
- state.col1 ? hostInits : rail1.btn,
314
+ // with the navigation column folded away the list goes with it
315
+ state.col0 ? listHost : null,
283
316
  state.col2 ? hostDetail : rail2.btn,
284
317
  hostTabs,
285
- ]);
318
+ ].filter(Boolean);
319
+ setChildren(bodyGrid, kids);
320
+ placeHosts(phone, kids);
286
321
 
287
322
  if (phone || state.col0) paint(mods.repos, 'renderRepos', hostRepos);
288
- if (phone || state.col1) paint(mods.inits, 'renderInitiatives', hostInits);
323
+ if (listShown) paint(mods.inits, 'renderInitiatives', hostInits);
289
324
  if (phone || state.col2) paint(mods.detail, 'renderDetail', hostDetail);
290
325
  paint(mods.tabs, 'renderTabs', hostTabs);
291
326
 
@@ -452,6 +452,12 @@ function recordFrom(payload) {
452
452
  sessions: (payload.sessions || []).map(sessionRow),
453
453
  links: linksFor(payload),
454
454
  arts: artsFor(payload),
455
+ // R4/R5/R6 — what remains to acceptance, the conversations that belong to it, and the files git says
456
+ // were changed for it. Each arrives already derived by the server; this layer only carries them.
457
+ nextSteps: payload.next_steps || null,
458
+ lcHistory: payload.lifecycle_history || [],
459
+ convs: Array.isArray(payload.conversations) ? payload.conversations : [],
460
+ gitFiles: payload.files || null,
455
461
  eval: evalFor(row && row.steps),
456
462
  l2: l2For(row),
457
463
  };
@@ -646,10 +652,24 @@ function foldWorkspaces(rows) {
646
652
 
647
653
  /** Load one product's board: the spine (structure) joined with the library (artifacts). */
648
654
  async function loadBoard(prod) {
649
- const [spine, lib] = await Promise.all([
655
+ const [spine, lib, surf] = await Promise.all([
650
656
  getJson('/api/console', { name: 'spine', ws: prod.ws }),
651
657
  getJson('/api/library', { ws: prod.ws }),
658
+ // The DERIVED status of every initiative (server.js deriveState + the lifecycle ledger) — what "group by
659
+ // status" reads. A failed read is kept as a reason, never as an empty map: an empty map would file every
660
+ // initiative under Unknown without saying why.
661
+ getJson('/api/surface', { name: 'initiatives', ws: prod.ws }),
652
662
  ]);
663
+ {
664
+ const items = (surf && surf.ok !== false && ((surf.surface && surf.surface.initiatives) || surf.initiatives)) || null;
665
+ prod.statuses = {};
666
+ if (items) {
667
+ for (const i of items) if (i && i.id) prod.statuses[i.id] = { status: i.status, statusText: i.statusText || '', lifecycle: i.lifecycle || null };
668
+ prod.statusError = null;
669
+ } else {
670
+ prod.statusError = (surf && surf.error) || 'the derived status of the initiatives could not be read';
671
+ }
672
+ }
653
673
  if (spine.ok === false) {
654
674
  prod.error = spine.error || 'the initiative spine could not be read';
655
675
  prod.board = [];
@@ -722,6 +742,7 @@ function publishBoards() {
722
742
  * Scoping it by the selected workspace is how a tab in one repo attached to a shell in another
723
743
  * (tab-term.js `wsForTab`). */
724
744
  const productWs = {};
745
+ const statuses = {}; const statusErrors = {};
725
746
  let records = {};
726
747
  for (const name of productOrder) {
727
748
  const p = products.get(name);
@@ -729,13 +750,14 @@ function publishBoards() {
729
750
  if (p.ws) productWs[name] = p.ws;
730
751
  if (!p.board) continue;
731
752
  board[name] = p.board;
753
+ statuses[name] = p.statuses || {}; if (p.statusError) statusErrors[name] = p.statusError;
732
754
  records = { ...records, ...p.records };
733
755
  }
734
756
  // `wsBranches` rides the same patch as `productWs` and for the same reason: it is a fact about
735
757
  // every registered working tree, read once from /api/workspaces, that a panel needs in order to
736
758
  // draw a row. panel 2 joins it against `state.sessions` to say which initiative each live agent is
737
759
  // on — the same `initiative/<id>` join lib/four-panel.js makes per initiative on the server.
738
- setState({ board, productWs, wsBranches: branchMap(), records: { ...records, ...(hydrated) } });
760
+ setState({ board, statuses, statusErrors, productWs, wsBranches: branchMap(), records: { ...records, ...(hydrated) } });
739
761
  }
740
762
 
741
763
  /* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
@@ -1403,14 +1425,18 @@ export async function saveScratchpad(key, text) {
1403
1425
  }
1404
1426
 
1405
1427
  /** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
1406
- export async function readDoc(path, ws) {
1428
+ export async function readDoc(path, ws, ref) {
1407
1429
  if (FIXTURE) return '';
1408
1430
  const p = products.get(currentProductName());
1409
1431
  // `ws` is the tree a search hit came from (overhaul-r1 R1.3) — a branch-only file must be read
1410
1432
  // from its own worktree, not from the product's main tree.
1411
- const url = '/api/doc?' + q({ ws: ws || state.wsId || (p ? p.ws : ''), path });
1433
+ const url = '/api/doc?' + q({ ws: ws || state.wsId || (p ? p.ws : ''), path, ...(ref ? { ref } : {}) });
1412
1434
  const res = await fetch(url);
1413
- if (!res.ok) throw new Error(res.status === 404 ? 'not a readable text document in this workspace' : 'HTTP ' + res.status);
1435
+ if (!res.ok) {
1436
+ // The server states why a branch read was refused; the plain 404 text is the generic one.
1437
+ const why = res.status === 404 ? (await res.text().catch(() => '')) : '';
1438
+ throw new Error(res.status === 404 ? (why && ref ? why : 'not a readable text document in this workspace') : 'HTTP ' + res.status);
1439
+ }
1414
1440
  return res.text();
1415
1441
  }
1416
1442
 
@@ -1558,6 +1584,16 @@ if (!FIXTURE) {
1558
1584
  * Each of these is reached only by a PM press. None runs on load, on a timer or as a side effect. */
1559
1585
  export function wsOf(name) { const p = products.get(name); return p ? p.ws : null; }
1560
1586
 
1587
+ /* ── next-step actions: prepare, go, review (the routes are in server.js; every one is reached by a PM press) ── */
1588
+ export async function nextStepPost(kind, body) {
1589
+ if (FIXTURE) return { ok: false, error: 'fixture mode starts nothing' };
1590
+ return (await postJson('/api/next-step/' + kind, { ws: wsOf(currentProductName()), ...body })) || { ok: false, error: 'the workspace server did not answer' };
1591
+ }
1592
+ export async function nextStepSummary(session) { return FIXTURE ? { ok: false, error: 'fixture mode' } : getJson('/api/next-step/summary', { session }); }
1593
+ export async function nextStepProfiles() { return FIXTURE ? { ok: false, profiles: [] } : getJson('/api/profiles'); }
1594
+ /** Re-read one initiative (its next steps, runs and verdicts) after something changed. */
1595
+ export async function refreshInitiative(slug) { if (FIXTURE || !slug) return; delete hydrated[slug]; await hydrate(slug); }
1596
+
1561
1597
  /** The lifecycle of every initiative in a repository, from the same surface the board uses. */
1562
1598
  export async function lifecycleMap(name) {
1563
1599
  const ws = wsOf(name);
@@ -62,7 +62,7 @@ const DEST_MODE = { board: 'product', scheduled: 'scheduled', agents: 'agent', m
62
62
  /* Two modes postdate the reducer's map (`connect` and `focus` arrived with the four-panel design).
63
63
  * They are named here rather than added to `DEST_FOR_MODE`, because MOBNAV-P7 pins that map to the
64
64
  * retired app.js's MODES exactly and a mode invented for a different surface would read as drift. */
65
- const EXTRA_DEST = { connect: 'more', focus: 'board' };
65
+ const EXTRA_DEST = { connect: 'more', focus: 'board', portfolio: 'more', guide: 'more' };
66
66
 
67
67
  const destForMode = (m) => (NAV && NAV.destForMode(m)) || EXTRA_DEST[m] || null;
68
68