create-pmos 0.109.0 → 0.111.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.109.0",
3
+ "version": "0.111.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@3e0aefb
1
+ kernel-source: pmos@2e6cfaa
2
2
  assembled: 2026-10-08
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-08T03:57:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T04:27:20Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-08T03:57:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T04:27:20Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-08T03:57:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T04:27:20Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -26,6 +26,9 @@ The Notes view shows one meeting, top to bottom. A bar at the top shows whether
26
26
  2. Press **Start**. The header shows a red dot, the time, and the sources. Tell people that you are taking notes.
27
27
  3. Each line shows in the transcript a few seconds after it is said, with who said it, the time and its label. Your lines are **You**. The meeting's lines are **Meeting**: click the name to change it. The transcript cannot tell the people in the meeting apart.
28
28
  4. Press **Stop** to end. The summary shows at the top of your notes. To carry on, press **Listen again**: the clock carries on from where it stopped, and the time between does not count. Line times and the kept audio use the same clock.
29
+ 5. If the workspace does not take a part of the audio, the page sends it again two times. If it still fails, a red message tells you how many seconds were not saved. Listening goes on. That part of the recording has a gap.
30
+
31
+ To avoid an echo, listen to the meeting through headphones. When the meeting plays from a speaker, your microphone hears it a second time, and the replay has two copies of each voice.
29
32
 
30
33
  The meeting can only be heard when local transcription is set up. Until then, **Listen** hears your microphone only, through the browser's own recognition on this device.
31
34
 
@@ -123,6 +126,8 @@ When a meeting was heard with local transcription, its audio is kept on this mac
123
126
  2. While it plays, the transcript line, its item and the line of your notes that you wrote at that moment are highlighted. Your notes cannot be edited while it plays: pause to edit them.
124
127
  3. Press a time in the transcript to play from there.
125
128
 
129
+ The replay is your microphone and the meeting added together. When both are loud at the same moment, the workspace lowers the peaks softly, so the sound does not distort. A quiet voice is not changed. The transcript is made from each source on its own, so this does not change what is written.
130
+
126
131
  To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
127
132
 
128
133
  ## Capture highlights during the meeting
@@ -163,7 +168,7 @@ The task appears in your My Work view and on the initiative's board when the not
163
168
  Under the title, a note shows two lines: **Meeting** and **Initiative**.
164
169
 
165
170
  - **Meeting** is the calendar event the note was taken in, or **Not on the calendar** for a hallway chat or a call. Press **Change** to select an event of the note's day or the day before, or **Not on the calendar**.
166
- - **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to select a suggestion, any open initiative of the selected repository, or **None**. Press the initiative's name to open it.
171
+ - **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to open the list. The list shows the suggestion first, then the initiatives that are active now, then **None**. Initiatives planned for Next and Later are in the menu under them, each marked with its horizon. When more than one repository is open, a **Repository** menu at the top of the list changes which repository's initiatives you see. If the repository is still being read, the list says so and fills in by itself. Press the initiative's name to open it.
167
172
 
168
173
  When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
169
174
 
@@ -50,8 +50,21 @@ function setKeep(runtimeDir, keep) {
50
50
  return { ok: true, keep };
51
51
  }
52
52
 
53
- /** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, and clipped. With `mixed:
54
- * false` only the source's own file is written: the PM keeps no audio for replay, and the final pass deletes it when done. */
53
+ // The mixed file passes every sample through this soft knee (notes-audio-quality R1, R2). Below KNEE the sample is unchanged, so one
54
+ // source alone is not made quieter. Above it the sample bends towards CEILING and never reaches full scale, so two loud sources
55
+ // are limited and not hard-clipped. It has no memory, so the order in which the two sources arrive does not matter.
56
+ const KNEE = 0.7 * 32768;
57
+ const CEILING = 0.98 * 32768;
58
+ function softLimit(x) {
59
+ const a = Math.abs(x);
60
+ if (a <= KNEE) return x;
61
+ const y = KNEE + (CEILING - KNEE) * Math.tanh((a - KNEE) / (CEILING - KNEE));
62
+ return Math.round(x < 0 ? -y : y);
63
+ }
64
+
65
+ /** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, then limited by softLimit. With
66
+ * `mixed: false` only the source's own file is written: the PM keeps no audio for replay, and the final pass deletes it when done.
67
+ * The per-source files are never limited: they are what the transcription reads. */
55
68
  function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
56
69
  if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
57
70
  const f = fileOf(runtimeDir, id);
@@ -69,7 +82,7 @@ function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
69
82
  const old = Buffer.alloc(len);
70
83
  fs.readSync(fd, old, 0, len, at);
71
84
  const out = Buffer.alloc(len);
72
- for (let i = 0; i < len; i += 2) out.writeInt16LE(Math.max(-32768, Math.min(32767, old.readInt16LE(i) + pcm.readInt16LE(i))), i);
85
+ for (let i = 0; i < len; i += 2) out.writeInt16LE(softLimit(old.readInt16LE(i) + pcm.readInt16LE(i)), i);
73
86
  fs.writeSync(fd, out, 0, len, at);
74
87
  } finally { fs.closeSync(fd); }
75
88
  }
@@ -138,4 +151,4 @@ function serve(req, res, runtimeDir, id) {
138
151
  fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
139
152
  }
140
153
 
141
- module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
154
+ module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, softLimit, RATE, BYTES_PER_MS, MAX_BYTES };
@@ -269,6 +269,32 @@ function glossaryOf(root) {
269
269
  glossaryCache.set(root, { at: Date.now(), terms: out });
270
270
  return out;
271
271
  }
272
+ /** The open initiatives of one repository, the active ones first. The Initiatives view reads the repository's documents
273
+ * (lib/initiative-rows.js) and places each one Now, Next or Later; a repository kept as documents has no planning/initiatives
274
+ * records, so those alone left the picker empty. Placed rows come first in horizon order; records the view does not place follow
275
+ * as Now. `pending` is true while the first read of the documents is still running. */
276
+ function openInitiativesOf(w) {
277
+ const legacy = notesLinks.initiativesOf(w.root);
278
+ let placed = []; let pending = false;
279
+ try {
280
+ const rowsLib = require('./initiative-rows.js');
281
+ const ri = require('./routes-initiatives.js');
282
+ const got = rowsLib.rowsFor(w.root);
283
+ if (got.pending) pending = true;
284
+ else if (got.ok) {
285
+ const rm = ri.roadmapOf(w, ri.frameOf(w, got.rows, { now: Date.now() }));
286
+ const byId = new Map(got.rows.map((r) => [r.id, r]));
287
+ for (const h of ['now', 'next', 'later']) {
288
+ for (const r of rm.horizons[h]) { const rec = byId.get(r.id); if (!(rec && rec.stopped)) placed.push({ id: r.id, title: r.title, anchor: r.anchor, intent: '', horizon: h }); }
289
+ }
290
+ }
291
+ } catch { placed = []; }
292
+ const seen = new Set(placed.map((i) => i.id));
293
+ const rest = legacy.filter((i) => !seen.has(i.id)).map((i) => ({ ...i, horizon: 'now' }));
294
+ const order = { now: 0, next: 1, later: 2 };
295
+ const open = [...placed, ...rest].sort((a, b) => order[a.horizon] - order[b.horizon]);
296
+ return { open, pending };
297
+ }
272
298
  /** The repository's terms for a note: its initiative's title, the glossary, and the product terms in the open initiatives' titles. */
273
299
  function repoTermsFor(ctx, note) {
274
300
  const w = note && note.ws && typeof ctx.getWs === 'function' ? ctx.getWs(note.ws) : null;
@@ -500,12 +526,12 @@ async function handle(req, res, url, ctx) {
500
526
  const wsId = url.searchParams.get('ws');
501
527
  const w = wsId ? ctx.getWs(wsId) : null;
502
528
  if (!w || w.id !== wsId) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
503
- const open = notesLinks.initiativesOf(w.root);
529
+ const { open, pending } = openInitiativesOf(w);
504
530
  const nid = url.searchParams.get('id');
505
531
  const r = nid ? notes.read(dir, nid) : null;
506
532
  if (r && !r.ok) { send(ctx, res, r); return true; }
507
533
  const s = r ? notesLinks.suggest(r.note, open) : { pick: null, ranked: [] };
508
- ctx.json(res, { ok: true, open: open.map((i) => ({ id: i.id, title: i.title })), pick: s.pick });
534
+ ctx.json(res, { ok: true, open: open.map((i) => ({ id: i.id, title: i.title, horizon: i.horizon })), pick: s.pick, pending });
509
535
  return true;
510
536
  }
511
537
  if (p === '/api/notes/engine') {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.109.0",
3
+ "version": "0.111.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.109.0",
9
+ "version": "0.111.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.109.0",
3
+ "version": "0.111.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",
@@ -0,0 +1,88 @@
1
+ /* audio-capture.js — how the Notes view takes sound from the browser (notes-audio-quality Run 1, R4 and R6).
2
+ *
3
+ * makeResampler(inRate) a stateful low-pass resampler: push(float32 block) → Int16Array at 16 kHz, flush() → the tail
4
+ * toInt16(input, inRate) one block, whole: the same filter, run once. Pure.
5
+ * openCapture(ac, src, sink, onBlock) → { mode, node } an AudioWorklet that hands float32 blocks to onBlock;
6
+ * a ScriptProcessor does the same where the browser has no worklet
7
+ *
8
+ * WHY A FILTER. A context that cannot run at 16 kHz gives 44.1 or 48 kHz blocks. Averaging the samples under each output sample
9
+ * is a poor low-pass: sound above 8 kHz folds back into the speech band as a hiss. The windowed-sinc filter below cuts at 7.2 kHz and
10
+ * keeps its state between blocks, so a block edge leaves no click. At 16 kHz the samples pass through unchanged.
11
+ */
12
+ export const RATE = 16000;
13
+ const CUT = 0.45; // the filter cuts at this share of the output rate
14
+ const CROSSINGS = 16; // zero crossings of the sinc on each side of the centre
15
+
16
+ const toS16 = (v) => { const c = v > 1 ? 1 : v < -1 ? -1 : v; return c < 0 ? c * 0x8000 : c * 0x7fff; };
17
+
18
+ export function makeResampler(inRate, outRate = RATE) {
19
+ const ratio = inRate / outRate;
20
+ if (ratio === 1) {
21
+ return { push(x) { const o = new Int16Array(x.length); for (let i = 0; i < x.length; i++) o[i] = toS16(x[i]); return o; }, flush() { return new Int16Array(0); } };
22
+ }
23
+ const fc = CUT / Math.max(1, ratio); // cycles per input sample
24
+ const half = Math.ceil(CROSSINGS / (2 * fc)); // input samples either side of the centre
25
+ let buf = new Float64Array(0); // input not yet used up; buf[0] is input sample `base`
26
+ let base = 0;
27
+ let centre = 0; // the next output sample sits at this input position
28
+ let seen = 0; // input samples received
29
+ const weight = (d) => {
30
+ const x = 2 * fc * d;
31
+ const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
32
+ const w = 0.42 + 0.5 * Math.cos(Math.PI * d / half) + 0.08 * Math.cos(2 * Math.PI * d / half); // Blackman, centred
33
+ return sinc * w;
34
+ };
35
+ const w = new Float64Array(2 * half + 2);
36
+ function run(limit, out) {
37
+ while (centre < limit && Math.floor(centre) + half < base + buf.length) {
38
+ const lo = Math.ceil(centre - half); const hi = Math.floor(centre + half);
39
+ let sum = 0; let norm = 0;
40
+ for (let i = lo; i <= hi; i++) { const k = weight(i - centre); w[i - lo] = k; norm += k; }
41
+ for (let i = lo; i <= hi; i++) { const j = i - base; if (j >= 0 && j < buf.length) sum += buf[j] * w[i - lo]; }
42
+ out.push(toS16(sum / norm));
43
+ centre += ratio;
44
+ }
45
+ }
46
+ function trim() {
47
+ const keep = Math.max(base, Math.floor(centre) - half);
48
+ if (keep > base) { buf = buf.slice(keep - base); base = keep; }
49
+ }
50
+ return {
51
+ push(x) {
52
+ const merged = new Float64Array(buf.length + x.length); merged.set(buf); merged.set(x, buf.length); buf = merged; seen += x.length;
53
+ const out = []; run(Infinity, out); trim();
54
+ return Int16Array.from(out);
55
+ },
56
+ flush() {
57
+ const pad = new Float64Array(buf.length + half + 1); pad.set(buf); buf = pad;
58
+ const out = []; run(seen, out); trim();
59
+ return Int16Array.from(out);
60
+ },
61
+ };
62
+ }
63
+
64
+ /** Float samples at `inRate` as 16 kHz 16-bit samples. Pure. */
65
+ export function toInt16(input, inRate) {
66
+ const r = makeResampler(inRate);
67
+ const a = r.push(input); const b = r.flush();
68
+ const out = new Int16Array(a.length + b.length); out.set(a); out.set(b, a.length);
69
+ return out;
70
+ }
71
+
72
+ /** Take blocks of the first channel from `src` and give each to onBlock as a Float32Array. `sink` is a muted node that keeps the
73
+ * graph pulling. Returns { mode: 'worklet' | 'script', node }. */
74
+ export async function openCapture(ac, src, sink, onBlock) {
75
+ if (ac.audioWorklet && typeof AudioWorkletNode === 'function') {
76
+ try {
77
+ await ac.audioWorklet.addModule(new URL('./notes-capture-worklet.js', import.meta.url).href);
78
+ const node = new AudioWorkletNode(ac, 'notes-capture', { numberOfInputs: 1, numberOfOutputs: 1, channelCount: 1, channelCountMode: 'explicit' });
79
+ node.port.onmessage = (e) => onBlock(e.data);
80
+ src.connect(node); node.connect(sink);
81
+ return { mode: 'worklet', node };
82
+ } catch { /* the module did not load: the older route below */ }
83
+ }
84
+ const node = ac.createScriptProcessor(4096, 1, 1);
85
+ node.onaudioprocess = (e) => onBlock(new Float32Array(e.inputBuffer.getChannelData(0)));
86
+ src.connect(node); node.connect(sink);
87
+ return { mode: 'script', node };
88
+ }
@@ -0,0 +1,15 @@
1
+ /* notes-capture-worklet.js — runs on the audio thread (notes-audio-quality R4). It copies the first channel of what it hears into
2
+ * blocks of 2048 samples and posts each block to the page, which resamples and sends it. It makes no sound. */
3
+ class NotesCapture extends AudioWorkletProcessor {
4
+ constructor() { super(); this.block = new Float32Array(2048); this.at = 0; }
5
+ process(inputs) {
6
+ const ch = inputs[0] && inputs[0][0];
7
+ if (!ch) return true;
8
+ for (let i = 0; i < ch.length; i++) {
9
+ this.block[this.at++] = ch[i];
10
+ if (this.at === this.block.length) { this.port.postMessage(this.block, [this.block.buffer]); this.block = new Float32Array(2048); this.at = 0; }
11
+ }
12
+ return true;
13
+ }
14
+ }
15
+ registerProcessor('notes-capture', NotesCapture);
@@ -45,12 +45,15 @@ import { pmGet, pmPost } from './http.js';
45
45
  import { mdRender } from './markdown.js';
46
46
  import { icon } from './icons.js';
47
47
  import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
48
+ import { toInt16, makeResampler, openCapture } from './audio-capture.js';
48
49
 
49
50
  const SAVE_MS = 700; // a pause this long in typing saves the note
50
51
  const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
51
52
  const AGENT_POLL_MS = 3000;
52
53
  const CHUNK_SAMPLES = 16000; // one second at 16 kHz
53
54
  const RATE = 16000;
55
+ const SEND_TRIES = 3; // a chunk that the server did not take is sent again this many times in all …
56
+ const SEND_BACKOFF_MS = 400; // … after 400 ms, then 1200 ms
54
57
  const SECTIONS = [['decision', 'Decisions'], ['action', 'Next steps'], ['question', 'Open questions']];
55
58
 
56
59
  /* ── module state: survives the redraw every setState causes ─────────────────────────────── */
@@ -194,19 +197,8 @@ export function noteMarkdown(n) {
194
197
  if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text + tagWords(x)), '');
195
198
  return out.join('\n');
196
199
  }
197
- /** Float samples at `inRate` as 16 kHz 16-bit samples. Each output sample averages the input samples it covers. Pure. */
198
- export function toInt16(input, inRate) {
199
- const ratio = inRate / RATE;
200
- const n = Math.floor(input.length / ratio);
201
- const out = new Int16Array(n);
202
- for (let i = 0; i < n; i++) {
203
- const a = Math.floor(i * ratio); const b = Math.max(a + 1, Math.floor((i + 1) * ratio));
204
- let s = 0; for (let k = a; k < b && k < input.length; k++) s += input[k];
205
- const v = Math.max(-1, Math.min(1, s / (b - a)));
206
- out[i] = v < 0 ? v * 0x8000 : v * 0x7fff;
207
- }
208
- return out;
209
- }
200
+ /** Float samples at `inRate` as 16 kHz 16-bit samples, through a low-pass filter (pm/audio-capture.js). Pure. */
201
+ export { toInt16 };
210
202
  function b64(int16) {
211
203
  const u = new Uint8Array(int16.buffer, int16.byteOffset, int16.byteLength);
212
204
  let s = '';
@@ -581,7 +573,7 @@ async function openSource(kind) {
581
573
  return new MediaStream(tracks);
582
574
  }
583
575
  async function startAudio(ctx, n, want) {
584
- const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, ended: 0 };
576
+ const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, lost: 0, ended: 0 };
585
577
  const opened = [];
586
578
  // the meeting first, straight from the press: the browser lets a page share a screen only right after a click
587
579
  for (const kind of want.slice().sort((x, y) => (x === 'meeting' ? -1 : y === 'meeting' ? 1 : 0))) {
@@ -603,14 +595,13 @@ async function startAudio(ctx, n, want) {
603
595
  for (const [kind, stream] of opened) {
604
596
  a.streams.push(stream); a.sources.push(kind); a.buf[kind] = []; a.queue[kind] = Promise.resolve();
605
597
  const src = ac.createMediaStreamSource(stream);
606
- const proc = ac.createScriptProcessor(4096, 1, 1);
607
- proc.onaudioprocess = (e) => {
598
+ const rs = a.rs[kind] = makeResampler(ac.sampleRate);
599
+ await openCapture(ac, src, sink, (block) => {
608
600
  if (audio !== a) return;
609
- const s = toInt16(e.inputBuffer.getChannelData(0), ac.sampleRate);
601
+ const s = rs.push(block);
610
602
  const b = a.buf[kind]; for (let i = 0; i < s.length; i++) b.push(s[i]);
611
603
  if (b.length >= CHUNK_SAMPLES) sendChunk(ctx, a, kind, false);
612
- };
613
- src.connect(proc); proc.connect(sink);
604
+ });
614
605
  for (const t of stream.getTracks()) t.addEventListener('ended', () => { if (audio !== a) return; a.ended++; if (a.ended >= a.streams.length) stopAudio(ctx); });
615
606
  }
616
607
  a.state = 'on';
@@ -621,9 +612,23 @@ async function startAudio(ctx, n, want) {
621
612
  function sendChunk(ctx, a, kind, final) {
622
613
  const b = a.buf[kind] || []; a.buf[kind] = [];
623
614
  const pcm = b64(Int16Array.from(b));
615
+ const seconds = b.length / RATE;
624
616
  a.queue[kind] = a.queue[kind].then(async () => {
625
- const r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, final });
626
- if (r && !r.ok && (r.status === 409 || r.status === 404) && audio === a) { banner = { tone: 'bad', text: 'Listening stopped: ' + r.error + '.' }; stopAudio(ctx); }
617
+ let r = null;
618
+ for (let tries = 0; tries < SEND_TRIES; tries++) {
619
+ r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, final });
620
+ if (r && r.ok) return;
621
+ if (r && (r.status === 409 || r.status === 404)) break; // the server refused this note's audio: sending again changes nothing
622
+ if (tries + 1 < SEND_TRIES) await new Promise((res) => setTimeout(res, SEND_BACKOFF_MS * 3 ** tries));
623
+ }
624
+ if (r && (r.status === 409 || r.status === 404)) {
625
+ if (audio === a) { banner = { tone: 'bad', text: 'Listening stopped: ' + r.error + '.' }; stopAudio(ctx); }
626
+ return;
627
+ }
628
+ a.lost += seconds; // not silent: the recording has a gap, and the PM is told how long
629
+ banner = { tone: 'bad', text: 'About ' + Math.max(1, Math.round(a.lost)) + ' s of the recording was not saved, because the workspace server did not take it'
630
+ + (r && r.error ? ' (' + r.error + ')' : '') + '.' + (audio === a ? ' Listening goes on.' : '') };
631
+ ctx.setState({});
627
632
  }).catch(() => {});
628
633
  return a.queue[kind];
629
634
  }
@@ -636,6 +641,7 @@ async function stopAudio(ctx, { discard = false } = {}) {
636
641
  try { if (a.ctx) await a.ctx.close(); } catch { /* closed */ }
637
642
  if (discard) { ctx.setState({}); return; }
638
643
  // the rest of what was said, then the summary once the last line is transcribed
644
+ for (const k of a.sources) { const tail = a.rs[k] ? a.rs[k].flush() : []; const b = a.buf[k]; for (let i = 0; i < tail.length; i++) b.push(tail[i]); }
639
645
  await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
640
646
  await pmPost('/api/notes/pause', { id: a.id });
641
647
  ctx.setState({});
@@ -1067,32 +1073,69 @@ async function meetingSheet(ctx, n) {
1067
1073
  }), evs.length ? null : el('p', { class: 'nt-hint' }, 'No calendar events on these days.'),
1068
1074
  opt('adhoc', 'Not on the calendar', 'a hallway chat, a call', pick === 'adhoc'));
1069
1075
  }
1076
+ const HORIZON_WORDS = { now: 'Now', next: 'Next', later: 'Later' };
1077
+ /** The repositories the notes can be about, one row per repository (its main tree), from the workspace server. */
1078
+ async function reposForNotes() {
1079
+ const r = await pmGet('/api/workspaces');
1080
+ const seen = new Map();
1081
+ for (const w of (r && r.workspaces) || []) {
1082
+ if (!w || !w.id) continue;
1083
+ const key = w.repo_key || w.root;
1084
+ if (!seen.has(key) || w.root === key) seen.set(key, { ws: w.id, name: w.label || w.repo_name || w.name || w.id });
1085
+ }
1086
+ return [...seen.values()];
1087
+ }
1070
1088
  async function initiativeSheet(ctx, n) {
1071
- const ws = n.ws || wsOf(ctx);
1089
+ let ws = n.ws || wsOf(ctx);
1090
+ let repo = (n.initiative && n.initiative.repo) || (ctx.state && ctx.state.product) || ctx.repo || null;
1072
1091
  const box = el('div', { class: 'nt-picks' }, el('p', { class: 'nt-hint' }, 'reading the initiatives …'));
1092
+ const where = el('div', { 'data-nt-ini-where': '' });
1073
1093
  let pick = n.initiative ? n.initiative.id : '';
1074
1094
  let open = [];
1075
- const h = sheet({ title: 'Which initiative is this note about?', sub: 'The note shows on the initiative\'s page.', body: [box], attrs: { 'data-nt-ini-sheet': '' },
1095
+ let radios = [];
1096
+ let sel = null;
1097
+ let loadId = 0;
1098
+ const h = sheet({ title: 'Which initiative is this note about?', sub: 'The note shows on the initiative\'s page.', body: [where, box], attrs: { 'data-nt-ini-sheet': '' },
1076
1099
  actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', attrs: { 'data-nt-ini-save': '' }, onClick: () => {
1077
1100
  h.close();
1078
1101
  if (pick === (n.initiative ? n.initiative.id : '') && (!n.initiative || n.initiative.by === 'pm')) return;
1079
1102
  if (!pick) return link(ctx, n, { initiative: null }, () => 'Not linked to an initiative.');
1080
1103
  const i = open.find((x) => x.id === pick) || { id: pick, title: pick };
1081
- return link(ctx, n, { initiative: { id: i.id, ws, repo: (ctx.state && ctx.state.product) || ctx.repo || null, title: i.title } }, () => 'Now on ' + i.title + '. The note shows on its page.');
1104
+ return link(ctx, n, { initiative: { id: i.id, ws, repo, title: i.title } }, () => 'Now on ' + i.title + '. The note shows on its page.');
1082
1105
  } })] });
1083
- if (!ws) { box.replaceChildren(el('p', { class: 'nt-hint' }, 'Open a repository first: its initiatives are the ones a note can belong to.')); return; }
1084
- const r = await pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id));
1085
- if (!h.open) return;
1086
- if (!r || !r.ok) { box.replaceChildren(el('p', { class: 'nt-hint', role: 'alert' }, (r && r.error) || 'The initiatives could not be read.')); return; }
1087
- open = r.open || [];
1088
- const radios = [];
1089
- const choose = (v) => { pick = v; for (const b of radios) b.setAttribute('aria-checked', b.dataset.ntIniPick === v ? 'true' : 'false'); if (sel.control.value !== v) sel.control.value = open.some((x) => x.id === v) ? v : ''; };
1106
+ const choose = (v) => { pick = v; for (const b of radios) b.setAttribute('aria-checked', b.dataset.ntIniPick === v ? 'true' : 'false'); if (sel && sel.control.value !== v) sel.control.value = open.some((x) => x.id === v && x.horizon !== 'now') ? v : ''; };
1090
1107
  const opt = (v, title, sub) => { const b = button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': pick === v ? 'true' : 'false', 'data-nt-ini-pick': v }, onClick: () => choose(v) }); radios.push(b); return b; };
1091
- const sel = field({ label: 'Or choose any open initiative', kind: 'select', value: open.some((x) => x.id === pick) ? pick : '', options: [{ value: '', label: 'Choose …' }, ...open.map((x) => ({ value: x.id, label: x.title }))], attrs: { 'data-nt-ini-all': '' } });
1092
- sel.control.addEventListener('change', () => choose(sel.control.value));
1093
- const sugg = r.pick ? [el('h3', { class: 'nt-sec__h' }, 'Suggested from what was said'), opt(r.pick.id, r.pick.title, r.pick.shared + ' words in common')] : [];
1094
- const current = n.initiative && (!r.pick || r.pick.id !== n.initiative.id) ? [opt(n.initiative.id, n.initiative.title || n.initiative.id, BY_WORDS[n.initiative.by] || '')] : [];
1095
- box.replaceChildren(el('div', { role: 'radiogroup', 'aria-label': 'Initiative' }, ...sugg, ...current, opt('', 'None', 'not about an initiative')), open.length ? sel : el('p', { class: 'nt-hint' }, 'This repository has no open initiatives.'));
1108
+ async function load(tries = 0) {
1109
+ const mine = ++loadId;
1110
+ open = []; radios = []; sel = null;
1111
+ if (!ws) { box.replaceChildren(el('p', { class: 'nt-hint' }, 'Choose a repository: its initiatives are the ones a note can belong to.')); return; }
1112
+ box.replaceChildren(el('p', { class: 'nt-hint' }, 'reading the initiatives …'));
1113
+ const r = await pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id));
1114
+ if (!h.open || mine !== loadId) return;
1115
+ if (!r || !r.ok) { box.replaceChildren(el('p', { class: 'nt-hint', role: 'alert' }, (r && r.error) || 'The initiatives could not be read.')); return; }
1116
+ open = r.open || [];
1117
+ if (r.pending && !open.length && tries < 8) { setTimeout(() => load(tries + 1), 1500); return; }
1118
+ // the active initiatives (Now) are listed as choices; Next and Later are in the list below them
1119
+ const active = open.filter((x) => x.horizon === 'now' && !(r.pick && r.pick.id === x.id));
1120
+ const later = open.filter((x) => x.horizon !== 'now');
1121
+ sel = field({ label: later.length ? 'Or choose a later initiative' : 'Or choose any open initiative', kind: 'select', value: later.some((x) => x.id === pick) ? pick : '',
1122
+ options: [{ value: '', label: 'Choose …' }, ...later.map((x) => ({ value: x.id, label: (HORIZON_WORDS[x.horizon] || 'Now') + ' · ' + x.title }))], attrs: { 'data-nt-ini-all': '' } });
1123
+ sel.control.addEventListener('change', () => choose(sel.control.value));
1124
+ const sugg = r.pick ? [el('h3', { class: 'nt-sec__h' }, 'Suggested from what was said'), opt(r.pick.id, r.pick.title, r.pick.shared + ' words in common')] : [];
1125
+ const current = n.initiative && (!r.pick || r.pick.id !== n.initiative.id) && !active.some((x) => x.id === n.initiative.id) ? [opt(n.initiative.id, n.initiative.title || n.initiative.id, BY_WORDS[n.initiative.by] || '')] : [];
1126
+ const now = active.length ? [el('h3', { class: 'nt-sec__h' }, 'Active now'), ...active.map((x) => opt(x.id, x.title, 'active'))] : [];
1127
+ box.replaceChildren(el('div', { role: 'radiogroup', 'aria-label': 'Initiative' }, ...sugg, ...current, ...now, opt('', 'None', 'not about an initiative')),
1128
+ later.length ? sel : (open.length ? null : el('p', { class: 'nt-hint' }, 'This repository has no open initiatives. Choose another repository above, or open the Initiatives tab to add one.')));
1129
+ }
1130
+ const repos = await reposForNotes();
1131
+ if (!h.open) return;
1132
+ if (repos.length > 1) {
1133
+ const rs = field({ label: 'Repository', kind: 'select', value: ws || '', options: [...(ws && !repos.some((x) => x.ws === ws) ? [{ value: ws, label: repo || ws }] : []), ...repos.map((x) => ({ value: x.ws, label: x.name }))], attrs: { 'data-nt-ini-repo': '' } });
1134
+ rs.control.addEventListener('change', () => { ws = rs.control.value; const f = repos.find((x) => x.ws === ws); repo = f ? f.name : repo; pick = ''; load(); });
1135
+ where.replaceChildren(rs);
1136
+ }
1137
+ if (!ws && repos.length) { ws = repos[0].ws; repo = repos[0].name; if (where.firstChild) where.firstChild.control && (where.firstChild.control.value = ws); }
1138
+ await load();
1096
1139
  }
1097
1140
  async function openInitiativeOf(ctx, ini) {
1098
1141
  const reg = await import('./registry.js');