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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +37 -1
- package/workspace/bin/pmos.js +8 -1
- package/workspace/guide/index.md +40 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +73 -0
- package/workspace/guide/proactive-engine.md +75 -0
- package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
- package/workspace/guide/statuses.md +53 -0
- package/workspace/guide/validate-with-typesafe.md +80 -0
- package/workspace/lib/brief.js +113 -0
- package/workspace/lib/claims.js +605 -0
- package/workspace/lib/doc-profiles.js +74 -0
- package/workspace/lib/initiative-files.js +37 -6
- package/workspace/lib/judgment.js +65 -0
- package/workspace/lib/mobile-nav.js +1 -1
- package/workspace/lib/next-step-actions.js +139 -0
- package/workspace/lib/next-steps.js +27 -7
- package/workspace/lib/preview-kind.js +17 -2
- package/workspace/lib/proactive-cli.js +137 -0
- package/workspace/lib/proactive.js +208 -0
- package/workspace/lib/repo-watch.js +120 -0
- package/workspace/lib/schedule-cli.js +5 -2
- package/workspace/lib/secret-scan.js +28 -0
- package/workspace/lib/session-budget.js +35 -0
- package/workspace/lib/status-groups.js +53 -10
- package/workspace/lib/typesafe-settings.js +122 -0
- package/workspace/lib/typesafe.js +124 -0
- package/workspace/lib/validate.js +304 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +53 -18
- package/workspace/public/pm/data.js +18 -3
- package/workspace/public/pm/mobile.js +1 -1
- package/workspace/public/pm/modes-live.js +373 -0
- package/workspace/public/pm/modes.js +26 -0
- package/workspace/public/pm/next-step-ui.js +185 -0
- package/workspace/public/pm/panel-detail.js +47 -11
- package/workspace/public/pm/panel-initiatives.js +26 -4
- package/workspace/public/pm/panel-repos.js +2 -2
- package/workspace/public/pm/state.js +3 -3
- package/workspace/public/pm/status-banner.js +87 -0
- package/workspace/public/pm/tab-doc.js +66 -8
- package/workspace/server.js +562 -18
- 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 };
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
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:
|
|
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
|
-
|
|
176
|
-
|
|
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, '
|
|
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
|
-
|
|
299
|
+
listBar.textContent = '▸ ' + modeTitle();
|
|
270
300
|
rail2.label.textContent = detailTitle() + ' ⟩';
|
|
271
301
|
|
|
272
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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);
|