create-pmos 0.240.0 → 0.242.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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-structure-real-test.js +211 -0
  7. package/workspace/c3-report-structure-test.js +382 -0
  8. package/workspace/guide/console-calendar.md +14 -0
  9. package/workspace/guide/index.md +2 -0
  10. package/workspace/guide/notes.md +22 -1
  11. package/workspace/guide/report-design.md +4 -0
  12. package/workspace/guide/report-structure.md +91 -0
  13. package/workspace/guide/reports-dashboards.md +1 -1
  14. package/workspace/guide/reports-set-up.md +2 -0
  15. package/workspace/lib/charts-store.js +124 -0
  16. package/workspace/lib/headless.js +17 -3
  17. package/workspace/lib/meeting-material.js +151 -0
  18. package/workspace/lib/meeting-replay.js +170 -0
  19. package/workspace/lib/needs-providers.js +1 -0
  20. package/workspace/lib/notes-screen.js +128 -8
  21. package/workspace/lib/report-structure-needs.js +64 -0
  22. package/workspace/lib/report-structure.js +515 -0
  23. package/workspace/lib/route-modules.js +3 -0
  24. package/workspace/lib/routes-meeting-focus.js +5 -3
  25. package/workspace/lib/routes-meeting-material.js +143 -0
  26. package/workspace/lib/routes-meeting-replay.js +75 -0
  27. package/workspace/lib/routes-notes.js +37 -4
  28. package/workspace/lib/routes-report-setups.js +11 -6
  29. package/workspace/lib/routes-report-structure.js +619 -0
  30. package/workspace/package-lock.json +2 -2
  31. package/workspace/package.json +1 -1
  32. package/workspace/public/pm/c3-calendar.js +15 -3
  33. package/workspace/public/pm/c3-demo-material.js +58 -0
  34. package/workspace/public/pm/c3-demo-rd.js +4 -0
  35. package/workspace/public/pm/c3-demo.js +5 -1
  36. package/workspace/public/pm/c3-design-studio.js +7 -3
  37. package/workspace/public/pm/c3-focus.js +83 -16
  38. package/workspace/public/pm/c3-listen.css +10 -0
  39. package/workspace/public/pm/c3-material.css +22 -0
  40. package/workspace/public/pm/c3-material.js +311 -0
  41. package/workspace/public/pm/c3-notes.js +12 -4
  42. package/workspace/public/pm/c3-replay.css +127 -0
  43. package/workspace/public/pm/c3-replay.js +413 -0
  44. package/workspace/public/pm/c3-report-setup.js +3 -0
  45. package/workspace/public/pm/c3-report-structure.css +40 -0
  46. package/workspace/public/pm/c3-report-structure.js +455 -0
  47. package/workspace/public/pm/c3-reports.css +2 -0
  48. package/workspace/public/pm/c3-reports.js +22 -3
  49. package/workspace/public/pm/c3-shell.js +2 -0
  50. package/workspace/public/pm/dest-notes.js +4 -2
  51. package/workspace/public/pm/listen-sheet.js +94 -0
  52. package/workspace/public/pm/listen-sources.js +82 -0
  53. package/workspace/public/pm/notes-aside.js +62 -0
  54. package/workspace/public/pm/notes-screen-capture.js +23 -10
  55. package/workspace/public/pm/notes-screen-view.js +18 -5
  56. package/workspace/public/pm/notes-shots.js +55 -0
  57. package/workspace/public/pm/shell-recording.js +10 -2
  58. package/workspace/public/pm/view-notes.js +48 -8
  59. package/workspace/server.js +38 -9
  60. package/workspace/skills/report-source-check.skill +45 -0
  61. package/workspace/skills/report-structure-dry.skill +51 -0
  62. package/workspace/skills/report-structure.skill +61 -0
  63. package/workspace/skills/report-template.skill +10 -0
  64. package/workspace/test-lists/meeting-material.json +6 -0
  65. package/workspace/test-lists/meeting-replay.json +6 -0
  66. package/workspace/test-lists/mm-capture.json +15 -0
  67. package/workspace/test-lists/report-structure-backend.json +5 -0
  68. package/workspace/test-lists/report-structure-ui.json +11 -0
@@ -8,6 +8,10 @@
8
8
  * getOn(runtimeDir) · setOn(runtimeDir, bool) "Capture the shared screen", on by default, for this machine
9
9
  * ocrDetect({ env? }) → { bin } | null the tesseract the PM installed
10
10
  * ocrIdle() → Promise resolves when the text queue is empty (tests)
11
+ * classifyShot(text, { hints? }) → { aside, app, call } does the text of a shot look like another app and not like the call? Pure.
12
+ * listAside(runtimeDir, id) · asideCount(runtimeDir, id) the shots set aside for review
13
+ * keep(runtimeDir, id, t) · discard(runtimeDir, id, t) · discardAll(runtimeDir, id) the PM's answer to a set-aside shot
14
+ * sweepAside(runtimeDir, { now?, days? }) → n delete the shots set aside for more than 7 days
11
15
  *
12
16
  * WHAT IT KEEPS. A folder next to the note's audio, `<runtime>/notes/recordings/<id>.screen/`: one image file per shot, named by
13
17
  * its meeting time in milliseconds (`000123456.jpg`), and `index.json`, an array of { t, file, w, h, bytes, why?, text? } sorted by t,
@@ -22,6 +26,13 @@
22
26
  * kept shot is read for its text, one at a time and in the background, and the text is added to that shot's row in the index.
23
27
  * A failed or timed-out read leaves the shot with no text and is not an error. Without tesseract nothing is read.
24
28
  *
29
+ * SET ASIDE. When the picture is the whole screen (the shot's `surface` is 'monitor'), every app the PM switches to is on it. After the
30
+ * text is read, a shot whose text looks like another app (Slack, a mailbox, a chat) and not like the call (Zoom's or Meet's controls) or
31
+ * what is shared in it is marked `aside: true` in the index. A set-aside shot is not listed and not served by default, so the note, the
32
+ * replay and the agent never see it. The PM reviews them (listAside) and answers each one: Keep it (the shot joins the note), Delete
33
+ * (file and row go) or Delete all. sweepAside deletes the shots set aside for more than 7 days. A shot with no text, a shot from a
34
+ * window or a tab, and every shot on a machine with no tesseract are kept, as before.
35
+ *
25
36
  * WHAT IT NEVER DOES. It sends nothing and opens no network module. The only program it runs is the tesseract the PM installed,
26
37
  * on a file in the note's own folder, with no shell and a time limit. The routes (lib/routes-notes.js) keep no shot for a private
27
38
  * note, for a note that is not listening, or when the PM switched capturing off, and delete the folder when a note is made
@@ -37,6 +48,8 @@ const MAX_BYTES = 500 * 1024 * 1024;
37
48
  const MAX_T = 999999999; // the file name holds nine digits of milliseconds
38
49
  const OCR_TIMEOUT_MS = 30000;
39
50
  const OCR_MAX_CHARS = 4000;
51
+ const ASIDE_DAYS = 7;
52
+ const SURFACES = ['window', 'browser', 'monitor'];
40
53
  const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
41
54
  const FILE_RE = /^\d{9}\.(jpg|png|webp)$/;
42
55
  const TYPES = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
@@ -80,7 +93,7 @@ const writeIndex = (runtimeDir, id, rows) => writeAtomic(indexOf(runtimeDir, id)
80
93
  const dimension = (n) => (Number.isFinite(Number(n)) && Number(n) > 0 ? Math.min(20000, Math.round(Number(n))) : 0);
81
94
 
82
95
  /** Keep one shot. `t` is the meeting time in ms, `buf` the image bytes, `w` and `h` its size in pixels. */
83
- function add(runtimeDir, { id, t, buf, w, h, why } = {}) {
96
+ function add(runtimeDir, { id, t, buf, w, h, why, surface } = {}) {
84
97
  if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
85
98
  const time = typeof t === 'number' ? t : NaN;
86
99
  if (!Number.isFinite(time) || time < 0 || time > MAX_T) return { ok: false, status: 400, error: 'a screenshot needs its meeting time' };
@@ -106,27 +119,36 @@ function add(runtimeDir, { id, t, buf, w, h, why } = {}) {
106
119
  // why the page kept it (the screen changed, a line pointed at it, a highlight): plain words, kept short
107
120
  const reason = typeof why === 'string' ? why.replace(/\s+/g, ' ').trim().slice(0, 120) : '';
108
121
  if (reason) frame.why = reason;
122
+ // what the picture shows: the meeting's window, a browser tab or the whole screen; only the whole screen is checked for other apps
123
+ if (SURFACES.includes(surface)) frame.surface = surface;
109
124
  writeIndex(runtimeDir, id, others.concat([frame]).sort((x, y) => x.t - y.t));
110
125
  queueOcr(runtimeDir, id, frame);
111
126
  return { ok: true, frame };
112
127
  }
113
128
 
114
- function list(runtimeDir, id) {
129
+ /** The note's shots, by meeting time. A set-aside shot is not in this list. */
130
+ function list(runtimeDir, id, { aside = false } = {}) {
131
+ if (!ID_RE.test(String(id || ''))) return [];
132
+ return readIndex(runtimeDir, id).filter((f) => !!f.aside === !!aside); // { aside: true } lists the set-aside shots instead
133
+ }
134
+ /** The shots set aside for review, by meeting time. */
135
+ function listAside(runtimeDir, id) {
115
136
  if (!ID_RE.test(String(id || ''))) return [];
116
- return readIndex(runtimeDir, id);
137
+ return readIndex(runtimeDir, id).filter((f) => f.aside);
117
138
  }
139
+ const asideCount = (runtimeDir, id) => listAside(runtimeDir, id).length;
118
140
 
119
141
  /** The shot at meeting time `t` as its image. A shot is never changed once kept, so the browser may keep it for good. */
120
- function serve(req, res, runtimeDir, id, t) {
142
+ function serve(req, res, runtimeDir, id, t, { aside = false } = {}) {
121
143
  const miss = () => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no screenshot at that time' })); };
122
144
  if (!ID_RE.test(String(id || '')) || t === null || t === undefined || t === '' || !Number.isFinite(Number(t))) return miss();
123
145
  const at = Math.round(Number(t));
124
- const f = readIndex(runtimeDir, id).find((x) => x.t === at);
146
+ const f = readIndex(runtimeDir, id).find((x) => x.t === at && !!x.aside === !!aside); // a set-aside shot is served only for the review
125
147
  if (!f) return miss();
126
148
  const file = path.join(dirOf(runtimeDir, id), f.file);
127
149
  let size;
128
150
  try { size = fs.statSync(file).size; } catch { return miss(); }
129
- res.writeHead(200, { 'Content-Type': TYPES[path.extname(f.file).slice(1)] || 'application/octet-stream', 'Content-Length': size, 'Cache-Control': 'private, max-age=31536000, immutable' });
151
+ res.writeHead(200, { 'Content-Type': TYPES[path.extname(f.file).slice(1)] || 'application/octet-stream', 'Content-Length': size, 'Cache-Control': aside ? 'private, no-store' : 'private, max-age=31536000, immutable' });
130
152
  if (req.method === 'HEAD') { res.end(); return; }
131
153
  fs.createReadStream(file).on('error', () => res.end()).pipe(res);
132
154
  }
@@ -140,6 +162,95 @@ function remove(runtimeDir, id) {
140
162
  return had;
141
163
  }
142
164
 
165
+ /* ── set aside: the PM's answer ──────────────────────────────────────────────────────────────────── */
166
+ const rmFile = (runtimeDir, id, f) => { try { fs.unlinkSync(path.join(dirOf(runtimeDir, id), f.file)); } catch { /* none */ } };
167
+ const asideRow = (rows, t) => rows.find((f) => f.t === Math.round(Number(t)) && f.aside);
168
+ /** Keep it: the shot joins the note, and it is not checked again. */
169
+ function keep(runtimeDir, id, t) {
170
+ if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
171
+ const rows = readIndex(runtimeDir, id);
172
+ const row = asideRow(rows, t);
173
+ if (!row) return { ok: false, status: 404, error: 'that shot is not set aside' };
174
+ delete row.aside; delete row.asideApp; delete row.asideAt;
175
+ row.kept = true;
176
+ writeIndex(runtimeDir, id, rows);
177
+ return { ok: true, frame: row };
178
+ }
179
+ /** Delete: the file and its row go. Only a set-aside shot can be deleted here. */
180
+ function discard(runtimeDir, id, t) {
181
+ if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
182
+ const rows = readIndex(runtimeDir, id);
183
+ const row = asideRow(rows, t);
184
+ if (!row) return { ok: false, status: 404, error: 'that shot is not set aside' };
185
+ rmFile(runtimeDir, id, row);
186
+ writeIndex(runtimeDir, id, rows.filter((f) => f !== row));
187
+ return { ok: true, deleted: 1 };
188
+ }
189
+ /** Delete all: every set-aside shot of the note. The kept shots stay. */
190
+ function discardAll(runtimeDir, id) {
191
+ if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
192
+ const rows = readIndex(runtimeDir, id);
193
+ const gone = rows.filter((f) => f.aside);
194
+ if (!gone.length) return { ok: true, deleted: 0 };
195
+ for (const f of gone) rmFile(runtimeDir, id, f);
196
+ writeIndex(runtimeDir, id, rows.filter((f) => !f.aside));
197
+ return { ok: true, deleted: gone.length };
198
+ }
199
+ /** Delete every shot set aside for more than `days` days, in every note. Returns how many went. */
200
+ function sweepAside(runtimeDir, { now = Date.now(), days = ASIDE_DAYS } = {}) {
201
+ let names = [];
202
+ try { names = fs.readdirSync(path.join(runtimeDir, 'notes', 'recordings')); } catch { return 0; }
203
+ let n = 0;
204
+ for (const name of names) {
205
+ if (!name.endsWith('.screen')) continue;
206
+ const id = name.slice(0, -'.screen'.length);
207
+ if (!ID_RE.test(id)) continue;
208
+ const rows = readIndex(runtimeDir, id);
209
+ if (!rows.some((f) => f.aside)) continue;
210
+ const left = rows.filter((f) => {
211
+ if (!f.aside || !Number.isFinite(f.asideAt) || now - f.asideAt < days * 864e5) return true;
212
+ rmFile(runtimeDir, id, f); n++; return false;
213
+ });
214
+ if (left.length !== rows.length) { try { writeIndex(runtimeDir, id, left); } catch { /* the folder went away */ } }
215
+ }
216
+ return n;
217
+ }
218
+
219
+ /* ── what looks like another app ─────────────────────────────────────────────────────────────────── */
220
+ // The call: the controls of Zoom, Meet and Teams, and what each says while someone shares. Any one of these keeps the shot.
221
+ const CALL = /\b(unmute|mute|stop video|start video|participants?|share screen|stop share(?:ing)?|new share|you are screen sharing|you are viewing|you'?re presenting|stop presenting|present now|presenting|raise hand|lower hand|breakout rooms?|leave meeting|leave call|end meeting|meeting controls|zoom meeting|zoom workplace|meet\.google\.com|google meet|turn (?:on|off) camera|turn (?:on|off) microphone|live captions|waiting room|in this meeting|hide self view|gallery view|speaker view)\b/i;
222
+ // Another app. `strong`: one is enough. `weak`: two different ones are needed.
223
+ const OTHER = [
224
+ { app: 'Slack', strong: [/\bslack\b/i], weak: [/\bdirect messages\b/i, /\bchannels\b/i, /\bthreads\b/i, /\bhuddles?\b/i, /\bmentions (?:&|and) reactions\b/i, /\bmessage #[a-z0-9_-]+/i] },
225
+ { app: 'Gmail', strong: [/\bgmail\b/i, /\binbox\b/i], weak: [/\bcompose\b/i, /\bstarred\b/i, /\bsnoozed\b/i, /\bsent mail\b/i, /\bdrafts\b/i, /\bprimary\b/i, /\bpromotions\b/i] },
226
+ { app: 'Outlook', strong: [/\boutlook\b/i], weak: [/\bnew mail\b/i, /\bjunk email\b/i, /\bsent items\b/i, /\bfocused\b/i] },
227
+ { app: 'WhatsApp', strong: [/\bwhatsapp\b/i], weak: [] },
228
+ { app: 'Telegram', strong: [/\btelegram\b/i], weak: [] },
229
+ { app: 'Discord', strong: [/\bdiscord\b/i], weak: [] },
230
+ { app: 'Messages', strong: [/\bimessage\b/i, /\bmessenger\b/i], weak: [] },
231
+ { app: 'Social media', strong: [/\bfacebook\b/i, /\binstagram\b/i, /\btwitter\b/i, /\blinkedin\b/i, /\btiktok\b/i], weak: [] },
232
+ { app: 'Video or music', strong: [/\byoutube\b/i, /\bnetflix\b/i, /\bspotify\b/i], weak: [] },
233
+ ];
234
+
235
+ /** Does the text of a shot look like another app and not like the call? Pure.
236
+ * → { aside, app, call }. `hints` are words from what is shared in the meeting (a deck's title, say): a shot that names one is kept.
237
+ * Text that is empty, or that names no app, is never set aside. */
238
+ function classifyShot(text, { hints = [] } = {}) {
239
+ const t = String(text || '');
240
+ if (!t.trim()) return { aside: false, app: null, call: false };
241
+ if (CALL.test(t)) return { aside: false, app: null, call: true };
242
+ const low = t.toLowerCase();
243
+ for (const h of hints || []) { const w = String(h || '').trim().toLowerCase(); if (w.length >= 4 && low.includes(w)) return { aside: false, app: null, call: false }; }
244
+ for (const o of OTHER) {
245
+ if (o.strong.some((re) => re.test(t)) || o.weak.filter((re) => re.test(t)).length >= 2) return { aside: true, app: o.app, call: false };
246
+ }
247
+ return { aside: false, app: null, call: false };
248
+ }
249
+ // the words of what is shared in the meeting, for classifyShot: set by whoever knows them (the material of the meeting), else none
250
+ let hintsOf = null;
251
+ /** fn(runtimeDir, id) → [word]: what is shared in this note's meeting. Optional. */
252
+ function setHints(fn) { hintsOf = typeof fn === 'function' ? fn : null; }
253
+
143
254
  /* ── on-screen text ──────────────────────────────────────────────────────────────────────────────── */
144
255
  const isExec = (f) => { try { fs.accessSync(f, fs.constants.X_OK); return fs.statSync(f).isFile(); } catch { return false; } };
145
256
  /** The tesseract on this machine: PMOS_TESSERACT, else the PATH, else where Homebrew puts it. */
@@ -168,7 +279,16 @@ function readText(bin, runtimeDir, id, frame) {
168
279
  // patch the row only if the same shot is still there (it may have been replaced or deleted while it was read)
169
280
  const rows = readIndex(runtimeDir, id);
170
281
  const row = rows.find((f) => f.t === frame.t && f.file === frame.file && f.bytes === frame.bytes);
171
- if (row) { row.text = text; try { writeIndex(runtimeDir, id, rows); } catch { /* the folder went away */ } }
282
+ if (row) {
283
+ row.text = text;
284
+ // the whole screen was the picture: a shot that looks like another app is set aside, for the PM to keep or delete
285
+ if (row.surface === 'monitor' && !row.kept) {
286
+ let hints = []; try { hints = hintsOf ? hintsOf(runtimeDir, id) || [] : []; } catch { hints = []; }
287
+ const c = classifyShot(text, { hints });
288
+ if (c.aside) { row.aside = true; row.asideApp = c.app; row.asideAt = Date.now(); }
289
+ }
290
+ try { writeIndex(runtimeDir, id, rows); } catch { /* the folder went away */ }
291
+ }
172
292
  }
173
293
  resolve();
174
294
  });
@@ -177,4 +297,4 @@ function readText(bin, runtimeDir, id, frame) {
177
297
  /** Resolves when every text read queued so far has ended. */
178
298
  function ocrIdle() { return queue.then(() => (waiting > 0 ? ocrIdle() : undefined)); }
179
299
 
180
- module.exports = { add, list, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
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 };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /* report-structure-needs.js — the needs provider of the report structure step (initiative report-structure, contract section 8).
3
+ * lib/needs-providers.js folds these rows into server.js deriveNeeds, and from there into /api/needs, the Briefing and Work.
4
+ *
5
+ * kinds: report-source-check
6
+ * rowsFor(w, { lead }) → rows · sig(w) → string
7
+ *
8
+ * report-source-check one row for each Chrome or computer source that failed its check while a scheduled draft waits for it:
9
+ * "Check <source> for <report>". The draft waits up to 4 hours (lib/routes-report-structure.js sweep), then is drafted
10
+ * without the source. The row goes when the draft starts. Kind 8 (needs repair); the act is Check again on the report.
11
+ *
12
+ * The rows are repository-wide, so a tree that is not the lead returns []. Nothing here writes.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const RD = require('./report-draft.js');
17
+
18
+ const NAME = 'report-structure-needs';
19
+
20
+ function holds(root) {
21
+ const out = [];
22
+ let base;
23
+ try { base = path.join(path.resolve(String(root)), RD.stateRel(root), 'reports'); } catch { return out; }
24
+ let ids = []; try { ids = fs.readdirSync(base); } catch { return out; }
25
+ for (const id of ids) {
26
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(id) || id === 'definitions') continue;
27
+ const dir = path.join(base, id, 'structure');
28
+ let hold = null; try { hold = JSON.parse(fs.readFileSync(path.join(dir, 'hold.json'), 'utf8')); } catch { hold = null; }
29
+ if (!hold || typeof hold !== 'object') continue;
30
+ out.push({ id, dir, hold });
31
+ }
32
+ return out;
33
+ }
34
+ const readJson = (f) => { try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return o && typeof o === 'object' ? o : null; } catch { return null; } };
35
+
36
+ function rowsFor(w, { lead } = {}) {
37
+ if (!lead || !w || !w.root) return [];
38
+ const rows = [];
39
+ for (const { id, dir, hold } of holds(w.root)) {
40
+ const since = Date.parse(hold.since);
41
+ let name = id;
42
+ try { const s = require('./report-setups.js').stateOf(w.root).setups.get(id); if (s && s.name) name = s.name; } catch { /* the id is the name */ }
43
+ let plan = null;
44
+ try { const v = readJson(path.join(dir, 'versions', `S${hold.saved}.json`)); plan = v; } catch { plan = null; }
45
+ for (const sid of Array.isArray(hold.sources) ? hold.sources : []) {
46
+ const c = readJson(path.join(dir, 'checks', `${sid}.json`));
47
+ if (!c || c.running || c.ok || !(Date.parse(c.at) >= since - 1000)) continue;
48
+ const src = plan && Array.isArray(plan.sources) ? plan.sources.find((s) => s.id === sid) : null;
49
+ const step = (c.steps || []).find((s) => s.state === 'needs-you');
50
+ rows.push({
51
+ run_id: `${NAME}:${id}:${sid}`, kind: 'report-source-check', initiative_id: null, question: `Check ${(src && src.name) || sid} for ${name}`, doc: null, ws: w.id,
52
+ today: { kind: 8, source: NAME, cause: step ? `${step.label}: ${step.reason || 'needs you'}` : 'The check did not pass', evidence: `${path.relative(path.resolve(w.root), dir).split(path.sep).join('/')}/checks/${sid}.json`,
53
+ act: { label: 'Check again', cli: null } },
54
+ });
55
+ }
56
+ }
57
+ return rows;
58
+ }
59
+ function sig(w) {
60
+ if (!w || !w.root) return '';
61
+ return holds(w.root).map((h) => `${h.id}:${h.hold.since}:${(Array.isArray(h.hold.sources) ? h.hold.sources : []).map((s) => { try { return fs.statSync(path.join(h.dir, 'checks', `${s}.json`)).mtimeMs; } catch { return 0; } }).join(',')}`).join('|');
62
+ }
63
+
64
+ module.exports = { kinds: ['report-source-check'], rowsFor, sig };