create-pmos 0.196.0 → 0.198.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 (50) 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-ib-items-test.js +16 -8
  7. package/workspace/c3-ib-more-test.js +3 -2
  8. package/workspace/c3-ib-triage-backlog-test.js +1 -1
  9. package/workspace/c3-quiet-test.js +4 -3
  10. package/workspace/c3-report-design-real-test.js +225 -0
  11. package/workspace/c3-report-design-test.js +403 -0
  12. package/workspace/c3-report-setup-test.js +3 -2
  13. package/workspace/c3-work-test.js +1 -1
  14. package/workspace/guide/index.md +1 -0
  15. package/workspace/guide/initiative-boards.md +9 -5
  16. package/workspace/guide/report-design.md +104 -0
  17. package/workspace/guide/report-slides.md +3 -1
  18. package/workspace/guide/reports-set-up.md +2 -1
  19. package/workspace/lib/headless.js +24 -6
  20. package/workspace/lib/report-design.js +741 -0
  21. package/workspace/lib/report-draft.js +8 -3
  22. package/workspace/lib/report-setups.js +1 -1
  23. package/workspace/lib/route-modules.js +1 -0
  24. package/workspace/lib/routes-doc-sheet.js +8 -1
  25. package/workspace/lib/routes-report-design.js +481 -0
  26. package/workspace/lib/routes-reports.js +2 -1
  27. package/workspace/package-lock.json +2 -2
  28. package/workspace/package.json +1 -1
  29. package/workspace/public/pm/c3-demo-rd.js +426 -0
  30. package/workspace/public/pm/c3-demo-rep2.js +2 -0
  31. package/workspace/public/pm/c3-demo-rs.js +5 -1
  32. package/workspace/public/pm/c3-design-studio.js +308 -0
  33. package/workspace/public/pm/c3-doc-formats.js +3 -2
  34. package/workspace/public/pm/c3-doc-sheet.js +30 -5
  35. package/workspace/public/pm/c3-ib-items.js +26 -17
  36. package/workspace/public/pm/c3-ib-releases.js +4 -7
  37. package/workspace/public/pm/c3-ib-sprints.js +5 -8
  38. package/workspace/public/pm/c3-ib-triage.js +4 -9
  39. package/workspace/public/pm/c3-ini-life.css +2 -0
  40. package/workspace/public/pm/c3-initiatives.js +2 -1
  41. package/workspace/public/pm/c3-quiet.css +1 -1
  42. package/workspace/public/pm/c3-quiet.js +4 -5
  43. package/workspace/public/pm/c3-report-deck.css +139 -0
  44. package/workspace/public/pm/c3-report-deck.js +558 -0
  45. package/workspace/public/pm/c3-report-frame.css +20 -0
  46. package/workspace/public/pm/c3-report-setup.js +18 -6
  47. package/workspace/server.js +17 -5
  48. package/workspace/skills/report-design.skill +61 -0
  49. package/workspace/test-lists/report-design-backend.json +5 -0
  50. package/workspace/test-lists/report-design-studio-ui.json +5 -0
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * draftOf(root, { cadence, slot, now, health }) → the fixtures/report-api/draft.json shape | { ok: false, status, error }
7
7
  * render(draft, { notes }) → markdown; each block carries its note markers (below)
8
- * writeDraft(root, draft, { actor, notes }) → { ok: true, path, sha } | { ok: false, status, error }
8
+ * writeDraft(root, draft, { actor, notes, fresh, quiet }) → { ok: true, path, sha } | { ok: false, status, error }
9
+ * onDraftWritten(fn) → unsubscribe; fn({ root, cadence, slot, path }) after each write that is not `quiet` (a note save)
9
10
  * readNotes(markdown) → { [initiativeId]: text } (frozen here, not a stub)
10
11
  * noteBlock(id, text) → the marker pair with the text between (frozen here)
11
12
  *
@@ -583,7 +584,10 @@ function render(draft, { notes } = {}) {
583
584
  * passes none still keeps them. `fresh: true` (`pmos report draft` without --redraft) refuses a slot that has a draft
584
585
  * already (409), from local state only (X18). An issued slot is frozen: 409.
585
586
  */
586
- function writeDraft(root, draft, { actor, notes, fresh } = {}) {
587
+ const written = new Set(); // report-design-studio: listeners told after a draft is written (the cycle render hook)
588
+ /** Call fn({ root, cadence, slot, path }) after each draft this process writes (not a note save, which passes `quiet`). → an unsubscribe function */
589
+ function onDraftWritten(fn) { if (typeof fn === 'function') written.add(fn); return () => written.delete(fn); }
590
+ function writeDraft(root, draft, { actor, notes, fresh, quiet } = {}) {
587
591
  if (!draft || draft.ok === false) return draft && draft.status ? draft : refuse(400, 'there is no draft to write');
588
592
  if (!actorLib.valid(actor)) return refuse(403, 'a draft is written by an actor from the call path; nothing was written');
589
593
  if (!isStr(draft.cadence) || !CADENCE_RE.test(draft.cadence) || !isDate(draft.slot)) return refuse(400, 'the draft has no valid cadence and slot');
@@ -617,10 +621,11 @@ function writeDraft(root, draft, { actor, notes, fresh } = {}) {
617
621
  try { fs.unlinkSync(tmp); } catch { /* not made */ }
618
622
  return refuse(500, 'could not write the draft; nothing was written: ' + String((e && e.message) || e).slice(0, 160));
619
623
  }
624
+ if (!quiet) for (const fn of written) { try { fn({ root: abs, cadence: draft.cadence, slot: draft.slot, path: rel }); } catch { /* a listener never fails the write */ } }
620
625
  return { ok: true, path: rel, sha: issues.blobSha(Buffer.from(md, 'utf8')) };
621
626
  }
622
627
 
623
628
  module.exports = {
624
- draftOf, render, writeDraft, readNotes, noteBlock, NOTE_OPEN, NOTE_CLOSE, NOTE_RE, NOTE_ID_RE,
629
+ draftOf, render, writeDraft, onDraftWritten, readNotes, noteBlock, NOTE_OPEN, NOTE_CLOSE, NOTE_RE, NOTE_ID_RE,
625
630
  readDefinition, stateRel, draftPath, definitionPath, FRAME, HEADLINE_NOTE, ASKS_NOTE, CHANGELOG_NOTE, SLIDES_OPEN,
626
631
  };
@@ -339,7 +339,7 @@ const SECTION_NOTES = Object.freeze({
339
339
  needs: 'Needs your call. Asks to the readers in `report.asks`: what, who, by when. Write "None this cycle" when there are none; never drop the section.',
340
340
  sources: 'Sources reached and not reached.',
341
341
  });
342
- const OUTPUT_WORDS = Object.freeze({ 'google-slides': 'a Google Slides deck', 'google-doc': 'a Google Doc', 'google-sheets': 'a Google Sheets spreadsheet', confluence: 'a Confluence page', pdf: 'a PDF' });
342
+ const OUTPUT_WORDS = Object.freeze({ 'google-slides': 'a slide deck in PMOS', 'google-doc': 'a Google Doc', 'google-sheets': 'a Google Sheets spreadsheet', confluence: 'a Confluence page', pdf: 'a PDF' });
343
343
 
344
344
  function scheduleSentence(s, now) {
345
345
  const first = firstSlot(s, now);
@@ -72,6 +72,7 @@ const MODULES = Object.freeze([
72
72
  'routes-scan', // horizon-scan-portfolio: one scan for the workspace: the watch list and setting, Scan now and Stop, report documents and their proposals (approve, change, dismiss, undo), plus the first scan's per-board and Briefing reads (scan.jsonl)
73
73
  'routes-report-setups', // report-workspace: recurring reports, the Ask box, the finder, the report documents and their export (report-setups.jsonl)
74
74
  'routes-core-docs', // core-documents: the core documents of each level, Draft it (a core-doc run), Issue it and Drop (core-docs.jsonl)
75
+ 'routes-report-design', // report-design-studio: the design studio (describe, samples, turns, versions, This is it), cycle renders and decisions, speaker notes and pins
75
76
  ]);
76
77
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
77
78
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -13,7 +13,8 @@
13
13
  * POST /api/doc-sheet/change-note { path, downstream? } a draft body only; nothing is sent
14
14
  * GET /api/doc-sheet/proposals?path= → { ok, proposals: [{ id, state, prompt, at, hash, after, sections: [{ name, kind, stale }] }] }
15
15
  * GET /api/doc-sheet/log?path= → { ok, revisions, events } the edit log: revisions (with a reason added later) and
16
- * the other entries (proposed, rejected, asked, marked current)
16
+ * the other entries (proposed, rejected, asked, marked current; a report's pins and design
17
+ * decisions from report-notes.jsonl, report-design-studio)
17
18
  * POST /api/doc-sheet/reject { path, id, why } page press; a why is required; the proposal is closed, the file untouched
18
19
  * POST /api/doc-sheet/why { path, rev, why } page press; a reason for a revision that has none (an outside change);
19
20
  * appended to annotations.jsonl, the revision itself is not rewritten
@@ -246,6 +247,12 @@ function logRoute(root, q, runtimeDir) {
246
247
  else if (a.event === 'failed') events.push({ kind: 'ask-failed', at: a.at, id: a.id, why: a.error, by: 'agent' });
247
248
  }
248
249
  for (const m of readLines(path.join(dirOf(runtimeDir), 'marks.jsonl'))) if (m && m.path === r.rel) events.push({ kind: 'marked', at: m.at, why: m.why, by: 'pm' });
250
+ // report-design-studio: pins on a designed report's slides and the decisions on a cycle that differs from its design (lib/routes-report-design.js appends them)
251
+ for (const n of readLines(path.join(dirOf(runtimeDir), 'report-notes.jsonl'))) {
252
+ if (!n || n.path !== r.rel) continue;
253
+ if (n.event === 'pin') events.push({ kind: 'pin', at: n.at, by: n.by === 'agent' ? 'agent' : 'pm', op: n.op, id: n.id, slide: n.slide, text: n.text || '', ...(n.section ? { section: n.section } : {}) });
254
+ else if (n.event === 'design') events.push({ kind: 'design', at: n.at, by: 'pm', choice: n.choice, diff: n.diff, what: n.what || null });
255
+ }
249
256
  return { status: 200, body: { ok: true, path: r.rel, revisions: revsOut(runtimeDir, r.rel), events } };
250
257
  }
251
258
 
@@ -0,0 +1,481 @@
1
+ 'use strict';
2
+ /* routes-report-design.js — the report design studio's routes, its report-design jobs and the cycle hook (initiative report-design-studio).
3
+ *
4
+ * GET /api/report-design?ws&id → { ok, setup: { id, name, source, output }, design } (lib/report-design.js designAnswer)
5
+ * GET /api/report-design/version?ws&id&n → text/html, the sanitized render of D<n>
6
+ * POST /api/report-design/describe { ws, id, text, format? }
7
+ * POST /api/report-design/sample { ws, id, name, kind, data } | { ws, id, link, name? } | { ws, id, doc, name? } → { ok, sample }
8
+ * POST /api/report-design/turn { ws, id, text, points: [{ n, slide, x, y, text }] } → { ok, running } ONE job per report; 409 while one runs
9
+ * POST /api/report-design/accept { ws, id, n } → { ok, design, brief } "This is it": writes brief.md
10
+ * POST /api/report-design/revert { ws, id, n } → { ok, design }
11
+ * GET /api/report-design/cycle?ws&path → { ok, designed, design_version, format, outline, differences, decisions, matches, rendering, previous }
12
+ * GET /api/report-design/cycle-html?ws&path → text/html, the sanitized cycle render
13
+ * POST /api/report-design/cycle-decision { ws, path, diff, choice: keep | adopt | match } → { ok, cycle }
14
+ * GET /api/report-notes?ws&path → { ok, speaker, suggestions, pins }
15
+ * POST /api/report-notes/speaker { ws, path, slide, text } | { ws, path, slide, keep: true }
16
+ * POST /api/report-notes/pin { ws, path, op, id?, slide, x, y, text, section? }
17
+ *
18
+ * WHO WRITES. Every POST passes pm-guard requirePagePress, and a request with an agent session header is refused first (403), as
19
+ * routes-report-setups.js does. A job's render is data: the server sanitizes it (lib/report-design.js sanitize) before it is written or served.
20
+ *
21
+ * THE JOB. A turn, a cycle render and a re-render to match each start ONE headless run (lib/headless.js kind 'report-design', origin
22
+ * `report-design:<cadence>`), in the report's folder, with the skill skills/report-design.skill and a block that names the files. The run may
23
+ * write one file, design/pending/<job>.html (argsFor writePaths), and prints a JSON result: { answer, took[], rules, html? }. When it ends the
24
+ * server reads the pending file (or `html`), sanitizes it and writes D<n> (a turn) or <slot>.design.html/.json (a cycle). A failed, stopped or
25
+ * timed-out run, or a render with no section, appends an 'error' line with the reason. One job per report at a time; a cycle that is drafted
26
+ * while a job runs waits in a queue and starts when the job ends.
27
+ *
28
+ * THE CYCLE HOOK. attach(ctxFactory) listens to report-draft.js writeDraft: a draft written in this process for a report whose design is saved
29
+ * starts a `cycle` job (not a note save, and never an issued slot). sweep(ctx), on the server tick, catches a draft another process wrote (the
30
+ * schedule's `pmos report draft`): a slot written after the design was saved that has no render yet. A slot whose render failed for the same
31
+ * document text is not tried again until the document changes.
32
+ *
33
+ * THE EDIT LOG. Each pin operation and each cycle decision appends a line to <runtime>/doc-sheet/report-notes.jsonl, which the document sheet's
34
+ * Edit log reads (routes-doc-sheet.js logRoute): { event: 'pin', path, at, by, op, id, slide, text, section? } and
35
+ * { event: 'design', path, at, by, choice, diff, what }.
36
+ */
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const crypto = require('crypto');
40
+ const guard = require('./pm-guard.js');
41
+ const D = require('./report-design.js');
42
+ const RS = require('./report-setups.js');
43
+ const RC = require('./report-checks.js');
44
+ const RDraft = require('./report-draft.js');
45
+ const headless = require('./headless.js');
46
+
47
+ const text = (v) => (typeof v === 'string' ? v : '');
48
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
49
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
50
+ const DESIGN_POSTS = ['describe', 'sample', 'turn', 'accept', 'revert', 'cycle-decision'];
51
+ const NOTES_POSTS = ['speaker', 'pin'];
52
+ const HTML_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:";
53
+
54
+ const jobs = new Map(); // `${root}\0${cadence}` → { id, mode, since, turn, slotPath, sessionId }
55
+ const queue = new Map(); // `${root}\0${cadence}` → [rel, …] cycles waiting for the job to end
56
+ let ctxFactory = null;
57
+ let lastCtx = null;
58
+ const keyOf = (root, cadence) => `${path.resolve(String(root))}\u0000${cadence}`;
59
+
60
+ function wsOf(ctx, id) {
61
+ const w = ctx.getWs(id || null);
62
+ return w && (!id || w.id === id) ? w : null;
63
+ }
64
+ function setupOf(root, id) {
65
+ let st = null;
66
+ try { st = RS.stateOf(root); } catch { st = null; }
67
+ const s = st && st.setups.get(id);
68
+ if (s && s.status !== 'removed') return { id: s.id, name: s.name, source: s.source, output: s.output };
69
+ // A report with no setup (the hand-written cadences) can still be designed when its folder has a cycle.
70
+ try { if (D.currentCycle(root, id)) return { id, name: id, source: 'work', output: null }; } catch { /* not a report id */ }
71
+ return null;
72
+ }
73
+ function runningOf(root, cadence) {
74
+ const j = jobs.get(keyOf(root, cadence));
75
+ return j ? { since: j.since, turn: j.turn } : null;
76
+ }
77
+
78
+ // ── the job ─────────────────────────────────────────────────────────────────────────────────────────────────
79
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
80
+ function promptFor(skill, { mode, root, cadence, setup, st, turnText, points, cycleRel, pending }) {
81
+ const p = D.paths(root, cadence);
82
+ const abs = path.resolve(String(root));
83
+ const lines = [`- Mode: ${mode}`, `- Report: ${setup.name} (${cadence})`, `- Today: ${new Date().toISOString().slice(0, 10)}`];
84
+ const cur = st.current != null ? D.versionMeta(root, cadence, st.current) : null;
85
+ const format = (mode === 'turn' ? st.format : null) || (cur && cur.format) || st.format || 'deck';
86
+ lines.push(`- Format: ${format === 'doc' ? 'doc (a document of pmos-sec sections)' : 'deck (pmos-slide slides on 1280 x 720)'}`);
87
+ lines.push(`- Description: ${st.description ? st.description.replace(/\s+/g, ' ') : 'none'}`);
88
+ const samples = [...st.samples.values()];
89
+ if (mode === 'turn') {
90
+ lines.push(samples.length ? '- Samples:' : '- Samples: none');
91
+ for (const s of samples) {
92
+ const f = D.sampleReadPath(root, cadence, s);
93
+ const where = s.kind === 'link' ? `a link: ${s.source} (you have no web tool)` : f ? `read ${f}` : 'its file could not be read';
94
+ lines.push(` - ${s.id}${s.took ? '' : ' (NEW)'} "${s.name}", ${s.kind}: ${where}`);
95
+ }
96
+ lines.push(`- Turn: ${turnText ? turnText.replace(/\s+/g, ' ') : 'none (make the first version from the description and the samples)'}`);
97
+ if (points && points.length) {
98
+ lines.push('- Point markers:');
99
+ for (const pt of points) lines.push(` - ${pt.n}. slide ${pt.slide} at x ${pt.x}%, y ${pt.y}%: ${pt.text.replace(/\s+/g, ' ')}`);
100
+ } else lines.push('- Point markers: none');
101
+ lines.push(`- Previous version: ${cur ? path.join(p.versions, `D${cur.n}.html`) : 'none (this is the first version)'}`);
102
+ } else {
103
+ lines.push(`- Reference version: ${path.join(p.versions, `D${st.saved.version}.html`)}`);
104
+ lines.push(`- Brief: ${fs.existsSync(p.brief) ? p.brief : 'none'}`);
105
+ }
106
+ const doc = cycleRel ? path.join(abs, cycleRel) : null;
107
+ lines.push(`- Current cycle document: ${doc || 'none'}`);
108
+ lines.push(`- Output path: ${pending}`);
109
+ return `${skill}\n\nThis job:\n${lines.join('\n')}\n\nWrite the render to the output path, then print the JSON result. Send nothing.\r`;
110
+ }
111
+
112
+ /** The JSON object in a run's final message: the message itself, a fenced block, or the outermost braces. */
113
+ function resultJson(t) {
114
+ const s = String(t || '').trim();
115
+ const tries = [s];
116
+ const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
117
+ if (fence) tries.push(fence[1].trim());
118
+ const a = s.indexOf('{'); const b = s.lastIndexOf('}');
119
+ if (a >= 0 && b > a) tries.push(s.slice(a, b + 1));
120
+ for (const x of tries) { try { const o = JSON.parse(x); if (isObj(o)) return o; } catch { /* next */ } }
121
+ return null;
122
+ }
123
+
124
+ /** Start a job. spec: { mode: turn | cycle | match, turnText, points, cycleRel }. → { ok, running } | { ok: false, status, error } */
125
+ function startJob(ctx, w, cadence, spec) {
126
+ const root = w.root;
127
+ const key = keyOf(root, cadence);
128
+ if (jobs.has(key)) return { ok: false, status: 409, error: 'PMOS is already working on this report. Wait for the answer, then try again.' };
129
+ if (typeof ctx.spawnSession !== 'function') return { ok: false, status: 409, error: 'this workspace cannot start background runs' };
130
+ const origin = `report-design:${cadence}`;
131
+ if (headless.kindFor({ agent: 'claude', origin }) !== 'report-design') return { ok: false, status: 409, error: 'Background runs are switched off here (PMOS_HEADLESS=off), so PMOS cannot make the design.' };
132
+ const skill = typeof ctx.skillPrompt === 'function' ? ctx.skillPrompt('report-design', w) : null;
133
+ if (!skill) return { ok: false, status: 500, error: 'the report-design skill is not installed' };
134
+ const setup = setupOf(root, cadence) || { id: cadence, name: cadence };
135
+ const st = D.stateOf(root, cadence);
136
+ if (spec.mode !== 'turn' && !st.saved) return { ok: false, status: 409, error: 'this report has no saved design' };
137
+ const p = D.paths(root, cadence);
138
+ const id = crypto.randomBytes(5).toString('hex');
139
+ const pending = path.join(p.pending, `${id}.html`);
140
+ try { fs.mkdirSync(p.pending, { recursive: true }); } catch (e) { return { ok: false, status: 500, error: 'could not make the pending folder: ' + String((e && e.code) || e) }; }
141
+ const cycleRel = spec.cycleRel || (D.currentCycle(root, cadence) || {}).path || null;
142
+ const slot = spec.cycleRel ? (D.cyclePaths(root, spec.cycleRel) || {}).slot : null;
143
+ const job = { id, mode: spec.mode, since: new Date().toISOString(), turn: spec.mode === 'turn' ? (spec.turnText || 'First version') : `Rendering the ${slot} cycle${spec.mode === 'match' ? ' to match the design' : ''}`,
144
+ slotPath: spec.mode === 'turn' ? null : spec.cycleRel, sha: spec.cycleRel ? D.docSha(path.join(path.resolve(root), spec.cycleRel)) : null, pending, sessionId: null };
145
+ jobs.set(key, job);
146
+ let s = null;
147
+ try {
148
+ s = ctx.spawnSession({ name: spec.mode === 'turn' ? 'Report design' : 'Report design · cycle', agent: 'claude', ws: w.id, origin, cwd: p.rel,
149
+ initialInput: promptFor(skill, { mode: spec.mode, root, cadence, setup, st, turnText: spec.turnText, points: spec.points, cycleRel, pending }),
150
+ writePaths: [pending], onDone: (r) => finish(ctx, w, cadence, job, r) });
151
+ } catch (e) { s = { state: 'exited', note: String((e && e.message) || e) }; }
152
+ if (!s || s.state === 'exited' || s.headless !== 'report-design') {
153
+ jobs.delete(key);
154
+ if (s && s.headless !== 'report-design' && s.state !== 'exited' && typeof ctx.stopSession === 'function') { try { ctx.stopSession(s.id); } catch { /* best effort */ } }
155
+ return { ok: false, status: 409, error: 'PMOS could not start the design run: ' + ((s && s.note) || 'no background run') };
156
+ }
157
+ job.sessionId = s.id;
158
+ return { ok: true, running: { since: job.since, turn: job.turn } };
159
+ }
160
+
161
+ function finish(ctx, w, cadence, job, r) {
162
+ const root = w.root;
163
+ const key = keyOf(root, cadence);
164
+ if (jobs.get(key) === job) jobs.delete(key);
165
+ const slot = job.slotPath ? (D.cyclePaths(root, job.slotPath) || {}).slot : undefined;
166
+ const fail = (why) => { try { D.append(root, cadence, { ev: 'error', text: why, mode: job.mode, ...(slot ? { slot, sha: job.sha } : {}) }); } catch { /* the folder is gone */ } };
167
+ try {
168
+ const res = (r && r.result) || {};
169
+ const ok = !!(r && !r.timedOut && !r.stopped && r.code === 0 && res.ok && !res.isError);
170
+ const out = resultJson(res.text);
171
+ let html = null;
172
+ try { html = fs.readFileSync(job.pending, 'utf8'); } catch { html = null; }
173
+ if (!html && out && typeof out.html === 'string') html = out.html;
174
+ if (!ok && !html) {
175
+ const why = r && r.timedOut ? `The design run took too long and was stopped (${r.note || 'timed out'}).` : r && r.stopped ? 'The design run was stopped.'
176
+ : `The design run failed: ${(r && r.note) || 'no result'}${res.text ? '. ' + String(res.text).replace(/\s+/g, ' ').slice(0, 300) : ''}`;
177
+ fail(why);
178
+ } else if (!html) fail('The design run ended without a render. Nothing changed.');
179
+ else if (job.mode === 'turn') {
180
+ const v = D.writeVersion(root, cadence, html, { turn: job.turn, answer: (out && typeof out.answer === 'string' && out.answer) || null, rules: out && out.rules, from: 'turn' });
181
+ if (!v.ok) fail(`The render could not be used: ${v.error}. Nothing changed.`);
182
+ if (out && Array.isArray(out.took)) for (const t of out.took.slice(0, 40)) if (isObj(t)) D.recordTook(root, cadence, text(t.id), t.took, typeof t.readable === 'boolean' ? t.readable : undefined);
183
+ } else {
184
+ const st = D.stateOf(root, cadence);
185
+ if (!st.saved) fail('The design was not saved any more, so the cycle was not rendered.');
186
+ else {
187
+ const c = D.writeCycle(root, job.slotPath, html, { designVersion: st.saved.version, mode: job.mode, sha: job.sha });
188
+ if (!c.ok) fail(`The ${slot} cycle render could not be used: ${c.error}.`);
189
+ }
190
+ }
191
+ } catch (e) { fail('The design run result could not be read: ' + String((e && e.message) || e).slice(0, 200)); }
192
+ try { fs.unlinkSync(job.pending); } catch { /* not written */ }
193
+ // The next cycle that waited for this job.
194
+ const q = queue.get(key);
195
+ if (q && q.length) {
196
+ const next = q.shift();
197
+ if (!q.length) queue.delete(key);
198
+ startCycle(ctx, w, cadence, next);
199
+ }
200
+ }
201
+
202
+ // ── the cycle hook ──────────────────────────────────────────────────────────────────────────────────────────
203
+ /** Start (or queue) the cycle render of one drafted slot, when the report's design is saved and the slot is not issued. */
204
+ function startCycle(ctx, w, cadence, rel) {
205
+ try {
206
+ const c = D.cyclePaths(w.root, rel);
207
+ if (!c || !fs.existsSync(c.md)) return { ok: false, error: 'no such cycle' };
208
+ const st = D.stateOf(w.root, cadence);
209
+ if (!st.saved) return { ok: false, error: 'no saved design' };
210
+ if (D.isIssuedSlot(w.root, cadence, c.slot)) return { ok: false, error: 'issued' };
211
+ const key = keyOf(w.root, cadence);
212
+ if (jobs.has(key)) {
213
+ const j = jobs.get(key);
214
+ if (j.slotPath === c.rel && j.sha === D.docSha(c.md)) return { ok: true, queued: false };
215
+ const q = queue.get(key) || [];
216
+ if (!q.includes(c.rel)) q.push(c.rel);
217
+ queue.set(key, q);
218
+ return { ok: true, queued: true };
219
+ }
220
+ return startJob(ctx, w, cadence, { mode: 'cycle', cycleRel: c.rel });
221
+ } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
222
+ }
223
+ function workspaceForRoot(ctx, root) {
224
+ const abs = path.resolve(String(root));
225
+ const list = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [ctx.getWs(null)].filter(Boolean);
226
+ return list.find((w) => w && w.root && path.resolve(w.root) === abs) || null;
227
+ }
228
+ /** server.js calls this once: drafts written in this process start their cycle render at once. */
229
+ function attach(factory) {
230
+ ctxFactory = typeof factory === 'function' ? factory : null;
231
+ RDraft.onDraftWritten(({ root, cadence, path: rel }) => {
232
+ setImmediate(() => {
233
+ try {
234
+ const ctx = ctxFactory ? ctxFactory() : lastCtx;
235
+ if (!ctx) return;
236
+ const w = workspaceForRoot(ctx, root);
237
+ if (w) startCycle(ctx, w, cadence, rel);
238
+ } catch { /* the sweep tries again */ }
239
+ });
240
+ });
241
+ }
242
+ /** On the tick: a slot drafted after the design was saved, with no render, is rendered (a draft the CLI wrote). */
243
+ function sweep(ctx) {
244
+ if (!ctx) return [];
245
+ lastCtx = ctx;
246
+ const started = [];
247
+ const list = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [ctx.getWs(null)].filter(Boolean);
248
+ for (const w of list) {
249
+ if (!w || !w.root) continue;
250
+ let base;
251
+ try { base = path.join(path.resolve(w.root), RDraft.stateRel(w.root), 'reports'); } catch { continue; }
252
+ let cadences = [];
253
+ try { cadences = fs.readdirSync(base); } catch { continue; }
254
+ for (const cadence of cadences) {
255
+ if (!D.CADENCE_RE.test(cadence) || cadence === 'definitions') continue;
256
+ let p; try { p = D.paths(w.root, cadence); } catch { continue; }
257
+ if (!fs.existsSync(p.events)) continue;
258
+ const st = D.stateOf(w.root, cadence);
259
+ if (!st.saved) continue;
260
+ const savedMs = Date.parse(st.saved.at);
261
+ let files = [];
262
+ try { files = fs.readdirSync(p.folder); } catch { continue; }
263
+ for (const f of files) {
264
+ if (!/^\d{4}-\d{2}-\d{2}\.md$/.test(f)) continue;
265
+ const rel = `${p.rel}/${f}`;
266
+ const c = D.cyclePaths(w.root, rel);
267
+ if (!c || fs.existsSync(c.json)) continue;
268
+ let mt = 0; try { mt = fs.statSync(c.md).mtimeMs; } catch { continue; }
269
+ if (mt < savedMs) continue;
270
+ const sha = D.docSha(c.md);
271
+ if (st.errors.some((e) => e.slot === c.slot && e.sha === sha)) continue;
272
+ const r = startCycle(ctx, w, cadence, rel);
273
+ if (r.ok) started.push(rel);
274
+ }
275
+ }
276
+ }
277
+ return started;
278
+ }
279
+
280
+ // ── the Edit log line ───────────────────────────────────────────────────────────────────────────────────────
281
+ function editLog(ctx, row) {
282
+ if (!ctx.runtimeDir) return false;
283
+ try {
284
+ const dir = path.join(ctx.runtimeDir, 'doc-sheet');
285
+ fs.mkdirSync(dir, { recursive: true });
286
+ fs.appendFileSync(path.join(dir, 'report-notes.jsonl'), JSON.stringify({ ...row, at: new Date().toISOString() }) + '\n');
287
+ return true;
288
+ } catch { return false; }
289
+ }
290
+
291
+ /** A turn that has no answer and no job in this server (the workspace restarted mid-run) gets an error line. */
292
+ function settleStale(root, cadence) {
293
+ if (jobs.has(keyOf(root, cadence))) return;
294
+ const ev = D.readEvents(root, cadence);
295
+ let lastTurn = -1; let lastEnd = -1;
296
+ ev.forEach((l, i) => { if (l.ev === 'turn') lastTurn = i; if (l.ev === 'version' || l.ev === 'error') lastEnd = i; });
297
+ if (lastTurn > lastEnd && Date.now() - Date.parse(ev[lastTurn].at) > 90000) D.append(root, cadence, { ev: 'error', text: 'The design run stopped when the workspace restarted. Send the turn again.', mode: 'turn' });
298
+ }
299
+
300
+ // ── the handler ─────────────────────────────────────────────────────────────────────────────────────────────
301
+ function sendHtml(res, html) {
302
+ const buf = Buffer.from(String(html), 'utf8');
303
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': buf.length, 'Cache-Control': 'no-store', 'Content-Security-Policy': HTML_CSP, 'X-Content-Type-Options': 'nosniff' });
304
+ res.end(buf);
305
+ return true;
306
+ }
307
+ function reportDoc(w, rel) {
308
+ if (!RC.isReportPath(rel)) return { error: bad(400, 'path must be a report document: <state>/reports/<cadence>/<slot>.md') };
309
+ const c = D.cyclePaths(w.root, rel);
310
+ if (!c) return { error: bad(400, 'path must be a report document') };
311
+ try {
312
+ const real = fs.realpathSync(c.md);
313
+ if (!real.startsWith(fs.realpathSync(path.resolve(w.root)) + path.sep)) return { error: bad(404, 'outside workspace') };
314
+ } catch { return { error: bad(404, 'no such report document') }; }
315
+ return { c };
316
+ }
317
+ function cycleAnswer(w, c) {
318
+ const st = D.stateOf(w.root, c.cadence);
319
+ const rec = D.readCycle(w.root, c.rel);
320
+ const j = jobs.get(keyOf(w.root, c.cadence));
321
+ const prev = D.previousCycle(w.root, c.rel);
322
+ const differences = rec && Array.isArray(rec.differences) ? rec.differences : [];
323
+ // designed: THIS cycle has a render drawn to a saved design (the contract's answer is per document). A cycle drafted before the design,
324
+ // or one whose first render is still running, is not designed yet: the page reads it in its format view and fetches no cycle-html.
325
+ return { ok: true, designed: !!st.saved && !!rec, design_version: rec ? rec.design_version : null,
326
+ format: (rec && rec.format) || (st.saved && (D.versionMeta(w.root, c.cadence, st.saved.version) || {}).format) || st.format || 'deck',
327
+ outline: rec && Array.isArray(rec.outline) ? rec.outline : [], differences, decisions: rec && Array.isArray(rec.decisions) ? rec.decisions : [],
328
+ matches: !!rec && differences.length === 0, rendering: !!(j && j.slotPath === c.rel), previous: prev ? { path: prev.path, slot: prev.slot } : null };
329
+ }
330
+
331
+ async function handle(req, res, url, ctx) {
332
+ lastCtx = ctx;
333
+ const p = url.pathname;
334
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
335
+ const isNotes = p === '/api/report-notes' || p.startsWith('/api/report-notes/');
336
+ const sub = isNotes ? p.replace(/^\/api\/report-notes\/?/, '') : p.replace(/^\/api\/report-design\/?/, '');
337
+
338
+ if (req.method === 'GET') {
339
+ const w = wsOf(ctx, url.searchParams.get('ws'));
340
+ if (!w) return send(bad(404, 'no such workspace'));
341
+ if (isNotes) {
342
+ if (sub !== '') return send(bad(404, 'no such route'));
343
+ const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
344
+ const n = D.notesOf(w.root, d.c.rel);
345
+ return send(n.ok ? { status: 200, body: n } : bad(n.status, n.error));
346
+ }
347
+ if (sub === '' || sub === 'version') {
348
+ const id = text(url.searchParams.get('id'));
349
+ if (!D.CADENCE_RE.test(id)) return send(bad(400, 'id must be a report id'));
350
+ const setup = setupOf(w.root, id);
351
+ if (!setup) return send(bad(404, 'no such report'));
352
+ if (sub === 'version') {
353
+ const n = Number(url.searchParams.get('n'));
354
+ const html = Number.isInteger(n) && n > 0 ? D.versionHtml(w.root, id, n) : null;
355
+ if (html == null) return send(bad(404, 'no such version'));
356
+ return sendHtml(res, D.sanitize(html));
357
+ }
358
+ try { settleStale(w.root, id); } catch { /* a read never fails on this */ }
359
+ return send({ status: 200, body: { ok: true, setup, design: D.designAnswer(w.root, id, { running: runningOf(w.root, id) }) } });
360
+ }
361
+ if (sub === 'cycle' || sub === 'cycle-html') {
362
+ const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
363
+ if (sub === 'cycle') return send({ status: 200, body: cycleAnswer(w, d.c) });
364
+ let html; try { html = fs.readFileSync(d.c.html, 'utf8'); } catch { return send(bad(404, 'this cycle has no render yet')); }
365
+ return sendHtml(res, D.sanitize(html));
366
+ }
367
+ return send(bad(404, 'no such route'));
368
+ }
369
+
370
+ if (!(isNotes ? NOTES_POSTS : DESIGN_POSTS).includes(sub)) return send(bad(404, 'no such route'));
371
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
372
+ const read = await ctx.readJson(req);
373
+ if (!read.ok) return send(bad(400, 'the body is not JSON'));
374
+ const body = read.body || {};
375
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot change a report design, its cycles or its notes. Only a press on the page does'));
376
+ const g = guard.requirePagePress(req, body);
377
+ if (!g.ok) return send(bad(g.status, g.error));
378
+ const w = wsOf(ctx, text(body.ws));
379
+ if (!w) return send(bad(404, 'no such workspace'));
380
+ const root = w.root;
381
+ const fail = (r) => send(bad(r.status || 400, r.error));
382
+
383
+ if (isNotes) {
384
+ const d = reportDoc(w, text(body.path)); if (d.error) return send(d.error);
385
+ if (sub === 'speaker') {
386
+ const r = D.setSpeaker(root, d.c.rel, { slide: body.slide, text: body.text, keep: body.keep === true });
387
+ if (!r.ok) return fail(r);
388
+ } else {
389
+ const r = D.pinOp(root, d.c.rel, body, { by: 'you' });
390
+ if (!r.ok) return fail(r);
391
+ editLog(ctx, { event: 'pin', path: d.c.rel, by: 'pm', op: r.op, id: r.pin.id, slide: r.pin.slide, text: r.pin.text, ...(r.pin.section ? { section: r.pin.section } : {}) });
392
+ }
393
+ return send({ status: 200, body: D.notesOf(root, d.c.rel) });
394
+ }
395
+
396
+ if (sub === 'cycle-decision') {
397
+ const d = reportDoc(w, text(body.path)); if (d.error) return send(d.error);
398
+ const c = d.c;
399
+ const choice = text(body.choice);
400
+ if (!D.CHOICES.includes(choice)) return send(bad(400, 'choice must be keep, adopt or match'));
401
+ if (D.isIssuedSlot(root, c.cadence, c.slot)) return send(bad(409, 'this cycle is issued and frozen with the design it was rendered with'));
402
+ const st = D.stateOf(root, c.cadence);
403
+ if (!st.saved) return send(bad(409, 'this report has no saved design'));
404
+ const rec = D.readCycle(root, c.rel);
405
+ if (!rec) return send(bad(404, 'this cycle has no render yet'));
406
+ const diffs = Array.isArray(rec.differences) ? rec.differences : [];
407
+ const diff = diffs.find((x) => x.id === text(body.diff));
408
+ if (!diff) return send(bad(404, 'no such difference on this cycle'));
409
+ let newVersion = null;
410
+ if (choice === 'match') {
411
+ const s = startJob(ctx, w, c.cadence, { mode: 'match', cycleRel: c.rel });
412
+ if (!s.ok) return fail(s);
413
+ } else if (choice === 'adopt') {
414
+ let html; try { html = fs.readFileSync(c.html, 'utf8'); } catch { return send(bad(404, 'this cycle has no render to adopt')); }
415
+ const v = D.writeVersion(root, c.cadence, html, { turn: `Made part of the design from the ${c.slot} cycle: ${diff.what}`, answer: `D${D.stateOf(root, c.cadence).versions.size + 1} is the design now, from the ${c.slot} cycle.`,
416
+ rules: D.rulesFor(root, c.cadence, st.saved.version), from: 'adopt', actor: g.actor });
417
+ if (!v.ok) return fail(v);
418
+ newVersion = v.n;
419
+ D.append(root, c.cadence, { ev: 'accept', n: v.n, rules: D.rulesFor(root, c.cadence, v.n) }, { actor: g.actor });
420
+ D.writeBrief(root, c.cadence, setupOf(root, c.cadence));
421
+ }
422
+ const r = D.decideCycle(root, c.rel, { diff: diff.id, choice, newVersion });
423
+ if (!r.ok) return fail(r);
424
+ D.append(root, c.cadence, { ev: 'cycle-decision', path: c.rel, slot: c.slot, diff: diff.id, choice }, { actor: g.actor });
425
+ editLog(ctx, { event: 'design', path: c.rel, by: 'pm', choice, diff: diff.id, what: diff.what });
426
+ return send({ status: 200, body: { ...cycleAnswer(w, c), cycle: r.cycle } });
427
+ }
428
+
429
+ const id = text(body.id);
430
+ if (!D.CADENCE_RE.test(id)) return send(bad(400, 'id must be a report id'));
431
+ const setup = setupOf(root, id);
432
+ if (!setup) return send(bad(404, 'no such report'));
433
+ const answer = () => send({ status: 200, body: { ok: true, setup, design: D.designAnswer(root, id, { running: runningOf(root, id) }) } });
434
+
435
+ if (sub === 'describe') {
436
+ const t = text(body.text).trim();
437
+ if (!t || t.length > 4000) return send(bad(400, 'text is required, at most 4000 characters'));
438
+ if (body.format !== undefined && !D.FORMATS.includes(body.format)) return send(bad(400, 'format must be deck or doc'));
439
+ D.append(root, id, { ev: 'describe', text: t, ...(body.format ? { format: body.format } : {}) }, { actor: g.actor });
440
+ return answer();
441
+ }
442
+ if (sub === 'sample') {
443
+ const r = D.addSample(root, id, body, { actor: g.actor });
444
+ if (!r.ok) return fail(r);
445
+ return send({ status: 200, body: { ok: true, sample: r.sample } });
446
+ }
447
+ if (sub === 'turn') {
448
+ if (jobs.has(keyOf(root, id))) return send(bad(409, 'PMOS is already working on this report. Wait for the answer, then try again.'));
449
+ const t = text(body.text).trim();
450
+ if (t.length > 4000) return send(bad(400, 'text is at most 4000 characters'));
451
+ if (body.points !== undefined && !Array.isArray(body.points)) return send(bad(400, 'points must be a list'));
452
+ const raw = Array.isArray(body.points) ? body.points : [];
453
+ if (raw.length > 20) return send(bad(400, 'at most 20 point markers in one turn'));
454
+ const points = [];
455
+ for (const [i, pt] of raw.entries()) {
456
+ if (!isObj(pt) || !D.SLIDE_ID_RE.test(text(pt.slide)) || typeof pt.x !== 'number' || typeof pt.y !== 'number' || !Number.isFinite(pt.x) || !Number.isFinite(pt.y) || !text(pt.text).trim() || text(pt.text).length > 1000) {
457
+ return send(bad(400, `point ${i + 1} needs slide (a slide id), x and y (numbers) and text`));
458
+ }
459
+ points.push({ n: Number.isInteger(pt.n) ? pt.n : i + 1, slide: pt.slide, x: Math.round(pt.x * 10) / 10, y: Math.round(pt.y * 10) / 10, text: text(pt.text).trim() });
460
+ }
461
+ const st = D.stateOf(root, id);
462
+ if (!t && !points.length && (st.versions.size || !st.description)) return send(bad(400, 'say what to change, or point at the preview'));
463
+ if (!D.currentCycle(root, id)) return send(bad(409, 'this report has no cycle yet. Draft it first, then design how it looks'));
464
+ D.append(root, id, { ev: 'turn', text: t, points, base: st.current }, { actor: g.actor });
465
+ const s = startJob(ctx, w, id, { mode: 'turn', turnText: t, points });
466
+ if (!s.ok) { D.append(root, id, { ev: 'error', text: s.error, mode: 'turn' }); return fail(s); }
467
+ return send({ status: 200, body: { ok: true, running: s.running } });
468
+ }
469
+ if (sub === 'accept' || sub === 'revert') {
470
+ const n = body.n;
471
+ if (!Number.isInteger(n) || !D.stateOf(root, id).versions.has(n)) return send(bad(404, 'no such version'));
472
+ if (sub === 'revert') { D.append(root, id, { ev: 'revert', n }, { actor: g.actor }); return answer(); }
473
+ D.append(root, id, { ev: 'accept', n, rules: D.rulesFor(root, id, n) }, { actor: g.actor });
474
+ const b = D.writeBrief(root, id, setup);
475
+ if (!b.ok) return fail(b);
476
+ return send({ status: 200, body: { ok: true, setup, brief: b.path, design: D.designAnswer(root, id, { running: runningOf(root, id) }) } });
477
+ }
478
+ return send(bad(404, 'no such route'));
479
+ }
480
+
481
+ module.exports = { prefixes: ['/api/report-design', '/api/report-notes'], handle, attach, sweep, startCycle, resultJson, _jobs: jobs };
@@ -145,7 +145,8 @@ async function handle(req, res, url, ctx) {
145
145
  const notes = { ...RD.readNotes(file.text), [id]: body.text };
146
146
  const draft = RD.draftOf(root, { cadence: cs.cadence, slot: cs.slot, now: foldedAt(file.text) || undefined });
147
147
  if (!draft || draft.ok === false) return failed(draft || { status: 500, error: 'the drafter answered nothing' });
148
- const r = RD.writeDraft(root, draft, { actor: g.actor, notes });
148
+ // quiet: a note save is not a new draft, so it does not re-render the cycle's design (report-design-studio)
149
+ const r = RD.writeDraft(root, draft, { actor: g.actor, notes, quiet: true });
149
150
  if (!r || r.ok === false) return failed(r || { status: 500, error: 'the writer answered nothing' });
150
151
  return done({ slot: cs.slot, path: r.path, sha: r.sha, id });
151
152
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.196.0",
3
+ "version": "0.198.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.196.0",
9
+ "version": "0.198.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.196.0",
3
+ "version": "0.198.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",