create-pmos 0.38.7 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +64 -1
  7. package/workspace/bin/pmos.js +10 -1
  8. package/workspace/guide/index.md +38 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +59 -0
  11. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  12. package/workspace/guide/statuses.md +53 -0
  13. package/workspace/guide/validate-with-typesafe.md +47 -0
  14. package/workspace/lib/initiative-files.js +134 -0
  15. package/workspace/lib/judgment.js +65 -0
  16. package/workspace/lib/mobile-nav.js +1 -1
  17. package/workspace/lib/next-step-actions.js +139 -0
  18. package/workspace/lib/next-steps.js +133 -0
  19. package/workspace/lib/prompt-input.js +18 -0
  20. package/workspace/lib/schedule-cli.js +259 -0
  21. package/workspace/lib/schedule-logic.js +23 -1
  22. package/workspace/lib/secret-scan.js +28 -0
  23. package/workspace/lib/session-budget.js +35 -0
  24. package/workspace/lib/skill-store.js +11 -3
  25. package/workspace/lib/status-groups.js +109 -0
  26. package/workspace/lib/transcripts.js +73 -0
  27. package/workspace/lib/typesafe-settings.js +119 -0
  28. package/workspace/lib/typesafe.js +124 -0
  29. package/workspace/lib/validate.js +304 -0
  30. package/workspace/package-lock.json +1 -1
  31. package/workspace/package.json +3 -3
  32. package/workspace/public/index.html +2 -0
  33. package/workspace/public/pm/app.js +53 -18
  34. package/workspace/public/pm/data.js +41 -5
  35. package/workspace/public/pm/mobile.js +1 -1
  36. package/workspace/public/pm/modes-live.js +171 -0
  37. package/workspace/public/pm/modes.js +25 -0
  38. package/workspace/public/pm/next-step-ui.js +185 -0
  39. package/workspace/public/pm/panel-detail.js +121 -1
  40. package/workspace/public/pm/panel-initiatives.js +61 -3
  41. package/workspace/public/pm/panel-repos.js +2 -2
  42. package/workspace/public/pm/panel-tabs.js +38 -2
  43. package/workspace/public/pm/panel-trees.js +9 -2
  44. package/workspace/public/pm/state.js +9 -4
  45. package/workspace/public/pm/status-banner.js +87 -0
  46. package/workspace/public/pm/tab-doc.js +11 -6
  47. package/workspace/server.js +434 -8
@@ -62,7 +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');
70
+ const { safeFirstInput } = require('./lib/prompt-input');
71
+ const transcriptsLib = require('./lib/transcripts');
72
+ const initiativeFiles = require('./lib/initiative-files');
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');
66
77
  const harness = require('./lib/harness-parse');
67
78
  const transcript = require('./lib/transcript-read');
68
79
  const skillStore = require('./lib/skill-store');
@@ -256,6 +267,9 @@ function sessionEnv() {
256
267
  for (const k of INHERITED_SESSION_MARKERS) delete env[k];
257
268
  // also drop any other CLAUDE_CODE_* the CLI adds to mark a nested run
258
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];
259
273
  return env;
260
274
  }
261
275
 
@@ -1642,6 +1656,13 @@ function readLifecycle(w) {
1642
1656
  }
1643
1657
  return latest;
1644
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
+ }
1645
1666
  function readAcceptances(w) {
1646
1667
  const acc = new Map(); // initiative_id -> { action, at }
1647
1668
  for (const r of readLedger(w, 'acceptances.jsonl')) {
@@ -2040,6 +2061,13 @@ const SEED_SETS = {
2040
2061
  function readSchedules() {
2041
2062
  try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
2042
2063
  }
2064
+ // R5 — count a fire against the schedule's own limits. A schedule that has run `max_runs` times, or whose `until`
2065
+ // day is over, turns itself off and says why; nothing about a schedule without either changes.
2066
+ function countRun(cur, now = new Date()) {
2067
+ cur.runs = (+cur.runs || 0) + 1;
2068
+ const why = scheduleLogic.finishReason(cur, now);
2069
+ if (why) { cur.enabled = false; cur.endedReason = why; cur.nextRun = null; }
2070
+ }
2043
2071
  function writeSchedules(list) {
2044
2072
  try { fs.writeFileSync(schedFile(), JSON.stringify(list)); }
2045
2073
  catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
@@ -2088,7 +2116,7 @@ function fireSchedule(sched) {
2088
2116
  modelAsk: { scheduleTier: sched.tier || null, skillTier: task.tier || null, skill: task.skill } });
2089
2117
  const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
2090
2118
  const started = s.state !== 'exited';
2091
- if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; writeSchedules(list); }
2119
+ if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; countRun(cur); writeSchedules(list); }
2092
2120
  // A run that started WITHOUT the profile it recorded is reported as such rather than as a
2093
2121
  // clean start: the session is real, the environment is not the one the schedule declared.
2094
2122
  return { ok: started, kind: 'agent', sessionId: s.id,
@@ -2110,7 +2138,7 @@ function fireSchedule(sched) {
2110
2138
  // stamp lastRun/lastStatus/nextRun via the pure module
2111
2139
  const list = readSchedules();
2112
2140
  const cur = list.find((s) => s.id === sched.id);
2113
- if (cur) { cur.lastRun = nowISO(); cur.lastStatus = ok ? 'ok' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
2141
+ if (cur) { cur.lastRun = nowISO(); cur.lastStatus = ok ? 'ok' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); countRun(cur); writeSchedules(list); }
2114
2142
  // surface it — the finished run reaches the PM through needs-you (R5), honest on failure
2115
2143
  try {
2116
2144
  const nf = needsFileFor(w);
@@ -2131,6 +2159,7 @@ function checkDueSchedules() {
2131
2159
  try {
2132
2160
  const list = readSchedules(); let changed = false;
2133
2161
  for (const s of list) if (scheduleLogic.isMissed(s)) { s.enabled = false; s.lastStatus = 'missed'; s.nextRun = null; changed = true; }
2162
+ for (const s of list) { const why = s.enabled && scheduleLogic.finishReason(s); if (why) { s.enabled = false; s.endedReason = why; s.nextRun = null; changed = true; } }
2134
2163
  if (changed) writeSchedules(list);
2135
2164
  } catch {}
2136
2165
  return fired;
@@ -2145,7 +2174,29 @@ const sessions = new Map();
2145
2174
  const allClients = new Set();
2146
2175
  let seq = 0;
2147
2176
 
2177
+ // The initiative a session belongs to, for the conversation record: the PM's or spawn's explicit word first,
2178
+ // else the `initiative/<id>` branch its working tree is on (the same join the initiative detail makes).
2179
+ function initiativeOfSession(s) {
2180
+ if (s && s.initiative) return String(s.initiative);
2181
+ try {
2182
+ const w = wsList().find((x) => x.id === (s && s.ws));
2183
+ const m = w && w.branch ? /^initiative\/(.+)$/.exec(String(w.branch)) : null;
2184
+ return m ? m[1] : null;
2185
+ } catch { return null; }
2186
+ }
2187
+ // Save the conversation of a session that belongs to an initiative, so ending or removing it no longer erases it.
2188
+ // Runtime state only, never the repository (PM ruling 2026-10-03). Quiet when the session is on no initiative.
2189
+ function recordConversation(s) {
2190
+ const initiative = initiativeOfSession(s);
2191
+ if (!initiative) return { ok: false, skipped: true };
2192
+ const r = transcriptsLib.save(STATE, { id: s.id, name: s.name, agent: s.agent, ws: s.ws, initiative,
2193
+ state: s.state, started: s.created ? new Date(s.created).toISOString() : null, text: s.buf });
2194
+ if (!r.ok) warnWriteFail('transcripts (conversation record)', new Error(r.error));
2195
+ return r;
2196
+ }
2148
2197
  function persist(s) {
2198
+ if (s.state === 'exited' && !s._txSaved) { s._txSaved = true; try { recordConversation(s); } catch {} }
2199
+ else if (s.state !== 'exited') s._txSaved = false;
2149
2200
  // note IS persisted — a restored session must still say why it exited. `root` is persisted
2150
2201
  // so a session survives even if the workspace registry is lost (it re-registers its root).
2151
2202
  const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
@@ -2872,7 +2923,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
2872
2923
  try { memoryLib.renderFor(root); } catch { /* memory is never a reason a session fails to start */ }
2873
2924
  // a scheduled run tags the session with its origin, and feeds the skill invocation as first input
2874
2925
  const tag = origin || null;
2875
- const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(initialInput); } catch {} }, 700); } catch {} } };
2926
+ // safeFirstInput: a first input that begins with `/` is read by the agent as a slash command, not as the task.
2927
+ const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(safeFirstInput(initialInput)); } catch {} }, 700); } catch {} } };
2876
2928
  // shell gets a real fallback to `sh`; agent CLIs run as-is (harness untouched), NO fallback —
2877
2929
  // falling back from `claude` to `sh` would be a lie.
2878
2930
  const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
@@ -3004,7 +3056,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3004
3056
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
3005
3057
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
3006
3058
  // git-then-maybe-spawn contract is unit-testable without a WS client.
3007
- 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 }) {
3008
3060
  const w = getWs(ws);
3009
3061
  if (!w) return { ok: false, error: 'no workspace' };
3010
3062
  // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
@@ -3051,7 +3103,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
3051
3103
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
3052
3104
  }
3053
3105
  }
3054
- 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 });
3055
3107
  // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
3056
3108
  // block above. The isolate default already computes that other sessions would be disturbed — and
3057
3109
  // then the whole git block is skipped when no branch was picked, which is the common path and the
@@ -3154,7 +3206,7 @@ function startPreview({ initiative, repoKey, ws }) {
3154
3206
  // One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
3155
3207
  // branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
3156
3208
  // nothing) is enforced in exactly one place and is not re-implemented here.
3157
- function startWork({ initiative, repoKey, ws, agent, name, profile }) {
3209
+ function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput }) {
3158
3210
  const key = repoKeyFor({ repoKey, ws });
3159
3211
  if (!key) return { ok: false, error: 'no repository selected' };
3160
3212
  const trees = treesOfRepo(key);
@@ -3177,11 +3229,111 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
3177
3229
  // arrived here, decided the branch and supplied a fallback display name, and was then dropped
3178
3230
  // — so the one start path DESIGNED to know what it was starting work on never told the session.
3179
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,
3180
3235
  });
3181
3236
  if (!r.ok) return r;
3182
3237
  return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
3183
3238
  }
3184
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
+
3185
3337
  // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
3186
3338
  // Isolation buys "your other sessions keep running" at the price of a directory per branch. That
3187
3339
  // price is only fair if the PM can see the folders and reclaim them without leaving the workspace
@@ -3302,6 +3454,7 @@ function removeSession(id) {
3302
3454
  const s = sessions.get(id);
3303
3455
  if (!s || s.pty) return false;
3304
3456
  if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
3457
+ try { recordConversation(s); } catch {} // the conversation outlives the session (surface-batch R5)
3305
3458
  forgetScratchpad(s); // the tab's staging text goes with the tab
3306
3459
  // Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
3307
3460
  // believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
@@ -4199,6 +4352,28 @@ function serveFile(res, file, type, noCache) {
4199
4352
  res.end(buf);
4200
4353
  });
4201
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
+ }
4202
4377
  function readBody(req) {
4203
4378
  return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
4204
4379
  }
@@ -4229,6 +4404,7 @@ async function handleRequest(req, res) {
4229
4404
  // is the same two-hopper lib/four-panel.js makes per initiative on the server; the board makes it
4230
4405
  // once over every session, from this module, so 178 rows cost no requests.
4231
4406
  if (p === '/lib/working-set.js') return serveFile(res, path.join(__dirname, 'lib', 'working-set.js'));
4407
+ if (p === '/lib/status-groups.js') return serveFile(res, path.join(__dirname, 'lib', 'status-groups.js'));
4232
4408
  // The editor computes its live diff with the SAME module the server saves through, so what the PM
4233
4409
  // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
4234
4410
  // a directory route — a <script src> for a file missing from this list 404s silently, and the
@@ -4840,6 +5016,29 @@ async function handleRequest(req, res) {
4840
5016
  if (err) return json(res, { ok: false, error: err }, 500);
4841
5017
  return json(res, { ok: true, prefs });
4842
5018
  }
5019
+ // One recorded conversation (R5): the live session's text, or the saved record once it has ended. Read-only.
5020
+ if (p === '/api/transcript' && req.method === 'GET') {
5021
+ const id = String(url.searchParams.get('id') || '');
5022
+ const live = sessions.get(id);
5023
+ if (live) {
5024
+ const initiative = initiativeOfSession(live);
5025
+ return json(res, { ok: true, id, name: live.name, initiative, state: live.state, live: live.state !== 'exited',
5026
+ stored: 'the runtime state directory — not the repository, and never sent anywhere',
5027
+ text: transcriptsLib.stripAnsi(live.buf).slice(-transcriptsLib.MAX_CHARS) });
5028
+ }
5029
+ const rec = transcriptsLib.read(STATE, id);
5030
+ if (!rec) return json(res, { ok: false, error: 'no recorded conversation with that id' }, 404);
5031
+ return json(res, { ok: true, ...rec, live: false, stored: 'the runtime state directory — not the repository, and never sent anywhere' });
5032
+ }
5033
+ // The tail of one session's terminal text, escape codes stripped, so a CLI can turn "what I just did" into a skill.
5034
+ // Read-only, local, and capped; it names the session it read or says there is none.
5035
+ if (p === '/api/sessions/transcript' && req.method === 'GET') {
5036
+ const sess = sessions.get(String(url.searchParams.get('id') || ''));
5037
+ if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);
5038
+ const max = Math.min(60000, Math.max(500, +url.searchParams.get('max') || 20000));
5039
+ const text = transcriptsLib.stripAnsi(sess.buf).slice(-max);
5040
+ return json(res, { ok: true, id: sess.id, name: sess.name, chars: text.length, text });
5041
+ }
4843
5042
  if (p === '/api/schedules' && req.method === 'GET') {
4844
5043
  // `all=1` (overhaul-r4 D1): every repository's schedules — the drawer lists `scheduled` under "all
4845
5044
  // repositories", so filtering to the home workspace there would hide the PM's other schedules
@@ -4905,8 +5104,32 @@ async function handleRequest(req, res) {
4905
5104
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
4906
5105
  const list = readSchedules(); const s = list.find((x) => x.id === body.id);
4907
5106
  if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
4908
- if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
5107
+ // R5 — `until` and `max_runs` are validated by the same rules a new schedule is, and null/'' clears one. A refused value
5108
+ // returns BEFORE anything is changed, so a bad edit leaves the record as it was.
5109
+ const lim = {};
5110
+ for (const k of ['until', 'max_runs']) if (k in body) lim[k] = body[k];
5111
+ if (Object.keys(lim).length) {
5112
+ const chk = scheduleLogic.normalizeSchedule({ name: s.name, cadence: s.cadence === 'once' ? 'daily' : s.cadence, skill: s.skill, ...lim });
5113
+ if (!chk.ok) return json(res, { ok: false, error: chk.error }, 400);
5114
+ for (const k of Object.keys(lim)) { if (lim[k] == null || lim[k] === '') delete s[k]; else s[k] = chk.schedule[k]; }
5115
+ }
5116
+ if (typeof body.enabled === 'boolean') { s.enabled = body.enabled; if (body.enabled) delete s.endedReason; }
5117
+ // Every field an edit may set is checked BEFORE any is applied (R2): a refused edit names the allowed values and
5118
+ // leaves the record untouched. `skill` must be one that loads for this schedule's repository.
5119
+ const bad = (m) => json(res, { ok: false, error: m }, 400);
5120
+ if (body.cadence != null && !scheduleLogic.CADENCES.includes(body.cadence)) return bad(`cadence must be one of ${scheduleLogic.CADENCES.join(', ')}`);
5121
+ if (body.day != null && !['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].includes(body.day)) return bad('day must be one of Mon, Tue, Wed, Thu, Fri, Sat, Sun');
5122
+ if (body.dayOfMonth != null && !(Number.isInteger(+body.dayOfMonth) && +body.dayOfMonth >= 1 && +body.dayOfMonth <= 28)) return bad('dayOfMonth must be a whole number from 1 to 28');
5123
+ if (body.time != null && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(String(body.time))) return bad('time must be HH:MM, 24-hour, this machine\'s clock');
5124
+ if (body.date != null && !/^\d{4}-\d{2}-\d{2}$/.test(String(body.date))) return bad('date must be YYYY-MM-DD');
5125
+ if (body.name != null && !String(body.name).trim()) return bad('name cannot be empty');
5126
+ if (body.args != null && String(body.args).length > 2000) return bad('args is limited to 2000 characters');
5127
+ for (const k of ['skill', 'task']) if (body[k] != null) {
5128
+ const ws0 = workspaces.get(s.ws);
5129
+ if (!taskById(body[k], ws0 ? ws0.root : null)) return bad(`no skill named "${body[k]}" is available to this schedule`);
5130
+ }
4909
5131
  for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill', 'date', 'args']) if (body[k] != null) s[k] = body[k];
5132
+ if ('args' in body && (body.args === null || body.args === '')) delete s.args;
4910
5133
  if ('tier' in body) { if (body.tier === null || body.tier === '') delete s.tier; else if (/^(fast|balanced|deep)$/.test(String(body.tier))) s.tier = String(body.tier); else return json(res, { ok: false, error: 'tier must be fast, balanced or deep' }, 400); }
4911
5134
  // `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
4912
5135
  // of the key rather than for a non-null value — the other fields above cannot be cleared.
@@ -5052,8 +5275,39 @@ async function handleRequest(req, res) {
5052
5275
  const doc = readLayouts(w);
5053
5276
  return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId, stored: doc.stored });
5054
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
+ }
5055
5300
  if (p === '/api/doc') {
5056
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
+ }
5057
5311
  const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
5058
5312
  if (doc == null) { res.writeHead(404); return res.end('not a readable text document in this workspace'); }
5059
5313
  // HTML is served as text/plain — the client renders it sandboxed on demand, never as live markup.
@@ -5189,7 +5443,35 @@ async function handleRequest(req, res) {
5189
5443
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
5190
5444
  let lanes = null;
5191
5445
  try { if (typeof engine.engine.lanesFor === 'function') lanes = engine.engine.lanesFor(root); } catch { lanes = null; }
5192
- return json(res, { ws: w ? w.id : null, ...out, lanes });
5446
+ // R4/R5/R6 — what remains to reach acceptance, the conversations that belong to this initiative, and the
5447
+ // files git says were changed for it. Each is read, never guessed; a failed read says so.
5448
+ const sessRows = sessionList();
5449
+ const liveConv = sessRows.filter((x) => initiativeOfSession(sessions.get(x.id)) === id)
5450
+ .map((x) => ({ id: x.id, name: x.name, state: x.state === 'exited' ? 'exited' : 'live', live: x.state !== 'exited', ws: x.ws }));
5451
+ const savedConv = transcriptsLib.listFor(STATE, id).filter((c) => !liveConv.some((l) => l.id === c.id))
5452
+ .map((c) => ({ id: c.id, name: c.name, state: 'ended', live: false, ws: c.ws, saved: c.saved, chars: c.chars, cut: c.cut }));
5453
+ const conversations = [...liveConv, ...savedConv];
5454
+ const listed = new Set(((out && out.artifacts) || []).map((a) => a && a.path).filter(Boolean));
5455
+ let files;
5456
+ try { files = initiativeFiles.filesFor(root, id, { exclude: listed }); }
5457
+ catch (e) { files = { ok: false, reason: 'the files could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }; }
5458
+ let nextSteps = null;
5459
+ try {
5460
+ const acc = readAcceptances(w).get(id) || {};
5461
+ const lc = readLifecycle(w).get(id) || null;
5462
+ nextSteps = deriveNextSteps({
5463
+ id, accepted: acc.action === 'accept', returned: acc.action === 'return',
5464
+ lifecycle: lc && lc.state !== 'active' ? lc : null, ladder,
5465
+ needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
5466
+ sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
5467
+ });
5468
+ } catch (e) { nextSteps = { state: 'unreadable', summary: 'The next steps could not be derived — ' + String((e && e.message) || 'error').slice(0, 120), items: [] }; }
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 });
5193
5475
  }
5194
5476
  if (p === '/api/audit' && req.method === 'GET') {
5195
5477
  /* The workflow audit (D77): the sweep run on one repository, read-only. It reports state that contradicts
@@ -5568,6 +5850,150 @@ async function handleRequest(req, res) {
5568
5850
  return json(res, { ws: w.id, name: w.name, ...out });
5569
5851
  } finally { syncing.delete(w.root); }
5570
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
+ }
5571
5997
  if (p === '/api/lifecycle' && req.method === 'POST') {
5572
5998
  // D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
5573
5999
  // appends ONE line to lifecycle.jsonl and never writes an acceptance.