create-pmos 0.123.0 → 0.125.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 (127) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/nine-stages.js +81 -0
  6. package/payload/scripts/workflow-state.js +2 -0
  7. package/payload/state/discoveries.md +1 -1
  8. package/payload/state/log.md +1 -1
  9. package/workspace/c3-one-home-test.js +131 -0
  10. package/workspace/fixtures/initiatives-api/list.json +171 -3
  11. package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
  12. package/workspace/fixtures/initiatives-api/sheet.json +57 -1
  13. package/workspace/fixtures/one-home-baseline.json +83 -0
  14. package/workspace/guide/layout.md +2 -0
  15. package/workspace/guide/notes.md +1 -0
  16. package/workspace/lib/engine/nine-stages.js +81 -0
  17. package/workspace/lib/engine/workflow-state.js +2 -0
  18. package/workspace/lib/facts-async.js +51 -0
  19. package/workspace/lib/item-steps.js +52 -0
  20. package/workspace/lib/jira-status.js +8 -3
  21. package/workspace/lib/jobs-to-be-done.js +52 -0
  22. package/workspace/lib/outbox-store.js +87 -0
  23. package/workspace/lib/pm-items.js +115 -0
  24. package/workspace/lib/route-modules.js +21 -0
  25. package/workspace/lib/routes-briefing.js +100 -0
  26. package/workspace/lib/routes-c3-init.js +53 -0
  27. package/workspace/lib/routes-c3-life.js +119 -0
  28. package/workspace/lib/routes-c3-strategy.js +169 -0
  29. package/workspace/lib/routes-c3-timeline.js +191 -0
  30. package/workspace/lib/routes-calendar-import.js +60 -0
  31. package/workspace/lib/routes-calendar-views.js +61 -0
  32. package/workspace/lib/routes-doc-health.js +79 -0
  33. package/workspace/lib/routes-doc-sheet.js +169 -0
  34. package/workspace/lib/routes-forecast.js +85 -0
  35. package/workspace/lib/routes-hooks.js +121 -0
  36. package/workspace/lib/routes-initiatives.js +3 -0
  37. package/workspace/lib/routes-intake.js +24 -0
  38. package/workspace/lib/routes-outbox.js +97 -0
  39. package/workspace/lib/routes-pm-items.js +216 -0
  40. package/workspace/lib/routes-records.js +61 -0
  41. package/workspace/lib/routes-releases.js +129 -0
  42. package/workspace/lib/routes-report-facts.js +84 -0
  43. package/workspace/lib/routes-report-families.js +32 -0
  44. package/workspace/lib/routes-roadmap-ops.js +141 -0
  45. package/workspace/lib/routes-runs.js +119 -0
  46. package/workspace/lib/routes-signals.js +50 -0
  47. package/workspace/lib/run-answers.js +69 -0
  48. package/workspace/package-lock.json +2 -2
  49. package/workspace/package.json +1 -1
  50. package/workspace/public/c3.html +26 -0
  51. package/workspace/public/pm/briefing-deck.js +415 -0
  52. package/workspace/public/pm/briefing-model.js +176 -0
  53. package/workspace/public/pm/c3-breakdown.js +240 -0
  54. package/workspace/public/pm/c3-brief.js +278 -0
  55. package/workspace/public/pm/c3-cal-strategy.js +528 -0
  56. package/workspace/public/pm/c3-case.js +173 -0
  57. package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
  58. package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
  59. package/workspace/public/pm/c3-demo-cal2.js +66 -0
  60. package/workspace/public/pm/c3-demo-cal3.js +173 -0
  61. package/workspace/public/pm/c3-demo-data.js +560 -0
  62. package/workspace/public/pm/c3-demo-init.js +373 -0
  63. package/workspace/public/pm/c3-demo-life.js +77 -0
  64. package/workspace/public/pm/c3-demo-misc2.js +46 -0
  65. package/workspace/public/pm/c3-demo-plan2.js +30 -0
  66. package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
  67. package/workspace/public/pm/c3-demo-rep2.js +92 -0
  68. package/workspace/public/pm/c3-demo-term.js +153 -0
  69. package/workspace/public/pm/c3-demo-time.js +49 -0
  70. package/workspace/public/pm/c3-demo-work-ix.js +80 -0
  71. package/workspace/public/pm/c3-demo-work2.js +13 -0
  72. package/workspace/public/pm/c3-demo.js +113 -0
  73. package/workspace/public/pm/c3-doc-sheet.js +314 -0
  74. package/workspace/public/pm/c3-dock.js +180 -0
  75. package/workspace/public/pm/c3-docs.js +290 -0
  76. package/workspace/public/pm/c3-expand.js +96 -0
  77. package/workspace/public/pm/c3-init-extras.js +170 -0
  78. package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
  79. package/workspace/public/pm/c3-misc-panels.js +89 -0
  80. package/workspace/public/pm/c3-plan-panels.js +163 -0
  81. package/workspace/public/pm/c3-plan.js +192 -0
  82. package/workspace/public/pm/c3-roadmap.js +249 -0
  83. package/workspace/public/pm/c3-sheet.js +107 -0
  84. package/workspace/public/pm/c3-shell.js +637 -0
  85. package/workspace/public/pm/c3-signals.js +196 -0
  86. package/workspace/public/pm/c3-strategy-panels.js +45 -0
  87. package/workspace/public/pm/c3-swipe.js +138 -0
  88. package/workspace/public/pm/c3-terminal-data.js +26 -0
  89. package/workspace/public/pm/c3-terminal-edge.js +91 -0
  90. package/workspace/public/pm/c3-terminal-icons.js +41 -0
  91. package/workspace/public/pm/c3-terminal-results.js +141 -0
  92. package/workspace/public/pm/c3-terminal.js +52 -0
  93. package/workspace/public/pm/c3-tickets.js +163 -0
  94. package/workspace/public/pm/c3-timeline.js +201 -0
  95. package/workspace/public/pm/c3-toast.js +16 -0
  96. package/workspace/public/pm/calendar-views.js +1015 -0
  97. package/workspace/public/pm/console3-lift.md +220 -0
  98. package/workspace/public/pm/console3.css +1480 -0
  99. package/workspace/public/pm/dest-initiatives.js +16 -3
  100. package/workspace/public/pm/dest-mywork.js +8 -1
  101. package/workspace/public/pm/dest-notes.js +25 -3
  102. package/workspace/public/pm/dest-reports.js +47 -1
  103. package/workspace/public/pm/dest-today.js +55 -1
  104. package/workspace/public/pm/doc-health-panel.js +110 -0
  105. package/workspace/public/pm/doc-rail.js +9 -3
  106. package/workspace/public/pm/lifecycle-lens-model.js +228 -0
  107. package/workspace/public/pm/lifecycle-lens.js +261 -0
  108. package/workspace/public/pm/new-initiative-sheet.js +116 -21
  109. package/workspace/public/pm/outbox-panel.js +125 -0
  110. package/workspace/public/pm/pm-work-view.js +919 -0
  111. package/workspace/public/pm/releases-view.js +248 -0
  112. package/workspace/public/pm/report-families-view.js +503 -0
  113. package/workspace/public/pm/run-card.js +244 -0
  114. package/workspace/public/pm/sessions-panel.js +149 -0
  115. package/workspace/public/pm/shell-bell.js +162 -0
  116. package/workspace/public/pm/shell-model.js +83 -0
  117. package/workspace/public/pm/shell-phone.js +124 -0
  118. package/workspace/public/pm/shell-readaloud.js +53 -0
  119. package/workspace/public/pm/shell-recording.js +110 -0
  120. package/workspace/public/pm/shell-ticker.js +90 -0
  121. package/workspace/public/pm/slide-confirm.js +163 -0
  122. package/workspace/public/pm/timeline-forecast-view.js +164 -0
  123. package/workspace/public/pm/today-day.js +8 -2
  124. package/workspace/public/pm/tokens.css +1 -0
  125. package/workspace/server.js +5 -2
  126. package/workspace/test-files.json +8 -0
  127. package/workspace/test-lists/browser-suites-green.json +5 -0
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+ /* routes-doc-sheet.js — the document sheet's server side (console3 wave E1c).
3
+ *
4
+ * GET /api/doc-sheet/revisions?path= → { ok, path, revisions: [{ rev, why, by, at, before_hash, after_hash }] }
5
+ * GET /api/doc-sheet/at?path=&rev= → { ok, path, rev, text } the fold of the revisions
6
+ * POST /api/doc-sheet/edit { path, text, why } page press; human edit; an empty why is refused
7
+ * POST /api/doc-sheet/propose { path, prompt, after, id? } agent session or PM; a proposal only, the file is NOT changed
8
+ * (an id revises a proposal: new hash, the old hash is void)
9
+ * POST /api/doc-sheet/accept { path, id, hash } page press, PM only; writes the file, records a revision, why = the prompt
10
+ * POST /api/doc-sheet/change-note { path, downstream? } a draft body only; nothing is sent
11
+ *
12
+ * Storage (append-only JSONL): <runtime>/doc-sheet/revisions.jsonl and proposals.jsonl.
13
+ * The write is the same one /api/doc/save makes: the path must resolve inside the repository root, only Markdown is
14
+ * written, and the why goes to the repository's doc-changes.jsonl as `note` with the before/after region
15
+ * (apply-change.changedRegion), the log /api/doc/save already keeps and /api/doc/changes already reads.
16
+ * Handlers are pure over (req, body, ctx) so a test needs no server.
17
+ */
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const guard = require('./pm-guard.js');
21
+ const applyChange = require('./apply-change.js');
22
+ const S = require('../../scripts/doc-sheet.js');
23
+
24
+ const ROOT = path.resolve(__dirname, '..', '..');
25
+ const fail = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
26
+ const dirOf = (runtimeDir) => path.join(runtimeDir, 'doc-sheet');
27
+
28
+ function readLines(file) {
29
+ let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
30
+ const out = [];
31
+ for (const l of raw.split('\n')) { if (!l.trim()) continue; try { out.push(JSON.parse(l)); } catch { /* torn line */ } }
32
+ return out;
33
+ }
34
+ function append(runtimeDir, name, row) {
35
+ fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
36
+ fs.appendFileSync(path.join(dirOf(runtimeDir), name), JSON.stringify(row) + '\n');
37
+ }
38
+
39
+ function inside(root, full) {
40
+ try { const r = fs.realpathSync(root), f = fs.realpathSync(full); return f === r || f.startsWith(r + path.sep); } catch { return false; }
41
+ }
42
+ // The same guard /api/doc/save uses: resolve under the root, markdown only, refuse anything outside.
43
+ function resolveDoc(root, rel) {
44
+ if (typeof rel !== 'string' || !rel.trim() || rel.includes('\0')) return { error: fail(400, 'path is required') };
45
+ const full = path.resolve(root, rel);
46
+ if (!full.startsWith(path.resolve(root) + path.sep) || !inside(root, path.dirname(full))) return { error: fail(404, 'outside workspace') };
47
+ if (!/\.md$/i.test(full)) return { error: fail(400, 'only markdown documents are editable here') };
48
+ let text; try { if (!inside(root, full)) return { error: fail(404, 'outside workspace') }; text = fs.readFileSync(full, 'utf8'); } catch { return { error: fail(404, 'could not read the document') }; }
49
+ return { full, rel: path.relative(root, full), text };
50
+ }
51
+
52
+ const revsOf = (runtimeDir, rel) => S.revisions(readLines(path.join(dirOf(runtimeDir), 'revisions.jsonl')), rel);
53
+
54
+ // Record the file's current text first when it moved outside this sheet (or was never seen), so the fold is complete.
55
+ function syncBaseline(runtimeDir, rel, text, now) {
56
+ const revs = revsOf(runtimeDir, rel), last = revs[revs.length - 1];
57
+ if (last && last.after_hash === S.hashText(text)) return last.rev;
58
+ const rev = last ? last.rev + 1 : 1;
59
+ const ev = { ...S.editWithWhy(last ? last.text : text, text, last ? 'changed outside the sheet' : 'first seen by the sheet', 'baseline', { rev, path: rel, at: now.toISOString() }) };
60
+ append(runtimeDir, 'revisions.jsonl', ev);
61
+ return rev;
62
+ }
63
+
64
+ function logChange(ctx, w, rel, before, after, why, by, now) {
65
+ const dir = ctx && ctx.stateDirFor ? ctx.stateDirFor(w, { create: true }) : null;
66
+ if (!dir) return null;
67
+ const region = applyChange.changedRegion(before, after);
68
+ const rec = { file: rel, at: now.toISOString(), pm: by, via: 'doc-sheet', note: why.slice(0, 400),
69
+ at_line: region.at_line, added: region.added, removed: region.removed, before: region.before, after: region.after };
70
+ try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(path.join(dir, 'doc-changes.jsonl'), JSON.stringify(rec) + '\n'); return true; } catch { return null; }
71
+ }
72
+
73
+ function writeDoc(root, rel, before, after, why, runtimeDir, ctx, w, now) {
74
+ const r = resolveDoc(root, rel); if (r.error) return r.error;
75
+ if (r.text !== before) return fail(409, 'stale: the document changed since it was read');
76
+ syncBaseline(runtimeDir, r.rel, r.text, now);
77
+ const rev = revsOf(runtimeDir, r.rel).length + 1;
78
+ const ev = S.editWithWhy(before, after, why, 'pm', { rev, path: r.rel, at: now.toISOString() });
79
+ try { fs.writeFileSync(r.full, after); } catch { return fail(500, 'could not write the document — nothing was changed'); }
80
+ append(runtimeDir, 'revisions.jsonl', ev);
81
+ const logged = logChange(ctx, w, r.rel, before, after, why, 'pm', now);
82
+ return { status: 200, body: { ok: true, path: r.rel, rev, revision: { ...ev, text: undefined }, logged: !!logged } };
83
+ }
84
+
85
+ function revisionsRoute(root, q, runtimeDir) {
86
+ const r = resolveDoc(root, q.path); if (r.error) return r.error;
87
+ return { status: 200, body: { ok: true, path: r.rel, revisions: revsOf(runtimeDir, r.rel).map(({ text, ...rest }) => rest) } };
88
+ }
89
+ function atRoute(root, q, runtimeDir) {
90
+ const r = resolveDoc(root, q.path); if (r.error) return r.error;
91
+ const rev = Number(q.rev);
92
+ if (!Number.isInteger(rev) || rev < 1) return fail(400, 'rev must be a positive integer');
93
+ const text = S.foldAt(revsOf(runtimeDir, r.rel), rev);
94
+ if (text == null) return fail(404, 'no such revision');
95
+ return { status: 200, body: { ok: true, path: r.rel, rev, text } };
96
+ }
97
+
98
+ function edit(req, body, ctx, now = new Date()) {
99
+ const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
100
+ if (typeof body.why !== 'string' || !body.why.trim()) return fail(400, 'a why is required: say why the document changes');
101
+ if (typeof body.text !== 'string') return fail(400, 'text is required');
102
+ const w = ctx.w || null, root = ctx.root;
103
+ const r = resolveDoc(root, body.path); if (r.error) return r.error;
104
+ return writeDoc(root, body.path, r.text, body.text, body.why, ctx.runtimeDir, ctx, w, now);
105
+ }
106
+
107
+ const proposalsOf = (runtimeDir) => {
108
+ const by = new Map();
109
+ for (const e of readLines(path.join(dirOf(runtimeDir), 'proposals.jsonl'))) by.set(e.id, { ...(by.get(e.id) || {}), ...e });
110
+ return by;
111
+ };
112
+
113
+ function propose(req, body, ctx, now = new Date()) {
114
+ // An agent session or the PM may propose; nothing here touches the document.
115
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return fail(400, 'body must be an object');
116
+ if (typeof body.after !== 'string') return fail(400, 'after is required');
117
+ const r = resolveDoc(ctx.root, body.path); if (r.error) return r.error;
118
+ let p; try { p = S.askAgentProposal({ before: r.text, after: body.after, prompt: body.prompt }); } catch (e) { return fail(400, e.message); }
119
+ const id = typeof body.id === 'string' && proposalsOf(ctx.runtimeDir).has(body.id) ? body.id : 'prop-' + require('crypto').randomBytes(6).toString('hex');
120
+ const prior = proposalsOf(ctx.runtimeDir).get(id);
121
+ if (prior && prior.path !== r.rel) return fail(400, 'that proposal belongs to another document');
122
+ const row = { event: prior ? 'revised' : 'proposed', id, path: r.rel, at: now.toISOString(), state: 'proposed', prompt: p.prompt, before_hash: p.before_hash, before_text: r.text, after: p.after, hash: p.hash };
123
+ append(ctx.runtimeDir, 'proposals.jsonl', row);
124
+ return { status: 200, body: { ok: true, id, state: 'proposed', hash: p.hash, diff: p.diff, voided_hash: prior ? prior.hash : null } };
125
+ }
126
+
127
+ function accept(req, body, ctx, now = new Date()) {
128
+ const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
129
+ const root = ctx.root;
130
+ const r = resolveDoc(root, body.path); if (r.error) return r.error;
131
+ const p = proposalsOf(ctx.runtimeDir).get(body.id);
132
+ if (!p || p.path !== r.rel) return fail(404, 'no such proposal');
133
+ const res = S.acceptProposal(p, { hash: body.hash, by: 'pm' }); // by is set here, never read from the body
134
+ if (!res.ok) return fail(409, res.reason, { reason: res.reason });
135
+ if (r.text !== p.before_text) return fail(409, 'stale: the document changed since this proposal was made', { reason: 'stale' });
136
+ const out = writeDoc(root, body.path, r.text, p.after, p.prompt, ctx.runtimeDir, ctx, ctx.w || null, now);
137
+ if (out.status === 200) append(ctx.runtimeDir, 'proposals.jsonl', { event: 'accepted', id: p.id, path: r.rel, at: now.toISOString(), state: 'accepted', hash: p.hash });
138
+ return out;
139
+ }
140
+
141
+ function changeNote(root, body, runtimeDir) {
142
+ const r = resolveDoc(root, body && body.path); if (r.error) return r.error;
143
+ const revs = revsOf(runtimeDir, r.rel);
144
+ const note = S.changeNote({ path: r.rel, rev: revs.length ? revs[revs.length - 1].rev : 1 }, Array.isArray(body.downstream) ? body.downstream : []);
145
+ return { status: 200, body: { ok: true, note } };
146
+ }
147
+
148
+ const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
149
+
150
+ module.exports = {
151
+ prefixes: ['/api/doc-sheet'],
152
+ resolveDoc, revisionsRoute, atRoute, edit, propose, accept, changeNote,
153
+ handle: async (req, res, url, ctx) => {
154
+ const p = url.pathname;
155
+ const w = (url.searchParams.get('ws') && ctx.getWs) ? ctx.getWs(url.searchParams.get('ws')) : null;
156
+ const base = { root: w ? w.root : (ctx.root || ROOT), runtimeDir: ctx.runtimeDir, stateDirFor: ctx.stateDirFor, w };
157
+ if (req.method === 'GET' && p === '/api/doc-sheet/revisions') return respond(ctx, res, revisionsRoute(base.root, Object.fromEntries(url.searchParams), base.runtimeDir));
158
+ if (req.method === 'GET' && p === '/api/doc-sheet/at') return respond(ctx, res, atRoute(base.root, Object.fromEntries(url.searchParams), base.runtimeDir));
159
+ const fn = req.method === 'POST' && { '/api/doc-sheet/edit': edit, '/api/doc-sheet/propose': propose, '/api/doc-sheet/accept': accept,
160
+ '/api/doc-sheet/change-note': (rq, b, c) => changeNote(c.root, b, c.runtimeDir) }[p];
161
+ if (!fn) return false;
162
+ const got = await ctx.readJson(req);
163
+ if (!got.ok) return respond(ctx, res, fail(400, 'invalid JSON body'));
164
+ const body = got.body || {};
165
+ const bw = body.ws && ctx.getWs ? ctx.getWs(body.ws) : w;
166
+ if (body.ws && !bw) return respond(ctx, res, fail(404, 'no workspace'));
167
+ return respond(ctx, res, fn(req, body, { ...base, root: bw ? bw.root : base.root, w: bw }));
168
+ },
169
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+ /* routes-forecast.js — console3 wave B5: the forecast, critical path, capacity and time machine of the Timeline. READ ONLY.
3
+ *
4
+ * GET /api/forecast?initiative=<id>[&ws=<id>] → { ok, initiative, today, items:[item + forecast], critical, problems, capacity, proposal }
5
+ * GET /api/forecast/at?initiative=<id>&date=YYYY-MM-DD → { ok, date, items } the plan as the fold of work events up to that day
6
+ * GET /api/forecast/events?initiative=<id> → { ok, events } the events the slider replays (from > the item history)
7
+ *
8
+ * Items are read as routes-timeline.js reads them: plan-items.readFold(root), the live (not proposed) items of one initiative.
9
+ * Dependencies are each item's `after` list. routes-timeline.js exports no reader, so this file does the same two lines.
10
+ * People and points come from <runtime>/people.json ({ people: [{ id, points_per_week, skills }] }) when it exists; an owner
11
+ * with no entry gets 5 points a week. Item `points` and `skill` are used when the item has them (default 1 point).
12
+ * LIMIT: the plan-items fold keeps only the current start and due, so the time machine shows an item's CURRENT dates from the
13
+ * day it was planned, and its state and later field changes from the day they happened. No write route exists.
14
+ */
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const planItems = require('./plan-items.js');
18
+ const F = require('../../scripts/plan-forecast.js');
19
+
20
+ const DEFAULT_PPW = 5;
21
+ const localYmd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
22
+
23
+ function liveItems(root, initiative, now) {
24
+ const fold = planItems.readFold(root, { now });
25
+ return Object.values(fold.items).filter((it) => it.initiative === initiative && !it.proposed);
26
+ }
27
+ function readPeople(ctx, items) {
28
+ let listed = [];
29
+ try { listed = JSON.parse(fs.readFileSync(path.join(ctx.runtimeDir, 'people.json'), 'utf8')).people || []; } catch { /* none */ }
30
+ const known = new Map(listed.filter((p) => p && p.id).map((p) => [p.id, { skills: [], ...p, points_per_week: Number(p.points_per_week) || DEFAULT_PPW }]));
31
+ for (const it of items) if (it.owner && !known.has(it.owner)) known.set(it.owner, { id: it.owner, points_per_week: DEFAULT_PPW, skills: [] });
32
+ return [...known.values()];
33
+ }
34
+ const shape = (it) => ({ id: it.id, title: it.title, start: it.start, due: it.due, state: it.state, after: it.after || [], owner: it.owner || null,
35
+ points: it.points, skill: it.skill, doneAt: it.doneAt ? String(it.doneAt).slice(0, 10) : null });
36
+
37
+ /** The work events of items: planned, then each set, moved. */
38
+ function eventsOf(items) {
39
+ const out = [];
40
+ for (const it of items) {
41
+ out.push({ at: it.createdAt, id: it.id, op: 'add', fields: { ...shape(it), state: 'todo', doneAt: null } });
42
+ for (const h of it.history || []) {
43
+ if (h.ev === 'set') out.push({ at: h.at, id: it.id, op: 'set', fields: h.fields });
44
+ else if (h.ev === 'moved') out.push({ at: h.at, id: it.id, op: 'set', fields: { state: h.to, doneAt: h.to === 'done' ? String(h.at).slice(0, 10) : null } });
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function build(w, ctx, initiative, now) {
51
+ const today = localYmd(new Date(now));
52
+ const items = liveItems(w.root, initiative, now).map(shape);
53
+ const f = F.forecast(items, null, today);
54
+ const cp = F.criticalPath(items, null);
55
+ const merged = items.map((it, i) => ({ ...it, forecast: f.rows[i] }));
56
+ const flat = merged.map((m) => ({ ...m, start: m.forecast.start, end: m.forecast.end }));
57
+ const capacity = F.capacityLoad(flat, readPeople(ctx, items), F.weeksFrom(today, 2));
58
+ return { ok: true, initiative, today, items: merged, critical: cp.ids, critical_days: cp.days, problems: f.problems, capacity, proposal: F.rebalanceProposal(capacity) };
59
+ }
60
+
61
+ module.exports = {
62
+ prefixes: ['/api/forecast'],
63
+ handle: async (req, res, url, ctx) => {
64
+ const p = url.pathname;
65
+ if (p !== '/api/forecast' && p !== '/api/forecast/at' && p !== '/api/forecast/events') return false;
66
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only: the forecast is derived, and plan changes go through the plan-items writer' }, 405); return true; }
67
+ const initiative = String(url.searchParams.get('initiative') || '');
68
+ if (!initiative) { ctx.json(res, { ok: false, error: 'initiative is required' }, 400); return true; }
69
+ const w = ctx.getWs(String(url.searchParams.get('ws') || ''));
70
+ if (!w || !w.root) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
71
+ let out;
72
+ try {
73
+ const now = Date.now();
74
+ if (p === '/api/forecast') out = build(w, ctx, initiative, now);
75
+ else if (p === '/api/forecast/events') out = { ok: true, events: eventsOf(liveItems(w.root, initiative, now)) };
76
+ else {
77
+ const date = String(url.searchParams.get('date') || '');
78
+ if (!/^\d{4}-\d\d-\d\d$/.test(date)) out = { ok: false, status: 400, error: 'date must be YYYY-MM-DD' };
79
+ else out = { ok: true, initiative, date, items: F.stateAt(eventsOf(liveItems(w.root, initiative, now)), date) };
80
+ }
81
+ } catch (e) { out = { ok: false, status: 500, error: 'the forecast could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }; }
82
+ ctx.json(res, out, out.ok ? 200 : out.status || 500);
83
+ return true;
84
+ },
85
+ };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ /* routes-hooks.js — the CLI hook endpoints. The policy is the PM's reply contract (scripts/reply-contract/reply-contract.js):
3
+ * permissionPolicy decides a permission request, stopDecision a Stop, askFromTool/askToolReply the CLI's question tool,
4
+ * planRequest/planReply a plan approval, sessionStartContext the first prompt, sessionSettings the per-session --settings.
5
+ *
6
+ * POST /api/hooks/<SessionStart|PreToolUse|PermissionRequest|Stop|Notification|SessionEnd> (also /api/hook/<pre-tool|permission-request|stop|notification|session-end>)
7
+ *
8
+ * The session token comes from the x-pmos-session header; the run is the session it names. Each call appends the event
9
+ * to <runtimeDir>/hooks/<session>.jsonl, and the contract's own hook event (src: "hook") to <runtimeDir>/replies/<session>.jsonl,
10
+ * where the fold reads it. A request the policy does not settle is denied "waiting for the PM" until the PM has answered it
11
+ * (lib/run-answers.js); then the answer decides. The policy's own denials are final.
12
+ */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const actor = require('./actor.js');
16
+ const runAnswers = require('./run-answers.js');
17
+ const C = require('../../scripts/reply-contract/reply-contract.js');
18
+ const R = require('../../scripts/replies.js');
19
+
20
+ const EVENTS = ['SessionStart', 'PreToolUse', 'PermissionRequest', 'Stop', 'Notification', 'SessionEnd'];
21
+ const ALIASES = { 'session-start': 'SessionStart', 'pre-tool': 'PreToolUse', 'permission-request': 'PermissionRequest', stop: 'Stop', notification: 'Notification', 'session-end': 'SessionEnd' };
22
+ const WAIT = 'waiting for the PM';
23
+ const ASK_TOOL = 'AskUserQuestion';
24
+ const PLAN_TOOL = 'ExitPlanMode';
25
+
26
+ const permit = (decision) => ({ status: 200, body: { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } } });
27
+ const whatOf = (inp) => String((inp && (inp.command || inp.file_path || inp.path || inp.pattern)) || '').slice(0, 300);
28
+
29
+ // The contract's hook event for what the CLI just told us. The workspace observed it, so it carries src "hook".
30
+ function eventFor(event, b, id) {
31
+ const inp = b.tool_input && typeof b.tool_input === 'object' ? b.tool_input : {};
32
+ switch (event) {
33
+ case 'SessionStart': return { kind: 'start' };
34
+ case 'PreToolUse': return { kind: 'tool', tool: b.tool_name || '', what: whatOf(inp) };
35
+ case 'PermissionRequest': return { kind: 'permission', id, tool: b.tool_name || '', command: whatOf(inp), cwd: b.cwd || '', why: '' };
36
+ case 'Stop': return { kind: 'stop', last_assistant_message: b.last_assistant_message || '' };
37
+ case 'Notification': return { kind: 'notify', ntype: b.notification_type || '', message: b.message || '' };
38
+ case 'SessionEnd': return { kind: 'end', exit: 0 };
39
+ default: return null;
40
+ }
41
+ }
42
+
43
+ function handleHook(eventIn, body, token, { sessDir, hooksDir, repliesDir, answersFile, rules, now = new Date() } = {}) {
44
+ const event = ALIASES[eventIn] || eventIn;
45
+ if (!EVENTS.includes(event)) return { status: 404, body: { ok: false, error: 'unknown hook event' } };
46
+ const who = actor.sessionForToken(token, { sessDir });
47
+ if (!who) return { status: 401, body: { ok: false, error: 'no valid session token' } };
48
+ const b = body && typeof body === 'object' ? body : {};
49
+ const run = R.runFor(who);
50
+ const file = path.join(hooksDir, who.session + '.jsonl');
51
+ const repliesFile = path.join(repliesDir || path.join(hooksDir, '..', 'replies'), who.session + '.jsonl');
52
+ const reqId = runAnswers.requestIdOf(b);
53
+ const answers = answersFile ? runAnswers.readAnswers(answersFile) : [];
54
+ const answer = answersFile ? runAnswers.answerFor(who.session, reqId, answers) : null;
55
+ const prior = R.readLines(file);
56
+ fs.mkdirSync(hooksDir, { recursive: true });
57
+ const logged = { event, session: who.session, initiative: who.initiative, at: now.toISOString(), body: b };
58
+
59
+ let out = { status: 200, body: { ok: true } };
60
+ if (event === 'PermissionRequest') {
61
+ const inp = b.tool_input && typeof b.tool_input === 'object' ? b.tool_input : {};
62
+ if (b.tool_name === PLAN_TOOL) {
63
+ const plan = C.planRequest({ tool: PLAN_TOOL, input: inp });
64
+ if (answer && answer.behavior === 'allow') out = { status: 200, body: C.planReply('approve') };
65
+ else if (answer && answer.behavior === 'deny') out = { status: 200, body: C.planReply('reject', answer.message) };
66
+ else out = permit({ behavior: 'deny', message: WAIT });
67
+ logged.plan = plan.plan || null;
68
+ } else {
69
+ const pol = C.permissionPolicy({ tool: b.tool_name, command: String(inp.command || ''), cwd: b.cwd || '' }, run, rules);
70
+ logged.policy = pol;
71
+ if (pol.act === 'deny') out = permit({ behavior: 'deny', message: pol.why });
72
+ else if (pol.act === 'allow') out = permit({ behavior: 'allow' });
73
+ else if (answer && (answer.behavior === 'allow' || answer.behavior === 'deny')) out = permit(answer.behavior === 'allow' ? { behavior: 'allow' } : { behavior: 'deny', message: answer.message || 'the PM denied this' });
74
+ else out = permit({ behavior: 'deny', message: WAIT });
75
+ }
76
+ } else if (event === 'PreToolUse' && b.tool_name === ASK_TOOL) {
77
+ const asked = C.askFromTool(b.tool_input);
78
+ if (!asked.error && answer && answer.behavior === 'answer') out = { status: 200, body: C.askToolReply([{ q: asked.asks[0].q, a: answer.message }]) };
79
+ else if (!asked.error) out = { status: 200, body: { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: WAIT } } };
80
+ } else if (event === 'SessionStart') {
81
+ const ctx = C.sessionStartContext(run, b.source);
82
+ if (ctx.hookSpecificOutput) out = { status: 200, body: ctx };
83
+ } else if (event === 'Stop') {
84
+ const vm = R.foldReplies(R.readLines(repliesFile), run);
85
+ const tries = prior.filter((l) => l && l.event === 'Stop' && l.blocked).length;
86
+ const d = C.stopDecision(vm, tries);
87
+ if (d.decision) { logged.blocked = true; out = { status: 200, body: d }; }
88
+ }
89
+
90
+ fs.appendFileSync(file, JSON.stringify(logged) + '\n');
91
+ const ev = eventFor(event, b, reqId);
92
+ if (ev) R.appendReply(repliesFile, { v: C.CUR_V, ...ev, run: run.id, session: run.session, src: 'hook' }, now);
93
+ return out;
94
+ }
95
+
96
+ const prefixes = ['/api/hooks', '/api/hook'];
97
+
98
+ async function handle(req, res, url, ctx) {
99
+ const m = /^\/api\/hooks?\/([A-Za-z-]+)$/.exec(url.pathname);
100
+ if (!m) return false;
101
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
102
+ const parsed = await ctx.readJson(req);
103
+ if (!parsed.ok) { ctx.json(res, { ok: false, error: 'body must be JSON' }, 400); return true; }
104
+ const token = req.headers['x-pmos-session'];
105
+ const r = handleHook(m[1], parsed.body, Array.isArray(token) ? token[0] : token, {
106
+ sessDir: ctx.sessDir || path.join(ctx.runtimeDir, 'sessions'),
107
+ hooksDir: path.join(ctx.runtimeDir, 'hooks'),
108
+ repliesDir: path.join(ctx.runtimeDir, 'replies'),
109
+ answersFile: runAnswers.answersFile(ctx.runtimeDir),
110
+ });
111
+ ctx.json(res, r.body, r.status);
112
+ return true;
113
+ }
114
+
115
+ // The JSON for `claude --settings <json>` on one session: hooks post to this workspace, the token goes in a header.
116
+ // `port` is the workspace's listening port. Call it where the workspace builds an agent session's launch command.
117
+ function settingsFor(port, host = '127.0.0.1') {
118
+ return C.sessionSettings('http://' + host + ':' + port + '/api');
119
+ }
120
+
121
+ module.exports = { prefixes, handle, handleHook, settingsFor, EVENTS };
@@ -57,6 +57,7 @@ const tidy = require('./tidy-documents.js');
57
57
  const jevDocs = require('./jev-documents.js');
58
58
  const tsSettings = require('./typesafe-settings');
59
59
  const docCatCorrections = require('./doc-category-corrections');
60
+ const { nineStages } = require('../../scripts/nine-stages.js'); // pure label layer; never workflow-state.js (it runs its CLI on require)
60
61
 
61
62
  const HORIZON_RANK = { now: 0, next: 1, later: 2 };
62
63
  const FILE_KEYS = [['brief_path', 'brief', 'Brief'], ['prd_path', 'prd', 'PRD'], ['rubric_path', 'rubric', 'Rubric'], ['doc_path', 'doc', 'Document']];
@@ -178,6 +179,8 @@ function rowOf(F, rec) {
178
179
  progress: { done: pr.done, total: pr.total, label: pr.label },
179
180
  next_due: nextDue(F, id),
180
181
  last_activity: rec.last_activity || null,
182
+ // The nine stage names over the engine's rung (scripts/nine-stages.js); on every row so Roadmap, List and sheet share one key set.
183
+ nine_stages: nineStages(rec.lane, { stage: rec.stage, steps: Array.isArray(rec.steps) ? rec.steps : [] }),
181
184
  };
182
185
  }
183
186
 
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ /* routes-intake.js — planning-flows run 4: the one-line intake draft.
3
+ * POST /api/intake/draft { line, acknowledged? } → { ok, draft }
4
+ * Drafts only. Creating the initiative stays with createInitiative, which refuses an unanchored create. */
5
+ const path = require('path');
6
+ const guard = require('./pm-guard.js');
7
+ const intake = require('../../scripts/intake.js');
8
+
9
+ const ROOT = path.resolve(__dirname, '..', '..');
10
+
11
+ module.exports = {
12
+ prefixes: ['/api/intake'],
13
+ handle: async (req, res, url, ctx) => {
14
+ if (req.method !== 'POST' || url.pathname !== '/api/intake/draft') return false;
15
+ const got = await ctx.readJson(req);
16
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
17
+ const body = got.body || {};
18
+ const g = guard.requirePagePress(req, body);
19
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
20
+ if (typeof body.line !== 'string' || !body.line.trim()) { ctx.json(res, { ok: false, error: 'line is required' }, 400); return true; }
21
+ ctx.json(res, { ok: true, draft: intake.intakeDraft({ line: body.line, root: ctx.root || ROOT, acknowledged: body.acknowledged === true }) });
22
+ return true;
23
+ },
24
+ };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ /* routes-outbox.js — the outbox queue (console3 wave B3). This wave sends NOTHING; no channel is connected.
3
+ *
4
+ * GET /api/outbox → { ok, entries: [{ id, channel, recipient, body, origin, state, hash,
5
+ * claims: { ok, refused: [line] }, last_result }], channels }
6
+ * POST /api/outbox/draft { channel, recipient, subject?, body } → { ok, entry } origin is 'pm' (a page press)
7
+ * POST /api/outbox/edit { id, body } → { ok, entry } a new hash; the old hash is void
8
+ * POST /api/outbox/confirm { id, hash, reason? } → { ok:false, reason:'no channel connected' } entry stays queued
9
+ * → 422 { ok:false, state:'blocked', refused } when a claim fails the trust rules
10
+ * and no written reason is given; with a reason it is an override (recorded)
11
+ *
12
+ * The hash, the claim rules, the PM-only confirm and the send all come from scripts/trust (the PM's sample).
13
+ *
14
+ * WHO WRITES. Every POST passes pm-guard. pm-guard's PM_ONLY_ACTS has no 'confirm' act, so every POST here uses
15
+ * requirePagePress (body, Origin and nonce checks): an agent session has no Origin and no nonce and is refused. The
16
+ * writer sets by:'pm'; no body field can. Registering a 'confirm' act needs a pm-guard edit, which this wave does not own.
17
+ * The handlers are pure over (req, body, runtimeDir) so a test needs no server.
18
+ */
19
+ const guard = require('./pm-guard.js');
20
+ const outbox = require('../../scripts/outbox.js');
21
+ const store = require('./outbox-store.js');
22
+ const crypto = require('crypto');
23
+
24
+ const fail = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
25
+
26
+ const claimCheck = outbox.claimCheck; // the trust.js claim rules over the body's claim lines
27
+
28
+ const view = (entry) => { const c = claimCheck(entry); return { ...entry, claims: { ok: c.ok, refused: c.refused } }; };
29
+
30
+ function list(runtimeDir) {
31
+ return { status: 200, body: { ok: true, entries: store.readOutbox(runtimeDir).map(view), channels: outbox.CHANNELS } };
32
+ }
33
+
34
+ function guarded(req, body) {
35
+ const g = guard.requirePagePress(req, body);
36
+ return g.ok ? null : fail(g.status, g.error);
37
+ }
38
+
39
+ function draft(req, body, runtimeDir, now = new Date()) {
40
+ const bad = guarded(req, body); if (bad) return bad;
41
+ let e;
42
+ try { e = outbox.draftOutbox({ channel: body.channel, recipient: body.recipient, subject: body.subject, body: body.body, origin: 'pm' }); }
43
+ catch (err) { return fail(400, err.message); }
44
+ const entry = { id: 'out-' + crypto.randomBytes(6).toString('hex'), ...e };
45
+ store.appendEvent(runtimeDir, { event: 'queued', ...entry }, now);
46
+ return { status: 200, body: { ok: true, entry: view({ ...entry, last_result: null }) } };
47
+ }
48
+
49
+ function edit(req, body, runtimeDir, now = new Date()) {
50
+ const bad = guarded(req, body); if (bad) return bad;
51
+ const cur = store.readOutbox(runtimeDir).find((e) => e.id === body.id);
52
+ if (!cur) return fail(404, 'no such outbox entry');
53
+ let e;
54
+ try { e = outbox.editOutbox(cur, { body: body.body }); } catch (err) { return fail(400, err.message); }
55
+ const { last_result, refused, created_at, ...rest } = e;
56
+ store.appendEvent(runtimeDir, { event: 'edited', ...rest }, now);
57
+ return { status: 200, body: { ok: true, entry: view({ ...e, last_result: null }), voided_hash: cur.hash } };
58
+ }
59
+
60
+ function confirm(req, body, runtimeDir, now = new Date(), senders) {
61
+ const bad = guarded(req, body); if (bad) return bad;
62
+ const cur = store.readOutbox(runtimeDir).find((e) => e.id === body.id);
63
+ if (!cur) return fail(404, 'no such outbox entry');
64
+ if (body.hash !== cur.hash) return fail(409, 'hash does not match the content', { reason: 'hash does not match the content' });
65
+ const claims = claimCheck(cur);
66
+ const reason = typeof body.reason === 'string' ? body.reason : '';
67
+ if (!claims.ok && !reason.trim()) {
68
+ return { status: 422, body: { ok: false, state: 'blocked', reason: 'unlabelled claim', refused: claims.refused } };
69
+ }
70
+ const { state, last_result, refused, created_at, claims: _c, ...content } = cur;
71
+ const r = senders ? outbox.confirmOutbox(content, { hash: body.hash, by: 'pm', reason }, senders) : outbox.confirmOutbox(content, { hash: body.hash, by: 'pm', reason });
72
+ if (!r.ok) {
73
+ if (r.reason === 'no channel connected') store.appendEvent(runtimeDir, { event: 'pressed', id: cur.id, hash: cur.hash, reason: r.reason, override: !claims.ok, override_reason: reason || null }, now);
74
+ return { status: r.reason === 'no channel connected' ? 200 : 409, body: { ok: false, reason: r.reason, state: 'queued' } };
75
+ }
76
+ // A sender is registered only in a later wave. The send is scripts/trust/outbox.js: queue, PM confirm bound to the hash, server execute.
77
+ store.executeSend(runtimeDir, cur, { reason: r.reason, claimsSend: claims.send }, senders[cur.channel], now);
78
+ return { status: 200, body: { ok: true, hash: r.hash, override: r.override, state: 'sent' } };
79
+ }
80
+
81
+ const ROUTES = { 'POST /api/outbox/draft': draft, 'POST /api/outbox/edit': edit, 'POST /api/outbox/confirm': confirm };
82
+
83
+ module.exports = {
84
+ prefixes: ['/api/outbox'],
85
+ claimCheck, list, draft, edit, confirm, ROUTES,
86
+ handle: async (req, res, url, ctx) => {
87
+ const p = url.pathname;
88
+ if (p === '/api/outbox' && req.method === 'GET') { const r = list(ctx.runtimeDir); ctx.json(res, r.body, r.status); return true; }
89
+ const fn = ROUTES[`${req.method} ${p}`];
90
+ if (!fn) return false;
91
+ const got = await ctx.readJson(req);
92
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
93
+ const r = fn(req, got.body || {}, ctx.runtimeDir);
94
+ ctx.json(res, r.body, r.status);
95
+ return true;
96
+ },
97
+ };