create-pmos 0.39.0 → 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.
@@ -65,6 +65,8 @@ const { createSinkGuard } = require('./lib/sink.js');
65
65
  const tsSettings = require('./lib/typesafe-settings.js');
66
66
  const tsClient = require('./lib/typesafe.js');
67
67
  const validateLib = require('./lib/validate.js');
68
+ const claimsLib = require('./lib/claims.js');
69
+ const repoWatch = require('./lib/repo-watch.js');
68
70
  tsSettings.adoptEnv(process.env); // D79 — take the env key into memory and delete it, before anything is spawned
69
71
  const scheduleLogic = require('./lib/schedule-logic');
70
72
  const { safeFirstInput } = require('./lib/prompt-input');
@@ -74,6 +76,8 @@ const { deriveNextSteps } = require('./lib/next-steps');
74
76
  const nextStepActions = require('./lib/next-step-actions');
75
77
  const sessionBudget = require('./lib/session-budget');
76
78
  const judgmentLib = require('./lib/judgment');
79
+ const proactiveLib = require('./lib/proactive');
80
+ const briefLib = require('./lib/brief');
77
81
  const harness = require('./lib/harness-parse');
78
82
  const transcript = require('./lib/transcript-read');
79
83
  const skillStore = require('./lib/skill-store');
@@ -1177,19 +1181,37 @@ function searchFamily(w, query, limit, only) {
1177
1181
  }
1178
1182
 
1179
1183
  // Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
1180
- // per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
1181
- // non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
1182
- // ⇒ refused. Returns { kind, text } or null. The traversal + cross-workspace guard is unchanged
1183
- // (insideRoot); what widened is the file KIND, never the reachable path set.
1184
- 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) {
1185
1191
  const full = path.resolve(root, rel || '');
1186
- if (!insideRoot(root, full)) return null; // per-workspace guard (traversal + cross-ws)
1187
- const kind = preview.previewKind(path.basename(full));
1188
- 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
1189
1206
  let text;
1190
- try { text = fs.readFileSync(full, 'utf8'); } catch { return null; }
1191
- if (text.indexOf(String.fromCharCode(0)) !== -1) return null; // NUL byte => binary masquerading as text
1192
- 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;
1193
1215
  }
1194
1216
 
1195
1217
  // ── The initiative lens: the library grouped the way the PM thinks. ───────────────────
@@ -2049,6 +2071,7 @@ function taskById(id, root) { return loadTasks(root).find((t) => t.id === id ||
2049
2071
  // D71 — the recommended schedules, one set per purpose. Times are host-local, like every schedule.
2050
2072
  const SEED_SETS = {
2051
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' }],
2052
2075
  memory: [{ name: 'Weekly memory grooming', skill: 'memory-groom', cadence: 'weekly', day: 'Mon', time: '08:00' }],
2053
2076
  skills: [{ name: 'Weekly skill discovery', skill: 'skill-discover', cadence: 'weekly', day: 'Wed', time: '09:00' }],
2054
2077
  reports: [
@@ -2165,7 +2188,7 @@ function checkDueSchedules() {
2165
2188
  return fired;
2166
2189
  }
2167
2190
  // the tick — the persistent server checks on an interval; unref'd so it never blocks exit
2168
- 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 {}
2169
2192
 
2170
2193
  // ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
2171
2194
  /** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
@@ -3334,6 +3357,88 @@ function prepareNextStep(body) {
3334
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.' } };
3335
3358
  }
3336
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
+
3337
3442
  // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
3338
3443
  // Isolation buys "your other sessions keep running" at the price of a directory per branch. That
3339
3444
  // price is only fair if the PM can see the folders and reclaim them without leaving the workspace
@@ -4352,6 +4457,40 @@ function serveFile(res, file, type, noCache) {
4352
4457
  res.end(buf);
4353
4458
  });
4354
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();
4355
4494
  // D79 — why a validation may not start, or null. The same words on the plan and the run, so the browser
4356
4495
  // never has to guess. No key means no request; no consent means this repository is never sent.
4357
4496
  const validating = new Set();
@@ -5308,8 +5447,9 @@ async function handleRequest(req, res) {
5308
5447
  res.writeHead(200, { 'Content-Type': at.kind === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8', 'X-Doc-Kind': at.kind });
5309
5448
  return res.end(at.text);
5310
5449
  }
5311
- const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
5312
- if (doc == null) { res.writeHead(404); return res.end('not a readable text document in this workspace'); }
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;
5313
5453
  // HTML is served as text/plain — the client renders it sandboxed on demand, never as live markup.
5314
5454
  const ctype = doc.kind === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
5315
5455
  res.writeHead(200, { 'Content-Type': ctype, 'X-Doc-Kind': doc.kind }); return res.end(doc.text);
@@ -5930,6 +6070,38 @@ async function handleRequest(req, res) {
5930
6070
  return json(res, { ok: true, budget: nsBudget() });
5931
6071
  }
5932
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
+ }
5933
6105
  // ── D79 — repository validation through TypeSafe ──────────────────────────────────────────────────
5934
6106
  // The ONLY routes that lead to an outbound request, and only through lib/typesafe.js. The key is never
5935
6107
  // in a response: `status` says present or absent. Nothing here edits a repository file.
@@ -5994,6 +6166,59 @@ async function handleRequest(req, res) {
5994
6166
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5995
6167
  return json(res, { ok: true, ws: w.id, name: w.name, ...validateLib.readFindings(validationFindingsFile(w)) });
5996
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
+ }
5997
6222
  if (p === '/api/lifecycle' && req.method === 'POST') {
5998
6223
  // D70 — close, hold, cancel or reopen an initiative. Only reached by an explicit PM press. It
5999
6224
  // appends ONE line to lifecycle.jsonl and never writes an acceptance.
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: morning-brief
3
+ label: "Morning brief"
4
+ description: "Prepare today's brief: the calendar, the priorities and what is worth asking. Read-only on every source; it submits the brief to the workspace and sends nothing."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Calendar
9
+ - Priorities
10
+ - Worth asking
11
+ - Sources reached and not reached
12
+ ---
13
+
14
+ /morning-brief — prepare today's brief for the PM.
15
+
16
+ Rules that always hold:
17
+ 1. READ-ONLY on every source. Never send, reply, forward, post, comment, create, edit, accept, decline, transition, label, archive or delete anything in mail, chat, Jira, Confluence, calendar, Wispr Flow or any other source.
18
+ 2. SETTINGS DECIDE THE SOURCES. Run `pmos proactive settings`. Read a source only if its line under "brief sources" says on. A source that is off is reported as "off". Mail is off unless the PM turned it on.
19
+ 3. LIGHT ON CONTENT. Titles, times, names and ids only. Never copy a message body, an attachment, a token or a credential. Skip anything marked private, personal, confidential or HR. At most one short evidence line per item.
20
+ 4. NOTHING INVENTED. A source you cannot reach in this session is reported as "not-reached". If nothing is waiting, submit a brief with no items. An honest short brief is correct.
21
+ 5. THE ONLY WRITE is `pmos proactive brief submit`. Do not edit repository files and do not commit.
22
+ 6. TYPED SCORING only if `pmos proactive settings` says "scoring on" under the brief line. Then use the Typesafe skill for confidence scores on titles and metadata only; the text you send leaves this machine. Otherwise score yourself. Set "judgment": {"used": true|false} to say which.
23
+
24
+ Procedure:
25
+ 1. Run `pmos proactive settings` and `pmos proactive today`. The second lists what the workspace derived from the ledgers. Each of its items may become a priority with source "derived".
26
+ 2. Read today's events from the calendar and today's and tomorrow's meeting notes from Wispr Flow, if those sources are on and reachable. Read Jira issues assigned to the PM or due soon, and Confluence pages changed since yesterday, if on and reachable. Read mail only if on: subjects and senders of threads waiting on the PM, no bodies.
27
+ 3. For each thing the PM should act on today, write one item: a short title, why it is for today, its source, the initiative id if one applies, who acts (pm or agent), effort (minutes, an-hour, half-day, day), your confidence from 0 to 1 that it belongs on today's list, and one short evidence line. Give a low confidence when you are guessing. The workspace shows an item under 0.5 as "worth asking", not as a priority.
28
+ 4. Write the brief as a JSON file in the scratch folder of this session, in this shape, and nothing else:
29
+ ```
30
+ {"date": "YYYY-MM-DD", "sources": {"calendar": "read", "wispr": "not-reached", "mail": "off", "jira": "read", "confluence": "read"},
31
+ "calendar": [{"start": "<ISO date and time>", "end": "<ISO>", "title": "…", "with": "…"}],
32
+ "items": [{"title": "…", "why_now": "…", "source": "calendar|wispr|mail|jira|confluence|derived", "initiative_id": "<id or omit>", "owner": "pm|agent", "effort": "minutes|an-hour|half-day|day", "confidence": 0.0, "evidence": "…"}],
33
+ "judgment": {"used": false}}
34
+ ```
35
+ Only report calendar events if calendar is "read". Only give an item a source that is "read" (or "derived").
36
+ 5. Run `pmos proactive brief submit --file <that file>`. If it is refused, read the reason, fix the file and submit once more. Do not work around a refusal.
37
+ 6. Finish by saying in two lines how many priorities and worth-asking items you submitted and which sources were not reached.
38
+
39
+ Output contract — produce EXACTLY these sections in your final message:
40
+ - Calendar
41
+ - Priorities
42
+ - Worth asking
43
+ - Sources reached and not reached