create-pmos 0.38.8 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +37 -1
  7. package/workspace/bin/pmos.js +8 -1
  8. package/workspace/guide/index.md +40 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +73 -0
  11. package/workspace/guide/proactive-engine.md +75 -0
  12. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  13. package/workspace/guide/statuses.md +53 -0
  14. package/workspace/guide/validate-with-typesafe.md +80 -0
  15. package/workspace/lib/brief.js +113 -0
  16. package/workspace/lib/claims.js +605 -0
  17. package/workspace/lib/doc-profiles.js +74 -0
  18. package/workspace/lib/initiative-files.js +37 -6
  19. package/workspace/lib/judgment.js +65 -0
  20. package/workspace/lib/mobile-nav.js +1 -1
  21. package/workspace/lib/next-step-actions.js +139 -0
  22. package/workspace/lib/next-steps.js +27 -7
  23. package/workspace/lib/preview-kind.js +17 -2
  24. package/workspace/lib/proactive-cli.js +137 -0
  25. package/workspace/lib/proactive.js +208 -0
  26. package/workspace/lib/repo-watch.js +120 -0
  27. package/workspace/lib/schedule-cli.js +5 -2
  28. package/workspace/lib/secret-scan.js +28 -0
  29. package/workspace/lib/session-budget.js +35 -0
  30. package/workspace/lib/status-groups.js +53 -10
  31. package/workspace/lib/typesafe-settings.js +122 -0
  32. package/workspace/lib/typesafe.js +124 -0
  33. package/workspace/lib/validate.js +304 -0
  34. package/workspace/package-lock.json +1 -1
  35. package/workspace/package.json +3 -3
  36. package/workspace/public/pm/app.js +53 -18
  37. package/workspace/public/pm/data.js +18 -3
  38. package/workspace/public/pm/mobile.js +1 -1
  39. package/workspace/public/pm/modes-live.js +373 -0
  40. package/workspace/public/pm/modes.js +26 -0
  41. package/workspace/public/pm/next-step-ui.js +185 -0
  42. package/workspace/public/pm/panel-detail.js +47 -11
  43. package/workspace/public/pm/panel-initiatives.js +26 -4
  44. package/workspace/public/pm/panel-repos.js +2 -2
  45. package/workspace/public/pm/state.js +3 -3
  46. package/workspace/public/pm/status-banner.js +87 -0
  47. package/workspace/public/pm/tab-doc.js +66 -8
  48. package/workspace/server.js +562 -18
  49. package/workspace/skills/morning-brief.skill +43 -0
@@ -62,11 +62,22 @@ 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
+ const claimsLib = require('./lib/claims.js');
69
+ const repoWatch = require('./lib/repo-watch.js');
70
+ tsSettings.adoptEnv(process.env); // D79 — take the env key into memory and delete it, before anything is spawned
65
71
  const scheduleLogic = require('./lib/schedule-logic');
66
72
  const { safeFirstInput } = require('./lib/prompt-input');
67
73
  const transcriptsLib = require('./lib/transcripts');
68
74
  const initiativeFiles = require('./lib/initiative-files');
69
75
  const { deriveNextSteps } = require('./lib/next-steps');
76
+ const nextStepActions = require('./lib/next-step-actions');
77
+ const sessionBudget = require('./lib/session-budget');
78
+ const judgmentLib = require('./lib/judgment');
79
+ const proactiveLib = require('./lib/proactive');
80
+ const briefLib = require('./lib/brief');
70
81
  const harness = require('./lib/harness-parse');
71
82
  const transcript = require('./lib/transcript-read');
72
83
  const skillStore = require('./lib/skill-store');
@@ -260,6 +271,9 @@ function sessionEnv() {
260
271
  for (const k of INHERITED_SESSION_MARKERS) delete env[k];
261
272
  // also drop any other CLAUDE_CODE_* the CLI adds to mark a nested run
262
273
  for (const k of Object.keys(env)) if (/^CLAUDE_CODE_/.test(k)) delete env[k];
274
+ // D79 — the TypeSafe key is the workspace server's own credential. A terminal runs an agent that can
275
+ // print its environment, so the key is never handed to one, whether it came from settings or the shell.
276
+ delete env[tsSettings.ENV_NAME];
263
277
  return env;
264
278
  }
265
279
 
@@ -1167,19 +1181,37 @@ function searchFamily(w, query, limit, only) {
1167
1181
  }
1168
1182
 
1169
1183
  // Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
1170
- // per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
1171
- // non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
1172
- // ⇒ refused. Returns { kind, text } or null. The traversal + cross-workspace guard is unchanged
1173
- // (insideRoot); what widened is the file KIND, never the reachable path set.
1174
- function readTextDoc(root, rel) {
1184
+ // per-workspace-guarded. Images, PDF and workbooks are NOT served here (they stream through /api/asset).
1185
+ // file-viewer-any-text: a file whose extension is on no list is TRIED as text rather than refused for
1186
+ // being unlisted — unless its type is binary by nature, it is over MAX_READ_BYTES, it holds a NUL byte, or
1187
+ // it is not valid UTF-8. The traversal + cross-workspace guard is unchanged (insideRoot); what widened is
1188
+ // the file KIND, never the reachable path set. A refusal carries its reason (readTextDocWhy).
1189
+ const MAX_READ_BYTES = 2 * 1024 * 1024;
1190
+ function readTextDocWhy(root, rel) {
1175
1191
  const full = path.resolve(root, rel || '');
1176
- if (!insideRoot(root, full)) return null; // per-workspace guard (traversal + cross-ws)
1177
- const kind = preview.previewKind(path.basename(full));
1178
- if (kind !== 'markdown' && kind !== 'text' && kind !== 'html') return null;
1192
+ if (!insideRoot(root, full)) return { ok: false, reason: 'not inside this workspace' }; // per-workspace guard (traversal + cross-ws)
1193
+ const name = path.basename(full);
1194
+ const kind = preview.previewKind(name);
1195
+ if (kind === 'image' || kind === 'pdf' || kind === 'sheet') return { ok: false, reason: 'this kind of file is opened another way, not as text' };
1196
+ const known = kind === 'markdown' || kind === 'text' || kind === 'html';
1197
+ if (!known && preview.isKnownBinary(name)) return { ok: false, reason: 'this is a binary file (.' + preview.extOf(name) + ') and cannot be shown as text' };
1198
+ let buf;
1199
+ try {
1200
+ const st = fs.statSync(full);
1201
+ if (!st.isFile()) return { ok: false, reason: 'this is not a file' };
1202
+ if (!known && st.size > MAX_READ_BYTES) return { ok: false, reason: 'the file is larger than 2 MB' };
1203
+ buf = fs.readFileSync(full);
1204
+ } catch { return { ok: false, reason: 'the file could not be read' }; }
1205
+ if (buf.indexOf(0) !== -1) return { ok: false, reason: 'the file is binary (it holds a NUL byte)' }; // binary masquerading as text
1179
1206
  let text;
1180
- try { text = fs.readFileSync(full, 'utf8'); } catch { return null; }
1181
- if (text.indexOf(String.fromCharCode(0)) !== -1) return null; // NUL byte => binary masquerading as text
1182
- return { kind, text };
1207
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf); }
1208
+ catch { if (!known) return { ok: false, reason: 'the file is not valid UTF-8 text' }; text = buf.toString('utf8'); }
1209
+ return { ok: true, kind: known ? kind : 'text', text };
1210
+ }
1211
+ // Returns { kind, text } or null — the contract every earlier caller relies on.
1212
+ function readTextDoc(root, rel) {
1213
+ const r = readTextDocWhy(root, rel);
1214
+ return r.ok ? { kind: r.kind, text: r.text } : null;
1183
1215
  }
1184
1216
 
1185
1217
  // ── The initiative lens: the library grouped the way the PM thinks. ───────────────────
@@ -1646,6 +1678,13 @@ function readLifecycle(w) {
1646
1678
  }
1647
1679
  return latest;
1648
1680
  }
1681
+ // The whole lifecycle history of one initiative, oldest first, so the detail can say how it got where it is.
1682
+ function lifecycleHistory(w, id) {
1683
+ return readLedger(w, 'lifecycle.jsonl')
1684
+ .filter((r) => r && r.initiative_id === id && (r.state === 'active' || STOPPED_STATES.includes(r.state)))
1685
+ .map((r) => ({ state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' }))
1686
+ .sort((a, b) => String(a.at).localeCompare(String(b.at))).slice(-20);
1687
+ }
1649
1688
  function readAcceptances(w) {
1650
1689
  const acc = new Map(); // initiative_id -> { action, at }
1651
1690
  for (const r of readLedger(w, 'acceptances.jsonl')) {
@@ -2032,6 +2071,7 @@ function taskById(id, root) { return loadTasks(root).find((t) => t.id === id ||
2032
2071
  // D71 — the recommended schedules, one set per purpose. Times are host-local, like every schedule.
2033
2072
  const SEED_SETS = {
2034
2073
  requirements: [{ name: 'Daily requirement gathering', skill: 'gather-requirements', cadence: 'daily', time: '07:30' }],
2074
+ brief: [{ name: 'Morning brief', skill: 'morning-brief', cadence: 'daily', time: '06:45' }],
2035
2075
  memory: [{ name: 'Weekly memory grooming', skill: 'memory-groom', cadence: 'weekly', day: 'Mon', time: '08:00' }],
2036
2076
  skills: [{ name: 'Weekly skill discovery', skill: 'skill-discover', cadence: 'weekly', day: 'Wed', time: '09:00' }],
2037
2077
  reports: [
@@ -2148,7 +2188,7 @@ function checkDueSchedules() {
2148
2188
  return fired;
2149
2189
  }
2150
2190
  // the tick — the persistent server checks on an interval; unref'd so it never blocks exit
2151
- try { const _tick = setInterval(checkDueSchedules, 60000); if (_tick.unref) _tick.unref(); } catch {}
2191
+ try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); }, 60000); if (_tick.unref) _tick.unref(); } catch {}
2152
2192
 
2153
2193
  // ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
2154
2194
  /** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
@@ -3039,7 +3079,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3039
3079
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
3040
3080
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
3041
3081
  // 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 }) {
3082
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput }) {
3043
3083
  const w = getWs(ws);
3044
3084
  if (!w) return { ok: false, error: 'no workspace' };
3045
3085
  // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
@@ -3086,7 +3126,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
3086
3126
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
3087
3127
  }
3088
3128
  }
3089
- const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk });
3129
+ const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput });
3090
3130
  // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
3091
3131
  // block above. The isolate default already computes that other sessions would be disturbed — and
3092
3132
  // then the whole git block is skipped when no branch was picked, which is the common path and the
@@ -3189,7 +3229,7 @@ function startPreview({ initiative, repoKey, ws }) {
3189
3229
  // One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
3190
3230
  // branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
3191
3231
  // nothing) is enforced in exactly one place and is not re-implemented here.
3192
- function startWork({ initiative, repoKey, ws, agent, name, profile }) {
3232
+ function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput }) {
3193
3233
  const key = repoKeyFor({ repoKey, ws });
3194
3234
  if (!key) return { ok: false, error: 'no repository selected' };
3195
3235
  const trees = treesOfRepo(key);
@@ -3212,11 +3252,193 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
3212
3252
  // arrived here, decided the branch and supplied a fallback display name, and was then dropped
3213
3253
  // — so the one start path DESIGNED to know what it was starting work on never told the session.
3214
3254
  initiative,
3255
+ // next-step-actions: a session started from a next step carries its origin (so the budget can count it) and its
3256
+ // prompt. Only the /api/next-step/go route passes either; the socket's `start` message never does.
3257
+ origin, initialInput,
3215
3258
  });
3216
3259
  if (!r.ok) return r;
3217
3260
  return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
3218
3261
  }
3219
3262
 
3263
+ // ── Next-step actions (initiative next-step-actions) ─────────────────────────────────────────────────────────────
3264
+ // A next step can be PREPARED (a read-only plan: branch, tree, prompt, sources, budget — nothing is created) and then
3265
+ // STARTED by the PM's press, with exactly the prompt they were shown. The session proposes only (lib/next-step-actions
3266
+ // RULES). Records live in the runtime state directory: what was started, and the PM's verdict on it.
3267
+ const NS_RUNS = () => path.join(STATE, 'next-step-runs.jsonl');
3268
+ const NS_REVIEWS = () => path.join(STATE, 'step-reviews.jsonl');
3269
+ const NS_SETTINGS = () => path.join(STATE, 'next-step-settings.json');
3270
+ function nsRead(file) {
3271
+ let text = ''; try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
3272
+ const out = [];
3273
+ for (const line of text.split('\n')) { if (!line.trim()) continue; try { out.push(JSON.parse(line)); } catch { /* a torn line is skipped */ } }
3274
+ return out;
3275
+ }
3276
+ function nsAppend(file, rec, what) {
3277
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); createSinkGuard({ sink: file, what }).appendLine(file, rec); return { ok: true }; }
3278
+ catch (e) { warnWriteFail(path.basename(file), e); return { ok: false, error: 'could not record — nothing was saved' }; }
3279
+ }
3280
+ function nsLimits() {
3281
+ 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 */ }
3282
+ return { ...sessionBudget.DEFAULTS };
3283
+ }
3284
+ function nsBudget() {
3285
+ const today = new Date().toDateString();
3286
+ const startedToday = nsRead(NS_RUNS()).filter((r) => r && r.at && new Date(r.at).toDateString() === today).length;
3287
+ const running = sessionList().filter((x) => x.origin === 'next-step' && x.state !== 'exited').length;
3288
+ return sessionBudget.check({ running, startedToday, limits: nsLimits() });
3289
+ }
3290
+ // The first repository file a step's own text names (planning/… .md or .jsonl) that exists in this repository.
3291
+ function nextStepPathOf(root, it) {
3292
+ const m = /(planning\/[\w\-./]+\.(?:md|jsonl))/.exec(`${it.why || ''} ${it.action || ''}`);
3293
+ if (!m) return null;
3294
+ const full = path.resolve(root, m[1]);
3295
+ return insideRoot(root, full) && fs.existsSync(full) ? m[1] : null;
3296
+ }
3297
+ // The latest run started from each item, and the PM's latest verdict on it.
3298
+ function attachRuns(ns, initiativeId) {
3299
+ if (!ns || !Array.isArray(ns.items)) return;
3300
+ const runs = nsRead(NS_RUNS()).filter((r) => r && r.initiative_id === initiativeId);
3301
+ const reviews = nsRead(NS_REVIEWS()).filter((r) => r && r.initiative_id === initiativeId);
3302
+ for (const it of ns.items) {
3303
+ const run = runs.filter((r) => r.item_id === it.id).pop();
3304
+ if (!run) continue;
3305
+ const s = sessions.get(run.session_id);
3306
+ const rv = reviews.filter((r) => r.session_id === run.session_id).pop() || null;
3307
+ it.run = { session_id: run.session_id, at: run.at, branch: run.branch || null, state: s ? (s.state === 'exited' ? 'ended' : 'live') : 'ended',
3308
+ review: rv ? { verdict: rv.verdict, note: rv.note || '', at: rv.at } : null };
3309
+ }
3310
+ }
3311
+ function nextStepsFor(w, id) {
3312
+ const root = w ? w.root : SELF_ROOT;
3313
+ const engine = consoleState.loadEngine();
3314
+ if (!engine.ok) return { ok: false, error: engine.error };
3315
+ const L = engine.engine.layout(root);
3316
+ const rec = fourPanel.readRecord({ root, dir: L.initiatives, id, parse: engine.engine.parseLeadingFrontmatter });
3317
+ if (!rec.ok || rec.absent) return { ok: false, error: `no initiative record "${id}" in ${w ? w.name : 'this repository'}`, status: 404 };
3318
+ let ladder;
3319
+ 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) }; }
3320
+ const sessRows = sessionList();
3321
+ const liveIds = new Set(sessRows.filter((x) => x.state !== 'exited' && initiativeOfSession(sessions.get(x.id)) === id).map((x) => x.id));
3322
+ const acc = readAcceptances(w).get(id) || {};
3323
+ const lc = readLifecycle(w).get(id) || null;
3324
+ const ns = deriveNextSteps({ id, accepted: acc.action === 'accept', returned: acc.action === 'return',
3325
+ lifecycle: lc && lc.state !== 'active' ? lc : null, ladder, needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
3326
+ sessions: sessRows.filter((x) => x.initiative === id || liveIds.has(x.id)) });
3327
+ nextStepActions.annotate(ns, (it) => nextStepPathOf(root, it));
3328
+ attachRuns(ns, id);
3329
+ return { ok: true, ns, title: (rec.frontmatter && rec.frontmatter.title) || id };
3330
+ }
3331
+ // Everything a prepare shows, computed WITHOUT creating or writing anything. `go` recomputes it and compares the hash.
3332
+ function prepareNextStep(body) {
3333
+ const w = getWs(body.ws);
3334
+ if (!w) return { ok: false, status: 400, error: 'no workspace' };
3335
+ const id = String(body.initiative_id || '');
3336
+ if (!fourPanel.validId(id)) return { ok: false, status: 400, error: 'an initiative id is required, and it must be a record name' };
3337
+ const got = nextStepsFor(w, id);
3338
+ if (!got.ok) return { ok: false, status: got.status || 500, error: got.error };
3339
+ const item = got.ns.items.find((x) => x.id === String(body.item_id || ''));
3340
+ if (!item) return { ok: false, status: 404, error: 'no such step — the list may have changed; reload the initiative' };
3341
+ const agent = body.agent === 'shell' ? 'shell' : 'claude';
3342
+ const judgmentOn = body.judgment === true;
3343
+ const budget = nsBudget();
3344
+ 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,
3345
+ 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 })) };
3346
+ const ref = nextStepActions.refusal(item, got.ns);
3347
+ if (ref) return { ...head, refused: ref, plan: null, prompt: null, prompt_sha: null, sources: [], judgment: { on: judgmentOn } };
3348
+ const preview = startPreview({ initiative: id, ws: w.id });
3349
+ 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 } };
3350
+ let mcp = [];
3351
+ 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 = []; } }
3352
+ const sources = nextStepActions.sourcesFor(item, mcp);
3353
+ const prompt = nextStepActions.buildPrompt({ initiative: id, title: got.title, item, branch: preview.branch || null, sources, judgment: judgmentLib.fragment(judgmentOn) });
3354
+ const sha = nextStepActions.planHash({ prompt, branch: preview.branch, wsId: preview.wsId, plan: preview.plan, judgment: judgmentOn });
3355
+ 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,
3356
+ profile: body.profile || null,
3357
+ 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.' } };
3358
+ }
3359
+
3360
+ // ── Proactive engine, slice D1 (initiative proactive-engine) ─────────────────────────────────────────────────────
3361
+ // A scan reads ledgers and git for five conditions and appends each new one to signals.jsonl; Today is computed from what
3362
+ // is true now. No model call, no network call, no write into any repository, no session started (lib/proactive.js is
3363
+ // pure). Records live in the runtime state directory, never in a repository.
3364
+ const PRO_SIGNALS = () => path.join(STATE, 'signals.jsonl');
3365
+ const PRO_FEEDBACK = () => path.join(STATE, 'proactive-feedback.jsonl');
3366
+ const PRO_SETTINGS = () => path.join(STATE, 'proactive-settings.json');
3367
+ let proLastScan = 0;
3368
+ const PRO_SCAN_EVERY_MS = 10 * 60 * 1000;
3369
+ function proSettings() {
3370
+ try { const n = proactiveLib.normalizeSettings(JSON.parse(fs.readFileSync(PRO_SETTINGS(), 'utf8'))); if (n.ok) return n.settings; } catch { /* defaults */ }
3371
+ return proactiveLib.normalizeSettings({}).settings;
3372
+ }
3373
+ function proactiveCtx(now, settings) {
3374
+ const repos = [];
3375
+ const allSchedules = readSchedules();
3376
+ // The same read the portfolio list uses: every initiative of every registered repository, from the cached boards.
3377
+ const pf = portfolio();
3378
+ for (const r of repoLeads()) {
3379
+ const entry = { name: r.name, ws: r.ws, initiatives: [], needs: [], returned: [], schedules: [] };
3380
+ repos.push(entry);
3381
+ try {
3382
+ const w = getWs(r.ws);
3383
+ const pr = pf.repos.find((x) => x.ws === r.ws);
3384
+ if (pr && pr.error) { entry.error = pr.error; continue; }
3385
+ if (pr && pr.pending) { entry.error = 'the board is still being read — try again in a few seconds'; continue; }
3386
+ const life = readLifecycle(w); const acc = readAcceptances(w);
3387
+ const lastRun = new Map(); const hasRuns = new Set();
3388
+ for (const x of readLedger(w, 'agent-runs.jsonl')) {
3389
+ const id = x && x.initiative_id; if (!id) continue;
3390
+ hasRuns.add(id);
3391
+ const t = Date.parse(x.recorded_at || x.created_at);
3392
+ if (Number.isFinite(t)) lastRun.set(id, Math.max(lastRun.get(id) || 0, t));
3393
+ }
3394
+ for (const b of pf.rows.filter((x) => x.ws === r.ws)) {
3395
+ if (!b || !b.id) continue;
3396
+ const lc = life.get(b.id); const stopped = !!(lc && lc.state !== 'active');
3397
+ entry.initiatives.push({ id: b.id, title: b.title || b.id, active: !b.accepted && !stopped, lastRun: lastRun.has(b.id) ? lastRun.get(b.id) : null, hasRuns: hasRuns.has(b.id) });
3398
+ }
3399
+ entry.needs = (readNeeds(w) || []).filter((n) => n).map((n) => ({ initiative_id: n.initiative_id || null, question: n.question || '', run_id: n.run_id || null }));
3400
+ for (const [id, a] of acc) if (a && a.action === 'return') entry.returned.push({ initiative_id: id, note: 'returned at the Acceptance Gate', at: a.at });
3401
+ entry.schedules = allSchedules.filter((x) => r.ids.has(x.ws)).map((x) => ({ id: x.id, name: x.name || x.label || x.skill || x.id, lastStatus: x.lastStatus || null, lastRun: x.lastRun || null }));
3402
+ } catch (e) { entry.error = 'the repository could not be read — ' + String((e && e.message) || 'error').slice(0, 120); }
3403
+ }
3404
+ const reviews = new Set(nsRead(NS_REVIEWS()).filter((x) => x && x.session_id).map((x) => x.session_id));
3405
+ const nextStepRuns = nsRead(NS_RUNS()).filter((x) => x && x.session_id).map((x) => {
3406
+ const s = sessions.get(x.session_id);
3407
+ return { session_id: x.session_id, initiative_id: x.initiative_id, ws: x.ws, key: x.key, ended: !s || s.state === 'exited', reviewed: reviews.has(x.session_id), at: x.at };
3408
+ });
3409
+ return { now, settings, repos, nextStepRuns };
3410
+ }
3411
+ function proactiveToday(now) {
3412
+ const settings = proSettings();
3413
+ const ctx = proactiveCtx(now, settings);
3414
+ const d = proactiveLib.detect(ctx);
3415
+ const dismissed = proactiveLib.dismissedIds(nsRead(PRO_FEEDBACK()), now);
3416
+ const live = d.candidates.filter((c) => !dismissed.has(c.id));
3417
+ return { settings, unreadable: d.unreadable, dismissed: d.candidates.length - live.length, candidates: settings.enabled ? proactiveLib.rank(live, now) : [], off: !settings.enabled };
3418
+ }
3419
+ function proactiveScan({ manual }) {
3420
+ const now = Date.now();
3421
+ const settings = proSettings();
3422
+ if (!manual && !settings.enabled) return { ok: true, skipped: 'the engine is off', appended: 0 };
3423
+ if (!manual && proactiveLib.inQuietHours(now, settings.quiet)) return { ok: true, skipped: 'quiet hours', appended: 0 };
3424
+ const ctx = proactiveCtx(now, settings);
3425
+ const d = proactiveLib.detect(ctx);
3426
+ const recent = nsRead(PRO_SIGNALS()).slice(-2000);
3427
+ const fresh = proactiveLib.newSignals(d.candidates, recent, now);
3428
+ let wrote = 0;
3429
+ for (const c of fresh) {
3430
+ const w = nsAppend(PRO_SIGNALS(), { at: new Date(now).toISOString(), key: c.key, id: c.id, kind: c.kind, initiative_id: c.initiative_id, ws: c.ws, why: c.why, evidence: c.evidence, confidence: c.confidence, manual: !!manual }, 'proactive signal');
3431
+ if (w.ok) wrote++;
3432
+ }
3433
+ return { ok: true, skipped: null, appended: wrote, found: d.candidates.length, unreadable: d.unreadable };
3434
+ }
3435
+ function proactiveTick() {
3436
+ const now = Date.now();
3437
+ if (now - proLastScan < PRO_SCAN_EVERY_MS) return;
3438
+ proLastScan = now;
3439
+ try { proactiveScan({ manual: false }); } catch (e) { warnWriteFail('proactive scan', e); }
3440
+ }
3441
+
3220
3442
  // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
3221
3443
  // Isolation buys "your other sessions keep running" at the price of a directory per branch. That
3222
3444
  // price is only fair if the PM can see the folders and reclaim them without leaving the workspace
@@ -4235,6 +4457,62 @@ function serveFile(res, file, type, noCache) {
4235
4457
  res.end(buf);
4236
4458
  });
4237
4459
  }
4460
+ // claims-ledger — the claims ledger and its local checks. Local only: no key, no network. The ledger is file-mode
4461
+ // state beside the repository's other ledgers; the caches (stamps, per-document index) live in the per-user runtime
4462
+ // directory, outside every repository.
4463
+ const claimsRunning = new Set();
4464
+ function claimsKey(w) { return require('crypto').createHash('sha1').update(w.root).digest('hex').slice(0, 12); }
4465
+ function claimsPaths(w) {
4466
+ const cacheDir = path.join(STATE, 'claims', claimsKey(w));
4467
+ let dir = null;
4468
+ if (isDir(path.join(w.root, 'planning', 'initiatives'))) dir = path.join(w.root, 'planning', 'runs');
4469
+ else if (isDir(path.join(w.root, 'pmos', 'state'))) dir = path.join(w.root, 'pmos', 'state');
4470
+ else dir = cacheDir;
4471
+ return { cacheDir, ledgerFile: path.join(dir, 'claims.jsonl'), ledgerDir: dir };
4472
+ }
4473
+ async function runClaims(w, { sweep = false } = {}) {
4474
+ if (claimsRunning.has(w.root)) return { ok: false, busy: true, error: 'a check is already running for this repository' };
4475
+ claimsRunning.add(w.root);
4476
+ try {
4477
+ const { cacheDir, ledgerFile, ledgerDir } = claimsPaths(w);
4478
+ try { fs.mkdirSync(ledgerDir, { recursive: true }); fs.mkdirSync(cacheDir, { recursive: true }); } catch { /* the writers report it */ }
4479
+ return await claimsLib.check({ root: w.root, cacheDir, ledgerFile, sweep });
4480
+ } finally { claimsRunning.delete(w.root); }
4481
+ }
4482
+ const claimsLast = new Map(); // root → { at, run, error }
4483
+ const claimsWatcher = repoWatch.createWatcher({
4484
+ repos: () => Array.from(workspaces.values()).map((w) => ({ id: w.id, root: w.root })),
4485
+ enabled: (root) => tsSettings.getRepo(STATE, root).watch === true,
4486
+ listFiles: claimsLib.listScope,
4487
+ check: async ({ root, sweep }) => { const w = Array.from(workspaces.values()).find((x) => x.root === root); return w ? runClaims(w, { sweep }) : { ok: false }; },
4488
+ // Timing only (a test shortens them); nothing here names a host or a path.
4489
+ pollMs: Math.max(250, Number(process.env.PMOS_CLAIMS_POLL_MS) || 10000),
4490
+ stableMs: Math.max(250, Number(process.env.PMOS_CLAIMS_STABLE_MS) || 8000),
4491
+ onResult: ({ root, ok, result, error, sweep }) => { claimsLast.set(root, { at: new Date().toISOString(), ok, sweep, run: result && result.run ? result.run : null, error: error || (result && result.error) || null }); },
4492
+ });
4493
+ if (!REQUIRED_AS_LIBRARY) claimsWatcher.start();
4494
+ // D79 — why a validation may not start, or null. The same words on the plan and the run, so the browser
4495
+ // never has to guess. No key means no request; no consent means this repository is never sent.
4496
+ const validating = new Set();
4497
+ function validationRefusal(w) {
4498
+ if (!tsSettings.status(STATE).present) return 'No TypeSafe key is set. Add one in Settings; without a key nothing is sent.';
4499
+ 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.';
4500
+ return null;
4501
+ }
4502
+ // Findings are file-mode state: a PMOS-shaped repository keeps them beside its other ledgers, so they travel
4503
+ // with it. Any other repository keeps them in the per-user runtime directory, never inside the repository.
4504
+ function validationFindingsFile(w) {
4505
+ let d = null;
4506
+ if (isDir(path.join(w.root, 'planning', 'initiatives'))) d = path.join(w.root, 'planning', 'runs');
4507
+ else if (isDir(path.join(w.root, 'pmos', 'state'))) d = path.join(w.root, 'pmos', 'state');
4508
+ else {
4509
+ d = path.join(STATE, 'validation');
4510
+ try { fs.mkdirSync(d, { recursive: true }); } catch { /* the sink guard reports it */ }
4511
+ return path.join(d, require('crypto').createHash('sha1').update(w.root).digest('hex').slice(0, 12) + '.jsonl');
4512
+ }
4513
+ try { fs.mkdirSync(d, { recursive: true }); } catch { /* the sink guard reports it */ }
4514
+ return path.join(d, 'validation-findings.jsonl');
4515
+ }
4238
4516
  function readBody(req) {
4239
4517
  return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
4240
4518
  }
@@ -5136,10 +5414,42 @@ async function handleRequest(req, res) {
5136
5414
  const doc = readLayouts(w);
5137
5415
  return json(res, { ok: true, layouts: doc.layouts, currentId: doc.currentId, stored: doc.stored });
5138
5416
  }
5417
+ if (p === '/api/guide' && req.method === 'GET') {
5418
+ // The user guide ships with the app (workspace/guide/*.md), so it is read from the app directory, not from a
5419
+ // registered repository. A page name is a bare token, never a path. Read-only.
5420
+ const gdir = path.join(__dirname, 'guide');
5421
+ const page = url.searchParams.get('page');
5422
+ if (page) {
5423
+ 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'); }
5424
+ 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); }
5425
+ catch { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('no such guide page'); }
5426
+ }
5427
+ let names = [];
5428
+ try { names = fs.readdirSync(gdir).filter((n) => /^[a-z0-9][a-z0-9-]*\.md$/.test(n)).map((n) => n.slice(0, -3)); }
5429
+ catch (e) { return json(res, { ok: false, error: 'the guide could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }); }
5430
+ const order = ['index', 'layout', 'statuses', 'initiatives-and-next-steps', 'scheduled-tasks-and-cli'];
5431
+ names.sort((a, b) => (order.indexOf(a) < 0 ? 99 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 99 : order.indexOf(b)) || a.localeCompare(b));
5432
+ const pages = names.map((n) => {
5433
+ let title = n;
5434
+ 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 */ }
5435
+ return { page: n, title };
5436
+ });
5437
+ return json(res, { ok: true, pages });
5438
+ }
5139
5439
  if (p === '/api/doc') {
5140
5440
  const w = getWs(url.searchParams.get('ws'));
5141
- const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
5142
- if (doc == null) { res.writeHead(404); return res.end('not a readable text document in this workspace'); }
5441
+ // `ref` — a file that exists only on an initiative's own branch (status-clarity R5). Read through git, never the
5442
+ // working tree, and only from an initiative branch; the reason for a refusal travels in the body.
5443
+ const ref = url.searchParams.get('ref');
5444
+ if (ref && w) {
5445
+ const at = initiativeFiles.readAtRef(w.root, ref, url.searchParams.get('path') || '');
5446
+ if (!at.ok) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end(at.reason); }
5447
+ res.writeHead(200, { 'Content-Type': at.kind === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8', 'X-Doc-Kind': at.kind });
5448
+ return res.end(at.text);
5449
+ }
5450
+ const got = w ? readTextDocWhy(w.root, url.searchParams.get('path') || '') : { ok: false, reason: 'no such workspace' };
5451
+ if (!got.ok) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end(got.reason); }
5452
+ const doc = got;
5143
5453
  // HTML is served as text/plain — the client renders it sandboxed on demand, never as live markup.
5144
5454
  const ctype = doc.kind === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
5145
5455
  res.writeHead(200, { 'Content-Type': ctype, 'X-Doc-Kind': doc.kind }); return res.end(doc.text);
@@ -5296,7 +5606,12 @@ async function handleRequest(req, res) {
5296
5606
  sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
5297
5607
  });
5298
5608
  } 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 });
5609
+ // next-step-actions: each item says what it can do (`act`), names the repository file it points at when there is one, and
5610
+ // carries the run started from it and the PM's verdict on that run.
5611
+ try { nextStepActions.annotate(nextSteps, (it) => nextStepPathOf(root, it)); attachRuns(nextSteps, id); } catch (e) { void e; }
5612
+ let lcHistory = [];
5613
+ try { lcHistory = lifecycleHistory(w, id); } catch { lcHistory = []; }
5614
+ return json(res, { ws: w ? w.id : null, ...out, lanes, conversations, files, next_steps: nextSteps, lifecycle_history: lcHistory });
5300
5615
  }
5301
5616
  if (p === '/api/audit' && req.method === 'GET') {
5302
5617
  /* The workflow audit (D77): the sweep run on one repository, read-only. It reports state that contradicts
@@ -5675,6 +5990,235 @@ async function handleRequest(req, res) {
5675
5990
  return json(res, { ws: w.id, name: w.name, ...out });
5676
5991
  } finally { syncing.delete(w.root); }
5677
5992
  }
5993
+ /* ── next-step actions: prepare is read-only; go starts exactly what was shown; review records the PM's word ── */
5994
+ if (p === '/api/next-step/prepare' && req.method === 'POST') {
5995
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5996
+ const r = prepareNextStep(body);
5997
+ return json(res, r, r.ok ? 200 : (r.status || 400));
5998
+ }
5999
+ if (p === '/api/next-step/go' && req.method === 'POST') {
6000
+ // Only reached by the PM's press. It re-prepares, and starts a session only when the PM confirmed AND the prompt and plan
6001
+ // are the ones they were shown (the hash). Anything else starts nothing.
6002
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6003
+ if (body.confirm !== true) return json(res, { ok: false, error: 'confirm is required: this starts a session' }, 400);
6004
+ const r = prepareNextStep(body);
6005
+ if (!r.ok) return json(res, r, r.status || 400);
6006
+ if (r.refused) return json(res, { ok: false, refused: r.refused, error: r.refused.why }, 409);
6007
+ 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);
6008
+ if (!r.budget.allowed) return json(res, { ok: false, error: r.budget.reason, budget: r.budget }, 409);
6009
+ const started = startWork({ initiative: r.initiative_id, ws: r.ws, agent: r.agent, name: `${r.item.key} · ${r.initiative_id}`, profile: r.profile || undefined,
6010
+ origin: 'next-step', initialInput: r.prompt + '\r' });
6011
+ if (!started.ok) return json(res, { ok: false, error: started.error }, 409);
6012
+ 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,
6013
+ branch: r.plan.branch || null, plan: r.plan.plan, prompt_sha: r.prompt_sha, judgment: r.judgment.on, profile: r.profile || null };
6014
+ const wrote = nsAppend(NS_RUNS(), rec, 'next-step run');
6015
+ 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 });
6016
+ }
6017
+ if (p === '/api/next-step/summary' && req.method === 'GET') {
6018
+ // What an ended (or running) next-step session did: the files git shows on its branch against trunk, the end of the
6019
+ // conversation, and every JUDGMENT line checked against its shape. Read-only.
6020
+ const sid = String(url.searchParams.get('session') || '');
6021
+ const run = nsRead(NS_RUNS()).filter((x) => x && x.session_id === sid).pop();
6022
+ if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
6023
+ const live = sessions.get(sid);
6024
+ let text = '';
6025
+ if (live) text = transcriptsLib.stripAnsi(live.buf);
6026
+ else { const rec = transcriptsLib.read(STATE, sid); text = rec && rec.text ? String(rec.text) : ''; }
6027
+ const w = getWs(run.ws);
6028
+ let files = []; let filesError = null;
6029
+ if (w && gitInfo.isRepo(w.root)) {
6030
+ const trunk = ['origin/main', 'main', 'origin/master', 'master'].find((b) => spawnSync('git', ['-C', w.root, 'rev-parse', '--verify', '--quiet', b], { encoding: 'utf8' }).status === 0);
6031
+ const tree = live && live.root ? live.root : w.root;
6032
+ const branch = run.branch && spawnSync('git', ['-C', tree, 'rev-parse', '--verify', '--quiet', run.branch], { encoding: 'utf8' }).status === 0 ? run.branch : 'HEAD';
6033
+ if (trunk) {
6034
+ const d = spawnSync('git', ['-C', tree, 'diff', '--name-status', `${trunk}...${branch}`], { encoding: 'utf8', timeout: 20000 });
6035
+ 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') }; });
6036
+ else filesError = 'git could not compare the branch with trunk';
6037
+ } else filesError = 'no trunk branch to compare against';
6038
+ } else filesError = 'the repository could not be read';
6039
+ const j = judgmentLib.extract(text);
6040
+ const reviews = nsRead(NS_REVIEWS()).filter((x) => x && x.session_id === sid);
6041
+ return json(res, { ok: true, session_id: sid, state: live ? (live.state === 'exited' ? 'ended' : 'live') : 'ended', branch: run.branch || null,
6042
+ files, files_error: filesError, tail: text.slice(-3000), judgments: j, review: reviews.pop() || null });
6043
+ }
6044
+ if (p === '/api/next-step/review' && req.method === 'POST') {
6045
+ // The PM's verdict on one step's run. It accepts or sends back THE STEP. It never records an acceptance of the
6046
+ // initiative, never changes a status and never touches a lifecycle line.
6047
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6048
+ const verdict = body.verdict === 'accept' ? 'accept' : body.verdict === 'send-back' ? 'send-back' : null;
6049
+ if (!verdict) return json(res, { ok: false, error: 'verdict must be accept or send-back' }, 400);
6050
+ const note = String(body.note || '').trim().slice(0, 500);
6051
+ if (verdict === 'send-back' && !note) return json(res, { ok: false, error: 'say what to change: a send-back needs a note' }, 400);
6052
+ const sid = String(body.session_id || '');
6053
+ const run = nsRead(NS_RUNS()).filter((x) => x && x.session_id === sid).pop();
6054
+ if (!run) return json(res, { ok: false, error: 'that session was not started from a next step' }, 404);
6055
+ const live = sessions.get(sid);
6056
+ if (live && live.state !== 'exited') return json(res, { ok: false, error: 'the session is still running — review it when it has ended' }, 409);
6057
+ const rec = { at: nowISO(), initiative_id: run.initiative_id, item_id: run.item_id, key: run.key, session_id: sid, verdict, note, pm: 'wawan' };
6058
+ const wrote = nsAppend(NS_REVIEWS(), rec, 'step review');
6059
+ if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
6060
+ return json(res, { ok: true, recorded: rec });
6061
+ }
6062
+ if (p === '/api/next-step/settings') {
6063
+ if (req.method === 'GET') return json(res, { ok: true, budget: nsBudget() });
6064
+ if (req.method === 'POST') {
6065
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6066
+ const n = sessionBudget.normalizeLimits({ ...nsLimits(), ...(body.budget || {}) });
6067
+ if (!n.ok) return json(res, { ok: false, error: n.error }, 400);
6068
+ try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(NS_SETTINGS(), JSON.stringify({ budget: n.limits }) + '\n'); }
6069
+ catch (e) { warnWriteFail('next-step-settings.json', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
6070
+ return json(res, { ok: true, budget: nsBudget() });
6071
+ }
6072
+ }
6073
+ // ── claims-ledger — the local checks, on a press or on a watch the PM switched on ────────────────────────────
6074
+ if (p === '/api/claims' && req.method === 'GET') {
6075
+ const w = getWs(url.searchParams.get('ws'));
6076
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6077
+ const { cacheDir, ledgerFile } = claimsPaths(w);
6078
+ const last = claimsLast.get(w.root) || null;
6079
+ return json(res, { ok: true, ws: w.id, name: w.name, ...claimsLib.readView({ cacheDir, ledgerFile }),
6080
+ watching: tsSettings.getRepo(STATE, w.root).watch === true, checking: claimsRunning.has(w.root), last_run: last,
6081
+ // the record's path, only when it is inside the repository AND something has been written to it
6082
+ ledger: (path.relative(w.root, ledgerFile).startsWith('..') || !fs.existsSync(ledgerFile)) ? null : path.relative(w.root, ledgerFile).split(path.sep).join('/') });
6083
+ }
6084
+ if (p === '/api/claims/check' && req.method === 'POST') {
6085
+ let body = {};
6086
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6087
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6088
+ const w = getWs(body.ws);
6089
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6090
+ const out = await runClaims(w, { sweep: body.sweep === true });
6091
+ if (out.ok) claimsLast.set(w.root, { at: new Date().toISOString(), ok: true, sweep: body.sweep === true, run: out.run, error: null });
6092
+ return json(res, { ws: w.id, name: w.name, ...out }, out.ok ? 200 : (out.busy ? 409 : 500));
6093
+ }
6094
+ if (p === '/api/claims/watch' && req.method === 'POST') {
6095
+ let body = {};
6096
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6097
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6098
+ const w = getWs(body.ws);
6099
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6100
+ if (typeof body.watch !== 'boolean') return json(res, { ok: false, error: 'watch must be true or false' }, 400);
6101
+ const out = tsSettings.setRepo(STATE, w.root, { watch: body.watch });
6102
+ if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6103
+ return json(res, { ok: true, ws: w.id, watching: tsSettings.getRepo(STATE, w.root).watch === true });
6104
+ }
6105
+ // ── D79 — repository validation through TypeSafe ──────────────────────────────────────────────────
6106
+ // The ONLY routes that lead to an outbound request, and only through lib/typesafe.js. The key is never
6107
+ // in a response: `status` says present or absent. Nothing here edits a repository file.
6108
+ if (p === '/api/typesafe' && req.method === 'GET') {
6109
+ return json(res, { ok: true, key: tsSettings.status(STATE), host: tsClient.HOST });
6110
+ }
6111
+ if (p === '/api/typesafe/key' && (req.method === 'PUT' || req.method === 'DELETE')) {
6112
+ let body = {};
6113
+ if (req.method === 'PUT') {
6114
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6115
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6116
+ }
6117
+ const out = req.method === 'PUT' ? tsSettings.setKey(STATE, body.key) : tsSettings.clearKey(STATE);
6118
+ if (!out.ok) return json(res, { ok: false, error: out.error }, req.method === 'PUT' && /look like a key/.test(out.error) ? 400 : 500);
6119
+ return json(res, { ok: true, key: tsSettings.status(STATE) });
6120
+ }
6121
+ if (p === '/api/validate/repo' && (req.method === 'GET' || req.method === 'POST')) {
6122
+ let body = {};
6123
+ if (req.method === 'POST') {
6124
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6125
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6126
+ }
6127
+ const w = getWs(req.method === 'POST' ? body.ws : url.searchParams.get('ws'));
6128
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6129
+ if (req.method === 'POST') {
6130
+ const out = tsSettings.setRepo(STATE, w.root, { consent: body.consent, exclude: body.exclude });
6131
+ if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6132
+ }
6133
+ return json(res, { ok: true, ws: w.id, name: w.name, ...tsSettings.getRepo(STATE, w.root), key: tsSettings.status(STATE) });
6134
+ }
6135
+ if (p === '/api/validate/plan' && req.method === 'POST') {
6136
+ let body = {};
6137
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6138
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6139
+ const w = getWs(body.ws);
6140
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6141
+ const refused = validationRefusal(w);
6142
+ if (refused) return json(res, { ok: false, error: refused }, 409);
6143
+ return json(res, { ok: true, ws: w.id, name: w.name, plan: validateLib.plan({ root: w.root, exclude: tsSettings.getRepo(STATE, w.root).exclude }) });
6144
+ }
6145
+ if (p === '/api/validate/run' && req.method === 'POST') {
6146
+ let body = {};
6147
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6148
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6149
+ const w = getWs(body.ws);
6150
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6151
+ const refused = validationRefusal(w);
6152
+ if (refused) return json(res, { ok: false, error: refused }, 409);
6153
+ if (validating.has(w.root)) return json(res, { ok: false, error: 'a validation is already running for this repository' }, 409);
6154
+ const file = validationFindingsFile(w);
6155
+ validating.add(w.root);
6156
+ try {
6157
+ const out = await validateLib.run({
6158
+ root: w.root, exclude: tsSettings.getRepo(STATE, w.root).exclude,
6159
+ key: tsSettings.getKey(STATE), findingsFile: file, expectRequests: body.expect_requests,
6160
+ });
6161
+ return json(res, { ws: w.id, name: w.name, ...out }, out.ok ? 200 : (out.conflict ? 409 : 500));
6162
+ } finally { validating.delete(w.root); }
6163
+ }
6164
+ if (p === '/api/validate/findings' && req.method === 'GET') {
6165
+ const w = getWs(url.searchParams.get('ws'));
6166
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6167
+ return json(res, { ok: true, ws: w.id, name: w.name, ...validateLib.readFindings(validationFindingsFile(w)) });
6168
+ }
6169
+ /* ── proactive engine (D1): Today is computed, feedback is recorded, nothing is sent, started or changed ── */
6170
+ if (p === '/api/proactive/today' && req.method === 'GET') {
6171
+ const now = Date.now();
6172
+ const t = proactiveToday(now);
6173
+ return json(res, { ok: true, as_of: new Date(now).toISOString(), ...t, quiet: proactiveLib.inQuietHours(now, t.settings.quiet), empty: !t.off && t.candidates.length === 0 && t.unreadable.length === 0,
6174
+ note: 'Derived from the ledgers and git on this computer, only while the workspace server runs. It does not read your mail or calendar.' });
6175
+ }
6176
+ if (p === '/api/proactive/scan' && req.method === 'POST') {
6177
+ const r = proactiveScan({ manual: true });
6178
+ return json(res, r);
6179
+ }
6180
+ if (p === '/api/proactive/feedback' && req.method === 'POST') {
6181
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6182
+ const v = proactiveLib.validateFeedback(body);
6183
+ if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
6184
+ const rec = { at: nowISO(), id: 'pf-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5), pm: 'wawan', ...v.record };
6185
+ const w = nsAppend(PRO_FEEDBACK(), rec, 'proactive feedback');
6186
+ if (!w.ok) return json(res, { ok: false, error: w.error }, 500);
6187
+ return json(res, { ok: true, recorded: rec });
6188
+ }
6189
+ if (p === '/api/proactive/brief') {
6190
+ // The brief is submitted by a scheduled skill in an agent session and only CHECKED and stored here. One file per day;
6191
+ // a second submission for the same day replaces the first. Nothing is sent, started or changed.
6192
+ const dir = path.join(STATE, 'briefs');
6193
+ if (req.method === 'POST') {
6194
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6195
+ const v = briefLib.validateBrief(body, { now: Date.now(), settings: proSettings() });
6196
+ if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
6197
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, v.brief.date + '.json'), JSON.stringify(v.brief) + '\n'); }
6198
+ catch (e) { warnWriteFail('briefs', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
6199
+ return json(res, { ok: true, brief: v.brief });
6200
+ }
6201
+ if (req.method === 'GET') {
6202
+ const now = Date.now(); const st = proSettings();
6203
+ let latest = null;
6204
+ try { const f = fs.readdirSync(dir).filter((n) => /^\d{4}-\d{2}-\d{2}\.json$/.test(n)).sort().pop(); if (f) latest = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { /* none yet */ }
6205
+ const sched = readSchedules().filter((x) => x.skill === 'morning-brief').map((x) => ({ id: x.id, ws: x.ws, time: x.time, enabled: x.enabled !== false, lastRun: x.lastRun, lastStatus: x.lastStatus }));
6206
+ return json(res, { ok: true, greeting: briefLib.greeting(now, st.name), today: briefLib.localDate(now), brief: latest, stale: !!latest && latest.date !== briefLib.localDate(now), scheduled: sched,
6207
+ note: 'A session reads your calendar and other sources and submits the brief. This server only checks and stores it.' });
6208
+ }
6209
+ }
6210
+ if (p === '/api/proactive/settings') {
6211
+ if (req.method === 'GET') return json(res, { ok: true, settings: proSettings() });
6212
+ if (req.method === 'POST') {
6213
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6214
+ const cur = proSettings();
6215
+ const n = proactiveLib.normalizeSettings({ ...cur, ...body, triggers: { ...cur.triggers, ...(body.triggers || {}) }, briefSources: { ...cur.briefSources, ...(body.briefSources || {}) } });
6216
+ if (!n.ok) return json(res, { ok: false, error: n.error }, 400);
6217
+ try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PRO_SETTINGS(), JSON.stringify(n.settings) + '\n'); }
6218
+ catch (e) { warnWriteFail('proactive-settings.json', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
6219
+ return json(res, { ok: true, settings: n.settings });
6220
+ }
6221
+ }
5678
6222
  if (p === '/api/lifecycle' && req.method === 'POST') {
5679
6223
  // D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
5680
6224
  // appends ONE line to lifecycle.jsonl and never writes an acceptance.