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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +29 -1
  7. package/workspace/guide/index.md +38 -0
  8. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  9. package/workspace/guide/layout.md +59 -0
  10. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  11. package/workspace/guide/statuses.md +53 -0
  12. package/workspace/guide/validate-with-typesafe.md +47 -0
  13. package/workspace/lib/initiative-files.js +29 -6
  14. package/workspace/lib/judgment.js +65 -0
  15. package/workspace/lib/mobile-nav.js +1 -1
  16. package/workspace/lib/next-step-actions.js +139 -0
  17. package/workspace/lib/next-steps.js +27 -7
  18. package/workspace/lib/schedule-cli.js +5 -2
  19. package/workspace/lib/secret-scan.js +28 -0
  20. package/workspace/lib/session-budget.js +35 -0
  21. package/workspace/lib/status-groups.js +53 -10
  22. package/workspace/lib/typesafe-settings.js +119 -0
  23. package/workspace/lib/typesafe.js +124 -0
  24. package/workspace/lib/validate.js +304 -0
  25. package/workspace/package-lock.json +1 -1
  26. package/workspace/package.json +3 -3
  27. package/workspace/public/pm/app.js +53 -18
  28. package/workspace/public/pm/data.js +18 -3
  29. package/workspace/public/pm/mobile.js +1 -1
  30. package/workspace/public/pm/modes-live.js +152 -0
  31. package/workspace/public/pm/modes.js +25 -0
  32. package/workspace/public/pm/next-step-ui.js +185 -0
  33. package/workspace/public/pm/panel-detail.js +47 -11
  34. package/workspace/public/pm/panel-initiatives.js +26 -4
  35. package/workspace/public/pm/panel-repos.js +2 -2
  36. package/workspace/public/pm/state.js +3 -3
  37. package/workspace/public/pm/status-banner.js +87 -0
  38. package/workspace/public/pm/tab-doc.js +11 -6
  39. 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
- const load = (dataMod && typeof dataMod.readDoc === 'function')
101
- ? dataMod.readDoc(tab.path, tab.ws)
102
- : fetch('/api/doc?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path))
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
- const modeBtns = ['view', 'raw', 'edit', 'diff'].map((mm) => el('button', {
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')
@@ -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
- return json(res, { ws: w ? w.id : null, ...out, lanes, conversations, files, next_steps: nextSteps });
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.