create-pmos 0.38.8 → 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.
- 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 +29 -1
- package/workspace/guide/index.md +38 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +59 -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 +47 -0
- package/workspace/lib/initiative-files.js +29 -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/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 +119 -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 +152 -0
- package/workspace/public/pm/modes.js +25 -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 +11 -6
- package/workspace/server.js +323 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-banner.js — status-clarity R2/R3/R4. The block at the top of an initiative that says where it stands.
|
|
3
|
+
*
|
|
4
|
+
* It states, from facts the server already derived: the group, why (the evidence behind it), who acts, the one
|
|
5
|
+
* thing to do next, how the status will change, and, for "needs you", each open item by its own words. It adds
|
|
6
|
+
* no fact of its own: the group text comes from lib/status-groups.js, the evidence from deriveState, the steps
|
|
7
|
+
* from lib/next-steps.js. A failed read says so and shows no group.
|
|
8
|
+
*
|
|
9
|
+
* The lifecycle control (hold, close, cancel, reopen) sits beside it with one sentence on what it does and the
|
|
10
|
+
* history of every change. Acceptance is the PM's and is recorded in a session, not by a button here (D12), so the
|
|
11
|
+
* banner says how and offers the list of what is waiting.
|
|
12
|
+
*/
|
|
13
|
+
import { el } from './dom.js';
|
|
14
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
15
|
+
|
|
16
|
+
const SG = () => (typeof window !== 'undefined' ? window.PMOSStatusGroups : null);
|
|
17
|
+
const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
18
|
+
const TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', 'awaiting-acceptance': 'var(--foreground)', unknown: 'var(--destructive)' };
|
|
19
|
+
const WHO = { pm: 'you', agent: 'the agent', system: 'nobody — it is derived' };
|
|
20
|
+
const iv = (t) => el('span', null, t == null ? '' : String(t));
|
|
21
|
+
|
|
22
|
+
function line(label, value, attr) {
|
|
23
|
+
return el('div', { style: 'display:grid;grid-template-columns:78px 1fr;gap:8px;padding:2px 0' },
|
|
24
|
+
el('span', { style: LAB + ';padding-top:2px' }, label),
|
|
25
|
+
el('span', { 'data-r18': attr, style: 'font-size:12px;line-height:1.5;color:var(--foreground);overflow-wrap:anywhere' }, iv(value)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @returns {HTMLElement|null} null in fixture mode (the prototype has no such block). */
|
|
29
|
+
export function statusBanner(product, slug, rec) {
|
|
30
|
+
const sg = SG();
|
|
31
|
+
if (FIXTURE || !sg || !slug) return null;
|
|
32
|
+
const err = state.statusErrors && state.statusErrors[product];
|
|
33
|
+
const row = state.statuses && state.statuses[product] && state.statuses[product][slug];
|
|
34
|
+
if (err || !row) {
|
|
35
|
+
return el('div', { 'data-r18': 'status-banner', 'data-key': 'unreadable', style: 'padding:8px 14px;border-bottom:1px solid var(--border)' },
|
|
36
|
+
el('span', { style: LAB }, 'status'),
|
|
37
|
+
el('div', { 'data-r18': 'status-error', style: 'font-size:12px;color:var(--destructive);line-height:1.5' },
|
|
38
|
+
'The status could not be read — ' + (err || 'the server returned no status for this initiative') + '. No group is shown because none can be derived.'));
|
|
39
|
+
}
|
|
40
|
+
const key = sg.keyOf(row);
|
|
41
|
+
const g = sg.BY_KEY[key];
|
|
42
|
+
const items = (rec && rec.nextSteps && rec.nextSteps.items) || [];
|
|
43
|
+
const waiting = items.filter((i) => i.kind === 'needs' || i.kind === 'returned');
|
|
44
|
+
// The one thing to do next: the engine's first item, unless it is not yours and something is waiting on you.
|
|
45
|
+
const mine = items.filter((i) => i.owner === 'pm' && !i.held);
|
|
46
|
+
const first = items.find((i) => i.first) || items[0] || null;
|
|
47
|
+
const next = g.owner === 'pm' ? (mine[0] || first) : first;
|
|
48
|
+
const nextText = next ? next.text + ' — ' + next.action : (g.key === 'accepted' ? 'Nothing remains.' : 'No step is outstanding.');
|
|
49
|
+
|
|
50
|
+
const kids = [
|
|
51
|
+
el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
|
|
52
|
+
el('span', { style: LAB }, 'status'),
|
|
53
|
+
el('span', { 'data-r18': 'status-group', style: 'font-size:13px;font-weight:600;color:' + (TONE[key] || 'var(--foreground)') }, g.label),
|
|
54
|
+
el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'derived, not typed')),
|
|
55
|
+
line('why', row.statusText ? row.statusText + '. ' + g.meaning : g.meaning, 'status-why'),
|
|
56
|
+
line('acts now', WHO[g.owner] || g.owner, 'status-owner'),
|
|
57
|
+
line('next', nextText, 'status-next'),
|
|
58
|
+
line('changes when', g.moves, 'status-moves'),
|
|
59
|
+
];
|
|
60
|
+
if (waiting.length) {
|
|
61
|
+
kids.push(el('div', { 'data-r18': 'needs-items', style: 'display:flex;flex-direction:column;gap:2px;padding-top:4px' },
|
|
62
|
+
el('span', { style: LAB }, 'waiting on you · ' + waiting.length),
|
|
63
|
+
waiting.map((w) => el('div', { 'data-r18': 'needs-item', style: 'font-size:12px;line-height:1.5;color:var(--foreground);padding-left:10px;border-left:2px solid var(--destructive)' },
|
|
64
|
+
iv(w.text + (w.action ? ' — ' + w.action : ''))))));
|
|
65
|
+
}
|
|
66
|
+
if (key === 'awaiting-acceptance' || key === 'needs-you') {
|
|
67
|
+
kids.push(el('div', { 'data-r18': 'acceptance-note', style: 'font-size:11px;line-height:1.5;color:var(--muted-foreground);padding-top:4px' },
|
|
68
|
+
'Acceptance is yours and cannot be delegated (D12). Tell the session that holds this initiative to record it, or run scripts/record-acceptance.js. The workspace never records one for you.'),
|
|
69
|
+
el('button', { 'data-r18': 'see-waiting', onClick: () => setState({ mode: 'focus', settingsOpen: false, col1: true }),
|
|
70
|
+
style: 'align-self:flex-start;font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin-top:4px' },
|
|
71
|
+
'see everything waiting on you'));
|
|
72
|
+
}
|
|
73
|
+
return el('div', { 'data-r18': 'status-banner', 'data-key': key, style: 'display:flex;flex-direction:column;gap:2px;padding:8px 14px;border-bottom:1px solid var(--border)' }, kids);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What the lifecycle control does, and every change made with it. Sits under the control row. */
|
|
77
|
+
export function lifecycleNotes(rec) {
|
|
78
|
+
if (FIXTURE) return null;
|
|
79
|
+
const hist = (rec && rec.lcHistory) || [];
|
|
80
|
+
return el('div', { 'data-r18': 'lifecycle-notes', style: 'display:flex;flex-direction:column;gap:2px;padding:4px 14px 6px;border-bottom:1px solid var(--border)' },
|
|
81
|
+
el('span', { 'data-r18': 'lifecycle-explain', style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' },
|
|
82
|
+
'Hold, close and cancel take this initiative off the board and stop it gating; reopen puts it back. They are the only statuses you set by hand. Every other status is read from git and the ledgers, so it cannot be edited here.'),
|
|
83
|
+
hist.length ? el('div', { 'data-r18': 'lifecycle-history', style: 'display:flex;flex-direction:column;gap:1px' },
|
|
84
|
+
el('span', { style: LAB + ';padding-top:2px' }, 'lifecycle history · ' + hist.length),
|
|
85
|
+
hist.map((h) => el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
|
|
86
|
+
iv(String(h.at || '').slice(0, 10) + ' · ' + h.state + (h.reason ? ' — ' + h.reason : ''))))) : null);
|
|
87
|
+
}
|
|
@@ -60,7 +60,7 @@ const wsId = () => state.wsId || state.ws || state.product || '';
|
|
|
60
60
|
* `ws`, and is read from and saved to THAT tree. Two trees can hold the same path with different
|
|
61
61
|
* content, so every per-document cache keys on tree + path; a main-tree tab keys on path alone,
|
|
62
62
|
* exactly as before. */
|
|
63
|
-
const docKey = (tab) => (tab && tab.ws ? tab.ws + '\u241f' : '') + (tab ? tab.path : '');
|
|
63
|
+
const docKey = (tab) => (tab && tab.guide ? 'guide\u241f' : '') + (tab && tab.ws ? tab.ws + '\u241f' : '') + (tab && tab.ref ? tab.ref + '\u241f' : '') + (tab ? tab.path : '');
|
|
64
64
|
const wsOf = (tab) => (tab && tab.ws) || wsId();
|
|
65
65
|
|
|
66
66
|
/* A REFERENCE is not a file. Panel 3's links section files `jira://ATL-501`, `confluence://PM/x`,
|
|
@@ -97,9 +97,12 @@ function requestDoc(tab) {
|
|
|
97
97
|
if (pending.has(path)) return;
|
|
98
98
|
pending.add(path);
|
|
99
99
|
const done = (text) => { pending.delete(path); disk.set(path, text); setState({}); };
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
// A guide page ships with the app and is read from it, not from the repository (status-clarity R6).
|
|
101
|
+
const load = tab.guide
|
|
102
|
+
? fetch('/api/guide?page=' + encodeURIComponent(tab.path)).then((r) => (r.ok ? r.text() : Promise.reject(new Error('no such guide page'))))
|
|
103
|
+
: (dataMod && typeof dataMod.readDoc === 'function')
|
|
104
|
+
? dataMod.readDoc(tab.path, tab.ws, tab.ref)
|
|
105
|
+
: fetch('/api/doc?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path) + (tab.ref ? '&ref=' + encodeURIComponent(tab.ref) : ''))
|
|
103
106
|
.then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))));
|
|
104
107
|
Promise.resolve(load).then(
|
|
105
108
|
(text) => { unread.delete(path); done(typeof text === 'string' ? text : String(text == null ? '' : text)); },
|
|
@@ -478,11 +481,13 @@ export function mountDoc(host, tab) {
|
|
|
478
481
|
if (!FIXTURE && extOfPath(tab.path) === 'xlsx') { setChildren(host, [sheetBody(tab)]); return; }
|
|
479
482
|
const raw = docText(tab);
|
|
480
483
|
const text = state.docEdits[docKey(tab)] ?? raw;
|
|
481
|
-
const dm = state.docModes[tab.id] ?? 'view';
|
|
484
|
+
const dm = (tab.readonly && (state.docModes[tab.id] === 'edit' || state.docModes[tab.id] === 'diff')) ? 'view' : (state.docModes[tab.id] ?? 'view');
|
|
482
485
|
const dirty = text !== raw;
|
|
483
486
|
const err = saveErr.get(docKey(tab));
|
|
484
487
|
|
|
485
|
-
|
|
488
|
+
// A file opened from the initiative's git files is evidence of what changed: it is read, never edited or saved here.
|
|
489
|
+
const readonly = !!tab.readonly;
|
|
490
|
+
const modeBtns = ['view', 'raw', 'edit', 'diff'].filter((mm) => !(readonly && (mm === 'edit' || mm === 'diff'))).map((mm) => el('button', {
|
|
486
491
|
onClick: () => setState((s) => ({ docModes: { ...s.docModes, [tab.id]: mm } })),
|
|
487
492
|
style: 'font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:'
|
|
488
493
|
+ (dm === mm ? 'var(--secondary)' : 'transparent')
|
package/workspace/server.js
CHANGED
|
@@ -62,11 +62,18 @@ ensureSpawnHelperExecutable();
|
|
|
62
62
|
|
|
63
63
|
const { checkSurface } = require('./lib/surface-contract');
|
|
64
64
|
const { createSinkGuard } = require('./lib/sink.js');
|
|
65
|
+
const tsSettings = require('./lib/typesafe-settings.js');
|
|
66
|
+
const tsClient = require('./lib/typesafe.js');
|
|
67
|
+
const validateLib = require('./lib/validate.js');
|
|
68
|
+
tsSettings.adoptEnv(process.env); // D79 — take the env key into memory and delete it, before anything is spawned
|
|
65
69
|
const scheduleLogic = require('./lib/schedule-logic');
|
|
66
70
|
const { safeFirstInput } = require('./lib/prompt-input');
|
|
67
71
|
const transcriptsLib = require('./lib/transcripts');
|
|
68
72
|
const initiativeFiles = require('./lib/initiative-files');
|
|
69
73
|
const { deriveNextSteps } = require('./lib/next-steps');
|
|
74
|
+
const nextStepActions = require('./lib/next-step-actions');
|
|
75
|
+
const sessionBudget = require('./lib/session-budget');
|
|
76
|
+
const judgmentLib = require('./lib/judgment');
|
|
70
77
|
const harness = require('./lib/harness-parse');
|
|
71
78
|
const transcript = require('./lib/transcript-read');
|
|
72
79
|
const skillStore = require('./lib/skill-store');
|
|
@@ -260,6 +267,9 @@ function sessionEnv() {
|
|
|
260
267
|
for (const k of INHERITED_SESSION_MARKERS) delete env[k];
|
|
261
268
|
// also drop any other CLAUDE_CODE_* the CLI adds to mark a nested run
|
|
262
269
|
for (const k of Object.keys(env)) if (/^CLAUDE_CODE_/.test(k)) delete env[k];
|
|
270
|
+
// D79 — the TypeSafe key is the workspace server's own credential. A terminal runs an agent that can
|
|
271
|
+
// print its environment, so the key is never handed to one, whether it came from settings or the shell.
|
|
272
|
+
delete env[tsSettings.ENV_NAME];
|
|
263
273
|
return env;
|
|
264
274
|
}
|
|
265
275
|
|
|
@@ -1646,6 +1656,13 @@ function readLifecycle(w) {
|
|
|
1646
1656
|
}
|
|
1647
1657
|
return latest;
|
|
1648
1658
|
}
|
|
1659
|
+
// The whole lifecycle history of one initiative, oldest first, so the detail can say how it got where it is.
|
|
1660
|
+
function lifecycleHistory(w, id) {
|
|
1661
|
+
return readLedger(w, 'lifecycle.jsonl')
|
|
1662
|
+
.filter((r) => r && r.initiative_id === id && (r.state === 'active' || STOPPED_STATES.includes(r.state)))
|
|
1663
|
+
.map((r) => ({ state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' }))
|
|
1664
|
+
.sort((a, b) => String(a.at).localeCompare(String(b.at))).slice(-20);
|
|
1665
|
+
}
|
|
1649
1666
|
function readAcceptances(w) {
|
|
1650
1667
|
const acc = new Map(); // initiative_id -> { action, at }
|
|
1651
1668
|
for (const r of readLedger(w, 'acceptances.jsonl')) {
|
|
@@ -3039,7 +3056,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3039
3056
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
3040
3057
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
3041
3058
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
3042
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk }) {
|
|
3059
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput }) {
|
|
3043
3060
|
const w = getWs(ws);
|
|
3044
3061
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
3045
3062
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -3086,7 +3103,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
3086
3103
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
3087
3104
|
}
|
|
3088
3105
|
}
|
|
3089
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk });
|
|
3106
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput });
|
|
3090
3107
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
3091
3108
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
3092
3109
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -3189,7 +3206,7 @@ function startPreview({ initiative, repoKey, ws }) {
|
|
|
3189
3206
|
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
3190
3207
|
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
3191
3208
|
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
3192
|
-
function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
3209
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput }) {
|
|
3193
3210
|
const key = repoKeyFor({ repoKey, ws });
|
|
3194
3211
|
if (!key) return { ok: false, error: 'no repository selected' };
|
|
3195
3212
|
const trees = treesOfRepo(key);
|
|
@@ -3212,11 +3229,111 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
|
3212
3229
|
// arrived here, decided the branch and supplied a fallback display name, and was then dropped
|
|
3213
3230
|
// — so the one start path DESIGNED to know what it was starting work on never told the session.
|
|
3214
3231
|
initiative,
|
|
3232
|
+
// next-step-actions: a session started from a next step carries its origin (so the budget can count it) and its
|
|
3233
|
+
// prompt. Only the /api/next-step/go route passes either; the socket's `start` message never does.
|
|
3234
|
+
origin, initialInput,
|
|
3215
3235
|
});
|
|
3216
3236
|
if (!r.ok) return r;
|
|
3217
3237
|
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
3218
3238
|
}
|
|
3219
3239
|
|
|
3240
|
+
// ── Next-step actions (initiative next-step-actions) ─────────────────────────────────────────────────────────────
|
|
3241
|
+
// A next step can be PREPARED (a read-only plan: branch, tree, prompt, sources, budget — nothing is created) and then
|
|
3242
|
+
// STARTED by the PM's press, with exactly the prompt they were shown. The session proposes only (lib/next-step-actions
|
|
3243
|
+
// RULES). Records live in the runtime state directory: what was started, and the PM's verdict on it.
|
|
3244
|
+
const NS_RUNS = () => path.join(STATE, 'next-step-runs.jsonl');
|
|
3245
|
+
const NS_REVIEWS = () => path.join(STATE, 'step-reviews.jsonl');
|
|
3246
|
+
const NS_SETTINGS = () => path.join(STATE, 'next-step-settings.json');
|
|
3247
|
+
function nsRead(file) {
|
|
3248
|
+
let text = ''; try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
3249
|
+
const out = [];
|
|
3250
|
+
for (const line of text.split('\n')) { if (!line.trim()) continue; try { out.push(JSON.parse(line)); } catch { /* a torn line is skipped */ } }
|
|
3251
|
+
return out;
|
|
3252
|
+
}
|
|
3253
|
+
function nsAppend(file, rec, what) {
|
|
3254
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); createSinkGuard({ sink: file, what }).appendLine(file, rec); return { ok: true }; }
|
|
3255
|
+
catch (e) { warnWriteFail(path.basename(file), e); return { ok: false, error: 'could not record — nothing was saved' }; }
|
|
3256
|
+
}
|
|
3257
|
+
function nsLimits() {
|
|
3258
|
+
try { const v = JSON.parse(fs.readFileSync(NS_SETTINGS(), 'utf8')); const n = sessionBudget.normalizeLimits(v && v.budget); if (n.ok) return n.limits; } catch { /* defaults */ }
|
|
3259
|
+
return { ...sessionBudget.DEFAULTS };
|
|
3260
|
+
}
|
|
3261
|
+
function nsBudget() {
|
|
3262
|
+
const today = new Date().toDateString();
|
|
3263
|
+
const startedToday = nsRead(NS_RUNS()).filter((r) => r && r.at && new Date(r.at).toDateString() === today).length;
|
|
3264
|
+
const running = sessionList().filter((x) => x.origin === 'next-step' && x.state !== 'exited').length;
|
|
3265
|
+
return sessionBudget.check({ running, startedToday, limits: nsLimits() });
|
|
3266
|
+
}
|
|
3267
|
+
// The first repository file a step's own text names (planning/… .md or .jsonl) that exists in this repository.
|
|
3268
|
+
function nextStepPathOf(root, it) {
|
|
3269
|
+
const m = /(planning\/[\w\-./]+\.(?:md|jsonl))/.exec(`${it.why || ''} ${it.action || ''}`);
|
|
3270
|
+
if (!m) return null;
|
|
3271
|
+
const full = path.resolve(root, m[1]);
|
|
3272
|
+
return insideRoot(root, full) && fs.existsSync(full) ? m[1] : null;
|
|
3273
|
+
}
|
|
3274
|
+
// The latest run started from each item, and the PM's latest verdict on it.
|
|
3275
|
+
function attachRuns(ns, initiativeId) {
|
|
3276
|
+
if (!ns || !Array.isArray(ns.items)) return;
|
|
3277
|
+
const runs = nsRead(NS_RUNS()).filter((r) => r && r.initiative_id === initiativeId);
|
|
3278
|
+
const reviews = nsRead(NS_REVIEWS()).filter((r) => r && r.initiative_id === initiativeId);
|
|
3279
|
+
for (const it of ns.items) {
|
|
3280
|
+
const run = runs.filter((r) => r.item_id === it.id).pop();
|
|
3281
|
+
if (!run) continue;
|
|
3282
|
+
const s = sessions.get(run.session_id);
|
|
3283
|
+
const rv = reviews.filter((r) => r.session_id === run.session_id).pop() || null;
|
|
3284
|
+
it.run = { session_id: run.session_id, at: run.at, branch: run.branch || null, state: s ? (s.state === 'exited' ? 'ended' : 'live') : 'ended',
|
|
3285
|
+
review: rv ? { verdict: rv.verdict, note: rv.note || '', at: rv.at } : null };
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
function nextStepsFor(w, id) {
|
|
3289
|
+
const root = w ? w.root : SELF_ROOT;
|
|
3290
|
+
const engine = consoleState.loadEngine();
|
|
3291
|
+
if (!engine.ok) return { ok: false, error: engine.error };
|
|
3292
|
+
const L = engine.engine.layout(root);
|
|
3293
|
+
const rec = fourPanel.readRecord({ root, dir: L.initiatives, id, parse: engine.engine.parseLeadingFrontmatter });
|
|
3294
|
+
if (!rec.ok || rec.absent) return { ok: false, error: `no initiative record "${id}" in ${w ? w.name : 'this repository'}`, status: 404 };
|
|
3295
|
+
let ladder;
|
|
3296
|
+
try { ladder = consoleState.ladder({ root, id }); } catch (e) { ladder = { ok: false, error: 'the ladder could not be derived: ' + String((e && e.message) || 'error').slice(0, 140) }; }
|
|
3297
|
+
const sessRows = sessionList();
|
|
3298
|
+
const liveIds = new Set(sessRows.filter((x) => x.state !== 'exited' && initiativeOfSession(sessions.get(x.id)) === id).map((x) => x.id));
|
|
3299
|
+
const acc = readAcceptances(w).get(id) || {};
|
|
3300
|
+
const lc = readLifecycle(w).get(id) || null;
|
|
3301
|
+
const ns = deriveNextSteps({ id, accepted: acc.action === 'accept', returned: acc.action === 'return',
|
|
3302
|
+
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
|
|
3303
|
+
sessions: sessRows.filter((x) => x.initiative === id || liveIds.has(x.id)) });
|
|
3304
|
+
nextStepActions.annotate(ns, (it) => nextStepPathOf(root, it));
|
|
3305
|
+
attachRuns(ns, id);
|
|
3306
|
+
return { ok: true, ns, title: (rec.frontmatter && rec.frontmatter.title) || id };
|
|
3307
|
+
}
|
|
3308
|
+
// Everything a prepare shows, computed WITHOUT creating or writing anything. `go` recomputes it and compares the hash.
|
|
3309
|
+
function prepareNextStep(body) {
|
|
3310
|
+
const w = getWs(body.ws);
|
|
3311
|
+
if (!w) return { ok: false, status: 400, error: 'no workspace' };
|
|
3312
|
+
const id = String(body.initiative_id || '');
|
|
3313
|
+
if (!fourPanel.validId(id)) return { ok: false, status: 400, error: 'an initiative id is required, and it must be a record name' };
|
|
3314
|
+
const got = nextStepsFor(w, id);
|
|
3315
|
+
if (!got.ok) return { ok: false, status: got.status || 500, error: got.error };
|
|
3316
|
+
const item = got.ns.items.find((x) => x.id === String(body.item_id || ''));
|
|
3317
|
+
if (!item) return { ok: false, status: 404, error: 'no such step — the list may have changed; reload the initiative' };
|
|
3318
|
+
const agent = body.agent === 'shell' ? 'shell' : 'claude';
|
|
3319
|
+
const judgmentOn = body.judgment === true;
|
|
3320
|
+
const budget = nsBudget();
|
|
3321
|
+
const head = { ok: true, ws: w.id, initiative_id: id, agent, item: { id: item.id, key: item.key, kind: item.kind, text: item.text, owner: item.owner, act: item.act }, budget,
|
|
3322
|
+
existing_sessions: sessionList().filter((x) => x.state !== 'exited' && initiativeOfSession(sessions.get(x.id)) === id).map((x) => ({ id: x.id, name: x.name, origin: x.origin || null })) };
|
|
3323
|
+
const ref = nextStepActions.refusal(item, got.ns);
|
|
3324
|
+
if (ref) return { ...head, refused: ref, plan: null, prompt: null, prompt_sha: null, sources: [], judgment: { on: judgmentOn } };
|
|
3325
|
+
const preview = startPreview({ initiative: id, ws: w.id });
|
|
3326
|
+
if (!preview.ok) return { ...head, refused: { code: 'no-plan', why: preview.error || 'no branch and tree could be chosen' }, plan: null, prompt: null, prompt_sha: null, sources: [], judgment: { on: judgmentOn } };
|
|
3327
|
+
let mcp = [];
|
|
3328
|
+
if (body.profile) { try { const rp = require('./lib/profiles').readProfiles(os.homedir()); const pr = rp.ok && (rp.profiles || []).find((x) => x.id === body.profile); if (pr && Array.isArray(pr.mcp)) mcp = pr.mcp; } catch { mcp = []; } }
|
|
3329
|
+
const sources = nextStepActions.sourcesFor(item, mcp);
|
|
3330
|
+
const prompt = nextStepActions.buildPrompt({ initiative: id, title: got.title, item, branch: preview.branch || null, sources, judgment: judgmentLib.fragment(judgmentOn) });
|
|
3331
|
+
const sha = nextStepActions.planHash({ prompt, branch: preview.branch, wsId: preview.wsId, plan: preview.plan, judgment: judgmentOn });
|
|
3332
|
+
return { ...head, refused: null, plan: { plan: preview.plan, why: preview.why, dir: preview.dir, branch: preview.branch, wsId: preview.wsId }, prompt, prompt_sha: sha, sources,
|
|
3333
|
+
profile: body.profile || null,
|
|
3334
|
+
judgment: { on: judgmentOn, outside_service: judgmentOn, needs_key: judgmentOn, note: judgmentOn ? 'The question text leaves this machine for the Typesafe service. It needs TYPESAFE_API_KEY in the session.' : 'No outside service is used.' } };
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3220
3337
|
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
3221
3338
|
// Isolation buys "your other sessions keep running" at the price of a directory per branch. That
|
|
3222
3339
|
// price is only fair if the PM can see the folders and reclaim them without leaving the workspace
|
|
@@ -4235,6 +4352,28 @@ function serveFile(res, file, type, noCache) {
|
|
|
4235
4352
|
res.end(buf);
|
|
4236
4353
|
});
|
|
4237
4354
|
}
|
|
4355
|
+
// D79 — why a validation may not start, or null. The same words on the plan and the run, so the browser
|
|
4356
|
+
// never has to guess. No key means no request; no consent means this repository is never sent.
|
|
4357
|
+
const validating = new Set();
|
|
4358
|
+
function validationRefusal(w) {
|
|
4359
|
+
if (!tsSettings.status(STATE).present) return 'No TypeSafe key is set. Add one in Settings; without a key nothing is sent.';
|
|
4360
|
+
if (!tsSettings.getRepo(STATE, w.root).consent) return 'Validation is off for this repository. Switch it on first; until then nothing from it is sent.';
|
|
4361
|
+
return null;
|
|
4362
|
+
}
|
|
4363
|
+
// Findings are file-mode state: a PMOS-shaped repository keeps them beside its other ledgers, so they travel
|
|
4364
|
+
// with it. Any other repository keeps them in the per-user runtime directory, never inside the repository.
|
|
4365
|
+
function validationFindingsFile(w) {
|
|
4366
|
+
let d = null;
|
|
4367
|
+
if (isDir(path.join(w.root, 'planning', 'initiatives'))) d = path.join(w.root, 'planning', 'runs');
|
|
4368
|
+
else if (isDir(path.join(w.root, 'pmos', 'state'))) d = path.join(w.root, 'pmos', 'state');
|
|
4369
|
+
else {
|
|
4370
|
+
d = path.join(STATE, 'validation');
|
|
4371
|
+
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the sink guard reports it */ }
|
|
4372
|
+
return path.join(d, require('crypto').createHash('sha1').update(w.root).digest('hex').slice(0, 12) + '.jsonl');
|
|
4373
|
+
}
|
|
4374
|
+
try { fs.mkdirSync(d, { recursive: true }); } catch { /* the sink guard reports it */ }
|
|
4375
|
+
return path.join(d, 'validation-findings.jsonl');
|
|
4376
|
+
}
|
|
4238
4377
|
function readBody(req) {
|
|
4239
4378
|
return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
|
|
4240
4379
|
}
|
|
@@ -5136,8 +5275,39 @@ async function handleRequest(req, res) {
|
|
|
5136
5275
|
const doc = readLayouts(w);
|
|
5137
5276
|
return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId, stored: doc.stored });
|
|
5138
5277
|
}
|
|
5278
|
+
if (p === '/api/guide' && req.method === 'GET') {
|
|
5279
|
+
// The user guide ships with the app (workspace/guide/*.md), so it is read from the app directory, not from a
|
|
5280
|
+
// registered repository. A page name is a bare token, never a path. Read-only.
|
|
5281
|
+
const gdir = path.join(__dirname, 'guide');
|
|
5282
|
+
const page = url.searchParams.get('page');
|
|
5283
|
+
if (page) {
|
|
5284
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(page)) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('no such guide page'); }
|
|
5285
|
+
try { const text = fs.readFileSync(path.join(gdir, page + '.md'), 'utf8'); res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8', 'X-Doc-Kind': 'markdown' }); return res.end(text); }
|
|
5286
|
+
catch { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('no such guide page'); }
|
|
5287
|
+
}
|
|
5288
|
+
let names = [];
|
|
5289
|
+
try { names = fs.readdirSync(gdir).filter((n) => /^[a-z0-9][a-z0-9-]*\.md$/.test(n)).map((n) => n.slice(0, -3)); }
|
|
5290
|
+
catch (e) { return json(res, { ok: false, error: 'the guide could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }); }
|
|
5291
|
+
const order = ['index', 'layout', 'statuses', 'initiatives-and-next-steps', 'scheduled-tasks-and-cli'];
|
|
5292
|
+
names.sort((a, b) => (order.indexOf(a) < 0 ? 99 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 99 : order.indexOf(b)) || a.localeCompare(b));
|
|
5293
|
+
const pages = names.map((n) => {
|
|
5294
|
+
let title = n;
|
|
5295
|
+
try { const m = /^title:\s*"?([^"\n]+)"?/m.exec(fs.readFileSync(path.join(gdir, n + '.md'), 'utf8').slice(0, 600)); if (m) title = m[1].trim(); } catch { /* keep the name */ }
|
|
5296
|
+
return { page: n, title };
|
|
5297
|
+
});
|
|
5298
|
+
return json(res, { ok: true, pages });
|
|
5299
|
+
}
|
|
5139
5300
|
if (p === '/api/doc') {
|
|
5140
5301
|
const w = getWs(url.searchParams.get('ws'));
|
|
5302
|
+
// `ref` — a file that exists only on an initiative's own branch (status-clarity R5). Read through git, never the
|
|
5303
|
+
// working tree, and only from an initiative branch; the reason for a refusal travels in the body.
|
|
5304
|
+
const ref = url.searchParams.get('ref');
|
|
5305
|
+
if (ref && w) {
|
|
5306
|
+
const at = initiativeFiles.readAtRef(w.root, ref, url.searchParams.get('path') || '');
|
|
5307
|
+
if (!at.ok) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end(at.reason); }
|
|
5308
|
+
res.writeHead(200, { 'Content-Type': at.kind === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8', 'X-Doc-Kind': at.kind });
|
|
5309
|
+
return res.end(at.text);
|
|
5310
|
+
}
|
|
5141
5311
|
const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
|
|
5142
5312
|
if (doc == null) { res.writeHead(404); return res.end('not a readable text document in this workspace'); }
|
|
5143
5313
|
// HTML is served as text/plain — the client renders it sandboxed on demand, never as live markup.
|
|
@@ -5296,7 +5466,12 @@ async function handleRequest(req, res) {
|
|
|
5296
5466
|
sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
|
|
5297
5467
|
});
|
|
5298
5468
|
} catch (e) { nextSteps = { state: 'unreadable', summary: 'The next steps could not be derived — ' + String((e && e.message) || 'error').slice(0, 120), items: [] }; }
|
|
5299
|
-
|
|
5469
|
+
// next-step-actions: each item says what it can do (`act`), names the repository file it points at when there is one, and
|
|
5470
|
+
// carries the run started from it and the PM's verdict on that run.
|
|
5471
|
+
try { nextStepActions.annotate(nextSteps, (it) => nextStepPathOf(root, it)); attachRuns(nextSteps, id); } catch (e) { void e; }
|
|
5472
|
+
let lcHistory = [];
|
|
5473
|
+
try { lcHistory = lifecycleHistory(w, id); } catch { lcHistory = []; }
|
|
5474
|
+
return json(res, { ws: w ? w.id : null, ...out, lanes, conversations, files, next_steps: nextSteps, lifecycle_history: lcHistory });
|
|
5300
5475
|
}
|
|
5301
5476
|
if (p === '/api/audit' && req.method === 'GET') {
|
|
5302
5477
|
/* The workflow audit (D77): the sweep run on one repository, read-only. It reports state that contradicts
|
|
@@ -5675,6 +5850,150 @@ async function handleRequest(req, res) {
|
|
|
5675
5850
|
return json(res, { ws: w.id, name: w.name, ...out });
|
|
5676
5851
|
} finally { syncing.delete(w.root); }
|
|
5677
5852
|
}
|
|
5853
|
+
/* ── next-step actions: prepare is read-only; go starts exactly what was shown; review records the PM's word ── */
|
|
5854
|
+
if (p === '/api/next-step/prepare' && req.method === 'POST') {
|
|
5855
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5856
|
+
const r = prepareNextStep(body);
|
|
5857
|
+
return json(res, r, r.ok ? 200 : (r.status || 400));
|
|
5858
|
+
}
|
|
5859
|
+
if (p === '/api/next-step/go' && req.method === 'POST') {
|
|
5860
|
+
// Only reached by the PM's press. It re-prepares, and starts a session only when the PM confirmed AND the prompt and plan
|
|
5861
|
+
// are the ones they were shown (the hash). Anything else starts nothing.
|
|
5862
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5863
|
+
if (body.confirm !== true) return json(res, { ok: false, error: 'confirm is required: this starts a session' }, 400);
|
|
5864
|
+
const r = prepareNextStep(body);
|
|
5865
|
+
if (!r.ok) return json(res, r, r.status || 400);
|
|
5866
|
+
if (r.refused) return json(res, { ok: false, refused: r.refused, error: r.refused.why }, 409);
|
|
5867
|
+
if (!body.prompt_sha || body.prompt_sha !== r.prompt_sha) return json(res, { ok: false, error: 'the plan changed since you reviewed it — prepare it again', prompt_sha: r.prompt_sha }, 409);
|
|
5868
|
+
if (!r.budget.allowed) return json(res, { ok: false, error: r.budget.reason, budget: r.budget }, 409);
|
|
5869
|
+
const started = startWork({ initiative: r.initiative_id, ws: r.ws, agent: r.agent, name: `${r.item.key} · ${r.initiative_id}`, profile: r.profile || undefined,
|
|
5870
|
+
origin: 'next-step', initialInput: r.prompt + '\r' });
|
|
5871
|
+
if (!started.ok) return json(res, { ok: false, error: started.error }, 409);
|
|
5872
|
+
const rec = { id: 'nsr-' + Date.now().toString(36), at: nowISO(), ws: r.ws, initiative_id: r.initiative_id, item_id: r.item.id, key: r.item.key, session_id: started.session.id,
|
|
5873
|
+
branch: r.plan.branch || null, plan: r.plan.plan, prompt_sha: r.prompt_sha, judgment: r.judgment.on, profile: r.profile || null };
|
|
5874
|
+
const wrote = nsAppend(NS_RUNS(), rec, 'next-step run');
|
|
5875
|
+
return json(res, { ok: true, session: { id: started.session.id, name: started.session.name }, notice: started.notice || null, plan: r.plan, run: wrote.ok ? rec.id : null, recorded: wrote.ok });
|
|
5876
|
+
}
|
|
5877
|
+
if (p === '/api/next-step/summary' && req.method === 'GET') {
|
|
5878
|
+
// What an ended (or running) next-step session did: the files git shows on its branch against trunk, the end of the
|
|
5879
|
+
// conversation, and every JUDGMENT line checked against its shape. Read-only.
|
|
5880
|
+
const sid = String(url.searchParams.get('session') || '');
|
|
5881
|
+
const run = nsRead(NS_RUNS()).filter((x) => x && x.session_id === sid).pop();
|
|
5882
|
+
if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
|
|
5883
|
+
const live = sessions.get(sid);
|
|
5884
|
+
let text = '';
|
|
5885
|
+
if (live) text = transcriptsLib.stripAnsi(live.buf);
|
|
5886
|
+
else { const rec = transcriptsLib.read(STATE, sid); text = rec && rec.text ? String(rec.text) : ''; }
|
|
5887
|
+
const w = getWs(run.ws);
|
|
5888
|
+
let files = []; let filesError = null;
|
|
5889
|
+
if (w && gitInfo.isRepo(w.root)) {
|
|
5890
|
+
const trunk = ['origin/main', 'main', 'origin/master', 'master'].find((b) => spawnSync('git', ['-C', w.root, 'rev-parse', '--verify', '--quiet', b], { encoding: 'utf8' }).status === 0);
|
|
5891
|
+
const tree = live && live.root ? live.root : w.root;
|
|
5892
|
+
const branch = run.branch && spawnSync('git', ['-C', tree, 'rev-parse', '--verify', '--quiet', run.branch], { encoding: 'utf8' }).status === 0 ? run.branch : 'HEAD';
|
|
5893
|
+
if (trunk) {
|
|
5894
|
+
const d = spawnSync('git', ['-C', tree, 'diff', '--name-status', `${trunk}...${branch}`], { encoding: 'utf8', timeout: 20000 });
|
|
5895
|
+
if (d.status === 0) files = d.stdout.split('\n').filter(Boolean).slice(0, 100).map((l) => { const [st, ...rest] = l.split('\t'); return { status: st, path: rest.join('\t') }; });
|
|
5896
|
+
else filesError = 'git could not compare the branch with trunk';
|
|
5897
|
+
} else filesError = 'no trunk branch to compare against';
|
|
5898
|
+
} else filesError = 'the repository could not be read';
|
|
5899
|
+
const j = judgmentLib.extract(text);
|
|
5900
|
+
const reviews = nsRead(NS_REVIEWS()).filter((x) => x && x.session_id === sid);
|
|
5901
|
+
return json(res, { ok: true, session_id: sid, state: live ? (live.state === 'exited' ? 'ended' : 'live') : 'ended', branch: run.branch || null,
|
|
5902
|
+
files, files_error: filesError, tail: text.slice(-3000), judgments: j, review: reviews.pop() || null });
|
|
5903
|
+
}
|
|
5904
|
+
if (p === '/api/next-step/review' && req.method === 'POST') {
|
|
5905
|
+
// The PM's verdict on one step's run. It accepts or sends back THE STEP. It never records an acceptance of the
|
|
5906
|
+
// initiative, never changes a status and never touches a lifecycle line.
|
|
5907
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5908
|
+
const verdict = body.verdict === 'accept' ? 'accept' : body.verdict === 'send-back' ? 'send-back' : null;
|
|
5909
|
+
if (!verdict) return json(res, { ok: false, error: 'verdict must be accept or send-back' }, 400);
|
|
5910
|
+
const note = String(body.note || '').trim().slice(0, 500);
|
|
5911
|
+
if (verdict === 'send-back' && !note) return json(res, { ok: false, error: 'say what to change: a send-back needs a note' }, 400);
|
|
5912
|
+
const sid = String(body.session_id || '');
|
|
5913
|
+
const run = nsRead(NS_RUNS()).filter((x) => x && x.session_id === sid).pop();
|
|
5914
|
+
if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
|
|
5915
|
+
const live = sessions.get(sid);
|
|
5916
|
+
if (live && live.state !== 'exited') return json(res, { ok: false, error: 'the session is still running — review it when it has ended' }, 409);
|
|
5917
|
+
const rec = { at: nowISO(), initiative_id: run.initiative_id, item_id: run.item_id, key: run.key, session_id: sid, verdict, note, pm: 'wawan' };
|
|
5918
|
+
const wrote = nsAppend(NS_REVIEWS(), rec, 'step review');
|
|
5919
|
+
if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
|
|
5920
|
+
return json(res, { ok: true, recorded: rec });
|
|
5921
|
+
}
|
|
5922
|
+
if (p === '/api/next-step/settings') {
|
|
5923
|
+
if (req.method === 'GET') return json(res, { ok: true, budget: nsBudget() });
|
|
5924
|
+
if (req.method === 'POST') {
|
|
5925
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5926
|
+
const n = sessionBudget.normalizeLimits({ ...nsLimits(), ...(body.budget || {}) });
|
|
5927
|
+
if (!n.ok) return json(res, { ok: false, error: n.error }, 400);
|
|
5928
|
+
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(NS_SETTINGS(), JSON.stringify({ budget: n.limits }) + '\n'); }
|
|
5929
|
+
catch (e) { warnWriteFail('next-step-settings.json', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|
|
5930
|
+
return json(res, { ok: true, budget: nsBudget() });
|
|
5931
|
+
}
|
|
5932
|
+
}
|
|
5933
|
+
// ── D79 — repository validation through TypeSafe ──────────────────────────────────────────────────
|
|
5934
|
+
// The ONLY routes that lead to an outbound request, and only through lib/typesafe.js. The key is never
|
|
5935
|
+
// in a response: `status` says present or absent. Nothing here edits a repository file.
|
|
5936
|
+
if (p === '/api/typesafe' && req.method === 'GET') {
|
|
5937
|
+
return json(res, { ok: true, key: tsSettings.status(STATE), host: tsClient.HOST });
|
|
5938
|
+
}
|
|
5939
|
+
if (p === '/api/typesafe/key' && (req.method === 'PUT' || req.method === 'DELETE')) {
|
|
5940
|
+
let body = {};
|
|
5941
|
+
if (req.method === 'PUT') {
|
|
5942
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5943
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5944
|
+
}
|
|
5945
|
+
const out = req.method === 'PUT' ? tsSettings.setKey(STATE, body.key) : tsSettings.clearKey(STATE);
|
|
5946
|
+
if (!out.ok) return json(res, { ok: false, error: out.error }, req.method === 'PUT' && /look like a key/.test(out.error) ? 400 : 500);
|
|
5947
|
+
return json(res, { ok: true, key: tsSettings.status(STATE) });
|
|
5948
|
+
}
|
|
5949
|
+
if (p === '/api/validate/repo' && (req.method === 'GET' || req.method === 'POST')) {
|
|
5950
|
+
let body = {};
|
|
5951
|
+
if (req.method === 'POST') {
|
|
5952
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5953
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5954
|
+
}
|
|
5955
|
+
const w = getWs(req.method === 'POST' ? body.ws : url.searchParams.get('ws'));
|
|
5956
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5957
|
+
if (req.method === 'POST') {
|
|
5958
|
+
const out = tsSettings.setRepo(STATE, w.root, { consent: body.consent, exclude: body.exclude });
|
|
5959
|
+
if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
|
|
5960
|
+
}
|
|
5961
|
+
return json(res, { ok: true, ws: w.id, name: w.name, ...tsSettings.getRepo(STATE, w.root), key: tsSettings.status(STATE) });
|
|
5962
|
+
}
|
|
5963
|
+
if (p === '/api/validate/plan' && req.method === 'POST') {
|
|
5964
|
+
let body = {};
|
|
5965
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5966
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5967
|
+
const w = getWs(body.ws);
|
|
5968
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5969
|
+
const refused = validationRefusal(w);
|
|
5970
|
+
if (refused) return json(res, { ok: false, error: refused }, 409);
|
|
5971
|
+
return json(res, { ok: true, ws: w.id, name: w.name, plan: validateLib.plan({ root: w.root, exclude: tsSettings.getRepo(STATE, w.root).exclude }) });
|
|
5972
|
+
}
|
|
5973
|
+
if (p === '/api/validate/run' && req.method === 'POST') {
|
|
5974
|
+
let body = {};
|
|
5975
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
5976
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5977
|
+
const w = getWs(body.ws);
|
|
5978
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5979
|
+
const refused = validationRefusal(w);
|
|
5980
|
+
if (refused) return json(res, { ok: false, error: refused }, 409);
|
|
5981
|
+
if (validating.has(w.root)) return json(res, { ok: false, error: 'a validation is already running for this repository' }, 409);
|
|
5982
|
+
const file = validationFindingsFile(w);
|
|
5983
|
+
validating.add(w.root);
|
|
5984
|
+
try {
|
|
5985
|
+
const out = await validateLib.run({
|
|
5986
|
+
root: w.root, exclude: tsSettings.getRepo(STATE, w.root).exclude,
|
|
5987
|
+
key: tsSettings.getKey(STATE), findingsFile: file, expectRequests: body.expect_requests,
|
|
5988
|
+
});
|
|
5989
|
+
return json(res, { ws: w.id, name: w.name, ...out }, out.ok ? 200 : (out.conflict ? 409 : 500));
|
|
5990
|
+
} finally { validating.delete(w.root); }
|
|
5991
|
+
}
|
|
5992
|
+
if (p === '/api/validate/findings' && req.method === 'GET') {
|
|
5993
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
5994
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
5995
|
+
return json(res, { ok: true, ws: w.id, name: w.name, ...validateLib.readFindings(validationFindingsFile(w)) });
|
|
5996
|
+
}
|
|
5678
5997
|
if (p === '/api/lifecycle' && req.method === 'POST') {
|
|
5679
5998
|
// D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
|
|
5680
5999
|
// appends ONE line to lifecycle.jsonl and never writes an acceptance.
|