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,141 @@
1
+ 'use strict';
2
+ /* routes-roadmap-ops.js — the Roadmap's and the Breakdown's writes (console3 wave E1b; HANDOFF 14e/14f).
3
+ *
4
+ * POST /api/roadmap-ops/bet/add { title, anchor, horizon?, confidence?, initiative? } → { ok, rid } | 400 refusal
5
+ * POST /api/roadmap-ops/bet/move { id, to, confirmSwap? } → { ok, written } | { ok, proposal } (a full column: nothing moved)
6
+ * POST /api/roadmap-ops/bet/take-off { id, reason } → { ok } reason required
7
+ * GET /api/roadmap-ops/bet/anchor-suggestions?id= → { ok, suggestions } word overlap only, empty when none
8
+ * POST /api/roadmap-ops/breakdown/add { title, phase, source?, owner?, points?, initiative? } → { ok, rid }
9
+ * POST /api/roadmap-ops/breakdown/edit { rid, fields } → { ok } | { ok, proposal:true, proposals } for Jira-owned fields
10
+ * POST /api/roadmap-ops/breakdown/delete { rid } → { ok, undo_ms } | 409 with unlink advice for a Jira-backed item
11
+ * POST /api/roadmap-ops/breakdown/undo { rid } → { ok } inside 8 seconds
12
+ * POST /api/roadmap-ops/breakdown/dependency { rid, jira, remove? } → { ok } the item waits on that Jira ticket
13
+ * POST /api/roadmap-ops/people { id, points_per_week } → { ok, people } writes <runtime>/people.json
14
+ *
15
+ * STATE: bets in planning/records/bets.jsonl (scripts/records); breakdown items in <records>/breakdown-pmos.jsonl
16
+ * (scripts/breakdown-ops.js); people in <runtime>/people.json (the file routes-forecast.js already reads).
17
+ * WHO WRITES: every POST passes pm-guard requirePagePress; an agent session is refused (403). Pure handlers are exported.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const guard = require('./pm-guard.js');
22
+ const records = require(path.join(__dirname, '..', '..', 'scripts', 'records', 'index.js'));
23
+ const ledger = require(path.join(__dirname, '..', '..', 'scripts', 'trust', 'ledger.js'));
24
+ const roadmap = require(path.join(__dirname, '..', '..', 'scripts', 'roadmap-ops.js'));
25
+ const breakdown = require(path.join(__dirname, '..', '..', 'scripts', 'breakdown-ops.js'));
26
+
27
+ const REPO = path.join(__dirname, '..', '..');
28
+ const rootOf = (ctx) => (ctx && ctx.recordsDir) || path.join((ctx && ctx.repoRoot) || REPO, 'planning', 'records');
29
+ const UI = () => ledger.actorFrom({ ui: true });
30
+ const out = (status, body) => ({ status, body });
31
+ const refusal = (r, status = 400) => out(status, { ok: false, error: r.error, ...Object.fromEntries(Object.entries(r).filter(([k]) => !['ok', 'error'].includes(k))) });
32
+
33
+ function writeBets(root, r) {
34
+ const w = roadmap.apply(root, r.events, UI());
35
+ return w.ok ? out(200, { ok: true, written: w.written, rid: w.written[0], ...(r.swapped ? { swapped: r.swapped } : {}) }) : out(400, { ok: false, error: w.reasons.join('; '), reasons: w.reasons });
36
+ }
37
+ function writeBreakdown(root, r, extra, opts) {
38
+ const w = breakdown.apply(root, r.events, UI(), opts);
39
+ return w.ok ? out(200, { ok: true, ...extra }) : out(400, { ok: false, error: w.error });
40
+ }
41
+
42
+ /** Pure handlers: (root, body, ...) -> { status, body }. The press check happens before these. */
43
+ function betAdd(root, body, ctx) {
44
+ const krs = records.list(root, 'kr-metrics').map((k) => ({ id: k.rid, title: k.metric }));
45
+ const r = roadmap.addBet(records.list(root, 'bets'), body, { krs: (ctx && ctx.krs) || krs });
46
+ return r.ok ? writeBets(root, r) : refusal(r);
47
+ }
48
+ function betMove(root, body, ctx) {
49
+ const krs = records.list(root, 'kr-metrics').map((k) => ({ id: k.rid, title: k.metric }));
50
+ const r = roadmap.moveBet(records.list(root, 'bets'), body.id, body.to, body.capacity, { confirmSwap: body.confirmSwap === true, krs: (ctx && ctx.krs) || krs });
51
+ if (!r.ok) return refusal(r);
52
+ return r.proposal ? out(200, { ok: true, proposal: r.proposal }) : writeBets(root, r);
53
+ }
54
+ function betTakeOff(root, body) {
55
+ const r = roadmap.takeOff(records.list(root, 'bets'), body.id, body.reason);
56
+ return r.ok ? writeBets(root, r) : refusal(r);
57
+ }
58
+ function healthBudgets(repoRoot) {
59
+ try {
60
+ const t = fs.readFileSync(path.join(repoRoot, 'planning', 'okrs', 'health-budgets.md'), 'utf8');
61
+ return [...t.matchAll(/^\|\s*`health:([a-z0-9-]+)`\s*\|\s*([^|]+?)\s*\|/gm)].map((m) => ({ id: 'health:' + m[1], title: m[2] }));
62
+ } catch { return []; }
63
+ }
64
+ function betAnchorSuggestions(root, query, ctx) {
65
+ const bet = records.list(root, 'bets').find((b) => b.rid === (query && query.id));
66
+ if (!bet) return out(404, { ok: false, error: 'no such bet' });
67
+ const krs = (ctx && ctx.krs) || records.list(root, 'kr-metrics').map((k) => ({ id: k.rid, title: k.metric }));
68
+ const hb = (ctx && ctx.healthBudgets) || healthBudgets((ctx && ctx.repoRoot) || REPO);
69
+ return out(200, { ok: true, id: bet.rid, suggestions: roadmap.anchorSuggestions(bet, krs, hb) });
70
+ }
71
+ function breakdownAdd(root, body) {
72
+ const r = breakdown.addItem(breakdown.list(root), body);
73
+ return r.ok ? writeBreakdown(root, r, { rid: r.rid }) : refusal(r);
74
+ }
75
+ function breakdownEdit(root, body) {
76
+ const r = breakdown.editItem(breakdown.list(root), body.rid, body.fields);
77
+ if (!r.ok) return refusal(r, /no such/.test(r.error) ? 404 : 400);
78
+ const extra = r.proposal ? { proposal: true, proposals: r.proposals, note: r.note, applied: r.events.length > 0 } : {};
79
+ return r.events.length ? writeBreakdown(root, r, extra) : out(200, { ok: true, ...extra });
80
+ }
81
+ function breakdownDelete(root, body, opts) {
82
+ const r = breakdown.deleteItem(breakdown.list(root), body.rid, opts);
83
+ if (!r.ok) return refusal(r, r.jira_backed ? 409 : /no such/.test(r.error) ? 404 : 400);
84
+ return writeBreakdown(root, r, { undo_ms: r.undo_ms }, opts);
85
+ }
86
+ function breakdownUndo(root, body, opts) {
87
+ const r = breakdown.undoEvent(breakdown.readLines(root), body.rid, opts && opts.now);
88
+ return r.ok ? writeBreakdown(root, r, {}, opts) : refusal(r, r.expired ? 410 : 404);
89
+ }
90
+ function breakdownDependency(root, body) {
91
+ const r = breakdown.setDependency(breakdown.list(root), body.rid, body.jira, { remove: body.remove === true });
92
+ return r.ok ? writeBreakdown(root, r, {}) : refusal(r);
93
+ }
94
+ function setPeople(runtimeDir, body) {
95
+ const ppw = Number(body && body.points_per_week);
96
+ if (!body || !String(body.id || '').trim()) return out(400, { ok: false, error: 'a person id is required' });
97
+ if (!Number.isFinite(ppw) || ppw < 0 || ppw > 100) return out(400, { ok: false, error: 'points_per_week must be a number from 0 to 100' });
98
+ const file = path.join(runtimeDir, 'people.json');
99
+ let doc = { people: [] };
100
+ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* none yet */ }
101
+ const people = Array.isArray(doc.people) ? doc.people : [];
102
+ const i = people.findIndex((p) => p && p.id === body.id);
103
+ if (i >= 0) people[i] = { ...people[i], points_per_week: ppw }; else people.push({ id: body.id, points_per_week: ppw, skills: [] });
104
+ fs.mkdirSync(runtimeDir, { recursive: true });
105
+ fs.writeFileSync(file, JSON.stringify({ ...doc, people }, null, 2) + '\n');
106
+ return out(200, { ok: true, people });
107
+ }
108
+
109
+ const POSTS = {
110
+ '/bet/add': (c, b) => betAdd(rootOf(c), b, c),
111
+ '/bet/move': (c, b) => betMove(rootOf(c), b, c),
112
+ '/bet/take-off': (c, b) => betTakeOff(rootOf(c), b),
113
+ '/breakdown/add': (c, b) => breakdownAdd(rootOf(c), b),
114
+ '/breakdown/edit': (c, b) => breakdownEdit(rootOf(c), b),
115
+ '/breakdown/delete': (c, b) => breakdownDelete(rootOf(c), b),
116
+ '/breakdown/undo': (c, b) => breakdownUndo(rootOf(c), b),
117
+ '/breakdown/dependency': (c, b) => breakdownDependency(rootOf(c), b),
118
+ '/people': (c, b) => setPeople(c.runtimeDir, b),
119
+ };
120
+
121
+ module.exports = {
122
+ prefixes: ['/api/roadmap-ops'],
123
+ betAdd, betMove, betTakeOff, betAnchorSuggestions, breakdownAdd, breakdownEdit, breakdownDelete, breakdownUndo, breakdownDependency, setPeople, healthBudgets, rootOf,
124
+ handle: async (req, res, url, ctx) => {
125
+ const sub = url.pathname.slice('/api/roadmap-ops'.length);
126
+ if (req.method === 'GET' && sub === '/bet/anchor-suggestions') {
127
+ const r = betAnchorSuggestions(rootOf(ctx), { id: url.searchParams.get('id') }, ctx);
128
+ ctx.json(res, r.body, r.status);
129
+ return true;
130
+ }
131
+ if (req.method !== 'POST' || !POSTS[sub]) return false;
132
+ const got = await ctx.readJson(req);
133
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
134
+ const body = got.body || {};
135
+ const g = guard.requirePagePress(req, body);
136
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
137
+ const r = POSTS[sub](ctx, body);
138
+ ctx.json(res, r.body, r.status);
139
+ return true;
140
+ },
141
+ };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+ /* routes-runs.js — the agent runs surface (console3 wave A3).
3
+ *
4
+ * GET /api/runs the sessions grouped (scripts/sessions-list.js) and, per run, its card (scripts/card-state.js)
5
+ * POST /api/runs/answer { run, request, behavior, message? } the PM answers a held request (page press only)
6
+ * POST /api/runs/correction { run, kind, reason } a Reject or Revise, counted (page press only)
7
+ *
8
+ * Runs are read from the session meta files (<runtimeDir>/sessions/<id>.json), the reply lines
9
+ * (<runtimeDir>/replies/<id>.jsonl) and the hook lines (<runtimeDir>/hooks/<id>.jsonl). Nothing is sent outward.
10
+ */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const guard = require('./pm-guard.js');
14
+ const actor = require('./actor.js');
15
+ const runAnswers = require('./run-answers.js');
16
+ const { groupSessions } = require('../../scripts/sessions-list.js');
17
+ const { cardFor, correctionEvent } = require('../../scripts/card-state.js');
18
+ const { readLines, runFor, foldReplies } = require('../../scripts/replies.js');
19
+ const C = require('../../scripts/reply-contract/reply-contract.js');
20
+ const { appendOutbox } = require('../../scripts/outbox.js');
21
+
22
+ const prefixes = ['/api/runs'];
23
+ const RUN_RE = /^sess-\d+$/;
24
+
25
+ function sessionsOf(sessDir) {
26
+ let names = [];
27
+ try { names = fs.readdirSync(sessDir).filter((n) => /^sess-\d+\.json$/.test(n)); } catch { return []; }
28
+ const out = [];
29
+ for (const n of names) {
30
+ let meta; let st;
31
+ try { const f = path.join(sessDir, n); meta = JSON.parse(fs.readFileSync(f, 'utf8')); st = fs.statSync(f); } catch { continue; }
32
+ if (!meta || !meta.id) continue;
33
+ const exited = meta.state === 'exited';
34
+ out.push({ id: meta.id, plan_item: meta.plan_item || null, name: meta.name || meta.id, origin: meta.origin || 'manual', cardId: meta.plan_item || null,
35
+ startedAt: meta.created || null, endedAt: exited ? st.mtimeMs : null });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Open permission requests: a PermissionRequest line with no PM answer yet.
41
+ function pendingFor(run, hookLines, answers) {
42
+ const seen = new Set();
43
+ const out = [];
44
+ for (const l of hookLines) {
45
+ if (!l || l.event !== 'PermissionRequest') continue;
46
+ const id = runAnswers.requestIdOf(l.body);
47
+ if (l.policy && l.policy.act !== 'card') continue; // the contract's policy settled it; no card
48
+ if (seen.has(id) || runAnswers.answerFor(run, id, answers)) continue;
49
+ seen.add(id);
50
+ const b = l.body || {};
51
+ const inp = b.tool_input && typeof b.tool_input === 'object' ? b.tool_input : {};
52
+ out.push({ request: id, tool: b.tool_name || '', command: String(inp.command || inp.file_path || '').slice(0, 300), cwd: b.cwd || '', at: l.at });
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function lastAtOf(lines) {
58
+ const t = lines.map((l) => l && l.at && Date.parse(l.at)).filter(Number.isFinite);
59
+ return t.length ? Math.max(...t) : 0;
60
+ }
61
+
62
+ function view(ctx, now = Date.now()) {
63
+ const sessions = sessionsOf(ctx.sessDir || path.join(ctx.runtimeDir, 'sessions'));
64
+ const answers = runAnswers.readAnswers(runAnswers.answersFile(ctx.runtimeDir));
65
+ const groups = groupSessions(sessions, now);
66
+ const cards = {};
67
+ for (const s of sessions) {
68
+ const live = s.endedAt == null;
69
+ const recent = !live && now - s.endedAt <= 7 * 864e5;
70
+ if (!live && !recent) continue;
71
+ const lines = readLines(path.join(ctx.runtimeDir, 'replies', s.id + '.jsonl'));
72
+ const run = runFor({ session: s.id, plan_item: s.plan_item });
73
+ const card = cardFor(lines, { run, extra: runAnswers.asEvents(run, answers), ended: !live, ctx: { now, lastAt: lastAtOf(lines) } });
74
+ card.asked = card.ask ? card.ask.id : null;
75
+ card.permissions = pendingFor(s.id, readLines(path.join(ctx.runtimeDir, 'hooks', s.id + '.jsonl')), answers);
76
+ cards[s.id] = card;
77
+ }
78
+ return { ok: true, groups, cards };
79
+ }
80
+
81
+ async function handle(req, res, url, ctx) {
82
+ const p = url.pathname;
83
+ if (p === '/api/runs') {
84
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
85
+ ctx.json(res, view(ctx));
86
+ return true;
87
+ }
88
+ if (p !== '/api/runs/answer' && p !== '/api/runs/correction') return false;
89
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
90
+ const got = await ctx.readJson(req);
91
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'body must be JSON' }, 400); return true; }
92
+ const body = got.body;
93
+ const tok = req.headers && req.headers['x-pmos-session'];
94
+ if (tok) { ctx.json(res, { ok: false, error: 'an agent session cannot answer a run' }, 403); return true; }
95
+ const g = guard.requirePagePress(req, body);
96
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
97
+ if (p === '/api/runs/answer') {
98
+ // An answer to the agent's own question must be one of the options it offered (the contract's answerLine).
99
+ if (body.behavior === 'answer' && typeof body.run === 'string' && RUN_RE.test(body.run)) {
100
+ const run = runFor({ session: body.run });
101
+ const open = foldReplies(readLines(path.join(ctx.runtimeDir, 'replies', body.run + '.jsonl')), run).ask;
102
+ if (open && open.id === body.request) {
103
+ const al = C.answerLine(run, open, body.message, { by: 'pm', via: 'ui' });
104
+ if (al.error) { ctx.json(res, { ok: false, error: al.error }, 400); return true; }
105
+ }
106
+ }
107
+ const r = runAnswers.recordAnswer(runAnswers.answersFile(ctx.runtimeDir), body, actor.fromUi());
108
+ ctx.json(res, r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? 200 : r.status);
109
+ return true;
110
+ }
111
+ if (typeof body.run !== 'string' || !RUN_RE.test(body.run)) { ctx.json(res, { ok: false, error: 'run must be a session id' }, 400); return true; }
112
+ let ev;
113
+ try { ev = correctionEvent(body.kind, body.reason, body.run); } catch (e) { ctx.json(res, { ok: false, error: e.message }, 400); return true; }
114
+ appendOutbox(path.join(ctx.runtimeDir, 'corrections.jsonl'), ev);
115
+ ctx.json(res, { ok: true });
116
+ return true;
117
+ }
118
+
119
+ module.exports = { prefixes, handle, view, sessionsOf };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ /* routes-signals.js — the Signals page records (console3 wave E2a). Rules from scripts/signal-rules.js.
3
+ *
4
+ * GET /api/signals → { ok, rules, readings, trips, open } readings = latest per metric; open = tripping now
5
+ * POST /api/signals/rule { id, metric, op, threshold, severity } → { ok, rule }
6
+ * POST /api/signals/reading { metric, value, read_at? } → { ok, trips } trips this reading started
7
+ *
8
+ * Every POST passes pm-guard requirePagePress: an agent token has no Origin and no nonce and is refused (403).
9
+ * File: <runtime>/signals/signals.jsonl (append only, scripts/trust/ledger.js). */
10
+ const path = require('path');
11
+ const guard = require('./pm-guard.js');
12
+ const S = require('../../scripts/signal-rules.js');
13
+ const L = require('../../scripts/trust/ledger.js');
14
+
15
+ const fileOf = (runtimeDir) => path.join(runtimeDir, 'signals', 'signals.jsonl');
16
+ const fail = (status, error) => ({ status, body: { ok: false, error } });
17
+
18
+ function list(runtimeDir) { return { status: 200, body: { ok: true, ...S.foldSignals(L.read(fileOf(runtimeDir))) } }; }
19
+
20
+ function ruleRoute(req, body, runtimeDir, now = new Date()) {
21
+ const g = guard.requirePagePress(req, body);
22
+ if (!g.ok) return fail(g.status, g.error);
23
+ try { const l = S.appendRule(fileOf(runtimeDir), body, L.actorFrom({ ui: true }), now.toISOString()); return { status: 200, body: { ok: true, rule: l.rule } }; }
24
+ catch (e) { return fail(400, e.message); }
25
+ }
26
+
27
+ function readingRoute(req, body, runtimeDir, now = new Date()) {
28
+ const g = guard.requirePagePress(req, body);
29
+ if (!g.ok) return fail(g.status, g.error);
30
+ try { const r = S.appendReading(fileOf(runtimeDir), { metric: body.metric, value: body.value, at: body.read_at }, L.actorFrom({ ui: true }), now.toISOString()); return { status: 200, body: { ok: true, trips: r.trips.map((t) => ({ rule_id: t.rule_id, severity: t.severity, reading: t.reading })) } }; }
31
+ catch (e) { return fail(400, e.message); }
32
+ }
33
+
34
+ const ROUTES = { 'POST /api/signals/rule': ruleRoute, 'POST /api/signals/reading': readingRoute };
35
+
36
+ module.exports = {
37
+ prefixes: ['/api/signals'],
38
+ list, ruleRoute, readingRoute, ROUTES, fileOf,
39
+ handle: async (req, res, url, ctx) => {
40
+ const p = url.pathname;
41
+ if (p === '/api/signals' && req.method === 'GET') { const r = list(ctx.runtimeDir); ctx.json(res, r.body, r.status); return true; }
42
+ const fn = ROUTES[`${req.method} ${p}`];
43
+ if (!fn) return false;
44
+ const got = await ctx.readJson(req);
45
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
46
+ const r = fn(req, got.body || {}, ctx.runtimeDir);
47
+ ctx.json(res, r.body, r.status);
48
+ return true;
49
+ },
50
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ /* run-answers.js — the PM's answers to a held request from a run (planning-flows console3 wave A3).
3
+ *
4
+ * requestIdOf(hookBody) → a stable id for a PermissionRequest body
5
+ * recordAnswer(file, input, actor, now) → { ok, line } | { ok: false, status, error } single-writer append
6
+ * readAnswers(file) → the lines
7
+ * answerFor(run, requestId, lines) → the PM's latest recorded answer, or null
8
+ * asEvents(run, lines) → the contract's fold events (answer, decision) for one run's PM answers
9
+ *
10
+ * ONLY a PM can answer. The route passes pm-guard requirePagePress; recordAnswer checks the actor again, so a call that
11
+ * carries an agent actor (a session token) is refused here too. The answer file is append-only JSONL. Nothing is sent
12
+ * outward: the hook endpoint reads the answer when the CLI asks again.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+ const actor = require('./actor.js');
18
+ const { appendOutbox } = require('../../scripts/outbox.js');
19
+
20
+ const BEHAVIORS = ['allow', 'deny', 'accept', 'answer'];
21
+ const RUN_RE = /^sess-\d+$/;
22
+
23
+ function requestIdOf(body) {
24
+ const b = body && typeof body === 'object' ? body : {};
25
+ if (typeof b.tool_use_id === 'string' && b.tool_use_id) return b.tool_use_id.slice(0, 120);
26
+ const basis = JSON.stringify([b.tool_name || '', b.tool_input || null]);
27
+ return 'req-' + crypto.createHash('sha256').update(basis).digest('hex').slice(0, 16);
28
+ }
29
+
30
+ function recordAnswer(file, input, who, now = new Date()) {
31
+ if (!actor.isPm(who)) return { ok: false, status: 403, error: 'only the PM can answer a run' };
32
+ const i = input && typeof input === 'object' ? input : {};
33
+ if (typeof i.run !== 'string' || !RUN_RE.test(i.run)) return { ok: false, status: 400, error: 'run must be a session id' };
34
+ if (typeof i.request !== 'string' || !i.request || i.request.length > 120) return { ok: false, status: 400, error: 'request must be a short id' };
35
+ if (!BEHAVIORS.includes(i.behavior)) return { ok: false, status: 400, error: 'behavior must be one of ' + BEHAVIORS.join(', ') };
36
+ const message = typeof i.message === 'string' ? i.message.slice(0, 400) : '';
37
+ const line = { type: 'answer', run: i.run, request: i.request, behavior: i.behavior, message, by: who.by };
38
+ appendOutbox(file, line, now);
39
+ return { ok: true, line };
40
+ }
41
+
42
+ function readAnswers(file) {
43
+ try { return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((s) => { try { return JSON.parse(s); } catch { return null; } }); }
44
+ catch { return []; }
45
+ }
46
+
47
+ function answerFor(run, requestId, lines = []) {
48
+ let found = null;
49
+ for (const l of lines) {
50
+ if (l && l.type === 'answer' && l.run === run && l.request === requestId && typeof l.by === 'string' && l.by.startsWith('pm:')) found = l;
51
+ }
52
+ return found;
53
+ }
54
+
55
+ // The PM's recorded answers as the contract's own events (reply-contract.js fold): an answer to an ask, a decision on a
56
+ // permission request. Only lines written by a PM become events; the fold then checks the ids against what is open.
57
+ function asEvents(run, lines = []) {
58
+ const out = [];
59
+ for (const l of lines) {
60
+ if (!l || l.type !== 'answer' || l.run !== run.id || typeof l.by !== 'string' || !l.by.startsWith('pm:')) continue;
61
+ if (l.behavior === 'answer') out.push({ kind: 'answer', run: run.id, session: run.session, ask_id: l.request, answer: l.message, by: 'pm' });
62
+ else if (l.behavior === 'allow' || l.behavior === 'deny') out.push({ kind: 'decision', run: run.id, session: run.session, perm_id: l.request, decision: l.behavior, by: 'pm', at: l.at });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ const answersFile = (runtimeDir) => path.join(runtimeDir, 'run-answers.jsonl');
68
+
69
+ module.exports = { BEHAVIORS, requestIdOf, recordAnswer, readAnswers, answerFor, asEvents, answersFile };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.123.0",
3
+ "version": "0.125.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.123.0",
9
+ "version": "0.125.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.123.0",
3
+ "version": "0.125.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",
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en" data-pm-page-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="pmos-nonce" content="" />
7
+ <title>PMOS console</title>
8
+ <!-- The console3 shell as its own page (header, tabs, three columns, dock). It composes the real views from pm/; the main
9
+ app at / is unchanged. Tokens and fonts are the main app's, so light and dark both resolve. pm/c3-shell.js sets the
10
+ theme attribute (default light, like the mockup). The nonce meta is filled by the server the way it is for /, so a press
11
+ from a view (Add, Accept, Allow) carries it. -->
12
+ <link rel="preload" href="/vendor/ibm-plex-mono-400.woff2" as="font" type="font/woff2" crossorigin />
13
+ <link rel="preload" href="/vendor/ibm-plex-mono-500.woff2" as="font" type="font/woff2" crossorigin />
14
+ <link rel="stylesheet" href="/pm/tokens.css" />
15
+ <style>
16
+ html { background: var(--background); }
17
+ body { margin: 0; background: var(--background); color: var(--foreground); }
18
+ noscript { display: block; padding: 16px; font-family: var(--font-mono, monospace); }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <div id="c3-root" class="c3 c3pg" data-pm-theme="light"></div>
23
+ <noscript>This page needs JavaScript.</noscript>
24
+ <script type="module" src="/pm/c3-shell.js"></script>
25
+ </body>
26
+ </html>