create-pmos 0.64.0 → 0.65.1
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 +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/guide/docs.md +59 -6
- package/workspace/guide/initiatives-screen.md +8 -0
- package/workspace/guide/notes.md +35 -6
- package/workspace/lib/doc-library.js +131 -0
- package/workspace/lib/notes-audio.js +111 -0
- package/workspace/lib/notes.js +105 -12
- package/workspace/lib/routes-notes.js +33 -9
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +389 -0
- package/workspace/public/pm/dest-docs.js +144 -44
- package/workspace/public/pm/doc-graph.js +272 -0
- package/workspace/public/pm/initiative-sheet.js +24 -2
- package/workspace/public/pm/initiatives-copy.js +8 -0
- package/workspace/public/pm/view-notes.js +228 -19
- package/workspace/server.js +21 -0
- package/workspace/test-lists/docs-library-graph.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.1",
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@5f54d4e
|
|
2
|
+
assembled: 2026-10-07
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T00:49:12Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T00:49:12Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T00:49:12Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
package/workspace/guide/docs.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
|
-
title: "Docs:
|
|
3
|
+
title: "Docs: every repository file by category, the files each one links to, and whether each copy in Google Docs or Confluence is the same"
|
|
4
4
|
timestamp: 2026-10-06
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,15 +8,65 @@ timestamp: 2026-10-06
|
|
|
8
8
|
|
|
9
9
|
Open Docs from the top bar, from ⌘K (**Docs**), or press `g d`.
|
|
10
10
|
|
|
11
|
-
Docs lists every
|
|
11
|
+
Docs lists every markdown file in the repository by category. For each file, it shows the files that it links to and the files that link to it. For each document that has a copy in Google Docs or Confluence, it shows whether the repository version (PMOS) or the copy is newer. You can then use one version over the other, one copy at a time.
|
|
12
12
|
|
|
13
13
|
Nothing syncs by itself. A copy changes only when you press a button for that copy.
|
|
14
14
|
|
|
15
15
|
## The list
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Docs has three views:
|
|
18
18
|
|
|
19
|
-
**Needs you** shows the copies that are not the same.
|
|
19
|
+
- **Needs you** shows the copies that are not the same.
|
|
20
|
+
- **With a copy** shows every copy.
|
|
21
|
+
- **All files** shows every markdown file in the repository.
|
|
22
|
+
|
|
23
|
+
The copies are grouped by category.
|
|
24
|
+
|
|
25
|
+
## Categories
|
|
26
|
+
|
|
27
|
+
The category of a file comes from its folder, then from its frontmatter `type`. You do not tag a file.
|
|
28
|
+
|
|
29
|
+
| Category | Files |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Specs | `planning/prd/`, or `type: PRD` |
|
|
32
|
+
| Initiatives | `planning/initiatives/` |
|
|
33
|
+
| Decisions | the ADR folder, `DECISIONS.md`, `pm-decisions.md` |
|
|
34
|
+
| Research & plans | the other `planning/` folders |
|
|
35
|
+
| Explainers | `planning/explainers/` |
|
|
36
|
+
| Evals | `planning/evals/`, or `type: Rubric` |
|
|
37
|
+
| Knowledge | the other `okf/` folders |
|
|
38
|
+
| Guides & rules | all other files |
|
|
39
|
+
| Prototypes | `prototype/` |
|
|
40
|
+
|
|
41
|
+
## All files
|
|
42
|
+
|
|
43
|
+
**All files** opens with one line for each category: its name, its number of files, and "N need you" when a copy in it is not the same. Press a category to open it. It shows its first 20 files: the files with a copy first, then the newest. Press **Show all** to see the rest.
|
|
44
|
+
|
|
45
|
+
A file with no copy is one line: its title, the short name of its initiative (or its path when no initiative owns it), and the date of its last edit. **Add copy** shows when you point at the line, and always on a touch screen.
|
|
46
|
+
|
|
47
|
+
## The links of a file
|
|
48
|
+
|
|
49
|
+
Press a file in **All files** to see its links:
|
|
50
|
+
|
|
51
|
+
- on the left, the files that link to it;
|
|
52
|
+
- in the middle, the file;
|
|
53
|
+
- on the right, the files that it links to.
|
|
54
|
+
|
|
55
|
+
Each side shows at most 7 files. A dashed line is a decision that the file cites. Press a file in the graph to put it in the middle. Press **Back** to go back. **Open file** opens the file in the reader.
|
|
56
|
+
|
|
57
|
+
**List all N links** lists every link in both directions, grouped by kind.
|
|
58
|
+
|
|
59
|
+
A link counts only when a file writes it:
|
|
60
|
+
|
|
61
|
+
- a markdown link to another file;
|
|
62
|
+
- a path in the frontmatter, such as `prd_path`;
|
|
63
|
+
- the `initiative_id` of the file;
|
|
64
|
+
- the anchor (`parent_kr`);
|
|
65
|
+
- a decision number, such as D65.
|
|
66
|
+
|
|
67
|
+
PMOS does not guess a link from similar words. `pmos kg neighbors <path>` shows the same links in the terminal.
|
|
68
|
+
|
|
69
|
+
On a phone the graph is not shown. The list is shown.
|
|
20
70
|
|
|
21
71
|
Each copy is one line:
|
|
22
72
|
|
|
@@ -49,7 +99,8 @@ Click a line to see its details:
|
|
|
49
99
|
- the copy: its address and its last edit;
|
|
50
100
|
- a **Use … version** button for each side;
|
|
51
101
|
- the first difference that the compare found, when it found one;
|
|
52
|
-
- **Compare again** and **Stop tracking this copy
|
|
102
|
+
- **Compare again** and **Stop tracking this copy**;
|
|
103
|
+
- the links of the file, as in **All files**.
|
|
53
104
|
|
|
54
105
|
**Stop tracking this copy** removes the link. The copy is not deleted. Press **Undo** in the message to link it again.
|
|
55
106
|
|
|
@@ -72,7 +123,9 @@ A compare runs only when you press it. There is no schedule. An edit to a reposi
|
|
|
72
123
|
|
|
73
124
|
## Add a copy
|
|
74
125
|
|
|
75
|
-
Type in
|
|
126
|
+
Type in the search box. The search finds every markdown file in the repository by its title, its path or the name of its initiative, also files that have no copy. The results are grouped by category, and the match is marked. Press Esc to clear the search.
|
|
127
|
+
|
|
128
|
+
Press **Add copy** on a file with no copy.
|
|
76
129
|
|
|
77
130
|
Press **Add copy**, paste the address of the Google Doc or Confluence page, and press Enter. A document can have one Google Doc and one Confluence page.
|
|
78
131
|
|
|
@@ -76,6 +76,14 @@ The Files tab lists every file that belongs to the initiative, in three groups:
|
|
|
76
76
|
- **Linked documents.** The documents the library joins to the initiative: a document that declares its `initiative_id`, a document the record names, a file name that starts with the id.
|
|
77
77
|
- **Changed for it in git.** The files that the commits for this initiative changed and that still exist. These are its own branch, trunk commits that name the id, and merged changes that carried its documents. The file opens read-only.
|
|
78
78
|
|
|
79
|
+
**List · Graph** at the top of the tab changes the view. **List** shows the groups above. **Graph** shows the files of the initiative as a network:
|
|
80
|
+
|
|
81
|
+
- a filled dot is a markdown file of the initiative: a file that the record names, or a linked document. Its label is its role, such as PRD, Rubric or Explainer;
|
|
82
|
+
- a hollow dot is a file outside the initiative that one of its files links to, or that links to one of its files. A decision shows its number, such as D65;
|
|
83
|
+
- a line is a link that a file writes. A dashed line is a decision that a file cites.
|
|
84
|
+
|
|
85
|
+
The graph shows at most 14 outside files, the most-linked first. The legend says how many are left out. Press a dot to mark its lines and to see its title, its path and **Open file**. The same files and links always give the same picture. On a phone the list is shown.
|
|
86
|
+
|
|
79
87
|
### A breakdown read from the spec
|
|
80
88
|
|
|
81
89
|
When nobody has planned items for an initiative, the Breakdown tab shows the items of its spec above the empty Breakdown: the user stories, or the entries of the Requirements section of the PRD that the record names. Each item shows the state of the whole initiative: **Done** after you accept it, **Built** when its build is on the trunk, and **Todo** before that. These items are read-only and nothing is written. To track an item, add it to the Breakdown. The card and the row show the same count, for example "0 of 8 · from board-delivery-truth-prd.md".
|
package/workspace/guide/notes.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
4
|
-
description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, and get a summary."
|
|
4
|
+
description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, and get a summary."
|
|
5
5
|
timestamp: 2026-10-06
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -28,9 +28,9 @@ The meeting can only be heard when local transcription is set up. Until then, **
|
|
|
28
28
|
|
|
29
29
|
## What is caught
|
|
30
30
|
|
|
31
|
-
Each line gets a label: decision, action, question, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, statements with a number go under **Facts mentioned**.
|
|
31
|
+
Each line gets a label: decision, action, question, fact, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, facts and statements with a number go under **Facts mentioned**.
|
|
32
32
|
|
|
33
|
-
- To remove an item that does not belong, press the **x** beside it.
|
|
33
|
+
- To remove an item that does not belong, press the **x** beside it. To take a line out of the summary, press the **x** beside it in the summary.
|
|
34
34
|
- When a caught line is about something in the repository, the closest file shows beside it: **see** the file, a check mark when Jev finds that the file says the same, or **≠** the file when Jev finds that the line says something different. Point at the mark to see the file and the line.
|
|
35
35
|
|
|
36
36
|
## Set up local transcription
|
|
@@ -57,6 +57,35 @@ Without Jev, the workspace labels each line with its own rules and finds the clo
|
|
|
57
57
|
|
|
58
58
|
With the switches on, each line goes to TypeSafe for its label. For a caught line, that line and one passage of the closest file go too. Audio, speaker names and private notes never go. A line that looks like a secret is not sent.
|
|
59
59
|
|
|
60
|
+
## Correct and tag a line
|
|
61
|
+
|
|
62
|
+
1. In the transcript, press the **…** button on a line.
|
|
63
|
+
2. Change what you need:
|
|
64
|
+
- **Words**: correct what was heard. The words that were heard are kept, the line shows **edited**, and **Use what was heard** puts them back.
|
|
65
|
+
- **Label**: decision, action, question, fact, topic, statement or small talk.
|
|
66
|
+
- **Summary**: **As picked**, **Always in the summary** or **Never in the summary**.
|
|
67
|
+
- **Tags**: words such as `#pricing #launch`.
|
|
68
|
+
- **People**: names, separated by commas. Press a name below the field to add a person from this repository.
|
|
69
|
+
3. Press **Save**. The tags and people show on the line, on its item in your notes and under **Tagged**. **Copy as Markdown** includes them.
|
|
70
|
+
|
|
71
|
+
## See what changed in your notes
|
|
72
|
+
|
|
73
|
+
Each change to your notes keeps the text from before it. Changes within two minutes count as one. The first text you typed is always kept.
|
|
74
|
+
|
|
75
|
+
1. When your notes have changed, the header shows **Edited**. Press it.
|
|
76
|
+
2. **History** shows each version, newest first: the lines that were added and removed, and the original at the end.
|
|
77
|
+
3. To go back, press **Use this version**. The text before it stays in **History**.
|
|
78
|
+
|
|
79
|
+
## Replay the meeting
|
|
80
|
+
|
|
81
|
+
When a meeting was heard with local transcription, its audio is kept on this machine.
|
|
82
|
+
|
|
83
|
+
1. Open the note. **Replay** is above your notes. Press play.
|
|
84
|
+
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.
|
|
85
|
+
3. Press a time in the transcript to play from there.
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
60
89
|
## Dictate into your notes
|
|
61
90
|
|
|
62
91
|
1. Put the cursor where the text must go, then press **Dictate** and speak.
|
|
@@ -66,13 +95,13 @@ With the switches on, each line goes to TypeSafe for its label. For a caught lin
|
|
|
66
95
|
## Copy, private and delete
|
|
67
96
|
|
|
68
97
|
- **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
|
|
69
|
-
- In the settings, **Mark private** stops listening at once. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
|
|
98
|
+
- In the settings, **Mark private** stops listening at once and deletes the note's audio. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
|
|
70
99
|
- **Summary by agent** starts an agent session in the selected repository that writes a summary into the note. Lines that look like a secret are removed first.
|
|
71
|
-
- **Delete** removes the note, its transcript and its
|
|
100
|
+
- **Delete** removes the note, its transcript, its summaries and its audio from this machine after you confirm. You cannot undo it.
|
|
72
101
|
|
|
73
102
|
## What stays on your machine
|
|
74
103
|
|
|
75
|
-
- Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes
|
|
104
|
+
- Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes that file at once. For replay, the workspace keeps one recording of each note in `notes/recordings/` in the state folder, until you delete the audio, the note, or mark the note private. The browser's own recognition is used only on the device, never in the cloud.
|
|
76
105
|
- Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
|
|
77
106
|
- Only Jev, with the switches on, and **Summary by agent**, when you press it, receive any text from a note.
|
|
78
107
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* doc-library.js — every tracked markdown file of a workspace, by category, with its initiative and the links between the
|
|
3
|
+
* files (initiative docs-library-graph, PRD R-L1 to R-L5).
|
|
4
|
+
*
|
|
5
|
+
* The links are the knowledge graph's (lib/kg.js through kg-cli `graphFor`): a markdown link, a path in the header, the
|
|
6
|
+
* initiative, the anchor, a decision number. Nothing is inferred from wording. A category is derived from where a file
|
|
7
|
+
* lives, then from its frontmatter type; it is never written into a file. The result is kept in memory per repository
|
|
8
|
+
* state (git HEAD plus the uncommitted markdown files), and the kg cache stays in the runtime state directory. Nothing
|
|
9
|
+
* here writes into the repository or reads the network.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const kgCli = require('./kg-cli');
|
|
15
|
+
|
|
16
|
+
/* [key, name] in display order */
|
|
17
|
+
const CATEGORIES = [
|
|
18
|
+
['spec', 'Specs'], ['ini', 'Initiatives'], ['dec', 'Decisions'], ['res', 'Research & plans'], ['exp', 'Explainers'],
|
|
19
|
+
['eval', 'Evals'], ['know', 'Knowledge'], ['guide', 'Guides & rules'], ['proto', 'Prototypes'],
|
|
20
|
+
];
|
|
21
|
+
/* the link kinds, in the order the client numbers them */
|
|
22
|
+
const KINDS = ['link', 'header', 'initiative', 'anchor', 'cites'];
|
|
23
|
+
|
|
24
|
+
const BY_TYPE = { prd: 'spec', initiative: 'ini', adr: 'dec', decision: 'dec', rubric: 'eval', eval: 'eval', verdict: 'eval', explainer: 'exp' };
|
|
25
|
+
|
|
26
|
+
/** The category of one file: where it lives first, then its frontmatter type, then Guides & rules. */
|
|
27
|
+
function categoryOf(rel, type) {
|
|
28
|
+
const p = String(rel || '');
|
|
29
|
+
if (/^prototype\//.test(p)) return 'proto';
|
|
30
|
+
if (/(^|\/)planning\/initiatives\//.test(p)) return 'ini';
|
|
31
|
+
if (/(^|\/)planning\/prd\//.test(p)) return 'spec';
|
|
32
|
+
if (/(^|\/)planning\/evals\//.test(p)) return 'eval';
|
|
33
|
+
if (/(^|\/)planning\/explainers\//.test(p)) return 'exp';
|
|
34
|
+
if (/^okf\/products\/[^/]+\/adr\//.test(p) || /^(DECISIONS|pm-decisions)\.md$/.test(p) || /(^|\/)planning\/decisions-/.test(p)) return 'dec';
|
|
35
|
+
if (/(^|\/)planning\//.test(p)) return 'res';
|
|
36
|
+
if (/^okf\//.test(p)) return 'know';
|
|
37
|
+
const t = String(type || '').trim().toLowerCase();
|
|
38
|
+
return BY_TYPE[t] || 'guide';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A short name for an initiative title: no "PRD:" prefix, the part before the first dash or colon, at most 44 characters. */
|
|
42
|
+
function shortTitle(title) {
|
|
43
|
+
let t = String(title || '').trim();
|
|
44
|
+
t = t.replace(/^(PRD|Initiative|Chore|Spike|Prototype Brief|Discovery|Incident|Content brief|Feature)\s*(?:[:—–]|\s-\s)\s*/i, '');
|
|
45
|
+
t = t.split(/\s[—–]\s|:\s|\.\s/)[0].trim();
|
|
46
|
+
if (t.length > 44) { const cut = t.slice(0, 43); t = (cut.includes(' ') ? cut.replace(/\s+\S*$/, '') : cut) + '…'; }
|
|
47
|
+
return t ? t[0].toUpperCase() + t.slice(1) : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The kind of one kg edge, or null for a kind the library does not show. */
|
|
51
|
+
function kindOf(e) {
|
|
52
|
+
if (e.type === 'link') return 'link';
|
|
53
|
+
if (e.type === 'initiative') return 'initiative';
|
|
54
|
+
if (e.type === 'anchor') return 'anchor';
|
|
55
|
+
if (e.type === 'cites') return e.typed ? 'header' : 'cites';
|
|
56
|
+
if (e.type === 'frontmatter' || e.type === 'supersedes' || e.type === 'amended_by') return 'header';
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
61
|
+
|
|
62
|
+
/** path → YYYY-MM-DD of its last commit (one git log; merge commits are skipped, their changes are in their parents). */
|
|
63
|
+
function commitDates(root) {
|
|
64
|
+
const out = new Map();
|
|
65
|
+
let text = '';
|
|
66
|
+
try { text = git(root, ['log', '--format=%x01%cs', '--name-only', '--', '*.md']); } catch { return out; }
|
|
67
|
+
let day = null;
|
|
68
|
+
for (const line of text.split('\n')) {
|
|
69
|
+
if (line.startsWith('\x01')) { day = line.slice(1); continue; }
|
|
70
|
+
if (line && day && !out.has(line)) out.set(line, day);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The uncommitted markdown files: their date is the file's own. */
|
|
76
|
+
function dirtyPaths(root) {
|
|
77
|
+
try {
|
|
78
|
+
return new Set(git(root, ['status', '--porcelain', '-z', '--untracked-files=no']).split('\0').filter(Boolean)
|
|
79
|
+
.map((l) => l.slice(3)).filter((p) => /\.md$/.test(p)));
|
|
80
|
+
} catch { return new Set(); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cache = new Map(); // root -> { key, value }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The library of one repository.
|
|
87
|
+
* owners: () => Map path → { id, title } — the library's own attribution (server.js initiativeIndex), real initiatives
|
|
88
|
+
* only. It is called only when the repository state changed, because the index reads every planning file.
|
|
89
|
+
* Returns { ok, files: [{path,title,category,date,initiative}], initiatives: {id: {title, short}}, kinds, edges: [[from, to, kind]], categories }.
|
|
90
|
+
* `from` and `to` index `files`; `kind` indexes `kinds`.
|
|
91
|
+
*/
|
|
92
|
+
function library(root, { stateDir, owners = () => new Map() } = {}) {
|
|
93
|
+
let key;
|
|
94
|
+
try { key = kgCli.cacheKey(root); } catch (e) { return { ok: false, error: 'this workspace is not a git repository, so there are no tracked files to list' }; }
|
|
95
|
+
const hit = cache.get(root);
|
|
96
|
+
if (hit && hit.key === key) return hit.value;
|
|
97
|
+
const { graph, unreadable } = kgCli.graphFor(root, stateDir ? { stateDir } : {});
|
|
98
|
+
let own = new Map();
|
|
99
|
+
try { own = owners() || new Map(); } catch { /* no attribution: every file has no initiative */ }
|
|
100
|
+
const dates = commitDates(root);
|
|
101
|
+
const dirty = dirtyPaths(root);
|
|
102
|
+
const dayOf = (rel) => {
|
|
103
|
+
if (!dirty.has(rel) && dates.has(rel)) return dates.get(rel);
|
|
104
|
+
try { return fs.statSync(path.join(root, rel)).mtime.toISOString().slice(0, 10); } catch { return dates.get(rel) || null; }
|
|
105
|
+
};
|
|
106
|
+
// An initiative is named by its record's title (planning/initiatives/<id>.md), else by the index's title for it.
|
|
107
|
+
const titleOf = new Map(graph.nodes.map((n) => [n.id, n.title]));
|
|
108
|
+
const named = (o) => String(titleOf.get(`planning/initiatives/${o.id}.md`) || o.title || o.id);
|
|
109
|
+
const initiatives = {};
|
|
110
|
+
const files = graph.nodes.map((n) => {
|
|
111
|
+
const o = own.get(n.id) || null;
|
|
112
|
+
if (o && !initiatives[o.id]) initiatives[o.id] = { title: named(o), short: shortTitle(named(o)) };
|
|
113
|
+
return { path: n.id, title: String(n.title || n.id), category: categoryOf(n.id, n.type), date: dayOf(n.id), initiative: o ? o.id : null };
|
|
114
|
+
});
|
|
115
|
+
const index = new Map(files.map((f, i) => [f.path, i]));
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const edges = [];
|
|
118
|
+
for (const e of graph.edges) {
|
|
119
|
+
const k = kindOf(e);
|
|
120
|
+
if (!k || e.from === e.to || !index.has(e.from) || !index.has(e.to)) continue;
|
|
121
|
+
const a = index.get(e.from); const b = index.get(e.to); const ki = KINDS.indexOf(k);
|
|
122
|
+
const id = a + '|' + b + '|' + ki;
|
|
123
|
+
if (seen.has(id)) continue;
|
|
124
|
+
seen.add(id); edges.push([a, b, ki]);
|
|
125
|
+
}
|
|
126
|
+
const value = { ok: true, key, files, initiatives, kinds: KINDS, edges, categories: CATEGORIES.map(([k, name]) => ({ key: k, name })), unreadable: unreadable || [] };
|
|
127
|
+
cache.set(root, { key, value });
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { CATEGORIES, KINDS, categoryOf, shortTitle, kindOf, library, commitDates };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-audio.js — the meeting audio a note keeps for replay (meeting-notes Run 3, D92). Local only.
|
|
3
|
+
*
|
|
4
|
+
* write(runtimeDir, { id, source, pcm, t }) add one chunk of 16 kHz mono 16-bit samples at meeting time t (ms)
|
|
5
|
+
* info(runtimeDir, id) → { bytes, ms } | null
|
|
6
|
+
* remove(runtimeDir, id) delete the note's audio
|
|
7
|
+
* serve(req, res, runtimeDir, id) the audio as a WAV file, with byte ranges so the page can seek
|
|
8
|
+
* getKeep(runtimeDir) · setKeep(runtimeDir, bool) "Keep the audio for replay", on by default, for this machine
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT KEEPS. One file per note, `<runtime>/notes/recordings/<id>.pcm`: raw samples with no header, sample k at
|
|
11
|
+
* k / 16000 seconds after the note's first Listen. The microphone and the meeting are added into the same samples, so the
|
|
12
|
+
* file is the meeting as the PM heard it. A chunk is placed straight after the previous chunk of its source when it
|
|
13
|
+
* arrives on time or early, so the small jitter of the page's timer leaves no clicks; a chunk that arrives much later (Listen again)
|
|
14
|
+
* is placed at its own time, and the gap stays silent.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The routes (lib/routes-notes.js) write nothing for a
|
|
17
|
+
* private note, delete the file when a note is made private or deleted, and write nothing when the PM switches keeping off.
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const RATE = 16000;
|
|
23
|
+
const BYTES_PER_MS = (RATE * 2) / 1000;
|
|
24
|
+
const ON_TIME_MS = 1500; // a chunk this close to where its source left off is placed straight after it
|
|
25
|
+
const MAX_BYTES = RATE * 2 * 60 * 60 * 6; // six hours; a longer meeting keeps its first six hours
|
|
26
|
+
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
27
|
+
|
|
28
|
+
const dirOf = (runtimeDir) => path.join(runtimeDir, 'notes', 'recordings');
|
|
29
|
+
const fileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.pcm');
|
|
30
|
+
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'audio-settings.json');
|
|
31
|
+
const next = new Map(); // `${file}|${source}` → the byte offset where that source's last chunk ended
|
|
32
|
+
|
|
33
|
+
function getKeep(runtimeDir) {
|
|
34
|
+
try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s.keep !== false; } catch { return true; }
|
|
35
|
+
}
|
|
36
|
+
function setKeep(runtimeDir, keep) {
|
|
37
|
+
if (typeof keep !== 'boolean') return { ok: false, error: 'say keep: true or keep: false' };
|
|
38
|
+
const f = settingsFile(runtimeDir);
|
|
39
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
40
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify({ keep }), { mode: 0o600 });
|
|
42
|
+
fs.renameSync(tmp, f);
|
|
43
|
+
return { ok: true, keep };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, and clipped. */
|
|
47
|
+
function write(runtimeDir, { id, source, pcm, t }) {
|
|
48
|
+
if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
|
|
49
|
+
const f = fileOf(runtimeDir, id);
|
|
50
|
+
const key = f + '|' + source;
|
|
51
|
+
let at = Math.max(0, Math.round((Number(t) || 0) * BYTES_PER_MS / 2) * 2);
|
|
52
|
+
const prev = next.get(key);
|
|
53
|
+
// a source never overlaps itself: a chunk that would start before its source left off, or just after, goes straight after it
|
|
54
|
+
if (prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
|
|
55
|
+
if (at >= MAX_BYTES) return { ok: true, kept: false };
|
|
56
|
+
const len = Math.min(pcm.length, MAX_BYTES - at);
|
|
57
|
+
fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
|
|
58
|
+
const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
|
|
59
|
+
try {
|
|
60
|
+
const old = Buffer.alloc(len);
|
|
61
|
+
fs.readSync(fd, old, 0, len, at);
|
|
62
|
+
const out = Buffer.alloc(len);
|
|
63
|
+
for (let i = 0; i < len; i += 2) out.writeInt16LE(Math.max(-32768, Math.min(32767, old.readInt16LE(i) + pcm.readInt16LE(i))), i);
|
|
64
|
+
fs.writeSync(fd, out, 0, len, at);
|
|
65
|
+
} finally { fs.closeSync(fd); }
|
|
66
|
+
next.set(key, at + len);
|
|
67
|
+
return { ok: true, kept: true, at_ms: at / BYTES_PER_MS };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function info(runtimeDir, id) {
|
|
71
|
+
if (!ID_RE.test(String(id || ''))) return null;
|
|
72
|
+
try { const st = fs.statSync(fileOf(runtimeDir, id)); return st.size ? { bytes: st.size, ms: Math.round(st.size / BYTES_PER_MS) } : null; } catch { return null; }
|
|
73
|
+
}
|
|
74
|
+
function remove(runtimeDir, id) {
|
|
75
|
+
if (!ID_RE.test(String(id || ''))) return false;
|
|
76
|
+
for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
|
|
77
|
+
try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function wavHeader(dataBytes) {
|
|
81
|
+
const h = Buffer.alloc(44);
|
|
82
|
+
h.write('RIFF', 0); h.writeUInt32LE(36 + dataBytes, 4); h.write('WAVE', 8);
|
|
83
|
+
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
|
|
84
|
+
h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
|
|
85
|
+
h.write('data', 36); h.writeUInt32LE(dataBytes, 40);
|
|
86
|
+
return h;
|
|
87
|
+
}
|
|
88
|
+
/** The audio as audio/wav. A `Range: bytes=a-b` request gets 206 with that part, which is how the page seeks. */
|
|
89
|
+
function serve(req, res, runtimeDir, id) {
|
|
90
|
+
const inf = info(runtimeDir, id);
|
|
91
|
+
if (!inf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no audio' })); return; }
|
|
92
|
+
const header = wavHeader(inf.bytes);
|
|
93
|
+
const total = header.length + inf.bytes;
|
|
94
|
+
let start = 0; let end = total - 1; let partial = false;
|
|
95
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || '').trim());
|
|
96
|
+
if (m && (m[1] || m[2])) {
|
|
97
|
+
if (m[1]) { start = Number(m[1]); if (m[2]) end = Math.min(total - 1, Number(m[2])); }
|
|
98
|
+
else { start = Math.max(0, total - Number(m[2])); }
|
|
99
|
+
if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); res.end(); return; }
|
|
100
|
+
partial = true;
|
|
101
|
+
}
|
|
102
|
+
res.writeHead(partial ? 206 : 200, { 'Content-Type': 'audio/wav', 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Cache-Control': 'no-store',
|
|
103
|
+
...(partial ? { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total } : {}) });
|
|
104
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
105
|
+
if (start < header.length) res.write(header.subarray(start, Math.min(header.length, end + 1)));
|
|
106
|
+
const from = Math.max(0, start - header.length); const to = end - header.length;
|
|
107
|
+
if (to < from) { res.end(); return; }
|
|
108
|
+
fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { write, info, remove, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
|