create-pmos 0.110.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.110.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@83b40d1
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-08T04:20:34Z"}
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-08T04:20:34Z"}
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-08T04:20:34Z"}
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
@@ -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 };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.110.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.110.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.110.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({});