create-pmos 0.194.0 → 0.196.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.194.0",
3
+ "version": "0.196.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@dcb0b7b
1
+ kernel-source: pmos@7bac547
2
2
  assembled: 2026-10-13
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-13T08:20:29Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T09:00:15Z"}
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-13T08:20:29Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T09:00:15Z"}
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-13T08:20:29Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T09:00:15Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -49,6 +49,24 @@ const { harness } = require('./c3-ini-harness');
49
49
  ok('R3 Issue it says nothing was sent and closes the sheet', await until(async () => /Issued\. Nothing was sent/.test(await toast()) && !(await sheetOpen())));
50
50
  ok('R4 the row is up to date and the draft is gone from the waiting list', await until(async () => (await rowState('invest')) === 'ok' && (await st(() => document.querySelectorAll('[data-cd="drafts"] [data-cd-wait]').length)) === 1));
51
51
 
52
+ /* ── a next step on every row (PRD amendment 2026-10-13): the OKRs, the scan and the status report not started ── */
53
+ await st(() => Promise.all(['okrs', 'scan', 'status'].map((key) => fetch('/api/core-docs/drop', { method: 'POST', body: JSON.stringify({ scope: 'portfolio', key, slot: 'x' }) }))));
54
+ await page.click('[data-ini="pf-tabs"] [data-ini-tab="overview"]');
55
+ await until(() => st(() => !!document.querySelector('[data-ini="boards"]')));
56
+ await page.click('[data-ini="pf-tabs"] [data-ini-tab="docs"]');
57
+ await settle('[data-cd-row="okrs"][data-cd-st="missing"]');
58
+ const btn = (sel) => st((x) => { const n = document.querySelector(x); return n ? n.textContent.trim() : ''; }, sel);
59
+ ok('X1 the OKRs offer Propose them, the scan Run a scan, the status report Set it up', (await btn('[data-cd-draft="okrs"]')) === 'Propose them' && (await btn('[data-cd-go="scan"]')) === 'Run a scan' && (await btn('[data-cd-go="status"]')) === 'Set it up');
60
+ await page.click('[data-cd-draft="okrs"]');
61
+ ok('X2 Propose them starts a draft of the OKRs', await waitToast(/PMOS is drafting the okrs and health budgets/));
62
+ ok('X3 the OKRs row shows "Agent drafting"', await until(async () => (await rowState('okrs')) === 'running'));
63
+ await page.click('[data-cd-go="scan"]');
64
+ ok('X4 Run a scan opens the Portfolio Overview, where Horizon scan lives', await until(() => st(() => !!document.querySelector('[data-ini="boards"]') && !document.querySelector('[data-cd="core"]'))));
65
+ await page.click('[data-ini="pf-tabs"] [data-ini-tab="docs"]');
66
+ await settle('[data-cd-go="status"]');
67
+ await page.click('[data-cd-go="status"]');
68
+ ok('X5 Set it up opens Reports', await until(() => st(() => { const t = document.querySelector('[data-c3-tab="rep"]'); return !!t && t.getAttribute('aria-selected') === 'true'; })));
69
+
52
70
  /* ── a board: IDPF ── */
53
71
  await open('board:idpf:docs');
54
72
  await settle('[data-cd="core"]');
@@ -29,11 +29,19 @@ Risks are written once, on the initiative. Board risks and cross-board risks col
29
29
  | Due | The cadence passed, or the current stage needs the document and it is not there. |
30
30
  | Draft ready | PMOS drafted it. It waits for you. |
31
31
  | Not started | Nothing is there, and the level needs it now. |
32
+ | None yet | Nothing is there yet, and this fills as the work goes: the decision log, kill conditions, market and user context. |
32
33
  | Comes later | The initiative has not reached the stage that needs it. |
33
34
  | Stale | It was issued before a change it depends on: a stage move (initiative risks, launch checklist, status update), a newer risk list or outcome review (rolled-up documents), or a change to the board settings (board charter). |
34
35
 
35
36
  A row with no source and no document is "Not started" and has no date. PMOS does not guess a date or a warning.
36
37
 
38
+ A row that is not started always has a next step:
39
+
40
+ - **Draft it**: PMOS drafts a document it keeps itself.
41
+ - **Propose them** (OKRs and health budgets): with no OKR files, PMOS drafts proposed key results and health budgets for you to review and issue. Once OKR files exist, they are the source.
42
+ - **Run a scan** (Horizon scan reports): opens the Portfolio Overview at Horizon scan, where Scan now is.
43
+ - **Set it up** (status reports): opens Reports, where you set up the report.
44
+
37
45
  ## How PMOS drafts a document
38
46
 
39
47
  - **When you ask.** Press **Draft it** on a row, or **Ask PMOS to change it** on a draft. PMOS starts one background run. The run reads the sources of that document and writes one draft. It has no tool that sends, posts or edits.
@@ -105,9 +105,10 @@ Each level has a set of core documents. A level answers one question for one rea
105
105
  - **Board:** the **Docs** tab starts with the core documents. The sprint review and the release notes show only when Build and deliver is on. The documents linked to the board follow under **Other documents**.
106
106
  - **Initiative:** the **Docs** tab has one section for each of Discover, Plan, Do and Learn. The current section lists its documents. A finished section, or one not reached yet, folds to one line.
107
107
 
108
- Each row shows what the document is for, its state, how often it changes, who reads it and who writes it. The states are Up to date, Due, Draft ready, Not started, Out of date and Comes later. Press a row to open the document, the tab, the stage tool or Reports that holds it.
108
+ Each row shows what the document is for, its state, how often it changes, who reads it and who writes it. The states are Up to date, Due, Draft ready, Not started, None yet, Out of date and Comes later. Press a row to open the document, the tab, the stage tool or Reports that holds it.
109
109
 
110
110
  - **Draft it:** on a document that is not started, PMOS drafts it. The row shows "Agent drafting" until the draft is ready.
111
+ - **Propose them, Run a scan, Set it up:** a row read from its own source offers the step that makes that source: OKRs are proposed as a draft, Horizon scan opens Scan now, and a status report opens Reports.
111
112
  - **Review the draft:** opens the draft in the document sheet. Above the draft, **Issue it** makes the draft the current document. **Ask PMOS to change it** asks for a new draft. PMOS sends nothing to anyone.
112
113
  - The Briefing shows one row for each level that has drafts waiting. Press the row to open that Docs view.
113
114
 
@@ -46,11 +46,11 @@ const LEVEL = Object.freeze({
46
46
  initiative: Object.freeze({ question: 'What are we building, why, and did it work?', reader: 'the PM, the builders and the reviewers', rhythm: 'at each stage' }),
47
47
  });
48
48
 
49
- const e = (level, o) => Object.freeze({ level, reader: null, cadence: null, build: false, cat: null, isNew: false, days: null, rollup: null, tool: null, ...o });
49
+ const e = (level, o) => Object.freeze({ level, reader: null, cadence: null, build: false, cat: null, isNew: false, days: null, rollup: null, tool: null, store: false, ...o });
50
50
  const SETS = Object.freeze({
51
51
  portfolio: Object.freeze([
52
52
  e('portfolio', { key: 'strategy', title: 'Product strategy', ic: 'target', for: 'Vision, where we play and what we will not do. Every board and key result traces to it.', reader: 'Leadership, every PM', cadence: 'Yearly, reviewed each quarter', by: 'you', kind: 'doc', days: 91, isNew: true }),
53
- e('portfolio', { key: 'okrs', title: 'OKRs and health budgets', ic: 'flag', for: 'The key results initiatives anchor to, and the budgets maintenance work runs on.', reader: 'Leadership, PMs', cadence: 'Quarterly', by: 'proposes', kind: 'derived' }),
53
+ e('portfolio', { key: 'okrs', title: 'OKRs and health budgets', ic: 'flag', for: 'The key results initiatives anchor to, and the budgets maintenance work runs on.', reader: 'Leadership, PMs', cadence: 'Quarterly', by: 'proposes', kind: 'derived', store: true, days: 91 }),
54
54
  e('portfolio', { key: 'roadmap', title: 'Portfolio roadmap', ic: 'cal', for: 'Bets by quarter (Now, Next, Later) and the key result each serves. A sendable copy of the Roadmap.', reader: 'Leadership, stakeholders', cadence: 'Monthly', by: 'drafts', kind: 'doc', days: 30 }),
55
55
  e('portfolio', { key: 'invest', title: 'Investment mix', ic: 'cols', for: 'Where the effort goes: by board, by key result, and feature work against upkeep.', reader: 'Leadership', cadence: 'Quarterly', by: 'drafts', kind: 'doc', days: 91, isNew: true }),
56
56
  e('portfolio', { key: 'scan', title: 'Horizon scan reports', ic: 'search', for: 'Benchmark, competitive, insight and productivity reports, each with proposals.', reader: 'PMs, leadership', cadence: 'Weekly scan', by: 'drafts', kind: 'derived' }),
@@ -120,6 +120,10 @@ const levelOfScope = (scope) => { const p = parseScope(scope); return p ? p.leve
120
120
  const scopeDir = (scope) => String(scope).replace(':', '-');
121
121
  /** A doc entry PMOS may draft: kind doc, and not one the PM writes. */
122
122
  const draftable = (entry) => !!entry && entry.kind === 'doc' && entry.by !== 'you';
123
+ /** A derived entry with `store` (the OKRs) is read from its own source, and PMOS may propose it as a core document while that source is absent. */
124
+ const storable = (entry) => !!entry && (entry.kind === 'doc' || !!entry.store);
125
+ /** A derived entry that fills as the work goes: with no source yet it is "none", not missing (portfolio and board only). */
126
+ const FILLS = Object.freeze(['files', 'watches', 'keeps']);
123
127
 
124
128
  /** The entries of one level. `build`: the board has Build and deliver; `wfTpl`: the initiative's workflow template; `has(key)`: the
125
129
  * initiative already has that document (a build-only entry it has stays). */
@@ -189,6 +193,8 @@ function sourceState(entry, facts) {
189
193
  return { ...out, st: 'ok', when, due: src.next || null, open };
190
194
  }
191
195
  // derived
196
+ if (!src && entry.store) return docState(entry, facts);
197
+ if (!src && entry.level !== 'initiative' && FILLS.includes(entry.by)) return { ...out, st: 'none' };
192
198
  if (!src) return { ...out, st: absent(entry, facts), open: entry.tool && entry.level === 'initiative' && catState(entry, facts) !== 'later' ? { tool: entry.tool } : null };
193
199
  const res = { ...out, st: src.st || 'ok', when: src.when || null, due: src.due || null, flag: src.flag || null, open: src.open || null };
194
200
  if (entry.level === 'initiative' && STALE_ON_MOVE.includes(entry.key) && src.atMs != null && facts.movedAt != null && facts.movedAt > src.atMs) {
@@ -254,7 +260,7 @@ function checkEntry(scope, key) {
254
260
  if (!p) return refuse(400, 'scope must be portfolio, board:<id> or initiative:<id>');
255
261
  const entry = entryOf(p.level, key);
256
262
  if (!entry) return refuse(400, `key must be one of ${SETS[p.level].map((x) => x.key).join(', ')} for the ${p.level} level`);
257
- if (entry.kind !== 'doc') return refuse(400, `${entry.title} is not a document PMOS keeps here: it is read from its own source`);
263
+ if (!storable(entry)) return refuse(400, `${entry.title} is not a document PMOS keeps here: it is read from its own source`);
258
264
  return { ok: true, entry, level: p.level, id: p.id };
259
265
  }
260
266
  const scopeLabel = (scope, label) => { const p = parseScope(scope); return !p ? String(scope) : p.level === 'portfolio' ? 'Portfolio' : (label || p.id); };
@@ -562,6 +568,17 @@ function gather(ctx, w, { level, board: boardId, id, now = Date.now() } = {}) {
562
568
  return { ok: true, facts: { level, scope: `initiative:${id}`, now, store, row, board: { id: board.id, name: board.name, build: !!board.build }, list } };
563
569
  }
564
570
 
571
+ /** The next step of a row that is not started: draft it, propose it, or go where its source is made. Null otherwise. */
572
+ function actOf(entry, st) {
573
+ if (st === 'stale' && draftable(entry)) return { label: 'Draft it', do: 'draft' };
574
+ if (st !== 'missing') return null;
575
+ if (draftable(entry)) return { label: 'Draft it', do: 'draft' };
576
+ if (entry.store && entry.by !== 'you') return { label: 'Propose them', do: 'draft' };
577
+ if (entry.key === 'scan') return { label: 'Run a scan', go: 'scan' };
578
+ if (entry.kind === 'report' && entry.level !== 'initiative') return { label: 'Set it up', go: 'reports' };
579
+ return null;
580
+ }
581
+
565
582
  /** The GET body. `running(scope, key)` → true while a core-doc run drafts that entry. */
566
583
  function answer(facts, { running = () => false } = {}) {
567
584
  const L = LEVEL[facts.level];
@@ -569,7 +586,7 @@ function answer(facts, { running = () => false } = {}) {
569
586
  const docs = list.map(({ entry: x, st, when, due, flag, staleWhy, open, draft }) => ({
570
587
  key: x.key, title: x.title, ic: x.ic, for: x.for, reader: x.reader, cadence: x.cadence, by: x.by, kind: x.kind, cat: x.cat, isNew: !!x.isNew,
571
588
  st, when: when || null, due: due || null, flag: flag || null, staleWhy: staleWhy || null, open: open || null, draft: draft || null,
572
- running: !!running(facts.scope, x.key),
589
+ act: actOf(x, st), running: !!running(facts.scope, x.key),
573
590
  }));
574
591
  const ini = facts.level === 'initiative';
575
592
  return {
@@ -628,6 +645,8 @@ const GUIDE = Object.freeze({
628
645
  sections: ['Headline (points committed and finished, as a percentage)', 'Finished', 'Carried over (a table: Item, Points, Why)', 'For the next sprint'] },
629
646
  retro: { sources: ['The learnings of the closed initiatives on this board (the stage tools)', 'Their issued outcome reviews: <state>/core-docs/initiative-<id>/outcome/', 'The issued sprint reviews: <state>/core-docs/board-<id>/sprint/'],
630
647
  sections: ['Patterns (each with the initiatives it comes from)', 'Keep, change, try (a table)'] },
648
+ okrs: { sources: ['The product strategy, if one is issued: <state>/core-docs/portfolio/strategy/', 'The boards and their initiatives with their anchors (`pmos board`)', 'planning/okrs/health-budgets.md, if any', 'The KR metric records and their readings (planning/records/kr-metrics.jsonl, `pmos measure`), if any'],
649
+ sections: ['A short line: these are proposals for the PM to change, keep or drop', 'Objectives, each with two to four key results (a table: Key result, Metric, Now, Target, Initiatives that serve it); write "not known" where no reading exists', 'Health budgets for upkeep work (a table: Budget, What it covers, Limit)', 'Initiatives with no key result to anchor to, if any'] },
631
650
  risks: { sources: ['The initiative record and every file it names (PRD, design, rubric)', 'The Jira snapshot of the initiative, if any (blocked and late tickets)', 'Meeting notes and requirement notes that name the initiative'],
632
651
  sections: ['A table: Risk, Impact (High, Medium, Low), Likelihood, Next step, Owner, Since', 'Waits on other boards, if any'] },
633
652
  outcome: { sources: ['The key result readings since the release (`pmos measure` records)', 'The release that shipped it (the releases ledger)', 'The initiative bet, its kill conditions and its learnings (the stage tools)', 'Support tickets and notes that name it, if any'],
@@ -669,7 +688,7 @@ function runPrompt(entry, scope, { today, label } = {}) {
669
688
 
670
689
  module.exports = {
671
690
  LEDGER, DIR, LEVELS, LEVEL, SETS, CATS, KINDS, EVENTS, BY, RESEARCH_KEEP, BUILD_ONLY_INI, STALE_ON_MOVE, ID_RE, SLOT_RE, GUIDE,
672
- entryOf, parseScope, levelOfScope, scopeDir, scopeLabel, draftable, setFor, stateOf, summaryOf, localDay, addDays,
691
+ entryOf, parseScope, levelOfScope, scopeDir, scopeLabel, draftable, storable, actOf, FILLS, setFor, stateOf, summaryOf, localDay, addDays,
673
692
  ledgerFile, readLines, append, fold, storeOf, checkEntry, writeDraft, issue, drop, frontmatterOf,
674
693
  gather, answer, initiativeDocs, initiativeSet, driverLists, runPrompt,
675
694
  };
@@ -22,7 +22,7 @@ const USAGE = [
22
22
  'usage: pmos coredoc write — the core documents of a portfolio, a board or an initiative',
23
23
  ' pmos coredoc write --scope <portfolio|board:<id>|initiative:<id>> --key <key> --file <path|->',
24
24
  ' Write ONE draft of a core document (the markdown from the file, or standard input with -). It waits for the PM, who issues it.',
25
- ' Keys: ' + CD.LEVELS.map((l) => `${l}: ${CD.SETS[l].filter((x) => x.kind === 'doc').map((x) => x.key).join(', ')}`).join(' · '),
25
+ ' Keys: ' + CD.LEVELS.map((l) => `${l}: ${CD.SETS[l].filter(CD.storable).map((x) => x.key).join(', ')}`).join(' · '),
26
26
  ' every command: [--json] [--root <dir>]. Nothing is sent.',
27
27
  ];
28
28
  const BOOL = new Set(['json', 'help']);
@@ -5,7 +5,7 @@
5
5
  * setupCommand(runtimeDir) → the shell commands the PM runs once for the better engine, speech detection and speakers
6
6
  * createFinal({ runtimeDir, onDone, onState, run?, detect?, qwen? }) → { queue(id, opts), cancel(id), state(id), idle() }
7
7
  * parseWhisper(json) · parseParakeet(json) · splitAtPauses(pcm) · assignSpeakers(segs, diar) · dropEcho(mic, meeting)
8
- * mergeSources(parts)
8
+ * mergeSources(parts) · dropSilent(segments, pcm)
9
9
  * transcribeClip(runtimeDir, pcm, { prompt }) → { ok, engine, segments } one stretch through the same engine (notes-live-settle)
10
10
  *
11
11
  * WHY. The live transcript is cut every few seconds so that lines appear while people talk. A cut that short gives Whisper
@@ -26,7 +26,8 @@
26
26
  * speakers: Speaker 1, Speaker 2 and so on. The microphone is the PM. In a room with one microphone and no meeting
27
27
  * audio, the microphone is split instead.
28
28
  * 4. The sources are merged in time order. A microphone line that repeats a meeting line said at the same moment is the
29
- * meeting heard through the PM's speakers, and is dropped.
29
+ * meeting heard through the PM's speakers, and is dropped. A line with no speech under it, and a short line said again
30
+ * and again in one source, are the engine writing on quiet audio, and are dropped too (dropSilent, notes-live-guard dropLoops).
30
31
  * The result goes to `onDone(id, final)`; lib/routes-notes.js stores it on the note and then asks the PM's agent session for
31
32
  * the notes. One job runs at a time, so the model and the speaker separation never share the memory of an 8 GB machine.
32
33
  *
@@ -41,6 +42,7 @@ const { execFile } = require('child_process');
41
42
  const whisper = require('./notes-whisper.js');
42
43
  const notesAudio = require('./notes-audio.js');
43
44
  const notesQwen = require('./notes-qwen.js');
45
+ const liveGuard = require('./notes-live-guard.js');
44
46
 
45
47
  const RUN_TIMEOUT_MS = 45 * 60 * 1000;
46
48
  const PARAKEET_MODEL = 'mlx-community/parakeet-tdt-0.6b-v2';
@@ -130,15 +132,19 @@ function parseParakeet(json) {
130
132
  /** The pieces Qwen3-ASR transcribes one at a time: [{ start, end }] in ms. A piece ends at a pause of PIECE_PAUSE_MS once it
131
133
  * is PIECE_MIN_MS long, or at its quietest frame between PIECE_MIN_MS and PIECE_MAX_MS. Quiet at either end is left out,
132
134
  * and a piece with less than PIECE_MIN_VOICED_MS of speech is dropped. */
133
- function splitAtPauses(pcm, rate = whisper.RATE) {
135
+ /** The loudness of each 100 ms frame and whether it is speech. A frame is speech above three times the room's quiet (the 10th
136
+ * percentile), but never above a third of the loud frames (the 90th), so a recording that is speech from end to end still has
137
+ * its pauses. */
138
+ function voicedFrames(pcm, rate = whisper.RATE) {
134
139
  const lv = whisper.levels(pcm, rate);
135
- const F = 100;
136
- // a frame is speech above three times the room's quiet (the 10th percentile), but never above a third of the loud frames
137
- // (the 90th), so a recording that is speech from end to end still has its pauses
138
140
  const sorted = lv.filter((v) => v > 0).sort((x, y) => x - y);
139
141
  const at = (q) => (sorted.length ? sorted[Math.floor((sorted.length - 1) * q)] : 0);
140
142
  const floor = Math.max(180, Math.min(at(0.1) * 3, at(0.9) * 0.3));
141
- const voiced = lv.map((v) => v > floor);
143
+ return { lv, voiced: lv.map((v) => v > floor) };
144
+ }
145
+ function splitAtPauses(pcm, rate = whisper.RATE) {
146
+ const { lv, voiced } = voicedFrames(pcm, rate);
147
+ const F = 100;
142
148
  const out = [];
143
149
  let i = 0;
144
150
  while (i < lv.length) {
@@ -163,6 +169,24 @@ function splitAtPauses(pcm, rate = whisper.RATE) {
163
169
  return out;
164
170
  }
165
171
 
172
+ /** The segments that have speech under them (notes-silence-loops). Whisper reads the whole file in 30-second windows, and on a
173
+ * window with nobody speaking it can still write a line, often the same one each time ("Off, Briefing." every half minute). A
174
+ * segment with less than PIECE_MIN_VOICED_MS of speech between its start and end (SEGMENT_SLACK_MS either side, as the
175
+ * engine's times are not exact) was said by nobody, and is dropped. A segment with no times is kept. */
176
+ const SEGMENT_SLACK_MS = 500;
177
+ function dropSilent(segments, pcm, rate = whisper.RATE) {
178
+ if (!pcm || !pcm.length) return segments || [];
179
+ const { voiced } = voicedFrames(pcm, rate);
180
+ const F = 100;
181
+ return (segments || []).filter((g) => {
182
+ if (!(g.end > g.start)) return true;
183
+ const a = Math.max(0, Math.floor((g.start - SEGMENT_SLACK_MS) / F)); const b = Math.min(voiced.length, Math.ceil((g.end + SEGMENT_SLACK_MS) / F));
184
+ if (a >= voiced.length) return true; // past the end of the audio: the times are not this file's, so they say nothing
185
+ let v = 0; for (let k = a; k < b; k++) if (voiced[k]) v++;
186
+ return v * F >= PIECE_MIN_VOICED_MS;
187
+ });
188
+ }
189
+
166
190
  /** Each segment gets the speaker who talks most during it. `diar`: [{ start, end, speaker }] in seconds. Speakers are
167
191
  * numbered in the order they first speak. → segments with `spk` (0, 1, …) and the number of speakers. */
168
192
  function assignSpeakers(segs, diar) {
@@ -208,7 +232,8 @@ function mergeSources(parts) {
208
232
  if (mic && meet) mic.segments = dropEcho(mic.segments, meet.segments);
209
233
  const out = [];
210
234
  for (const p of parts) {
211
- for (const g of p.segments) {
235
+ // a short line said again and again in one source is the engine looping on quiet audio (notes-silence-loops)
236
+ for (const g of liveGuard.dropLoops(p.segments)) {
212
237
  const spk = p.speakers > 1 && g.spk !== null && g.spk !== undefined ? p.source + '-' + (g.spk + 1) : p.source;
213
238
  out.push({ t: g.start, end: g.end, text: g.text, source: p.source, spk });
214
239
  }
@@ -290,6 +315,8 @@ async function bestTranscript({ run, qwen }, d, pcmFile, wav, tmp, { prompt = ''
290
315
  : e === 'parakeet' ? await transcribeParakeet(run, d, wav, tmp) : await transcribeWhisper(run, d, wav, tmp, { lang: d.lang, prompt });
291
316
  if (r.ok) break;
292
317
  }
318
+ // Qwen3-ASR is given only the pieces with speech; Whisper and Parakeet read the whole file, quiet stretches included
319
+ if (r.ok && r.engine !== 'qwen') { try { r = { ...r, segments: dropSilent(r.segments, fs.readFileSync(pcmFile)) }; } catch { /* no samples to check */ } }
293
320
  return r;
294
321
  }
295
322
  /**
@@ -394,5 +421,5 @@ function createFinal({ runtimeDir, onDone, onState = () => {}, run = runProgram,
394
421
  };
395
422
  }
396
423
 
397
- module.exports = { detect, setupCommand, createFinal, transcribeClip, runProgram, parseWhisper, parseParakeet, splitAtPauses, assignSpeakers, dropEcho, mergeSources, finalDir,
424
+ module.exports = { detect, setupCommand, createFinal, transcribeClip, runProgram, parseWhisper, parseParakeet, splitAtPauses, assignSpeakers, dropEcho, mergeSources, dropSilent, finalDir,
398
425
  PARAKEET_MODEL, VAD_FILE, DIARIZE_THRESHOLD };
@@ -5,6 +5,8 @@
5
5
  * repeats(text, previous) → true when a cut only says again what the line before it said
6
6
  * onceEach(text) → the text with a sentence repeated back to back kept once
7
7
  * looksForeign(text, reported) → true when a cut that should be English came back in another language
8
+ * loopKey(text) → the key a short line is counted under for the loop guard, or '' when the line is never counted
9
+ * dropLoops(lines) → the lines without the short lines said LOOP_TIMES times or more (Whisper's loop on quiet audio)
8
10
  *
9
11
  * ECHO. On speakers the remote voice also reaches the microphone, so the microphone's cut holds the meeting's words a second
10
12
  * time. lib/notes-final.js drops such lines after Stop (dropEcho); this is the same rule, live: a run of ECHO_RUN or more words
@@ -16,6 +18,12 @@
16
18
  * the previous line's words, or end it, adds nothing. A short reply ("Yes.") said again later is kept: a short line counts
17
19
  * as a repeat only within REPEAT_MS.
18
20
  *
21
+ * LOOPS (notes-silence-loops). On a long quiet stretch, or on a voice too far from the microphone to make out, Whisper writes
22
+ * the same short line again and again, often words from its --prompt: "Off, Briefing." every half minute for a quarter of an
23
+ * hour. The lines are far apart, so the repetition guard above does not see them. A short line (LOOP_MAX_WORDS words or fewer)
24
+ * that comes back word for word LOOP_TIMES times or more in one source of one note is such a loop, and every copy goes. Lines made
25
+ * only of backchannel words ("Yeah, yeah.", "Okay.", "Thank you.") are never counted: people do say those many times.
26
+ *
19
27
  * LANGUAGE (decision 8 of the console3 feedback plan: accented English is always English). A cut is foreign when the engine
20
28
  * says another language, when most of its letters are not Latin, or when most of its words are Indonesian function words.
21
29
  */
@@ -24,6 +32,13 @@ const ECHO_WORDS = 0.5;
24
32
  const ECHO_RUN = 4;
25
33
  const ECHO_SLACK_MS = 1500;
26
34
  const REPEAT_MS = 15000;
35
+ const LOOP_MAX_WORDS = 8;
36
+ const LOOP_TIMES = 3;
37
+ // words people say many times in a meeting; a line made only of them is never taken for a loop
38
+ const BACKCHANNEL = new Set(['yeah', 'yes', 'yep', 'yup', 'no', 'nope', 'ok', 'okay', 'right', 'sure', 'alright', 'thanks', 'thank', 'you',
39
+ 'mm', 'mhm', 'hmm', 'uh', 'um', 'huh', 'oh', 'ah', 'so', 'and', 'but', 'well', 'i', 'see', 'got', 'it', 'good', 'great', 'cool', 'nice',
40
+ 'exactly', 'true', 'correct', 'sorry', 'hi', 'hello', 'bye', 'agree', 'agreed', 'indeed', 'fine', 'perfect', 'wow', 'really', 'for', 'very',
41
+ 'much', 'ya', 'iya', 'oke', 'betul', 'sip']);
27
42
 
28
43
  const norm = (w) => String(w || '').toLowerCase().replace(/[^\p{L}\p{N}']/gu, '');
29
44
  const tokens = (text) => String(text || '').split(/\s+/).filter(Boolean);
@@ -98,6 +113,20 @@ function onceEach(text) {
98
113
  return out.join('').trim();
99
114
  }
100
115
 
116
+ /** The key a short line is counted under: its words, lower case, without punctuation. '' for a line that is long, empty, or
117
+ * only backchannel words, which the loop guard never counts. */
118
+ function loopKey(text) {
119
+ const w = tokens(text).map(norm).filter(Boolean);
120
+ if (!w.length || w.length > LOOP_MAX_WORDS || w.every((x) => BACKCHANNEL.has(x))) return '';
121
+ return w.join(' ');
122
+ }
123
+ /** `lines`: [{ text, … }] of one source. → the same lines without every copy of a short line said `times` times or more. */
124
+ function dropLoops(lines, { times = LOOP_TIMES } = {}) {
125
+ const count = new Map();
126
+ for (const l of lines || []) { const k = loopKey(l && l.text); if (k) count.set(k, (count.get(k) || 0) + 1); }
127
+ return (lines || []).filter((l) => { const k = loopKey(l && l.text); return !k || count.get(k) < times; });
128
+ }
129
+
101
130
  const ID_WORDS = new Set(['yang', 'dan', 'di', 'ini', 'itu', 'tidak', 'dengan', 'untuk', 'ada', 'saya', 'kita', 'kami', 'akan', 'sudah', 'belum', 'juga',
102
131
  'dari', 'ke', 'karena', 'jadi', 'bisa', 'apa', 'kalau', 'tapi', 'atau', 'nya', 'lagi', 'harus', 'sama', 'mau', 'gak', 'nggak', 'ya', 'kamu', 'mereka', 'dia', 'pada', 'dalam', 'seperti', 'lebih', 'masih', 'aja', 'saja']);
103
132
  /** Did a cut that should be English come back in another language? `reported` is the engine's language name, when it says one. */
@@ -114,4 +143,4 @@ function looksForeign(text, reported) {
114
143
  return false;
115
144
  }
116
145
 
117
- module.exports = { stripEcho, repeats, onceEach, looksForeign, sameWords, ECHO_OVERLAP, ECHO_WORDS, ECHO_RUN, REPEAT_MS };
146
+ module.exports = { stripEcho, repeats, onceEach, looksForeign, sameWords, loopKey, dropLoops, LOOP_TIMES, LOOP_MAX_WORDS, ECHO_OVERLAP, ECHO_WORDS, ECHO_RUN, REPEAT_MS };
@@ -32,7 +32,9 @@
32
32
  *
33
33
  * GUARDS (notes-recording-reliability, lib/notes-live-guard.js). A cut that repeats the line before it is dropped, a sentence
34
34
  * repeated back to back is kept once, the meeting's words are taken out of the microphone's line (the echo through the PM's
35
- * speakers), and with English chosen a cut that comes back in another language is run again in English.
35
+ * speakers), and with English chosen a cut that comes back in another language is run again in English. A short line that
36
+ * comes back word for word for the LOOP_TIMES-th time in a note's source is Whisper looping on quiet audio, and is dropped
37
+ * (the first copies stay live; the final pass after Stop drops them all).
36
38
  *
37
39
  * INVENTED LINES. whisper.cpp's no-speech threshold (`-nth`) drops segments it thinks are silence. A cut that is mostly
38
40
  * quiet and comes back as only "Thank you." or "you" is a known invention and is dropped (cleanText). A program too old
@@ -266,6 +268,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
266
268
  echo = true, holdMs = HOLD_MS, recent = null, lastLine = null }) {
267
269
  const bufs = new Map(); // `${id}|${source}` → { id, source, chunks, ms, voicedMs, quietMs, t, noise }
268
270
  const lastText = new Map(); // `${id}|${source}` → { text, t }: the last line heard there, for the repetition guard
271
+ const said = new Map(); // `${id}|${source}` → Map(loopKey → times written), for the loop guard
269
272
  const vocabOf = (job) => { if (typeof vocab !== 'function') return prompt || ''; try { return String(vocab({ id: job.id, source: job.source }) || ''); } catch { return prompt || ''; } };
270
273
  const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
271
274
  let queue = Promise.resolve();
@@ -302,6 +305,12 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
302
305
  let prev = lastText.get(key);
303
306
  if (!prev && typeof lastLine === 'function') { try { prev = lastLine(job.id, job.source) || null; } catch { prev = null; } }
304
307
  if (liveGuard.repeats(text, prev, job.t)) return;
308
+ const lk = liveGuard.loopKey(text);
309
+ if (lk) {
310
+ const m = said.get(key) || new Map(); said.set(key, m);
311
+ const n = (m.get(lk) || 0) + 1; m.set(lk, n);
312
+ if (n >= liveGuard.LOOP_TIMES) return;
313
+ }
305
314
  lastText.set(key, { text, t: job.t });
306
315
  remember(job, text);
307
316
  await onText({ ...job, text, ...(extra || {}) });
@@ -438,6 +447,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
438
447
  function drop(id) {
439
448
  for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k);
440
449
  for (const k of Array.from(lastText.keys())) if (k.startsWith(id + '|')) lastText.delete(k);
450
+ for (const k of Array.from(said.keys())) if (k.startsWith(id + '|')) said.delete(k);
441
451
  for (let i = held.length - 1; i >= 0; i--) if (held[i].job.id === id) held.splice(i, 1);
442
452
  heard.delete(id); meetingAt.delete(id); meetingTo.delete(id);
443
453
  for (const k of Array.from(cutEnd.keys())) if (k.startsWith(id + '|')) cutEnd.delete(k);
@@ -151,7 +151,7 @@ async function handle(req, res, url, ctx) {
151
151
  if (sub === 'draft') {
152
152
  const r = resolve(ctx, w, scope, key, now);
153
153
  if (!r.ok) return send(bad(r.status || 400, r.error));
154
- if (r.entry.kind !== 'doc') return send(bad(400, `${r.entry.title} is read from its own source; there is nothing to draft here`));
154
+ if (!CD.storable(r.entry)) return send(bad(400, `${r.entry.title} is read from its own source; there is nothing to draft here`));
155
155
  if (r.entry.by === 'you') return send(bad(400, `You write the ${r.entry.title.toLowerCase()}. PMOS only checks it`));
156
156
  if (r.level === 'board' && r.entry.build && !r.facts.board.build) return send(bad(400, `${r.entry.title} is only for a board with Build and deliver`));
157
157
  if (r.level === 'initiative' && !r.facts.list.some((x) => x.entry.key === key)) return send(bad(400, `${r.entry.title} is not in this initiative's set`));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.194.0",
3
+ "version": "0.196.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.194.0",
9
+ "version": "0.196.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.194.0",
3
+ "version": "0.196.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",
@@ -7,10 +7,10 @@
7
7
  * The server holds the sets, the states and the drafts; this file draws them and holds nothing.
8
8
  *
9
9
  * coreDocs(env) -> { left, right, dispose } two hosts to put in the columns of cols(): the sections and the cards. env =
10
- * { api, level, board?, id?, ws, today, toast, nav: { tab(tab), tool(tool), reports() }, boardRow?, iniDocs?: { docs, initiatives } }
10
+ * { api, level, board?, id?, ws, today, toast, nav: { tab(tab), tool(tool), reports(), scan()? }, boardRow?, iniDocs?: { docs, initiatives } }
11
11
  * needsLoc(row) -> the `init=` address of the Docs view a Briefing row (kind core-doc-drafts) opens
12
12
  * segBadge(n) the count badge of the Portfolio's Documents segment
13
- * A press on a row opens `open`: { path } the document sheet, { tab } the board tab, { tool } the initiative's Overview tool, { reports } Reports.
13
+ * A press on a row opens `open`: { path } the document sheet, { tab } the board tab, { tool } the initiative's Overview tool, { reports } Reports. A row not started carries `act`: { do: 'draft' } (Draft it, Propose them) or { go: 'scan' | 'reports' } (Run a scan, Set it up).
14
14
  * Review the draft opens the draft in the document sheet. The sheet holds the actions: a panel above the draft (docAbove, the sheet's own hook)
15
15
  * with Issue it and Ask PMOS to change it, which asks for a new draft. Nothing is sent to anyone; the PM's press issues it. */
16
16
  import { el } from './dom.js';
@@ -91,6 +91,7 @@ function stChip(d) {
91
91
  case 'draft': return el('span', { class: 'st cd-ag', ...a }, icon('spark', 'sm'), 'Draft ready' + (d.due ? ', due ' + day(d.due) : ''));
92
92
  case 'missing': return st('bad', 'alert', 'Not started', a);
93
93
  case 'stale': return st('warn', 'clock', 'Out of date', a);
94
+ case 'none': return el('span', { class: 'chip2 dim', ...a }, 'None yet');
94
95
  default: return el('span', { class: 'chip2 dim', ...a }, 'Comes later');
95
96
  }
96
97
  }
@@ -140,7 +141,8 @@ function coreRow(env, d, redraw) {
140
141
  d.flag ? st('warn', 'alert', d.flag) : null, d.staleWhy && d.st === 'stale' ? chip(d.staleWhy) : null];
141
142
  let action = null;
142
143
  if (!d.running && d.st === 'draft') action = pbtn('primary', 'Review the draft', 'spark', { 'data-cd-review': d.key, onclick: review(env, d, redraw) });
143
- else if (!d.running && d.kind === 'doc' && d.by !== 'you' && (d.st === 'missing' || d.st === 'stale')) action = pbtn('outline', 'Draft it', 'spark', { 'data-cd-draft': d.key, onclick: () => draftIt(env, d, redraw) });
144
+ else if (!d.running && d.act && d.act.do === 'draft') action = pbtn('outline', d.act.label, 'spark', { 'data-cd-draft': d.key, onclick: () => draftIt(env, d, redraw) });
145
+ else if (!d.running && d.act && d.act.go && env.nav[d.act.go]) action = pbtn('outline', d.act.label, d.act.go === 'scan' ? 'search' : 'report', { 'data-cd-go': d.key, onclick: () => env.nav[d.act.go]() });
144
146
  return nrow({ ic: d.ic || 'doc', hot: isAgent(d) || d.st === 'missing', title: [d.title, d.isNew ? [' ', tag('new')] : null], onOpen: open || undefined, openLabel: open ? 'Open ' + d.title : undefined,
145
147
  titleAttrs: { 'data-cd-open': d.key }, sub: d['for'] || '',
146
148
  chips: [stChip(d), d.cadence ? chip(icon('cal', 'sm'), d.cadence) : null, d.reader ? chip(icon('user', 'sm'), d.reader) : null, byChip(d.by), ...meta],
@@ -20,7 +20,7 @@ const RHYTHM = { portfolio: 'each quarter, with a monthly pulse', board: 'every
20
20
  /* the core sets: key, title, ic, for, reader, cadence, by, kind (doc | live | report), cat, isNew, state, when, due, flag, home */
21
21
  const PF = [
22
22
  { key: 'strategy', title: 'Product strategy', ic: 'target', for: 'Vision, where we play and what we will not do. Every board and key result traces to it.', reader: 'Leadership, every PM', cadence: 'Yearly, reviewed each quarter', by: 'you', st: 'ok', w: -9, flag: '2 key results do not trace to it', isNew: true },
23
- { key: 'okrs', title: 'OKRs and health budgets', ic: 'flag', for: 'The key results initiatives anchor to, and the budgets maintenance work runs on.', reader: 'Leadership, PMs', cadence: 'Quarterly', by: 'proposes', st: 'ok', w: -12 },
23
+ { key: 'okrs', title: 'OKRs and health budgets', ic: 'flag', for: 'The key results initiatives anchor to, and the budgets maintenance work runs on.', reader: 'Leadership, PMs', cadence: 'Quarterly', by: 'proposes', st: 'ok', w: -12, kind: 'derived', store: true },
24
24
  { key: 'roadmap', title: 'Portfolio roadmap', ic: 'cal', for: 'Bets by quarter (Now, Next, Later) and the key result each serves. A sendable copy of the Roadmap.', reader: 'Leadership, stakeholders', cadence: 'Monthly', by: 'drafts', st: 'draft', w: 0, due: 4 },
25
25
  { key: 'invest', title: 'Investment mix', ic: 'cols', for: 'Where the effort goes: by board, by key result, and feature work against upkeep.', reader: 'Leadership', cadence: 'Quarterly', by: 'drafts', st: 'draft', w: 0, due: 4, isNew: true },
26
26
  { key: 'scan', title: 'Horizon scan reports', ic: 'search', for: 'Benchmark, competitive, insight and productivity reports, each with proposals.', reader: 'PMs, leadership', cadence: 'Weekly scan', by: 'drafts', st: 'ok', w: -1, kind: 'report' },
@@ -99,6 +99,16 @@ function iniInfo(id) {
99
99
  return { id, board: r.board, stage: r.stage, cat: stage.cat || 'Plan', wfTpl: r.template || (b && b.template) || 'product' };
100
100
  }
101
101
 
102
+ /** The next step of a row not started, as lib/core-docs.js actOf. */
103
+ function actOf(e, stt) {
104
+ const kind = e.kind || 'doc';
105
+ if (kind === 'doc' && e.by !== 'you' && (stt === 'missing' || stt === 'stale')) return { label: 'Draft it', do: 'draft' };
106
+ if (stt !== 'missing') return null;
107
+ if (e.store && e.by !== 'you') return { label: 'Propose them', do: 'draft' };
108
+ if (e.key === 'scan') return { label: 'Run a scan', go: 'scan' };
109
+ if (kind === 'report' && !e.cat) return { label: 'Set it up', go: 'reports' };
110
+ return null;
111
+ }
102
112
  function entryOf(scope, e, base) {
103
113
  const o = OV.get(ovKey(scope, e.key)) || {};
104
114
  const stt = o.st || base.st;
@@ -109,7 +119,7 @@ function entryOf(scope, e, base) {
109
119
  else if (stt === 'draft') open = { path: draft.path };
110
120
  else if (stt === 'ok' || stt === 'due' || stt === 'stale') open = when ? { path: pathOf(scope, e.key, when) } : null;
111
121
  return { key: e.key, title: e.title, ic: e.ic, for: e.for, reader: e.reader || null, cadence: e.cadence || null, by: e.by, kind: e.kind || 'doc', cat: e.cat || null, isNew: !!e.isNew,
112
- st: stt, when: when || null, due: base.due != null ? (typeof base.due === 'number' ? ymd(base.due) : base.due) : null, flag: o.st ? null : base.flag || null, staleWhy: null, open, draft, running: !!o.running };
122
+ st: stt, when: when || null, due: base.due != null ? (typeof base.due === 'number' ? ymd(base.due) : base.due) : null, flag: o.st ? null : base.flag || null, staleWhy: null, open, draft, act: actOf(e, stt), running: !!o.running };
113
123
  }
114
124
  const summaryOf = (docs) => { const n = (s) => docs.filter((d) => d.st === s).length; return { draft: n('draft'), due: n('due'), missing: n('missing'), stale: n('stale') }; };
115
125
 
@@ -248,7 +248,9 @@ export function mountInitiatives(pane, opts) {
248
248
  const total = boards.reduce((n, b) => n + ((b.counts || {}).total || 0), 0);
249
249
  const badge = segBadge(0);
250
250
  const lhHost = el('div', { class: 'cd-host' });
251
- const cd = coreDocs({ api: pageApi, level: 'portfolio', ws: opts.ws || (boards[0] && boards[0].ws) || '', today: P.today, toast, nav: { reports: () => (opts.showTab || (() => {}))('rep'), tab: () => {}, tool: () => {} },
251
+ const cd = coreDocs({ api: pageApi, level: 'portfolio', ws: opts.ws || (boards[0] && boards[0].ws) || '', today: P.today, toast, nav: { reports: () => (opts.showTab || (() => {}))('rep'), tab: () => {}, tool: () => {},
252
+ // Run a scan: the Horizon scan section, with Scan now, lives on the Portfolio Overview.
253
+ scan: () => { go({ view: 'portfolio' }); setTimeout(() => { const h = root.querySelector('[data-scan-setup]'); if (h && h.scrollIntoView) h.scrollIntoView({ block: 'start' }); }, 400); } },
252
254
  onData: (r) => { setBadge(badge, r); put(lhHost, lh('doc', 'Portfolio documents', PORTFOLIO + ': the documents leadership reads to judge the bets. ' + (r.docs.filter((d) => d.st === 'draft').length ? plural(r.docs.filter((d) => d.st === 'draft').length, 'draft') + ' to review, ' : '') + r.docs.filter((d) => d.st === 'due').length + ' due.')); } });
253
255
  P.dispose = () => cd.dispose();
254
256
  put(root, pathCrumbs(null, null, el('div', { class: 'chips2' }, mono(plural(boards.length, 'board') + ' · ' + plural(total, 'initiative') + ' · ' + plural(asks, 'new ask')), pbtn('primary', 'New board', 'plus', { 'data-ini': 'new-board', onclick: () => openNewBoard() }))),
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "notes-silence-loops: Whisper looping on quiet audio. The loop guard (live and in the final pass) and the final pass dropping segments with no speech under them (notes-loops.test.js).",
3
+ "modules": ["notes-loops.test.js"],
4
+ "seconds": { "notes-loops.test.js": 3 }
5
+ }