create-pmos 0.529.0 → 0.531.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 +27 -49
- package/payload/KIT-VERSION +1 -1
- 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/workspace/changelog.json +26 -0
- package/workspace/guide/notes.md +2 -2
- package/workspace/guide/reports.md +3 -1
- package/workspace/lib/jev-notes.js +58 -3
- package/workspace/lib/jev-screen.js +110 -0
- package/workspace/lib/meeting-replay.js +5 -2
- package/workspace/lib/notes-screen.js +15 -1
- package/workspace/lib/report-cli.js +16 -3
- package/workspace/lib/report-engine.js +67 -17
- package/workspace/lib/report-export.js +155 -3
- package/workspace/lib/report-library.js +76 -0
- package/workspace/lib/report-pyramid.js +2 -0
- package/workspace/lib/report-slides.js +580 -0
- package/workspace/lib/routes-meeting-replay.js +13 -2
- package/workspace/lib/routes-notes.js +25 -4
- package/workspace/lib/typesafe.js +71 -5
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-vw.css +1 -0
- package/workspace/public/pm/notes-screen-capture.js +5 -4
- package/workspace/public/pm/premium-report-read.js +24 -3
- package/workspace/public/pm/premium-vw-mtg.js +15 -0
- package/workspace/public/pm/view-notes.js +2 -2
- package/workspace/skills/report-write.skill +2 -1
- package/workspace/test-files.json +1 -0
- package/workspace/test-lists/report-slides.json +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.531.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": {
|
|
@@ -32,6 +32,32 @@
|
|
|
32
32
|
"audit"
|
|
33
33
|
],
|
|
34
34
|
"pmosChanges": [
|
|
35
|
+
{
|
|
36
|
+
"v": "0.531.0",
|
|
37
|
+
"at": "2026-11-04T18:44:50.726Z",
|
|
38
|
+
"f": 4,
|
|
39
|
+
"prs": [
|
|
40
|
+
{
|
|
41
|
+
"n": 1247,
|
|
42
|
+
"t": "Report library frame and 23 slide layouts",
|
|
43
|
+
"s": "Report library frame and 23 slide layouts",
|
|
44
|
+
"k": "new"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"v": "0.530.0",
|
|
50
|
+
"at": "2026-11-04T14:38:26.528Z",
|
|
51
|
+
"f": 4,
|
|
52
|
+
"prs": [
|
|
53
|
+
{
|
|
54
|
+
"n": 1246,
|
|
55
|
+
"t": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
56
|
+
"s": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
57
|
+
"k": "new"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
35
61
|
{
|
|
36
62
|
"v": "0.529.0",
|
|
37
63
|
"at": "2026-11-04T13:14:44.790Z",
|
|
@@ -393,54 +419,6 @@
|
|
|
393
419
|
"k": "new"
|
|
394
420
|
}
|
|
395
421
|
]
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
"v": "0.511.0",
|
|
399
|
-
"at": "2026-11-03T15:10:29.930Z",
|
|
400
|
-
"f": 4,
|
|
401
|
-
"prs": [
|
|
402
|
-
{
|
|
403
|
-
"n": 1202,
|
|
404
|
-
"t": "A closed initiative is never late on the Board",
|
|
405
|
-
"s": "A closed initiative is never late on the Board",
|
|
406
|
-
"k": "fix"
|
|
407
|
-
},
|
|
408
|
-
{
|
|
409
|
-
"n": 1200,
|
|
410
|
-
"t": "Open the chat on the Board, and dock panels left or right",
|
|
411
|
-
"s": "Open the chat on the Board, and dock panels left or right",
|
|
412
|
-
"k": "new"
|
|
413
|
-
},
|
|
414
|
-
{
|
|
415
|
-
"n": 1201,
|
|
416
|
-
"t": "Settings Updates tells each change as Claude's plain-language story (design E)",
|
|
417
|
-
"s": "Settings Updates tells each change as Claude's plain-language story (design E)",
|
|
418
|
-
"k": "new",
|
|
419
|
-
"h": "Updates read like a story",
|
|
420
|
-
"d": "Updates listed pull request titles and numbers, grouped by kind, which was hard to skim. Updates opens with a short summary of the week, then the newest version, then each change as a plain story, with a dot for New and Fixed."
|
|
421
|
-
}
|
|
422
|
-
]
|
|
423
|
-
},
|
|
424
|
-
{
|
|
425
|
-
"v": "0.510.0",
|
|
426
|
-
"at": "2026-11-03T14:33:47.373Z",
|
|
427
|
-
"f": 4,
|
|
428
|
-
"prs": [
|
|
429
|
-
{
|
|
430
|
-
"n": 1197,
|
|
431
|
-
"t": "Clean Updates lines: no, one PR number, no repeated title, right group",
|
|
432
|
-
"s": "Clean Updates lines: no, one PR number, no repeated title, right group",
|
|
433
|
-
"k": "fix",
|
|
434
|
-
"h": "Clearer update notes",
|
|
435
|
-
"d": "Update notes could repeat a line, show odd tags, or put every change under Improved. Now each change shows once, in the right group."
|
|
436
|
-
},
|
|
437
|
-
{
|
|
438
|
-
"n": 1199,
|
|
439
|
-
"t": "A Board product head's name opens its page in the Viewer",
|
|
440
|
-
"s": "A Board product head's name opens its page in the Viewer",
|
|
441
|
-
"k": "new"
|
|
442
|
-
}
|
|
443
|
-
]
|
|
444
422
|
}
|
|
445
423
|
]
|
|
446
424
|
}
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
|
|
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-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
|
|
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-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/changelog.json
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "0.531.0",
|
|
4
|
+
"at": "2026-11-04T18:44:50.726Z",
|
|
5
|
+
"f": 4,
|
|
6
|
+
"prs": [
|
|
7
|
+
{
|
|
8
|
+
"n": 1247,
|
|
9
|
+
"t": "Report library frame and 23 slide layouts",
|
|
10
|
+
"s": "Report library frame and 23 slide layouts",
|
|
11
|
+
"k": "new"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"v": "0.530.0",
|
|
17
|
+
"at": "2026-11-04T14:38:26.528Z",
|
|
18
|
+
"f": 4,
|
|
19
|
+
"prs": [
|
|
20
|
+
{
|
|
21
|
+
"n": 1246,
|
|
22
|
+
"t": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
23
|
+
"s": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
24
|
+
"k": "new"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
2
28
|
{
|
|
3
29
|
"v": "0.529.0",
|
|
4
30
|
"at": "2026-11-04T13:14:44.790Z",
|
package/workspace/guide/notes.md
CHANGED
|
@@ -190,7 +190,7 @@ Without Jev, the workspace labels each line with its own rules and finds the clo
|
|
|
190
190
|
1. Save a TypeSafe key and switch on validation for the repository (see [Validate with TypeSafe](validate-with-typesafe.md)).
|
|
191
191
|
2. Open the settings and switch on **Label lines with Jev**. Switch on **Check notes against this repository with Jev** for the checks too. Both are off until you switch them on, for each repository.
|
|
192
192
|
|
|
193
|
-
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.
|
|
193
|
+
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. Each screen capture goes too, so Jev can find the slide or document in it (see the meeting player below). Audio, speaker names and private notes never go. A line that looks like a secret is not sent, and a capture set aside as another app is not sent.
|
|
194
194
|
|
|
195
195
|
## Correct and tag a line
|
|
196
196
|
|
|
@@ -253,7 +253,7 @@ A meeting with a recording opens wide, in two columns. Press **Back to the side*
|
|
|
253
253
|
|
|
254
254
|
The captures row is hidden until you press the captures icon. It shows the five captures near the playing time, and **+N more** shows the rest.
|
|
255
255
|
|
|
256
|
-
When Jev is on for the repository (Settings › **Label lines with Jev**) and Jev gave a box for a capture, the player and the captures row show only the slide or document in that box. The meeting window and the faces are cut off, and a capture that shows only faces is not in the captures row. The icon at the top right of the picture shows the whole frame. When Jev is off, or gave no box, you see the whole frame.
|
|
256
|
+
When Jev is on for the repository (Settings › **Label lines with Jev**) and Jev gave a box for a capture, the player and the captures row show only the slide or document in that box. The meeting window and the faces are cut off, and a capture that shows only faces is not in the captures row. The icon at the top right of the picture shows the whole frame. When Jev is off, or gave no box, you see the whole frame. Captures kept before you switched Jev on are sent the next time you open the meeting. Jev reads pictures up to 1568 px a side. On a Mac a larger capture from an earlier meeting is made smaller first; on another computer it shows the whole frame.
|
|
257
257
|
|
|
258
258
|
### Fix a word that was heard wrong
|
|
259
259
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Reports: draft and issue a cadence report"
|
|
4
4
|
description: "How pmos report drafts a cadence report with every number, how the notes are kept when you draft again, and how you issue and freeze a report."
|
|
5
|
-
timestamp: 2026-11-
|
|
5
|
+
timestamp: 2026-11-04
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Reports: draft and issue a cadence report
|
|
@@ -90,6 +90,8 @@ pmos report issue biweekly 2026-10-16
|
|
|
90
90
|
|
|
91
91
|
`pmos report finder` shows the report finder setting, or changes it.
|
|
92
92
|
|
|
93
|
+
`pmos report library` writes one page with every slide layout of the report library, filled with placeholder words. Open it in a browser to see the layouts a report can use. Add `--out <file>` to choose the file.
|
|
94
|
+
|
|
93
95
|
Every command accepts `--root <dir>` to name the repository. `draft` and `list` also accept `--json`.
|
|
94
96
|
|
|
95
97
|
## The window
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* gatesFor(key, flags) · checkGatesFor(key, flags) · senderFor(key, flags, askOpts) · infoFor(present, flags)
|
|
5
5
|
* label({ ask, key, askOpts, text, pause }) → { cat, p } | { cat: null, reason }
|
|
6
6
|
* compare({ ask, key, askOpts, text, passage, pause }) → { verdict: 'agrees'|'differs'|'related', words } | { verdict: null, reason }
|
|
7
|
+
* screen({ askImage, key, askOpts, image, root }) → { kind: 'slide'|'document'|'faces'|'unsure', box?, p } | { kind: null, reason }
|
|
7
8
|
* getSwitch(runtimeDir, root) · setSwitch(runtimeDir, root, { jev, check })
|
|
8
9
|
*
|
|
9
10
|
* WHAT MAY LEAVE THE MACHINE. For a label: one cleaned transcript line and one Choice question. For a check: the line and
|
|
@@ -13,6 +14,9 @@
|
|
|
13
14
|
* per-user runtime directory. Never sent: audio, a speaker's name, a line or passage the secret scan matches, a private
|
|
14
15
|
* note (the caller refuses it before this module), anything while paused.
|
|
15
16
|
*
|
|
17
|
+
* For a screen capture (MpJ): the capture's picture and nothing else, with one Choice and one Box question, under the same three
|
|
18
|
+
* gates. The caller sends only shots the note lists: never a shot set aside as another app, never one from a private note.
|
|
19
|
+
*
|
|
16
20
|
* WHAT A RESULT IS. Advisory (D40), shown in words. A label under UNSURE_BELOW keeps the local label. A check says
|
|
17
21
|
* "agrees" or "differs" only above SURE; otherwise "related", which is what the local match alone also says.
|
|
18
22
|
*
|
|
@@ -68,8 +72,8 @@ const checkGatesFor = (key, f) => checkGates({ key: typesafe.validKey(key), cons
|
|
|
68
72
|
/** { ask, check, askOpts }: `ask` sends labels and `check` is true when checks may also be sent. Both shut when a gate is. */
|
|
69
73
|
function senderFor(key, flags, askOpts = {}) {
|
|
70
74
|
const g = gatesFor(key, flags);
|
|
71
|
-
if (!g.open) return { ask: null, check: false, gates: g, askOpts: {} };
|
|
72
|
-
return { ask: (a) => typesafe.ask(a), check: checkGatesFor(key, flags).open, gates: g, askOpts: { ...NOTES_ASK, ...askOpts } };
|
|
75
|
+
if (!g.open) return { ask: null, askImage: null, check: false, gates: g, askOpts: {} };
|
|
76
|
+
return { ask: (a) => typesafe.ask(a), askImage: (a) => typesafe.askImage(a), check: checkGatesFor(key, flags).open, gates: g, askOpts: { ...NOTES_ASK, ...askOpts } };
|
|
73
77
|
}
|
|
74
78
|
/** What the view needs to explain the two switches. Booleans and sentences only. */
|
|
75
79
|
function infoFor(present, flags) {
|
|
@@ -86,6 +90,11 @@ function getSwitch(runtimeDir, root) {
|
|
|
86
90
|
const r = s && s.repos && root ? s.repos[root] : null;
|
|
87
91
|
return { jev: !!(r && r.jev === true), check: !!(r && r.check === true) };
|
|
88
92
|
}
|
|
93
|
+
/** The repositories whose switch "notes may use Jev" is on, sorted: a note bound to no repository looks at its shots under the first of these that also has consent (routes-notes.js lookAtShots). */
|
|
94
|
+
function onRoots(runtimeDir) {
|
|
95
|
+
const s = readJson(switchFile(runtimeDir), {});
|
|
96
|
+
return Object.entries((s && s.repos) || {}).filter(([, v]) => v && v.jev === true).map(([r]) => r).sort();
|
|
97
|
+
}
|
|
89
98
|
function setSwitch(runtimeDir, root, { jev, check } = {}) {
|
|
90
99
|
const s = readJson(switchFile(runtimeDir), {});
|
|
91
100
|
const prev = getSwitch(runtimeDir, root);
|
|
@@ -156,7 +165,53 @@ async function compare({ ask, key, askOpts = {}, text, passage, root = '' }) {
|
|
|
156
165
|
if (same != null && same >= SURE) return { verdict: 'agrees', words: wordsFor(same), model: res.model || null };
|
|
157
166
|
return { verdict: 'related', words: null, model: res.model || null };
|
|
158
167
|
}
|
|
168
|
+
/* ── screen captures (MpJ) ──────────────────────────────────────────────────────────────────────── */
|
|
169
|
+
const SHOT_KINDS = {
|
|
170
|
+
slide: 'A shared slide or presentation is on the screen.',
|
|
171
|
+
document: 'A shared document, web page, spreadsheet, board or app screen is on the screen.',
|
|
172
|
+
faces: 'Only the people in the meeting: their video tiles, pictures or names. Nothing is shared.',
|
|
173
|
+
other: 'Something else, such as an empty, loading or desktop screen.',
|
|
174
|
+
};
|
|
175
|
+
const SHOT_KIND_Q = 'The `capture` is a screenshot taken during a video meeting. What does it mainly show?';
|
|
176
|
+
const SHOT_BOX_Q = 'the shared slide or document in `capture`, without the meeting app\'s toolbars and without the video tiles of the people';
|
|
177
|
+
const SHOT_SHOWN = ['slide', 'document'];
|
|
178
|
+
const SHOT_ASK = { timeoutMs: 20000, retries: 1, backoffMs: 600 };
|
|
179
|
+
const r4 = (n) => Math.round(n * 10000) / 10000;
|
|
180
|
+
/** Jev's { top, bottom, left, right } (fractions of the picture) as [x, y, w, h], or null when it is not a useful crop. */
|
|
181
|
+
function boxOf(ans) {
|
|
182
|
+
const b = ans && ans.box;
|
|
183
|
+
if (!b || typeof b !== 'object') return null;
|
|
184
|
+
const [t, bo, l, ri] = [b.top, b.bottom, b.left, b.right].map(Number);
|
|
185
|
+
if (![t, bo, l, ri].every((v) => Number.isFinite(v) && v >= -0.01 && v <= 1.01)) return null;
|
|
186
|
+
const x = Math.max(0, l), y = Math.max(0, t), w = Math.min(1, ri) - x, h = Math.min(1, bo) - y;
|
|
187
|
+
if (!(w > 0.1) || !(h > 0.1)) return null; // a sliver is not a slide
|
|
188
|
+
if (w * h > 0.95) return null; // the whole frame already: nothing to crop
|
|
189
|
+
return [r4(x), r4(y), r4(w), r4(h)];
|
|
190
|
+
}
|
|
191
|
+
/** What one capture shows, from Jev. Faces are named only when Jev is sure; a crop only for a slide or a document. */
|
|
192
|
+
async function screen({ askImage, key, askOpts = {}, image, root = '' }) {
|
|
193
|
+
const p = pauseOf(root);
|
|
194
|
+
if (!askImage) return { kind: null, reason: 'off' };
|
|
195
|
+
if (p.paused) return { kind: null, reason: 'paused', sentence: p.paused };
|
|
196
|
+
const questions = { kind: { type: 'choice', instructions: SHOT_KIND_Q, criteria: SHOT_KINDS }, area: { type: 'box', instructions: SHOT_BOX_Q } };
|
|
197
|
+
const res = await askImage({ key, image, name: 'capture', questions, ...SHOT_ASK, ...askOpts });
|
|
198
|
+
if (!res.requested) return { kind: null, reason: 'refused', sentence: res.reason || null };
|
|
199
|
+
note(p, res);
|
|
200
|
+
if (!res.ok) return { kind: null, reason: 'failed', sentence: res.reason || null };
|
|
201
|
+
const probs = choiceProbs(res.answers && res.answers.kind);
|
|
202
|
+
const top = Object.entries(probs).filter(([k]) => SHOT_KINDS[k]).sort((a, b) => b[1] - a[1])[0];
|
|
203
|
+
const model = res.model || null;
|
|
204
|
+
if (!top) return { kind: null, reason: 'unreadable' };
|
|
205
|
+
if (top[0] === 'faces' && top[1] >= SURE) return { kind: 'faces', p: r4(top[1]), model };
|
|
206
|
+
if (SHOT_SHOWN.includes(top[0]) && top[1] >= UNSURE_BELOW) {
|
|
207
|
+
const box = boxOf(res.answers && res.answers.area);
|
|
208
|
+
return box ? { kind: top[0], box, p: r4(top[1]), model } : { kind: top[0], p: r4(top[1]), model };
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'unsure', p: r4(top[1]), model };
|
|
211
|
+
}
|
|
212
|
+
|
|
159
213
|
function pauseStatus(root) { const p = pauses.get(root); return p && p.paused ? p.paused : null; }
|
|
160
214
|
|
|
161
|
-
module.exports = { gates, checkGates, gatesFor, checkGatesFor, gateSentence, senderFor, infoFor, getSwitch, setSwitch, label, compare,
|
|
215
|
+
module.exports = { gates, checkGates, gatesFor, checkGatesFor, gateSentence, senderFor, infoFor, getSwitch, setSwitch, onRoots, label, compare, screen, boxOf,
|
|
216
|
+
pictures: { imageSize: typesafe.imageSize, imageRefusal: typesafe.imageRefusal, IMAGE_SIDE: typesafe.IMAGE_SIDE }, choiceProbs, pauseStatus, SHOT_KINDS,
|
|
162
217
|
CATS, UNSURE_BELOW, SURE, REJECTED_LIMIT, UNREACHABLE_LIMIT };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* jev-screen.js — Jev looks at a note's screen captures and keeps only the useful area (batch 27 item 12, board MpJ).
|
|
3
|
+
*
|
|
4
|
+
* run({ runtimeDir, id, g, screen?, jev?, fit? }) → Promise<{ asked, kept, skipped, stopped? }>
|
|
5
|
+
* busy(id) → boolean a run for the note is in progress
|
|
6
|
+
* fitFor(buf, { platform?, sips? }) → Promise<{ buf } | { reason }> a picture in Jev's size range, or why there is none
|
|
7
|
+
*
|
|
8
|
+
* WHAT IT DOES. For each shot the note lists that has no `jev` yet, in meeting-time order, it sends the picture to Jev
|
|
9
|
+
* (lib/jev-notes.js screen, through lib/typesafe.js askImage) and keeps the answer on the shot's row in the index:
|
|
10
|
+
* { kind: 'slide'|'document', box: [x, y, w, h] } the page shows only the box, with the whole-frame icon (premium-mtg-replay.js)
|
|
11
|
+
* { kind: 'faces' } the page leaves the shot out of the captures row
|
|
12
|
+
* { kind: 'slide'|'document'|'unsure' } (no box) the page shows the whole frame, as without Jev
|
|
13
|
+
* { kind: 'skip', why } not sent (out of Jev's size range and no way to resize here); whole frame
|
|
14
|
+
* x, y, w, h are fractions of the picture, so they hold for the kept file at any size.
|
|
15
|
+
*
|
|
16
|
+
* GATES. `g` is routes-notes.js gatesFor: no `askImage` (no key, no consent, the switch "notes may use Jev" off) means no
|
|
17
|
+
* request. Private notes and shots set aside as another app are never sent: the caller passes only an open note, and
|
|
18
|
+
* notes-screen.js list leaves set-aside shots out. It waits for the text reads first, because a text read is what sets a
|
|
19
|
+
* shot aside. A failed ask leaves the row as it was, so the next run asks again; a pause (jev-notes.js) stops the run.
|
|
20
|
+
*
|
|
21
|
+
* SIZE. Jev does not resize: each side 280 to 1568 px. The page captures at most 1568 px a side (notes-screen-capture.js);
|
|
22
|
+
* older shots were 1600 px wide. On a Mac, `sips` (part of macOS) makes a smaller copy in the temp folder, which is sent and
|
|
23
|
+
* deleted. Elsewhere such a shot is marked skip and shows whole.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { execFile } = require('child_process');
|
|
30
|
+
const jevNotes = require('./jev-notes');
|
|
31
|
+
// the size rules come through the notes bridge: only the bridges require lib/typesafe.js (no-network.test.js)
|
|
32
|
+
const typesafe = jevNotes.pictures;
|
|
33
|
+
const notesScreen = require('./notes-screen');
|
|
34
|
+
|
|
35
|
+
const SIPS = '/usr/bin/sips';
|
|
36
|
+
const MAX_PER_RUN = 400;
|
|
37
|
+
const running = new Map(); // note id → the run in progress, so two triggers never ask twice for one shot
|
|
38
|
+
|
|
39
|
+
/** A copy of the picture in Jev's size range, made by sips on a Mac. */
|
|
40
|
+
function fitFor(buf, { platform = process.platform, sips = SIPS } = {}) {
|
|
41
|
+
const size = typesafe.imageSize(buf);
|
|
42
|
+
if (!size) return Promise.resolve({ reason: 'not a PNG or a JPEG' });
|
|
43
|
+
if (!typesafe.imageRefusal(size, buf.length)) return Promise.resolve({ buf });
|
|
44
|
+
const { min, max } = typesafe.IMAGE_SIDE;
|
|
45
|
+
if (size.w < min || size.h < min) return Promise.resolve({ reason: `${size.w} × ${size.h} px is smaller than Jev reads` });
|
|
46
|
+
let can = platform === 'darwin';
|
|
47
|
+
try { fs.accessSync(sips, fs.constants.X_OK); } catch { can = false; }
|
|
48
|
+
if (!can) return Promise.resolve({ reason: `${size.w} × ${size.h} px is larger than Jev reads, and this machine cannot make it smaller` });
|
|
49
|
+
const tmp = path.join(os.tmpdir(), 'pmos-jev-' + crypto.randomBytes(6).toString('hex'));
|
|
50
|
+
const src = tmp + '.' + size.format, out = tmp + '-fit.' + size.format;
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
try { fs.writeFileSync(src, buf, { mode: 0o600 }); } catch { resolve({ reason: 'the temp folder cannot be written' }); return; }
|
|
53
|
+
execFile(sips, ['-Z', String(max), src, '--out', out], { timeout: 20000, windowsHide: true }, (err) => {
|
|
54
|
+
let fit = null;
|
|
55
|
+
try { if (!err) fit = fs.readFileSync(out); } catch { fit = null; }
|
|
56
|
+
for (const f of [src, out]) { try { fs.unlinkSync(f); } catch { /* gone */ } }
|
|
57
|
+
const s2 = fit ? typesafe.imageSize(fit) : null;
|
|
58
|
+
resolve(fit && s2 && !typesafe.imageRefusal(s2, fit.length) ? { buf: fit } : { reason: 'the smaller copy could not be made' });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runNow({ runtimeDir, id, g, screen = notesScreen, jev = jevNotes, fit = fitFor }) {
|
|
64
|
+
const out = { asked: 0, kept: 0, skipped: 0 };
|
|
65
|
+
if (!g || !g.askImage) return { ...out, stopped: 'off' };
|
|
66
|
+
await screen.ocrIdle();
|
|
67
|
+
const todo = screen.list(runtimeDir, id).filter((f) => !f.jev).slice(0, MAX_PER_RUN);
|
|
68
|
+
for (const f of todo) {
|
|
69
|
+
const buf = screen.readShot(runtimeDir, id, f);
|
|
70
|
+
if (!buf) continue;
|
|
71
|
+
const ok = await fit(buf);
|
|
72
|
+
if (!ok.buf) { screen.setJev(runtimeDir, id, f, { kind: 'skip', why: ok.reason }); out.skipped++; continue; }
|
|
73
|
+
out.asked++;
|
|
74
|
+
const r = await jev.screen({ askImage: g.askImage, key: g.key, askOpts: g.askOpts, image: ok.buf, root: g.root });
|
|
75
|
+
if (r.reason === 'paused') return { ...out, stopped: 'paused' };
|
|
76
|
+
if (r.reason === 'refused') { screen.setJev(runtimeDir, id, f, { kind: 'skip', why: r.sentence || 'refused' }); out.skipped++; continue; }
|
|
77
|
+
if (!r.kind) continue; // failed or unreadable: asked again next run
|
|
78
|
+
const row = { kind: r.kind, at: Date.now() };
|
|
79
|
+
if (r.box) row.box = r.box;
|
|
80
|
+
if (r.p != null) row.p = r.p;
|
|
81
|
+
if (r.model) row.model = r.model;
|
|
82
|
+
if (screen.setJev(runtimeDir, id, f, row)) out.kept++;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Ask Jev about the note's shots that have no answer yet. One run per note at a time: a call during a run makes the run
|
|
88
|
+
* go once more at its end, so a shot kept meanwhile is asked about too. */
|
|
89
|
+
function run(a) {
|
|
90
|
+
const cur = running.get(a.id);
|
|
91
|
+
if (cur) { cur.again = a; return cur.p; }
|
|
92
|
+
const st = { again: null, p: null };
|
|
93
|
+
st.p = (async () => {
|
|
94
|
+
let last;
|
|
95
|
+
let next = a;
|
|
96
|
+
while (next) {
|
|
97
|
+
st.again = null;
|
|
98
|
+
last = await runNow(next).catch(() => ({ asked: 0, kept: 0, skipped: 0, stopped: 'error' }));
|
|
99
|
+
next = last.stopped ? null : st.again;
|
|
100
|
+
}
|
|
101
|
+
return last;
|
|
102
|
+
})().finally(() => running.delete(a.id));
|
|
103
|
+
running.set(a.id, st);
|
|
104
|
+
return st.p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether Jev is looking at this note's shots right now (the replay tells the page to read again when it ends). */
|
|
108
|
+
const busy = (id) => running.has(id);
|
|
109
|
+
|
|
110
|
+
module.exports = { run, busy, fitFor, MAX_PER_RUN };
|
|
@@ -107,11 +107,14 @@ function timeOfKey(item, lines) {
|
|
|
107
107
|
|
|
108
108
|
/** What Jev for images said of a shot (batch 27 item 12, MpJ), when a shot's index row holds it: the kind ('content', a slide or a
|
|
109
109
|
* document; 'faces', only people) and the box around the useful area, as fractions of the frame [x, y, w, h]. Anything else is dropped. */
|
|
110
|
+
/* lib/jev-screen.js keeps Jev's kind as slide or document; both are content here */
|
|
111
|
+
const CONTENT = ['content', 'slide', 'document'];
|
|
110
112
|
function jevOf(j) {
|
|
111
|
-
if (!j || typeof j !== 'object' || !
|
|
113
|
+
if (!j || typeof j !== 'object' || !(CONTENT.includes(j.kind) || j.kind === 'faces')) return null;
|
|
112
114
|
const b = Array.isArray(j.box) && j.box.length === 4 && j.box.every((x) => Number.isFinite(x) && x >= 0 && x <= 1) ? j.box.map((x) => Math.round(x * 1000) / 1000) : null;
|
|
113
115
|
const box = b && b[2] >= 0.05 && b[3] >= 0.05 && b[0] + b[2] <= 1.001 && b[1] + b[3] <= 1.001 ? b : null;
|
|
114
|
-
|
|
116
|
+
const kind = j.kind === 'faces' ? 'faces' : 'content';
|
|
117
|
+
return { kind, ...(box && kind === 'content' ? { box } : {}) };
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
/** Join the three records. */
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* list(runtimeDir, id) → [{ t, file, w, h, bytes, why?, text? }] sorted by t; empty when the note has none
|
|
6
6
|
* serve(req, res, runtimeDir, id, t) the shot at t as its image, cached for good; 404 JSON when there is none
|
|
7
7
|
* remove(runtimeDir, id) → boolean delete the note's screenshots
|
|
8
|
+
* readShot(runtimeDir, id, frame) → Buffer | null · setJev(runtimeDir, id, frame, jev) → boolean what Jev saw in a shot (lib/jev-screen.js)
|
|
8
9
|
* getOn(runtimeDir) · setOn(runtimeDir, bool) "Capture the shared screen", on by default, for this machine
|
|
9
10
|
* ocrDetect({ env? }) → { bin } | null the tesseract the PM installed
|
|
10
11
|
* ocrIdle() → Promise resolves when the text queue is empty (tests)
|
|
@@ -294,7 +295,20 @@ function readText(bin, runtimeDir, id, frame) {
|
|
|
294
295
|
});
|
|
295
296
|
});
|
|
296
297
|
}
|
|
298
|
+
/** The bytes of one listed shot, or null. */
|
|
299
|
+
function readShot(runtimeDir, id, frame) {
|
|
300
|
+
if (!ID_RE.test(String(id || '')) || !frame || !FILE_RE.test(String(frame.file || ''))) return null;
|
|
301
|
+
try { return fs.readFileSync(path.join(dirOf(runtimeDir, id), frame.file)); } catch { return null; }
|
|
302
|
+
}
|
|
303
|
+
/** Keep what Jev saw in a shot ({ kind, box?, … }) on its row, only if the same shot is still there and not set aside. */
|
|
304
|
+
function setJev(runtimeDir, id, frame, jev) {
|
|
305
|
+
const rows = readIndex(runtimeDir, id);
|
|
306
|
+
const row = rows.find((f) => f.t === frame.t && f.file === frame.file && f.bytes === frame.bytes && !f.aside);
|
|
307
|
+
if (!row) return false;
|
|
308
|
+
row.jev = jev;
|
|
309
|
+
try { writeIndex(runtimeDir, id, rows); return true; } catch { return false; }
|
|
310
|
+
}
|
|
297
311
|
/** Resolves when every text read queued so far has ended. */
|
|
298
312
|
function ocrIdle() { return queue.then(() => (waiting > 0 ? ocrIdle() : undefined)); }
|
|
299
313
|
|
|
300
|
-
module.exports = { add, list, listAside, asideCount, keep, discard, discardAll, sweepAside, classifyShot, setHints, ASIDE_DAYS, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|
|
314
|
+
module.exports = { add, list, readShot, setJev, listAside, asideCount, keep, discard, discardAll, sweepAside, classifyShot, setHints, ASIDE_DAYS, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|
|
@@ -37,7 +37,7 @@ const actorLib = require('./actor.js');
|
|
|
37
37
|
const RS = require('./report-setups.js');
|
|
38
38
|
const { parseArgs } = require('./schedule-cli');
|
|
39
39
|
|
|
40
|
-
const COMMANDS = { report: ['make', 'read', 'find', 'write', 'check', 'rule', 'draft', 'issue', 'list', 'propose', 'definitions', 'setups', 'finder'] };
|
|
40
|
+
const COMMANDS = { report: ['make', 'read', 'find', 'write', 'check', 'rule', 'draft', 'issue', 'list', 'propose', 'definitions', 'setups', 'finder', 'library'] };
|
|
41
41
|
const USAGE = [
|
|
42
42
|
'usage: pmos report make|read|find|write|check|rule — the report engine: brief, read, find, answer, render (any report, any shape)',
|
|
43
43
|
' pmos report make <id> [--ask "<the question>"] [--readers "<who>"] [--source <kind:ref>,…] [--slot <date>]',
|
|
@@ -54,6 +54,8 @@ const USAGE = [
|
|
|
54
54
|
' pmos report check <id> [--slot <date>] the checks of the last write: substance first, then form',
|
|
55
55
|
' pmos report rule <id> list | add <voice|wording|off|keep> "<text or part>" | remove <n>',
|
|
56
56
|
' What the readers taught this report: a voice or wording rule, or a part beyond the ask turned off or kept.',
|
|
57
|
+
'usage: pmos report library [--out <file>] — the slide library: every layout in the report frame, with placeholder words',
|
|
58
|
+
' Writes one HTML page (default: report-library.html in the current folder). Open it in a browser to check the look.',
|
|
57
59
|
'usage: pmos report draft|issue|list — cadence reports (for example the biweekly)',
|
|
58
60
|
' pmos report draft <cadence> [<slot>] [--now <time>] [--redraft]',
|
|
59
61
|
' Write the draft for a slot (default: today) with every number. Refuses a slot that has a draft;',
|
|
@@ -74,9 +76,9 @@ const USAGE = [
|
|
|
74
76
|
' every command: [--json] [--root <dir>]. The cadence is the report skill id without "report-", for example biweekly.',
|
|
75
77
|
];
|
|
76
78
|
const BOOL = new Set(['json', 'redraft', 'help']);
|
|
77
|
-
const VALUE = new Set(['now', 'root', 'ask', 'readers', 'source', 'slot', 'from']);
|
|
79
|
+
const VALUE = new Set(['now', 'root', 'ask', 'readers', 'source', 'slot', 'from', 'out']);
|
|
78
80
|
const ALLOWED = { make: ['json', 'now', 'root', 'ask', 'readers', 'source', 'slot'], read: ['json', 'now', 'root', 'slot', 'from', 'source'], find: ['json', 'now', 'root', 'slot', 'from'],
|
|
79
|
-
write: ['json', 'now', 'root', 'slot', 'from'], check: ['json', 'root', 'slot'], rule: ['json', 'root'], draft: ['json', 'redraft', 'now', 'root'], issue: ['root'], list: ['json', 'root'], propose: ['json', 'root'], definitions: ['json', 'root'], setups: ['json', 'root'], finder: ['json', 'root'] };
|
|
81
|
+
write: ['json', 'now', 'root', 'slot', 'from'], check: ['json', 'root', 'slot'], rule: ['json', 'root'], draft: ['json', 'redraft', 'now', 'root'], issue: ['root'], list: ['json', 'root'], propose: ['json', 'root'], definitions: ['json', 'root'], setups: ['json', 'root'], finder: ['json', 'root'], library: ['json', 'out'] };
|
|
80
82
|
const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
81
83
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
82
84
|
|
|
@@ -145,6 +147,17 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
145
147
|
return 0;
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
if (verb === 'library') {
|
|
151
|
+
// the slide library as one page: sanitized as a report render is, with the workspace face inlined so the file shows it anywhere
|
|
152
|
+
const out = path.resolve(cwd || process.cwd(), typeof flags.out === 'string' && flags.out ? flags.out : 'report-library.html');
|
|
153
|
+
const D = require('./report-design.js');
|
|
154
|
+
let face = '';
|
|
155
|
+
try { face = `<style>@font-face{font-family:"Plus Jakarta Sans";font-weight:200 800;src:url(data:font/woff2;base64,${fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'plus-jakarta-sans-var.woff2')).toString('base64')}) format("woff2")}body{margin:0;background:#888}.pmos-slide{margin:0 auto 12px}</style>`; } catch { face = ''; }
|
|
156
|
+
const html = D.sanitize(require('./report-library.js').libraryHtml());
|
|
157
|
+
try { fs.writeFileSync(out, html.replace('<style>', face + '<style>')); } catch (e) { return fail(`could not write ${out}: ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
158
|
+
if (json) say(JSON.stringify({ ok: true, path: out })); else say(`Wrote ${out}`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
148
161
|
if (['make', 'read', 'find', 'write', 'check', 'rule'].includes(verb)) return engineVerb(verb, { pos, flags, root, json, say, fail, isTTY, env, stdin, now });
|
|
149
162
|
if (verb === 'definitions' || verb === 'setups' || verb === 'propose' || verb === 'finder') return reportWorkspace(verb, { pos, flags, root, json, say, fail, cwd, isTTY, env, stdin, now });
|
|
150
163
|
|