create-pmos 0.178.0 → 0.180.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 (59) 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-setup-test.js +190 -0
  7. package/workspace/c3-reports-test.js +30 -97
  8. package/workspace/guide/console-calendar.md +17 -2
  9. package/workspace/guide/document-sheet.md +14 -0
  10. package/workspace/guide/index.md +3 -2
  11. package/workspace/guide/reports-dashboards.md +17 -28
  12. package/workspace/guide/reports-page.md +10 -5
  13. package/workspace/guide/reports-set-up.md +56 -0
  14. package/workspace/guide/reports.md +13 -1
  15. package/workspace/guide/settings.md +11 -2
  16. package/workspace/lib/doc-sync.js +28 -6
  17. package/workspace/lib/docs-cli.js +7 -11
  18. package/workspace/lib/four-panel.js +1 -1
  19. package/workspace/lib/headless.js +21 -2
  20. package/workspace/lib/notes-series.js +357 -0
  21. package/workspace/lib/notes.js +53 -4
  22. package/workspace/lib/report-checks.js +103 -0
  23. package/workspace/lib/report-cli.js +89 -4
  24. package/workspace/lib/report-export.js +347 -0
  25. package/workspace/lib/report-finder.js +156 -0
  26. package/workspace/lib/report-needs.js +1 -1
  27. package/workspace/lib/report-setups.js +743 -0
  28. package/workspace/lib/route-modules.js +1 -0
  29. package/workspace/lib/routes-c3-reports.js +35 -17
  30. package/workspace/lib/routes-doc-health.js +15 -1
  31. package/workspace/lib/routes-notes.js +27 -2
  32. package/workspace/lib/routes-report-setups.js +368 -0
  33. package/workspace/lib/schedule-logic.js +5 -2
  34. package/workspace/package-lock.json +2 -2
  35. package/workspace/package.json +1 -1
  36. package/workspace/public/pm/c3-demo-rep2.js +15 -0
  37. package/workspace/public/pm/c3-demo-rs.js +281 -0
  38. package/workspace/public/pm/c3-demo.js +1 -1
  39. package/workspace/public/pm/c3-doc-formats.js +152 -0
  40. package/workspace/public/pm/c3-doc-sheet.js +115 -25
  41. package/workspace/public/pm/c3-notes.js +299 -34
  42. package/workspace/public/pm/c3-quiet.js +2 -2
  43. package/workspace/public/pm/c3-report-print.css +20 -0
  44. package/workspace/public/pm/c3-report-setup.css +62 -0
  45. package/workspace/public/pm/c3-report-setup.js +260 -0
  46. package/workspace/public/pm/c3-report-store.js +208 -0
  47. package/workspace/public/pm/c3-reports.css +4 -0
  48. package/workspace/public/pm/c3-reports.js +47 -181
  49. package/workspace/public/pm/c3-settings.js +4 -3
  50. package/workspace/public/pm/c3-shell.js +15 -31
  51. package/workspace/public/pm/console3.css +13 -0
  52. package/workspace/run-tests.js +25 -1
  53. package/workspace/server.js +66 -5
  54. package/workspace/skills/doc-sync.skill +17 -8
  55. package/workspace/skills/report-find.skill +75 -0
  56. package/workspace/skills/report-setup.skill +67 -0
  57. package/workspace/test-files.json +12 -0
  58. package/workspace/test-lists/c3-notes-series.json +5 -0
  59. package/workspace/test-lists/report-workspace-ui.json +5 -0
@@ -70,6 +70,7 @@ const MODULES = Object.freeze([
70
70
  'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
71
71
  'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
72
72
  'routes-scan', // horizon-scan: a board's watch list, cadence and last scan, the day's scan for the Briefing, the PM's watch and cadence presses (scan.jsonl)
73
+ 'routes-report-setups', // report-workspace: recurring reports, the Ask box, the finder, the report documents and their export (report-setups.jsonl)
73
74
  ]);
74
75
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
75
76
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -9,6 +9,7 @@
9
9
  * POST /api/report-dashboards/schedule { key, plan, audience } → { ok, key, schedule, schedules } plan: none|weekly|monthly; none removes it.
10
10
  * 400 on a bad key, plan or audience; 403 without a page press. (lib/report-schedules.js)
11
11
  * runDue(ctx, now) the server tick: each due schedule appends ONE draft to the outbox. Nothing is sent.
12
+ * A key that is a recurring report now (report-workspace) is skipped: lib/routes-report-setups.js drafts it.
12
13
  *
13
14
  * Initiatives come from the workspace's facts (lib/facts-async.js), like routes-calendar-views.js. Key results, bets and hypotheses
14
15
  * come from the same records as routes-c3-strategy.js. The handlers are pure over their inputs so a test needs no server.
@@ -91,10 +92,10 @@ function titleOf(key, c) {
91
92
  }
92
93
 
93
94
  /**
94
- * Run one schedule: the dashboard's data for its scope, its current layout (or the scope's default), the frame and text the Share sheet
95
- * makes, then ONE outbox draft. deps { facts, slug, build(today), hypotheses, runtimeDir, today }. Returns { ok, entry } | { ok:false, error }.
95
+ * The frame of a dashboard (its data, its current layout or the scope's default, the frame the Share sheet makes): { ok, F } | { ok:false, error }.
96
+ * sched { key, audience }; deps { facts, slug, build(today), hypotheses, runtimeDir, today }; `title` replaces the dashboard's own title.
96
97
  */
97
- function runOne(sched, deps, now = new Date()) {
98
+ function frameFor(sched, deps, title) {
98
99
  const key = sched.key;
99
100
  const scope = key === 'portfolio' ? 'portfolio' : key.slice(0, key.indexOf(':'));
100
101
  const id = scope === 'portfolio' ? null : key.slice(key.indexOf(':') + 1);
@@ -104,11 +105,35 @@ function runOne(sched, deps, now = new Date()) {
104
105
  const ids = Object.prototype.hasOwnProperty.call(layouts, key) ? layouts[key] : rd.DEFAULTS[scope];
105
106
  const reports = ids.map((x) => rd.LIBRARY.find((l) => l.id === x)).filter(Boolean);
106
107
  const c = catalogue(deps.facts, deps.slug, deps.index);
107
- const F = RF.frameOf({ title: titleOf(key, c), reports, raw: r.body, audience: sched.audience, today: r.body.today });
108
- try { return { ok: true, entry: rs.makeDraft(deps.runtimeDir, sched, F, RF.draftBody(F), now) }; }
108
+ return { ok: true, F: RF.frameOf({ title: title || titleOf(key, c), reports, raw: r.body, audience: sched.audience, today: r.body.today }) };
109
+ }
110
+
111
+ /** Run one schedule: the frame, then ONE outbox draft. deps as frameFor. Returns { ok, entry } | { ok:false, error }. */
112
+ function runOne(sched, deps, now = new Date()) {
113
+ const f = frameFor(sched, deps);
114
+ if (!f.ok) return f;
115
+ try { return { ok: true, entry: rs.makeDraft(deps.runtimeDir, sched, f.F, RF.draftBody(f.F), now) }; }
109
116
  catch (e) { return { ok: false, error: 'the draft could not be saved: ' + String((e && e.message) || e) }; }
110
117
  }
111
118
 
119
+ /** The deps frameFor needs for one workspace, read now. ctx { getWs, runtimeDir, today, readFacts, build }. The tick here and lib/routes-report-setups.js use it. */
120
+ async function depsFor(ctx, wsId) {
121
+ const w = ctx.getWs(wsId || null);
122
+ if (!w) throw new Error('no workspace is registered');
123
+ const slug = w.id || (w.root ? path.basename(path.resolve(w.root)) : null);
124
+ const facts = await (ctx.readFacts || readFacts)(w);
125
+ const root = rootOf(ctx, wsId || null);
126
+ let hypotheses = [];
127
+ try { hypotheses = require(repoScript('records/index.js')).list(root, 'hypotheses'); } catch { hypotheses = []; }
128
+ return { facts, slug, hypotheses, runtimeDir: ctx.runtimeDir, today: ctx.today,
129
+ build: ctx.build || ((today) => strategy.build(root, path.join(ctx.runtimeDir, 'signals', 'signals.jsonl'), today)) };
130
+ }
131
+
132
+ /** True when the key is a recurring report now (lib/report-setups.js dashboardKeys). A repository that cannot be read holds nothing. */
133
+ function handled(ctx, s) {
134
+ try { const w = ctx.getWs(s.ws || null); return !!w && require('./report-setups.js').dashboardKeys(w.root).has(s.key); } catch { return false; }
135
+ }
136
+
112
137
  /** The tick. lastRun moves first, so a slow read never runs the same slot twice; the outcome is written after. */
113
138
  let running = false;
114
139
  async function runDue(ctx, now = new Date()) {
@@ -117,19 +142,12 @@ async function runDue(ctx, now = new Date()) {
117
142
  const done = [];
118
143
  try {
119
144
  for (const s of rs.dueOf(rs.readSchedules(ctx.runtimeDir), now)) {
145
+ // A key that became a recurring report (lib/report-setups.js) is drafted there, as a document. This tick leaves it alone.
146
+ if (handled(ctx, s)) continue;
120
147
  rs.markRun(ctx.runtimeDir, s.key, { lastRun: now.toISOString() });
121
148
  let out;
122
- try {
123
- const w = ctx.getWs(s.ws || null);
124
- if (!w) throw new Error('no workspace is registered');
125
- const slug = w.id || (w.root ? path.basename(path.resolve(w.root)) : null);
126
- const facts = await (ctx.readFacts || readFacts)(w);
127
- const root = rootOf(ctx, s.ws || null);
128
- let hypotheses = [];
129
- try { hypotheses = require(repoScript('records/index.js')).list(root, 'hypotheses'); } catch { hypotheses = []; }
130
- out = runOne(s, { facts, slug, hypotheses, runtimeDir: ctx.runtimeDir, today: ctx.today,
131
- build: ctx.build || ((today) => strategy.build(root, path.join(ctx.runtimeDir, 'signals', 'signals.jsonl'), today)) }, now);
132
- } catch (e) { out = { ok: false, error: String((e && e.message) || e) }; }
149
+ try { out = runOne(s, await depsFor(ctx, s.ws || null), now); }
150
+ catch (e) { out = { ok: false, error: String((e && e.message) || e) }; }
133
151
  rs.markRun(ctx.runtimeDir, s.key, out.ok ? { last_status: 'ok', last_error: null, last_draft: out.entry.id } : { last_status: 'failed', last_error: out.error });
134
152
  done.push({ key: s.key, ...out });
135
153
  }
@@ -146,7 +164,7 @@ async function readFacts(w) {
146
164
 
147
165
  module.exports = {
148
166
  prefixes: ['/api/report-dashboards'],
149
- catalogue, list, data, layout, schedule, runOne, runDue,
167
+ catalogue, list, data, layout, schedule, frameFor, depsFor, runOne, runDue,
150
168
  handle: async (req, res, url, ctx) => {
151
169
  const p = url.pathname;
152
170
  const send = (r) => { ctx.json(res, r.body, r.status); return true; };
@@ -8,12 +8,14 @@
8
8
  * of every relative Markdown link it cites (a save or a "still current" mark in the document sheet counts as a change), and the last change of each ADR a `D<n>` id in its text names. Everything
9
9
  * else (requiredHeadings, data, readers, current, staleAfterDays) comes only from `context` in the body; a check without
10
10
  * its input answers "not enough input". No write routes in this wave.
11
+ * A document under reports/<cadence>/ also gets the three report checks (lib/report-checks.js) appended to `health.checks`; passed and total count them.
11
12
  */
12
13
  const fs = require('fs');
13
14
  const path = require('path');
14
15
  const { spawnSync } = require('child_process');
15
16
  const { repoScript } = require('./repo-script.js');
16
17
  const H = require(repoScript('doc-health.js'));
18
+ const RC = require('./report-checks.js');
17
19
 
18
20
  const ROOT = path.resolve(__dirname, '..', '..');
19
21
  const bad = (error, status = 400) => ({ status, body: { ok: false, error } });
@@ -71,7 +73,19 @@ function check(root, body, runtimeDir) {
71
73
  const context = { ...given, sources: sources.length ? sources : given.sources,
72
74
  decisionChanges: Object.keys(decisionChanges).length ? decisionChanges : given.decisionChanges };
73
75
  const doc = { text, type: given.type || body.type, changed };
74
- return { status: 200, body: { ok: true, path: relOut, health: H.healthOf(doc, context) } };
76
+ const health = H.healthOf(doc, context);
77
+ // A report document (<state>/reports/<cadence>/<slot>.md) is also held to three report checks (lib/report-checks.js): numbers have a source,
78
+ // dates are written in full, and Decisions and Risk watch are present when the report's definition lists them.
79
+ if (RC.isReportPath(relOut)) {
80
+ let listed = [];
81
+ try { listed = RC.listedOf(fs.readFileSync(path.join(path.dirname(path.dirname(full)), 'definitions', RC.cadenceOfPath(relOut) + '.md'), 'utf8')); } catch { listed = []; }
82
+ const extra = RC.reportChecks(text, { listed });
83
+ health.checks = health.checks.concat(extra);
84
+ const answered = health.checks.filter((k) => k.pass !== null);
85
+ health.passed = answered.filter((k) => k.pass).length;
86
+ health.total = answered.length;
87
+ }
88
+ return { status: 200, body: { ok: true, path: relOut, health } };
75
89
  }
76
90
 
77
91
  module.exports = {
@@ -63,6 +63,13 @@
63
63
  * POST /api/notes/wispr-fetch { id } → { ok, wispr } the PM asks for Wispr Flow's notes now, after the automatic fetch failed or
64
64
  * found nothing; the agent session is asked at once and again every two minutes for up to 20 minutes
65
65
  * POST /api/notes/final-edit { id, sec: 'overview'|'decision'|'action'|'question', i?, text, field?: 'owner'|'due'|'why' } → { ok, final_notes } the PM's correction
66
+ * GET /api/notes also answers `series_open`: { <series key>: { open, carried } }, the actions each meeting series carries open.
67
+ * GET /api/notes/series?key= → { ok, series } one regular meeting's notes together (lib/notes-series.js thread)
68
+ * GET /api/notes/search?q= → { ok, ids } the notes whose title, notes or transcript hold every word (a private note: its title)
69
+ * POST /api/notes/series-done { key, text, kind: 'action'|'question', done? } → { ok } the PM marks a carried action done, a question answered
70
+ * POST /api/notes/series-bring { key, text } → { ok, line } · POST /api/notes/series-unbring { key, id } → { ok } lines to bring up next time
71
+ * POST /api/notes/series-member { id, key: '<series key>'|'off'|null } → { ok, series } put a note in a series, take it out, or back to automatic
72
+ * GET /api/notes/one also answers `place`: the note's series, its position, the meeting before and after, and Since the last one.
66
73
  *
67
74
  * Notes are the PM's own and live in the per-user runtime directory (ctx.runtimeDir), never in a repository, so no route here
68
75
  * writes into a registered workspace. Every POST goes through pm-guard `requirePagePress` (a body that is not an object or carries
@@ -81,6 +88,7 @@
81
88
  */
82
89
  const guard = require('./pm-guard.js');
83
90
  const notes = require('./notes.js');
91
+ const notesSeries = require('./notes-series.js');
84
92
  const whisper = require('./notes-whisper.js');
85
93
  const notesCapture = require('./notes-capture.js');
86
94
  const notesRepo = require('./notes-repo.js');
@@ -103,7 +111,7 @@ const VOCAB_TTL_MS = 60 * 1000;
103
111
  const prefixes = ['/api/notes'];
104
112
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
105
113
  '/api/notes/private', '/api/notes/pause', '/api/notes/stop', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
106
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/screen', '/api/notes/screen-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch']);
114
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/screen', '/api/notes/screen-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch', '/api/notes/series-done', '/api/notes/series-bring', '/api/notes/series-unbring', '/api/notes/series-member']);
107
115
 
108
116
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
109
117
  const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
@@ -673,7 +681,11 @@ async function handle(req, res, url, ctx) {
673
681
  if (resumedFor !== dir) { try { resumeStuck(ctx); } catch { /* a note that cannot be picked up stays as it was */ } }
674
682
  if (p === '/api/notes' || p === '/api/notes/one') {
675
683
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
676
- if (p === '/api/notes') { const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) }); if (l.ok) sweepExpired(ctx, l.notes); send(ctx, res, l); return true; }
684
+ if (p === '/api/notes') {
685
+ const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) });
686
+ if (l.ok) { sweepExpired(ctx, l.notes); try { l.series_open = notesSeries.openCounts(dir, l.notes); } catch { l.series_open = {}; } }
687
+ send(ctx, res, l); return true;
688
+ }
677
689
  const nid = url.searchParams.get('id');
678
690
  // the bullets the agent wrote since the last read; Jev labels and checks them after the note's earlier work
679
691
  const imp = notes.importBullets(dir, { id: nid });
@@ -688,10 +700,18 @@ async function handle(req, res, url, ctx) {
688
700
  r.screen = { frames: r.note.private ? [] : notesScreen.list(dir, nid) };
689
701
  if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
690
702
  r.bullets_state = bulletsState(ctx, r.note);
703
+ // where the note sits in its meeting series, and what changed since the meeting before (c3-notes-series)
704
+ try { r.place = notesSeries.placeOf(dir, nid); } catch { r.place = null; }
691
705
  }
692
706
  send(ctx, res, r);
693
707
  return true;
694
708
  }
709
+ if (p === '/api/notes/series' || p === '/api/notes/search') {
710
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
711
+ if (p === '/api/notes/search') { send(ctx, res, notesSeries.search(dir, url.searchParams.get('q') || '')); return true; }
712
+ send(ctx, res, notesSeries.thread(dir, url.searchParams.get('key') || ''));
713
+ return true;
714
+ }
695
715
  if (p === '/api/notes/screen' && (req.method === 'GET' || req.method === 'HEAD')) {
696
716
  const nid = url.searchParams.get('id');
697
717
  const r = notes.read(dir, nid);
@@ -748,6 +768,11 @@ async function handle(req, res, url, ctx) {
748
768
  if (r.ok) r.reused = false;
749
769
  return done(r);
750
770
  }
771
+ // the meeting series (c3-notes-series): the PM's marks and lines per series, and which series a note is in
772
+ if (p === '/api/notes/series-done') return done(notesSeries.markDone(dir, { key: body.key, text: body.text, kind: body.kind, done: body.done !== false }));
773
+ if (p === '/api/notes/series-bring') return done(notesSeries.bring(dir, { key: body.key, text: body.text }));
774
+ if (p === '/api/notes/series-unbring') return done(notesSeries.unbring(dir, { key: body.key, id: body.id }));
775
+ if (p === '/api/notes/series-member') return done(notes.setSeries(dir, { id, key: body.key }));
751
776
  // from_event: the initiative is the calendar event's own, and never replaces the PM's choice
752
777
  if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, board: body.board, portfolio: typeof body.portfolio === 'boolean' ? body.portfolio : undefined, by: body.from_event === true ? 'event' : 'pm' }));
753
778
  if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
@@ -0,0 +1,368 @@
1
+ 'use strict';
2
+ /* routes-report-setups.js — the Reports workspace's routes (initiative report-workspace, PRD sections 2 to 6).
3
+ *
4
+ * GET /api/report-setups?ws → { setups: [{ id, name, purpose, audience, source, output, schedule, label, next_draft, last_issued, status, cycles,
5
+ * copy: { kind, state: none|in-step|pmos-ahead|copy-changed|not-checked, url } }],
6
+ * findings: [{ id, name, why, evidence: [{ kind, text }], confidence: likely|not-sure, proposal }],
7
+ * asks: [{ id, text, state: running|ready|failed, changed, proposal, error }],
8
+ * finder: { cadence, sources: { calendar, slack, mail, drive }, last_run, next_run } }
9
+ * POST /api/report-setups/ask { ws, text } → { ok, id } starts the report-setup run (headless)
10
+ * POST /api/report-setups/change { ws, id, text } → { ok } a correction to a proposal; a second run revises it
11
+ * POST /api/report-setups/cancel { ws, id } → { ok }
12
+ * POST /api/report-setups/setup { ws, ask? | finding?, proposal? } → { ok, setup, first_draft } THE press that sets a report up
13
+ * POST /api/report-setups/dismiss { ws, finding } → { ok } "Not a report"
14
+ * POST /api/report-setups/pause | resume { ws, id } → { ok, setup }
15
+ * POST /api/report-setups/from-dashboard { ws, key, name, output, schedule, audience } → { ok, setup }
16
+ * POST /api/report-setups/finder { ws, cadence?, sources? } → { ok, finder }
17
+ * GET /api/report-documents?ws → { docs: [{ path, id, setup, name, slot, state: draft|issued, source, output, made_by: schedule|you|agent, v, copy }] }
18
+ * GET /api/report-export?ws&path&fmt=pptx|xlsx|docx|csv → the file, Content-Disposition: attachment. PDF is printed by the browser: there is no route.
19
+ *
20
+ * WHO SETS A REPORT UP. Only a PM press does. Every POST passes pm-guard requirePagePress, and a request that carries an agent session header is
21
+ * refused first (403), as routes-scan.js does. A proposal from the Ask box or from the finder is DATA in report-setups.jsonl: the report-setup
22
+ * and report-find runs (lib/headless.js) file it with `pmos report propose` and have no tool that writes anything else. Nothing here, and nothing
23
+ * those runs do, sends anything outward (D65: the factory is propose-only).
24
+ *
25
+ * A DOCUMENT is any <state>/reports/<cadence>/<slot>.md, one-offs included, joined to issues.jsonl. Its `id` is "<cadence>/<slot>"; `setup` is the
26
+ * recurring report that made it (the cadence folder is the setup id), or null for a one-off. `made_by` reads the file's own `made_by` line; with
27
+ * none, a setup's folder says "schedule" and a one-off says "you". `v` is 1 plus the saves the document sheet kept for the file. `copy` is the linked
28
+ * copy's state from lib/doc-sync-store.js (kind: the output's copy kind; state: none | in-step | pmos-ahead | copy-changed | not-checked).
29
+ *
30
+ * TICK. tick(ctx, now) moves the old dashboard schedules into recurring reports (once per repository), then drafts each due dashboard-sourced setup
31
+ * as a document under <state>/reports/<id>/<slot>.md. The server calls it before the old dashboard tick, which then skips the migrated keys.
32
+ * ctx adds to the usual route ctx: { scheduleStore: { read, write }, skillPrompt(id, w), stopSession(id), workspaceList() }.
33
+ */
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const guard = require('./pm-guard.js');
37
+ const RS = require('./report-setups.js');
38
+ const RN = require('./report-needs.js');
39
+ const RC = require('./report-checks.js');
40
+ const RX = require('./report-export.js');
41
+ const RF = require('./report-finder.js');
42
+ const logic = require('./schedule-logic.js');
43
+ const docSync = require('./doc-sync.js');
44
+ const headless = require('./headless.js');
45
+
46
+ const text = (v) => (typeof v === 'string' ? v : '');
47
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
48
+ const POSTS = ['ask', 'change', 'cancel', 'setup', 'dismiss', 'pause', 'resume', 'from-dashboard', 'finder'];
49
+ const MADE_BY = ['schedule', 'you', 'agent'];
50
+
51
+ const runs = new Map(); // ask id → session id, for the report-setup runs this server started
52
+
53
+ function wsOf(ctx, id) {
54
+ const w = ctx.getWs(id || null);
55
+ return w && (!id || w.id === id) ? w : null;
56
+ }
57
+ const isHome = (ctx, w) => { const h = ctx.getWs(null); return !!h && h.id === w.id; };
58
+ const dayEnd = (ctx) => { try { const s = ctx.proSettings ? ctx.proSettings() : null; return (s && s.dayCheck && s.dayCheck.end) || '18:00'; } catch { return '18:00'; } };
59
+ const finderClock = (env) => { const t = (env || process.env).PMOS_REPORT_FIND_NOW ? Date.parse((env || process.env).PMOS_REPORT_FIND_NOW) : NaN; return Number.isFinite(t) ? t : null; };
60
+
61
+ // ── the old dashboard schedules become recurring reports, once ───────────────────────────────────────────
62
+ /** Move this workspace's dashboard schedules into setups. Nothing to move: nothing is written (no state folder is made). Idempotent: a marker line. */
63
+ function ensureMigrated(ctx, w, now = new Date()) {
64
+ if (!ctx.runtimeDir) return null;
65
+ try {
66
+ const st = RS.stateOf(w.root);
67
+ if (st.migrated) return null;
68
+ const rs = require('./report-schedules.js');
69
+ const mine = Object.values(rs.readSchedules(ctx.runtimeDir)).filter((s) => (s.ws ? s.ws === w.id : isHome(ctx, w)));
70
+ if (!mine.length) return null;
71
+ let outbox = [];
72
+ try { outbox = require('./outbox-store.js').readOutbox(ctx.runtimeDir); } catch { outbox = []; }
73
+ return RS.migrate(w.root, { runtimeDir: ctx.runtimeDir, wsId: w.id, isHome: isHome(ctx, w), outbox, now });
74
+ } catch { return null; }
75
+ }
76
+
77
+ // ── what the page reads ──────────────────────────────────────────────────────────────────────────────────
78
+ function copyOf(link, kind) {
79
+ if (!link) return { kind: kind || null, state: 'none', url: null };
80
+ const state = link.signal === 'same' ? 'in-step' : link.signal === 'ahead' ? 'pmos-ahead' : (link.signal === 'behind' || link.signal === 'diverged') ? 'copy-changed' : 'not-checked';
81
+ return { kind: link.kind, state, url: link.target || null };
82
+ }
83
+ function copyIndex(root) {
84
+ const idx = new Map();
85
+ try { const v = require('./doc-sync-store.js').view(root); if (v.ok) for (const d of v.docs) idx.set(d.path, d.links); } catch { /* no link store */ }
86
+ return idx;
87
+ }
88
+ function linkFor(idx, rel, output) {
89
+ const links = idx.get(rel) || [];
90
+ const want = docSync.copyKindOf(output);
91
+ return links.find((l) => l.kind === want) || links[0] || null;
92
+ }
93
+
94
+ function documentsOf(ctx, w) {
95
+ const root = w.root;
96
+ const st = RS.stateOf(root);
97
+ const r = RN.reportsOf(root);
98
+ const idx = copyIndex(root);
99
+ let revs = [];
100
+ try { revs = fs.readFileSync(path.join(ctx.runtimeDir, 'doc-sheet', 'revisions.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { revs = []; }
101
+ const saves = new Map();
102
+ for (const e of revs) if (e && typeof e.rev === 'number' && e.path) saves.set(e.path, (saves.get(e.path) || 0) + 1);
103
+ const abs = path.resolve(root);
104
+ const docs = [];
105
+ for (const x of r.reports) {
106
+ const fm = RN.frontmatter(path.join(abs, x.path));
107
+ const setup = st.setups.get(x.cadence) || null;
108
+ const output = fm.output || (setup && setup.output) || null;
109
+ docs.push({
110
+ path: x.path, id: `${x.cadence}/${x.slot}`, setup: setup ? setup.id : null,
111
+ name: setup ? setup.name : (fm.title || x.cadence), slot: x.slot, state: x.issued ? 'issued' : 'draft',
112
+ source: fm.source || (setup ? setup.source : 'work'), output,
113
+ made_by: MADE_BY.includes(fm.made_by) ? fm.made_by : (setup ? 'schedule' : 'you'),
114
+ v: 1 + (saves.get(x.path) || 0), copy: copyOf(linkFor(idx, x.path, output), docSync.copyKindOf(output)),
115
+ });
116
+ }
117
+ return { docs, reports: r.reports };
118
+ }
119
+
120
+ function setupOut(s, now, docsOf, idx) {
121
+ const mine = (docsOf.reports || []).filter((x) => x.cadence === s.id);
122
+ const issued = mine.filter((x) => x.issued).map((x) => x.slot).sort().pop() || null;
123
+ const latest = mine.slice().sort((a, b) => (a.slot < b.slot ? 1 : -1))[0] || null;
124
+ let next = null;
125
+ if (s.status === 'active') { try { const n = RS.nextDraftAt(s.schedule, s.last_run, now); next = n ? n.toISOString() : null; } catch { next = null; } }
126
+ return {
127
+ id: s.id, name: s.name, purpose: s.purpose, audience: s.audience, source: s.source, output: s.output, schedule: s.schedule, label: RS.scheduleLabel(s.schedule),
128
+ next_draft: next, last_issued: issued, status: s.status, cycles: mine.length,
129
+ copy: copyOf(latest ? linkFor(idx, latest.path, s.output) : null, docSync.copyKindOf(s.output)),
130
+ };
131
+ }
132
+
133
+ function finderOut(ctx, st, now) {
134
+ const last = st.finds.length ? st.finds[st.finds.length - 1] : null;
135
+ const clock = finderClock() != null ? finderClock() : now;
136
+ const next = RF.off(process.env) ? null : RF.nextRun(st, clock, { dayEnd: dayEnd(ctx) });
137
+ return { cadence: st.finder.cadence, sources: { ...st.finder.sources }, last_run: last ? last.start : null, next_run: next == null ? null : new Date(next).toISOString() };
138
+ }
139
+
140
+ /** A running ask whose run this server no longer holds (the server restarted) is over. */
141
+ function settleStale(file, st, now) {
142
+ let changed = false;
143
+ for (const a of st.asks.values()) {
144
+ if (a.state !== 'running' || runs.has(a.id) || now - Date.parse(a.started) < 90000) continue;
145
+ RS.askEnd(file, { id: a.id, ok: false, error: 'The run stopped when the workspace restarted. Ask again.' });
146
+ changed = true;
147
+ }
148
+ return changed;
149
+ }
150
+
151
+ function listAnswer(ctx, w, now) {
152
+ const file = RS.ledgerFile(w.root);
153
+ let st = RS.fold(RS.readLines(file));
154
+ if (settleStale(file, st, now)) st = RS.fold(RS.readLines(file));
155
+ const docsOf = documentsOf(ctx, w);
156
+ const idx = copyIndex(w.root);
157
+ return {
158
+ setups: [...st.setups.values()].map((s) => setupOut(s, new Date(now), docsOf, idx)),
159
+ findings: RS.openFindings(st).map((f) => ({ id: f.id, name: f.name, why: f.why, evidence: f.evidence, confidence: f.confidence, proposal: f.proposal })),
160
+ asks: [...st.asks.values()].map((a) => ({ id: a.id, text: a.text, state: a.state, changed: !!a.changed, proposal: a.proposal, error: a.error })),
161
+ finder: finderOut(ctx, st, now),
162
+ };
163
+ }
164
+
165
+ // ── the report-setup run ─────────────────────────────────────────────────────────────────────────────────
166
+ function askPrompt(skill, ask, extra) {
167
+ const lines = [`- Ask: ${ask.id}`, `- Today: ${new Date().toISOString().slice(0, 10)}`, `- Request: ${ask.text}`];
168
+ if (extra && extra.correction) lines.push(`- Correction: ${extra.correction}`, `- Current proposal: ${JSON.stringify(extra.proposal || {})}`);
169
+ return `${skill}\n\nThis request:\n${lines.join('\n')}\n\nFile ONE proposal with \`pmos report propose --json -\`. Send nothing.\r`;
170
+ }
171
+
172
+ /** Start (or restart, for a correction) the run of an ask. → { ok } | { ok: false, status, error } */
173
+ function startRun(ctx, w, file, ask, extra) {
174
+ if (typeof ctx.spawnSession !== 'function') return { ok: false, status: 409, error: 'this workspace cannot start sessions' };
175
+ const origin = `report-setup:${ask.id}`;
176
+ if (headless.kindFor({ agent: 'claude', origin }) !== 'report-setup') return { ok: false, status: 409, error: 'Background runs are switched off here (PMOS_HEADLESS=off), so this page cannot start the request.' };
177
+ const skill = typeof ctx.skillPrompt === 'function' ? ctx.skillPrompt('report-setup', w) : null;
178
+ if (!skill) return { ok: false, status: 500, error: 'the report-setup skill is not installed' };
179
+ const s = ctx.spawnSession({ name: 'Report setup', agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, ask, extra),
180
+ extraEnv: { PMOS_REPORT_FILE: file, PMOS_REPORT_RUN: `setup:${ask.id}` },
181
+ onDone: (r) => {
182
+ runs.delete(ask.id);
183
+ const res = (r && r.result) || {};
184
+ const ok = !!(r && !r.timedOut && !r.stopped && r.code === 0 && res.ok && !res.isError);
185
+ try { RS.askEnd(file, { id: ask.id, ok, error: ok ? null : (r && r.note) || 'the run failed' }); } catch { /* the next read settles it */ }
186
+ } });
187
+ if (!s || s.state === 'exited' || s.headless !== 'report-setup') {
188
+ if (s && s.headless !== 'report-setup' && s.state !== 'exited' && typeof ctx.stopSession === 'function') { try { ctx.stopSession(s.id); } catch { /* best effort */ } }
189
+ return { ok: false, status: 409, error: 'The request could not start: ' + ((s && s.note) || 'no background run') };
190
+ }
191
+ runs.set(ask.id, s.id);
192
+ return { ok: true };
193
+ }
194
+
195
+ // ── the tick ─────────────────────────────────────────────────────────────────────────────────────────────
196
+ let ticking = false;
197
+ /** Migrate, then draft each due dashboard-sourced setup as a document. ctx { getWs, workspaceList, runtimeDir, … }. → [{ id, slot, ok, error? }] */
198
+ async function tick(ctx, now = new Date()) {
199
+ if (ticking || !ctx || !ctx.runtimeDir) return [];
200
+ ticking = true;
201
+ const done = [];
202
+ try {
203
+ const list = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [ctx.getWs(null)].filter(Boolean);
204
+ for (const w of list) {
205
+ if (!w || !w.root) continue;
206
+ ensureMigrated(ctx, w, now);
207
+ let st;
208
+ try { st = RS.stateOf(w.root); } catch { continue; }
209
+ for (const s of st.setups.values()) {
210
+ if (s.status !== 'active' || !String(s.source).startsWith('dashboard:')) continue;
211
+ const sched = RS.scheduleOf(s);
212
+ if (!logic.isDue(sched, now)) continue;
213
+ const slotAt = logic.lastSlot(sched, now);
214
+ if (!slotAt) continue;
215
+ const slot = RS.localDay(slotAt);
216
+ if (s.drafted.some((d) => d.slot === slot)) continue;
217
+ let out;
218
+ try {
219
+ const c3 = require('./routes-c3-reports.js');
220
+ const deps = await c3.depsFor({ ...ctx, getWs: (id) => (id === w.id || id == null ? w : ctx.getWs(id)) }, w.id);
221
+ const f = c3.frameFor({ key: RS.keyOfSource(s.source), audience: s.audience }, deps, s.name);
222
+ if (!f.ok) out = f;
223
+ else {
224
+ const w2 = RS.writeDocument(w.root, s, slot, RS.frameMarkdown(s, f.F, slot, now), { now });
225
+ out = w2.ok ? { ok: true } : { ok: false, error: w2.error, existed: w2.status === 409 };
226
+ }
227
+ } catch (e) { out = { ok: false, error: String((e && e.message) || e) }; }
228
+ done.push({ id: s.id, slot, ...out });
229
+ }
230
+ }
231
+ } finally { ticking = false; }
232
+ return done;
233
+ }
234
+
235
+ // ── the handler ──────────────────────────────────────────────────────────────────────────────────────────
236
+ function exportAnswer(ctx, res, w, url) {
237
+ const rel = text(url.searchParams.get('path'));
238
+ const fmt = text(url.searchParams.get('fmt'));
239
+ if (fmt === 'pdf') return bad(400, 'PDF is printed by the browser. Use Print in the document.');
240
+ if (!Object.prototype.hasOwnProperty.call(RX.FORMATS, fmt)) return bad(400, `fmt must be one of ${Object.keys(RX.FORMATS).join(', ')}`);
241
+ if (!RC.isReportPath(rel)) return bad(400, 'path must be a report document: <state>/reports/<cadence>/<slot>.md');
242
+ const abs = path.resolve(w.root);
243
+ const full = path.resolve(abs, rel);
244
+ if (!full.startsWith(abs + path.sep)) return bad(404, 'outside workspace');
245
+ let md;
246
+ try {
247
+ const real = fs.realpathSync(full);
248
+ if (!real.startsWith(fs.realpathSync(abs) + path.sep)) return bad(404, 'outside workspace');
249
+ md = fs.readFileSync(real, 'utf8');
250
+ } catch { return bad(404, 'no such document'); }
251
+ const out = RX.exportOf(md, fmt);
252
+ if (!out.ok) return bad(500, out.error);
253
+ return { status: 200, file: out };
254
+ }
255
+
256
+ async function handle(req, res, url, ctx) {
257
+ const p = url.pathname;
258
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
259
+ const isDocs = p === '/api/report-documents' || p.startsWith('/api/report-documents/');
260
+ const isExport = p === '/api/report-export' || p.startsWith('/api/report-export/');
261
+ const sub = isDocs ? 'documents' : isExport ? 'export' : p.replace(/^\/api\/report-setups\/?/, '');
262
+
263
+ if (isDocs || isExport || (sub === '' && req.method === 'GET')) {
264
+ if (req.method !== 'GET') return send(bad(405, 'GET only'));
265
+ if ((isDocs && p !== '/api/report-documents') || (isExport && p !== '/api/report-export')) return send(bad(404, 'no such report route'));
266
+ const w = wsOf(ctx, url.searchParams.get('ws'));
267
+ if (!w) return send(bad(404, 'no such workspace'));
268
+ const now = Date.now();
269
+ ensureMigrated(ctx, w, new Date(now));
270
+ if (isExport) {
271
+ const r = exportAnswer(ctx, res, w, url);
272
+ if (!r.file) return send(r);
273
+ const f = r.file;
274
+ res.writeHead(200, { 'Content-Type': f.mime, 'Content-Length': f.buffer.length, 'Content-Disposition': `attachment; filename="${f.name}"`, 'Cache-Control': 'no-store' });
275
+ res.end(f.buffer);
276
+ return true;
277
+ }
278
+ if (isDocs) return send({ status: 200, body: { docs: documentsOf(ctx, w).docs } });
279
+ return send({ status: 200, body: listAnswer(ctx, w, now) });
280
+ }
281
+ if (!POSTS.includes(sub)) return send(bad(404, 'no such report route'));
282
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
283
+
284
+ const read = await ctx.readJson(req);
285
+ if (!read.ok) return send(bad(400, 'the body is not JSON'));
286
+ const body = read.body || {};
287
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot set up, change or dismiss a report. Only a press on the page does'));
288
+ const g = guard.requirePagePress(req, body);
289
+ if (!g.ok) return send(bad(g.status, g.error));
290
+ const w = wsOf(ctx, text(body.ws));
291
+ if (!w) return send(bad(404, 'no such workspace'));
292
+ const root = w.root;
293
+ const file = RS.ledgerFile(root);
294
+ const opts = { by: g.actor, wsId: w.id, scheduleStore: ctx.scheduleStore };
295
+ const fail = (r) => send(bad(r.status || 400, r.error));
296
+
297
+ if (sub === 'ask') {
298
+ if (typeof ctx.spawnSession !== 'function' || headless.kindFor({ agent: 'claude', origin: 'report-setup:probe' }) !== 'report-setup') {
299
+ return send(bad(409, 'Background runs are switched off here (PMOS_HEADLESS=off), so this page cannot start the request.'));
300
+ }
301
+ const a = RS.askStart(file, { text: body.text, ws: w.id, by: g.actor });
302
+ if (!a.ok) return fail(a);
303
+ const ask = RS.fold(RS.readLines(file)).asks.get(a.id);
304
+ const s = startRun(ctx, w, file, ask);
305
+ if (!s.ok) { RS.askCancel(file, { id: a.id, by: g.actor }); return fail(s); }
306
+ return send({ status: 200, body: { ok: true, id: a.id } });
307
+ }
308
+ if (sub === 'change') {
309
+ const c = RS.askChange(file, { id: text(body.id), text: body.text, by: g.actor });
310
+ if (!c.ok) return fail(c);
311
+ const ask = RS.fold(RS.readLines(file)).asks.get(text(body.id));
312
+ const s = startRun(ctx, w, file, ask, { correction: String(body.text).replace(/\s+/g, ' ').trim(), proposal: c.ask.proposal });
313
+ if (!s.ok) { RS.askEnd(file, { id: ask.id, ok: false, error: s.error }); return fail(s); }
314
+ return send({ status: 200, body: { ok: true } });
315
+ }
316
+ if (sub === 'cancel') {
317
+ const id = text(body.id);
318
+ const r = RS.askCancel(file, { id, by: g.actor });
319
+ if (!r.ok) return fail(r);
320
+ const sid = runs.get(id);
321
+ runs.delete(id);
322
+ if (sid && typeof ctx.stopSession === 'function') { try { ctx.stopSession(sid); } catch { /* the run ends by itself */ } }
323
+ return send({ status: 200, body: { ok: true } });
324
+ }
325
+ if (sub === 'setup') {
326
+ const st = RS.fold(RS.readLines(file));
327
+ let from = null; let base = null;
328
+ if (body.ask) { const a = st.asks.get(text(body.ask)); if (!a) return send(bad(404, 'no such request')); from = { ask: a.id }; base = a.proposal; }
329
+ else if (body.finding) { const f = st.findings.get(text(body.finding)); if (!f || f.state !== 'open') return send(bad(404, 'no such finding')); from = { finding: f.id }; base = f.proposal; }
330
+ const proposal = body.proposal && typeof body.proposal === 'object' && !Array.isArray(body.proposal) ? body.proposal : base;
331
+ if (!proposal) return send(bad(400, 'say which request or finding to set up, or give the proposal'));
332
+ const r = RS.setUp(root, { ...proposal, ...(from ? { from } : {}) }, opts);
333
+ if (!r.ok) return fail(r);
334
+ const docsOf = { reports: [] };
335
+ return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), docsOf, new Map()), first_draft: r.first_draft } });
336
+ }
337
+ if (sub === 'dismiss') {
338
+ const r = RS.dismissFinding(file, { id: text(body.finding), by: g.actor });
339
+ if (!r.ok) return fail(r);
340
+ return send({ status: 200, body: { ok: true } });
341
+ }
342
+ if (sub === 'pause' || sub === 'resume') {
343
+ const r = RS[sub](root, text(body.id), opts);
344
+ if (!r.ok) return fail(r);
345
+ return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), documentsOf(ctx, w), copyIndex(root)) } });
346
+ }
347
+ if (sub === 'from-dashboard') {
348
+ const key = text(body.key);
349
+ if (!require('./report-dashboards.js').validKey(key)) return send(bad(400, 'key must be portfolio, board:<id> or initiative:<id>'));
350
+ const source = `dashboard:${key}`;
351
+ if ([...RS.fold(RS.readLines(file)).setups.values()].some((s) => s.source === source)) return send(bad(409, 'that dashboard is a recurring report already'));
352
+ const name = text(body.name).trim() || RS.dashTitle(key);
353
+ const audience = text(body.audience).trim();
354
+ const proposal = { name, purpose: `The ${name} dashboard as a report for ${audience || 'its readers'}.`, audience, source, output: text(body.output) || 'pdf',
355
+ schedule: body.schedule, sources: [`The ${name} dashboard`], sections: RS.FRAME.slice(), learned_from: 'Set up from the dashboard.', fixes: [] };
356
+ const r = RS.setUp(root, proposal, opts);
357
+ if (!r.ok) return fail(r);
358
+ return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), { reports: [] }, new Map()) } });
359
+ }
360
+ if (sub === 'finder') {
361
+ const r = RS.setFinder(file, { cadence: body.cadence, sources: body.sources, by: g.actor });
362
+ if (!r.ok) return fail(r);
363
+ return send({ status: 200, body: { ok: true, finder: finderOut(ctx, RS.fold(RS.readLines(file)), Date.now()) } });
364
+ }
365
+ return send(bad(404, 'no such report route'));
366
+ }
367
+
368
+ module.exports = { prefixes: ['/api/report-setups', '/api/report-documents', '/api/report-export'], handle, tick, ensureMigrated, documentsOf, listAnswer, copyOf };
@@ -101,7 +101,7 @@ function finishReason(schedule, now = new Date()) {
101
101
  return null;
102
102
  }
103
103
  // Is the schedule due to fire at `now`? Enabled + a slot in (lastRun, now]. For bi-weekly, also
104
- // require ≥ 13 days since lastRun (weekly slots, fired every other week).
104
+ // require the slot to be ≥ 12.5 days after lastRun (weekly slots, fired every other week).
105
105
  function isDue(schedule, now = new Date()) {
106
106
  if (!schedule || !schedule.enabled) return false;
107
107
  if (finishReason(schedule, now)) return false;
@@ -111,8 +111,11 @@ function isDue(schedule, now = new Date()) {
111
111
  if (last && last >= slot) return false;
112
112
  // A one-off fires inside its grace window, once; after that it is missed (see isMissed), never caught up.
113
113
  if (schedule.cadence === 'once') return !last && (now - slot) <= ONCE_GRACE_MS;
114
+ // The SLOT must be about two weeks after the last run, not the clock: counting from `now` fired a day early on the second cycle
115
+ // (13 days after a 09:00:30 fire is a Tuesday 09:00:30, and the newest slot then is the Wednesday before) and drifted a day each cycle.
116
+ // Half a day of slack lets a slot fire after a run a few minutes late. A slot a week after the last run is far outside it.
114
117
  if (schedule.cadence === 'bi-weekly' && last) {
115
- if ((now - last) / 86400000 < 13) return false;
118
+ if ((slot - last) / 86400000 < 12.5) return false;
116
119
  }
117
120
  return true;
118
121
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.178.0",
3
+ "version": "0.180.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.178.0",
9
+ "version": "0.180.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.178.0",
3
+ "version": "0.180.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",