create-pmos 0.38.8 → 0.40.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 (49) 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 +37 -1
  7. package/workspace/bin/pmos.js +8 -1
  8. package/workspace/guide/index.md +40 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +73 -0
  11. package/workspace/guide/proactive-engine.md +75 -0
  12. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  13. package/workspace/guide/statuses.md +53 -0
  14. package/workspace/guide/validate-with-typesafe.md +80 -0
  15. package/workspace/lib/brief.js +113 -0
  16. package/workspace/lib/claims.js +605 -0
  17. package/workspace/lib/doc-profiles.js +74 -0
  18. package/workspace/lib/initiative-files.js +37 -6
  19. package/workspace/lib/judgment.js +65 -0
  20. package/workspace/lib/mobile-nav.js +1 -1
  21. package/workspace/lib/next-step-actions.js +139 -0
  22. package/workspace/lib/next-steps.js +27 -7
  23. package/workspace/lib/preview-kind.js +17 -2
  24. package/workspace/lib/proactive-cli.js +137 -0
  25. package/workspace/lib/proactive.js +208 -0
  26. package/workspace/lib/repo-watch.js +120 -0
  27. package/workspace/lib/schedule-cli.js +5 -2
  28. package/workspace/lib/secret-scan.js +28 -0
  29. package/workspace/lib/session-budget.js +35 -0
  30. package/workspace/lib/status-groups.js +53 -10
  31. package/workspace/lib/typesafe-settings.js +122 -0
  32. package/workspace/lib/typesafe.js +124 -0
  33. package/workspace/lib/validate.js +304 -0
  34. package/workspace/package-lock.json +1 -1
  35. package/workspace/package.json +3 -3
  36. package/workspace/public/pm/app.js +53 -18
  37. package/workspace/public/pm/data.js +18 -3
  38. package/workspace/public/pm/mobile.js +1 -1
  39. package/workspace/public/pm/modes-live.js +373 -0
  40. package/workspace/public/pm/modes.js +26 -0
  41. package/workspace/public/pm/next-step-ui.js +185 -0
  42. package/workspace/public/pm/panel-detail.js +47 -11
  43. package/workspace/public/pm/panel-initiatives.js +26 -4
  44. package/workspace/public/pm/panel-repos.js +2 -2
  45. package/workspace/public/pm/state.js +3 -3
  46. package/workspace/public/pm/status-banner.js +87 -0
  47. package/workspace/public/pm/tab-doc.js +66 -8
  48. package/workspace/server.js +562 -18
  49. package/workspace/skills/morning-brief.skill +43 -0
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /* typesafe.js — the ONE place the workspace server makes an outbound request (D79).
3
+ *
4
+ * D69, D70 and D71 say the PMOS server makes no outbound request. D79 narrows that by one named
5
+ * exception: HTTPS to `api.typesafe.ai`, from this module, and nowhere else. Three properties keep the
6
+ * exception small, and each has a test:
7
+ *
8
+ * 1. ONE HOST, FIXED IN SOURCE. The host is a constant. No argument, setting or environment variable
9
+ * names it. A response that redirects is REFUSED, not followed, because a redirect is how a fixed
10
+ * host becomes another host.
11
+ * 2. THE KEY LEAVES THIS PROCESS IN ONE HEADER. It is an argument to `ask`, never stored here, never
12
+ * placed in an error, a log line or a returned object.
13
+ * 3. NO KEY, NO REQUEST. `ask` refuses before opening a socket when the key is absent.
14
+ *
15
+ * What Jev is: a classifier. The caller sends text (`state`) and typed questions; it returns answers
16
+ * with probabilities. It reads no files and takes no action — the caller reads the files.
17
+ * Noul "high means yes"; the single `noul` number is P(yes). There is no separate confidence.
18
+ * Score levels 0..N-1; `score` is the probability-weighted mean; `confidence` is reported.
19
+ * Choice one option plus a distribution.
20
+ */
21
+ const https = require('https');
22
+ const http = require('http');
23
+
24
+ const HOST = 'api.typesafe.ai';
25
+ const PATH = '/v1/systemone';
26
+ const DEFAULT_MODEL = 'jev-latest';
27
+ const MAX_STATE_CHARS = 60000; // a conservative cap; the documentation states no limit
28
+ const MAX_RESPONSE_BYTES = 1 << 20; // 1 MiB — an answer set is small; anything larger is not one
29
+ const MAX_QUESTIONS = 16;
30
+
31
+ /* A seam for tests, gated like the sink's. It lets a suite point this module at a loopback stub; it
32
+ * is unreachable in a production process, so no setting can redirect the key to another host. */
33
+ let endpointForTests = null;
34
+ function __setEndpointForTests(e) {
35
+ const on = (v) => v === '1' || v === 'true' || v === 'yes';
36
+ if (process.env.NODE_ENV !== 'test' && !on(process.env.PMOS_TEST)) {
37
+ throw new Error('__setEndpointForTests is a test seam — set NODE_ENV=test or PMOS_TEST=1 to use it');
38
+ }
39
+ endpointForTests = e || null;
40
+ }
41
+ function endpoint() {
42
+ return endpointForTests || { protocol: 'https:', hostname: HOST, port: 443 };
43
+ }
44
+
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
+ const retryable = (status) => status === 429 || status === 529 || (status >= 500 && status < 600);
47
+
48
+ function validKey(key) {
49
+ return typeof key === 'string' && key.length >= 8 && key.length <= 512 && !/[\s\u0000-\u001f\u007f]/.test(key);
50
+ }
51
+
52
+ function once({ key, body, timeoutMs }) {
53
+ const ep = endpoint();
54
+ const mod = ep.protocol === 'http:' ? http : https;
55
+ const payload = Buffer.from(body, 'utf8');
56
+ return new Promise((resolve) => {
57
+ let done = false;
58
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
59
+ const req = mod.request({
60
+ protocol: ep.protocol, hostname: ep.hostname, port: ep.port, path: PATH, method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json', 'Content-Length': payload.length,
63
+ Authorization: 'Bearer ' + key, 'User-Agent': 'pmos-workspace',
64
+ },
65
+ timeout: timeoutMs,
66
+ }, (res) => {
67
+ const status = res.statusCode || 0;
68
+ // Refused, not followed: the host is fixed in source and a redirect would change it.
69
+ if (status >= 300 && status < 400) { res.resume(); return finish({ ok: false, status, reason: 'the service sent a redirect, which is refused' }); }
70
+ const chunks = []; let size = 0;
71
+ res.on('data', (c) => {
72
+ size += c.length;
73
+ if (size > MAX_RESPONSE_BYTES) { finish({ ok: false, status, reason: 'the response was larger than expected and was dropped' }); req.destroy(); return; }
74
+ chunks.push(c);
75
+ });
76
+ res.on('end', () => {
77
+ let json = null;
78
+ try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* not JSON */ }
79
+ if (status >= 200 && status < 300 && json && typeof json.answers === 'object' && json.answers) {
80
+ return finish({ ok: true, status, model: String(json.model || ''), answers: json.answers, usage: json.usage || null });
81
+ }
82
+ const why = status === 401 ? 'the service rejected the key (401)'
83
+ : status === 422 ? 'the service rejected the request as malformed (422)'
84
+ : status === 429 ? 'the service rate-limited the request (429)'
85
+ : status === 529 ? 'the service is overloaded (529)'
86
+ : `the service answered ${status || 'with something unreadable'}`;
87
+ finish({ ok: false, status, reason: why });
88
+ });
89
+ res.on('error', () => finish({ ok: false, status, reason: 'the connection broke while reading the answer' }));
90
+ });
91
+ req.on('timeout', () => { finish({ ok: false, status: 0, reason: 'the service did not answer in time' }); req.destroy(); });
92
+ req.on('error', (e) => finish({ ok: false, status: 0, reason: 'the service could not be reached (' + String((e && e.code) || 'network error') + ')' }));
93
+ req.end(payload);
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Ask Jev typed questions about `state`.
99
+ *
100
+ * @param {object} a
101
+ * @param {string} a.key the API key. Required; no key means no request.
102
+ * @param {string} a.state the text to judge. Refused above MAX_STATE_CHARS.
103
+ * @param {object} a.questions { name: { type: 'noul'|'score'|'choice', instructions, criteria? } }
104
+ * @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
105
+ * `requested` says whether a request left this process, so a caller can tell a refusal from a failure.
106
+ */
107
+ async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 30000, retries = 2, backoffMs = 600 } = {}) {
108
+ if (!validKey(key)) return { ok: false, requested: false, reason: 'no TypeSafe key is set' };
109
+ if (typeof state !== 'string' || !state.trim()) return { ok: false, requested: false, reason: 'there is no text to judge' };
110
+ if (state.length > MAX_STATE_CHARS) return { ok: false, requested: false, reason: `the text is longer than ${MAX_STATE_CHARS} characters` };
111
+ const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
112
+ if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
113
+ const body = JSON.stringify({ state, model, questions });
114
+ let last = null;
115
+ for (let attempt = 0; attempt <= retries; attempt++) {
116
+ last = await once({ key, body, timeoutMs });
117
+ if (last.ok) return { ...last, requested: true };
118
+ if (!(last.status === 0 || retryable(last.status)) || attempt === retries) break;
119
+ await sleep(backoffMs * Math.pow(2, attempt));
120
+ }
121
+ return { ok: false, requested: true, status: last.status, reason: last.reason };
122
+ }
123
+
124
+ module.exports = { ask, validKey, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, __setEndpointForTests };
@@ -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.8",
3
+ "version": "0.40.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.8",
3
+ "version": "0.40.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 && node schedule-cli.test.js && node initiative-surface.test.js",
13
+ "test:modules": "node proactive.test.js && 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 claims.test.js && node repo-watch.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 && node pm-r16-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 && node pm-r22-test.js && node pm-r23-test.js",
19
19
  "test:offline": "node offline-tarball.test.js"
20
20
  },
21
21
  "engines": {
@@ -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', today: 'today',
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
 
@@ -455,6 +455,7 @@ function recordFrom(payload) {
455
455
  // R4/R5/R6 — what remains to acceptance, the conversations that belong to it, and the files git says
456
456
  // were changed for it. Each arrives already derived by the server; this layer only carries them.
457
457
  nextSteps: payload.next_steps || null,
458
+ lcHistory: payload.lifecycle_history || [],
458
459
  convs: Array.isArray(payload.conversations) ? payload.conversations : [],
459
460
  gitFiles: payload.files || null,
460
461
  eval: evalFor(row && row.steps),
@@ -1424,14 +1425,18 @@ export async function saveScratchpad(key, text) {
1424
1425
  }
1425
1426
 
1426
1427
  /** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
1427
- export async function readDoc(path, ws) {
1428
+ export async function readDoc(path, ws, ref) {
1428
1429
  if (FIXTURE) return '';
1429
1430
  const p = products.get(currentProductName());
1430
1431
  // `ws` is the tree a search hit came from (overhaul-r1 R1.3) — a branch-only file must be read
1431
1432
  // from its own worktree, not from the product's main tree.
1432
- 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 } : {}) });
1433
1434
  const res = await fetch(url);
1434
- 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 read was refused (binary, too large, not text, outside the workspace); show that reason.
1437
+ const why = res.status === 404 ? (await res.text().catch(() => '')) : '';
1438
+ throw new Error(res.status === 404 ? (why || 'not a readable text document in this workspace') : 'HTTP ' + res.status);
1439
+ }
1435
1440
  return res.text();
1436
1441
  }
1437
1442
 
@@ -1579,6 +1584,16 @@ if (!FIXTURE) {
1579
1584
  * Each of these is reached only by a PM press. None runs on load, on a timer or as a side effect. */
1580
1585
  export function wsOf(name) { const p = products.get(name); return p ? p.ws : null; }
1581
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
+
1582
1597
  /** The lifecycle of every initiative in a repository, from the same surface the board uses. */
1583
1598
  export async function lifecycleMap(name) {
1584
1599
  const ws = wsOf(name);