create-pmos 0.61.1 → 0.63.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 (46) 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/first-paint-audit.js +4 -2
  7. package/workspace/guide/index.md +6 -3
  8. package/workspace/guide/layout.md +5 -4
  9. package/workspace/guide/notes.md +72 -0
  10. package/workspace/guide/people.md +70 -0
  11. package/workspace/lib/calendar-memory.js +2 -1
  12. package/workspace/lib/jev-notes.js +162 -0
  13. package/workspace/lib/notes-repo.js +133 -0
  14. package/workspace/lib/notes-whisper.js +200 -0
  15. package/workspace/lib/notes.js +511 -0
  16. package/workspace/lib/people-cli.js +135 -0
  17. package/workspace/lib/people-store.js +123 -0
  18. package/workspace/lib/people.js +399 -0
  19. package/workspace/lib/pmos-verbs.js +3 -0
  20. package/workspace/lib/route-modules.js +1 -0
  21. package/workspace/lib/routes-notes.js +230 -0
  22. package/workspace/package-lock.json +1 -1
  23. package/workspace/package.json +1 -1
  24. package/workspace/public/pm/app.js +2 -2
  25. package/workspace/public/pm/components.css +441 -0
  26. package/workspace/public/pm/dest-people.js +385 -0
  27. package/workspace/public/pm/dest-settings.js +44 -3
  28. package/workspace/public/pm/frame-copy.js +7 -0
  29. package/workspace/public/pm/initiative-sheet.js +5 -2
  30. package/workspace/public/pm/initiatives-copy.js +1 -0
  31. package/workspace/public/pm/item-sheet.js +18 -1
  32. package/workspace/public/pm/panel-cockpit.js +3 -0
  33. package/workspace/public/pm/people-link.js +106 -0
  34. package/workspace/public/pm/registry.js +2 -0
  35. package/workspace/public/pm/today-agenda.js +3 -1
  36. package/workspace/public/pm/today-day.js +2 -0
  37. package/workspace/public/pm/topbar.js +4 -3
  38. package/workspace/public/pm/view-notes.js +723 -0
  39. package/workspace/replaced-tests.json +14 -0
  40. package/workspace/server.js +30 -1
  41. package/workspace/skills/gather-requirements.skill +5 -3
  42. package/workspace/skills/jira-sync.skill +5 -4
  43. package/workspace/skills/live-notes.skill +3 -1
  44. package/workspace/skills/morning-brief.skill +5 -3
  45. package/workspace/test-lists/meeting-notes.json +15 -0
  46. package/workspace/test-lists/people-directory.json +6 -0
@@ -41,6 +41,9 @@ const VERBS = Object.freeze({
41
41
  // Not a pm-os-absorb verb (run: null): docs-three-way-sync added it through the same table.
42
42
  docs: Object.freeze({ module: 'docs-cli.js', run: null, guide: 'docs.md',
43
43
  help: 'pmos docs [list|link|unlink|find] … — is each linked document the same as, ahead of or behind its Google Docs and Confluence copy, and record a sync (pmos docs help)' }),
44
+ // Not a pm-os-absorb verb (run: null): people-directory added it through the same table.
45
+ people: Object.freeze({ module: 'people-cli.js', run: null, guide: 'people.md',
46
+ help: 'pmos people list|show|record|settings … — the people you work with in this repository (pmos people help)' }),
44
47
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
45
48
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
46
49
  });
@@ -36,6 +36,7 @@ const MODULES = Object.freeze([
36
36
  'routes-timeline', // Run 4 (4C): GET /api/timeline
37
37
  'routes-board', // Run 4 (4D): the cross-initiative Board, Release
38
38
  'routes-reports', // Run 5 (5C): drafts, Issue, notes
39
+ 'routes-notes', // meeting-notes Run 1: the Notes view (typed notes, dictation, transcripts, summaries)
39
40
  ]);
40
41
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
41
42
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+ /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 and 2; lib/route-modules.js).
3
+ *
4
+ * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
+ * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
6
+ * POST /api/notes/new { title? } → { ok, note }
7
+ * POST /api/notes/save { id, title?, body? } → { ok, updated_at }
8
+ * POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
9
+ * POST /api/notes/record { id, ws? } → { ok, recorded_from } `ws` is the repository the note's lines are checked against
10
+ * POST /api/notes/transcript { id, text, source? } → { ok, added: [{ at, t, text, source, cat, index }] }
11
+ * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
12
+ * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
13
+ * POST /api/notes/speaker { id, source, name } → { ok, speakers }
14
+ * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
15
+ * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
16
+ * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
17
+ * POST /api/notes/private { id, private } → { ok, private }
18
+ * POST /api/notes/summarize { id } → { ok, summary } the local summary
19
+ * POST /api/notes/summarize-agent { id, ws? } → { ok, started, session, handoff, out, withheld, reason }
20
+ * POST /api/notes/delete { id } → { ok }
21
+ *
22
+ * Notes are the PM's own and live in the per-user runtime directory (ctx.runtimeDir), never in a repository, so no route here
23
+ * writes into a registered workspace. Every POST goes through pm-guard `requirePagePress` (a body that is not an object or carries
24
+ * a writer field: 400; no Origin, a foreign Origin or a wrong nonce: 403). No route makes an outbound request of its own: the
25
+ * agent summary starts the PM's own agent session in the repository (ctx.spawnSession), as the skill draft does; Whisper runs
26
+ * on this machine (lib/notes-whisper.js); a label or a check goes to Jev only through lib/jev-notes.js, behind the D91 gates.
27
+ *
28
+ * AFTER A LINE IS ADDED (Run 2). Each new line already has its local label. Then, one note at a time and in order: Jev's label
29
+ * when the label gates hold; for a decision, action or statement, the closest repository passage (local, lib/notes-repo.js),
30
+ * and Jev's comparison when the check gates hold too. The page reads the results with GET /api/notes/one.
31
+ */
32
+ const guard = require('./pm-guard.js');
33
+ const notes = require('./notes.js');
34
+ const whisper = require('./notes-whisper.js');
35
+ const notesRepo = require('./notes-repo.js');
36
+ const jevNotes = require('./jev-notes.js');
37
+ const tsSettings = require('./typesafe-settings.js');
38
+
39
+ const MAX_PCM_BYTES = 16000 * 2 * 15; // 15 s of 16 kHz 16-bit audio in one request
40
+ const CHECKED = new Set(['decision', 'action', 'statement']);
41
+
42
+ const prefixes = ['/api/notes'];
43
+ const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
44
+ '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
45
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup']);
46
+
47
+ /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
48
+ const chains = new Map(); // note id → the promise of its enrichment so far
49
+ function enrich(ctx, id, added) {
50
+ if (!added || !added.length) return Promise.resolve();
51
+ const next = (chains.get(id) || Promise.resolve()).then(() => enrichNow(ctx, id, added)).catch(() => {});
52
+ chains.set(id, next);
53
+ next.then(() => { if (chains.get(id) === next) chains.delete(id); });
54
+ return next;
55
+ }
56
+ function gatesFor(ctx, root) {
57
+ const dir = ctx.runtimeDir;
58
+ if (!root) return { root: null, exclude: [], ask: null, check: false, key: null, askOpts: {} };
59
+ const repo = tsSettings.getRepo(dir, root);
60
+ const sw = jevNotes.getSwitch(dir, root);
61
+ const key = tsSettings.getKey(dir);
62
+ const snd = jevNotes.senderFor(key, { consent: repo.consent, jev: sw.jev, check: sw.check }, typeof ctx.jevModelOpts === 'function' ? ctx.jevModelOpts(root) : {});
63
+ return { root, exclude: repo.exclude, ask: snd.ask, check: snd.check, key: snd.ask ? key : null, askOpts: snd.askOpts };
64
+ }
65
+ const stillOpen = (dir, id) => { const r = notes.read(dir, id); return r.ok && !r.note.private ? r.note : null; };
66
+ async function enrichNow(ctx, id, added) {
67
+ const dir = ctx.runtimeDir;
68
+ const note = stillOpen(dir, id);
69
+ if (!note) return;
70
+ const w = note.ws ? ctx.getWs(note.ws) : null;
71
+ const g = gatesFor(ctx, w ? w.root : null);
72
+ for (const seg of added) {
73
+ let cat = seg.cat;
74
+ if (g.ask && stillOpen(dir, id)) {
75
+ const l = await jevNotes.label({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: seg.text, root: g.root });
76
+ if (l.cat) { const u = notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { cat: l.cat, cat_by: 'jev' } }); if (u.ok) cat = u.segment.cat; }
77
+ else if (l.reason === 'failed' || l.reason === 'paused') notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { jev: l.reason } });
78
+ }
79
+ if (!g.root || !CHECKED.has(cat)) continue;
80
+ const pass = notesRepo.closest(g.root, seg.text, { exclude: g.exclude });
81
+ if (!pass) continue;
82
+ let check = { doc: pass.path, line: pass.line, verdict: 'related', by: 'local' };
83
+ if (g.ask && g.check && stillOpen(dir, id)) {
84
+ const c = await jevNotes.compare({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: seg.text, passage: pass, root: g.root });
85
+ if (c.verdict) check = { doc: pass.path, line: pass.line, verdict: c.verdict, words: c.words || null, by: 'jev' };
86
+ }
87
+ notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { check } });
88
+ }
89
+ }
90
+
91
+ /* ── local Whisper: one transcriber for the process ───────────────────────────────────────────── */
92
+ let transcriber = null; let transcriberDir = null;
93
+ const audioErrors = new Map(); // note id → the last thing that went wrong, for the page
94
+ function transcriberFor(ctx) {
95
+ if (transcriber && transcriberDir === ctx.runtimeDir) return transcriber;
96
+ transcriberDir = ctx.runtimeDir;
97
+ transcriber = whisper.createTranscriber({
98
+ runtimeDir: ctx.runtimeDir,
99
+ onText: async (c) => {
100
+ const r = notes.appendTranscript(ctx.runtimeDir, { id: c.id, text: c.text, source: c.source, t: c.t });
101
+ if (r.ok) { audioErrors.delete(c.id); await enrich(ctx, c.id, r.added).catch(() => {}); }
102
+ },
103
+ onError: (c) => { audioErrors.set(c.id, { at: new Date().toISOString(), error: c.error }); },
104
+ });
105
+ return transcriber;
106
+ }
107
+ function engineInfo(ctx, w) {
108
+ const d = whisper.detect(ctx.runtimeDir);
109
+ const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size };
110
+ if (!w) return { whisper: out, jev: null };
111
+ const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
112
+ const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
113
+ const info = jevNotes.infoFor(tsSettings.status(ctx.runtimeDir).present, { consent: repo.consent, jev: sw.jev, check: sw.check });
114
+ return { whisper: out, jev: { ...info, paused: jevNotes.pauseStatus(w.root) } };
115
+ }
116
+
117
+ const send = (ctx, res, r) => ctx.json(res, r, r.ok ? 200 : (r.status || 400));
118
+
119
+ async function handle(req, res, url, ctx) {
120
+ const p = url.pathname;
121
+ const dir = ctx.runtimeDir;
122
+ if (!dir) { ctx.json(res, { ok: false, error: 'the notes store is not available' }, 500); return true; }
123
+ if (p === '/api/notes' || p === '/api/notes/one') {
124
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
125
+ if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
126
+ const nid = url.searchParams.get('id');
127
+ const r = notes.read(dir, nid);
128
+ if (r.ok) r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null };
129
+ send(ctx, res, r);
130
+ return true;
131
+ }
132
+ if (p === '/api/notes/engine') {
133
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
134
+ const wsId = url.searchParams.get('ws');
135
+ const w = wsId ? ctx.getWs(wsId) : null;
136
+ ctx.json(res, { ok: true, ...engineInfo(ctx, w && w.id === wsId ? w : null) });
137
+ return true;
138
+ }
139
+ if (!POSTS.has(p)) { ctx.json(res, { ok: false, error: 'no such notes route' }, 404); return true; }
140
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
141
+ const parsed = await ctx.readJson(req);
142
+ if (!parsed.ok) { ctx.json(res, { ok: false, error: 'The body is not valid JSON.' }, 400); return true; }
143
+ const body = parsed.body;
144
+ const g = guard.requirePagePress(req, body);
145
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
146
+ const id = body && typeof body.id === 'string' ? body.id : '';
147
+ const done = (r) => { send(ctx, res, r); return true; };
148
+ if (p === '/api/notes/new') return done(notes.create(dir, { title: body.title }));
149
+ if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
150
+ if (p === '/api/notes/save') return done(notes.save(dir, { id, title: body.title, body: body.body }));
151
+ const wsOf = () => {
152
+ if (body.ws == null) return { ok: true, w: null };
153
+ const w = typeof body.ws === 'string' ? ctx.getWs(body.ws) : null;
154
+ return w && w.id === body.ws ? { ok: true, w } : { ok: false };
155
+ };
156
+ if (p === '/api/notes/record') {
157
+ const o = wsOf();
158
+ if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
159
+ return done(notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id }));
160
+ }
161
+ if (p === '/api/notes/transcript') {
162
+ const r = notes.appendTranscript(dir, { id, text: body.text, source: 'speech' });
163
+ if (r.ok) enrich(ctx, id, r.added);
164
+ return done(r);
165
+ }
166
+ if (p === '/api/notes/audio') {
167
+ const source = body.source === 'meeting' ? 'meeting' : body.source === 'mic' ? 'mic' : null;
168
+ if (!source) return done({ ok: false, status: 400, error: 'the source must be mic or meeting' });
169
+ const note = notes.read(dir, id);
170
+ if (!note.ok) return done(note);
171
+ if (note.note.private) { transcriberFor(ctx).drop(id); return done({ ok: false, status: 409, error: 'this note is marked private, so nothing more is recorded in it' }); }
172
+ if (!note.note.recorded_from) return done({ ok: false, status: 409, error: 'press Listen first' });
173
+ const b64 = typeof body.pcm === 'string' ? body.pcm : '';
174
+ if (b64.length > Math.ceil(MAX_PCM_BYTES / 3) * 4) return done({ ok: false, status: 413, error: 'that audio chunk is too long' });
175
+ const pcm = Buffer.from(b64, 'base64');
176
+ if (pcm.length % 2) return done({ ok: false, status: 400, error: 'the audio must be 16-bit samples' });
177
+ if (!whisper.detect(dir).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
178
+ const ms = (pcm.length / 2 / whisper.RATE) * 1000;
179
+ const t = Math.max(0, Date.now() - Date.parse(note.note.recorded_from) - ms);
180
+ return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
181
+ }
182
+ if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
183
+ if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
184
+ if (p === '/api/notes/switch') {
185
+ const o = wsOf();
186
+ if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
187
+ jevNotes.setSwitch(dir, o.w.root, { jev: typeof body.jev === 'boolean' ? body.jev : undefined, check: typeof body.check === 'boolean' ? body.check : undefined });
188
+ ctx.json(res, { ok: true, ...engineInfo(ctx, o.w) });
189
+ return true;
190
+ }
191
+ if (p === '/api/notes/whisper-setup') {
192
+ const o = wsOf();
193
+ if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
194
+ const command = whisper.setupCommand(dir);
195
+ if (typeof ctx.spawnSession !== 'function') { ctx.json(res, { ok: true, started: false, command, reason: 'this workspace cannot start sessions' }); return true; }
196
+ // typed, not run: the PM reads the commands in their own terminal and presses Enter (D91)
197
+ const s = ctx.spawnSession({ name: 'notes: set up Whisper', agent: 'shell', ws: o.w ? o.w.id : undefined, origin: 'notes-whisper-setup', initialInput: command });
198
+ const started = !!s && s.state !== 'exited';
199
+ ctx.json(res, { ok: true, started, session: s ? s.id : null, command, reason: started ? '' : ((s && s.note) || 'could not start a terminal') });
200
+ return true;
201
+ }
202
+ if (p === '/api/notes/private') {
203
+ const r = notes.setPrivate(dir, { id, private: body.private });
204
+ if (r.ok && r.private && transcriber) transcriber.drop(id);
205
+ return done(r);
206
+ }
207
+ if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
208
+ if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); return done(notes.remove(dir, id)); }
209
+ // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
210
+ let w = null;
211
+ if (body.ws != null) {
212
+ w = typeof body.ws === 'string' ? ctx.getWs(body.ws) : null;
213
+ if (!w || w.id !== body.ws) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
214
+ }
215
+ const h = notes.agentHandoff(dir, { id });
216
+ if (!h.ok) return done(h);
217
+ if (typeof ctx.spawnSession !== 'function') {
218
+ notes.agentStarted(dir, { id, started: false, reason: 'this workspace cannot start sessions' });
219
+ ctx.json(res, { ok: true, started: false, reason: 'this workspace cannot start sessions', handoff: h.handoff, out: h.out, withheld: h.withheld });
220
+ return true;
221
+ }
222
+ const s = ctx.spawnSession({ name: 'notes: summary', agent: 'claude', ws: w ? w.id : undefined, origin: 'notes-summary:' + id, initialInput: h.prompt + '\r' });
223
+ const started = !!s && s.state !== 'exited';
224
+ const reason = started ? '' : ((s && s.note) || 'could not start a claude session');
225
+ notes.agentStarted(dir, { id, session: s ? s.id : null, started, reason });
226
+ ctx.json(res, { ok: true, started, session: s ? s.id : null, reason, handoff: h.handoff, out: h.out, withheld: h.withheld });
227
+ return true;
228
+ }
229
+
230
+ module.exports = { prefixes, handle, enrich, __idle: () => Promise.all([transcriber ? transcriber.idle() : null, ...chains.values()]) };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.61.1",
3
+ "version": "0.63.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.61.1",
3
+ "version": "0.63.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -406,7 +406,7 @@ function render() {
406
406
 
407
407
  const FRAME = !FIXTURE && !LEGACY_LAYOUT_FLAG;
408
408
  const FRAME_KEY = 'pmos.workspace.pm.frame';
409
- const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'settings']);
409
+ const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'people', 'settings']);
410
410
 
411
411
  function readFrame() {
412
412
  if (!FRAME) return {};
@@ -637,7 +637,7 @@ function pressIn(r, key) {
637
637
  if (b && !b.disabled) { b.click(); return true; }
638
638
  return false;
639
639
  }
640
- const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs' };
640
+ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs', p: 'people' };
641
641
 
642
642
  function frameKey(e) {
643
643
  const mod = e.metaKey || e.ctrlKey;