create-pmos 0.53.0 → 0.53.1

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 (47) 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/README.md +2 -0
  7. package/workspace/REMOTE.md +24 -63
  8. package/workspace/bin/pmos.js +4 -2
  9. package/workspace/guide/command-palette.md +1 -1
  10. package/workspace/guide/index.md +2 -1
  11. package/workspace/guide/launcher.md +51 -0
  12. package/workspace/guide/layout.md +4 -0
  13. package/workspace/guide/proactive-engine.md +21 -5
  14. package/workspace/lib/board-fold.js +2 -2
  15. package/workspace/lib/breakdown-export.js +1 -6
  16. package/workspace/lib/brief.js +20 -2
  17. package/workspace/lib/goals.js +165 -0
  18. package/workspace/lib/initiative-rows.js +7 -3
  19. package/workspace/lib/launcher-cli.js +207 -0
  20. package/workspace/lib/plan-tree.js +15 -2
  21. package/workspace/lib/pmos-verbs.js +3 -0
  22. package/workspace/lib/proactive-cli.js +58 -2
  23. package/workspace/lib/proactive.js +2 -2
  24. package/workspace/lib/qr.js +293 -0
  25. package/workspace/lib/remote-access.js +93 -1
  26. package/workspace/lib/report-draft.js +1 -2
  27. package/workspace/lib/routes-timeline.js +1 -1
  28. package/workspace/package-lock.json +1 -1
  29. package/workspace/package.json +1 -1
  30. package/workspace/public/icons/apple-touch-icon.png +0 -0
  31. package/workspace/public/icons/icon-192.png +0 -0
  32. package/workspace/public/icons/icon-512.png +0 -0
  33. package/workspace/public/index.html +11 -0
  34. package/workspace/public/pm/app.js +39 -8
  35. package/workspace/public/pm/components.css +82 -1
  36. package/workspace/public/pm/dest-today.js +8 -2
  37. package/workspace/public/pm/dock.js +73 -3
  38. package/workspace/public/pm/frame-copy.js +5 -0
  39. package/workspace/public/pm/panel-tabs.js +14 -12
  40. package/workspace/public/pm/today-copy.js +29 -0
  41. package/workspace/public/pm/today-goals.js +172 -0
  42. package/workspace/replaced-tests.json +13 -0
  43. package/workspace/server.js +134 -4
  44. package/workspace/skills/morning-brief.skill +15 -9
  45. package/workspace/test-files.json +2 -0
  46. package/workspace/test-lists/workspace-launcher.json +5 -0
  47. package/workspace/test-lists/workspace-phone-app.json +5 -0
@@ -1977,12 +1977,55 @@
1977
1977
  background: var(--sidebar);
1978
1978
  }
1979
1979
  .fr-dock {
1980
- width: clamp(360px, 36vw, 640px);
1980
+ width: var(--fr-dock-w, clamp(360px, 36vw, 640px));
1981
+ max-width: calc(100vw - 280px);
1981
1982
  border-left: 1px solid var(--border);
1982
1983
  }
1984
+ /* 0.53.1: while the dock is open its rail is a thin grip on the dock's left edge that resizes it (dock.js dockGrip). */
1985
+ .fr-dock-grip {
1986
+ display: none;
1987
+ }
1983
1988
  .fr-body[data-fr-dock="open"] > .fr-dock-rail {
1989
+ width: 6px;
1990
+ padding: 0;
1991
+ border-left: 0;
1992
+ align-items: stretch;
1993
+ background: transparent;
1994
+ }
1995
+ .fr-body[data-fr-dock="open"] > .fr-dock-rail > :not(.fr-dock-grip) {
1996
+ display: none;
1997
+ }
1998
+ .fr-body[data-fr-dock="open"] .fr-dock-grip {
1999
+ display: block;
2000
+ flex: 1;
2001
+ cursor: col-resize;
2002
+ touch-action: none;
2003
+ outline: none;
2004
+ }
2005
+ /* The grip shows a 1px line without hover (a touch screen has none); a press, a drag or the keyboard focus fills it. */
2006
+ .fr-body[data-fr-dock="open"] .fr-dock-grip {
2007
+ border-left: 1px solid var(--border);
2008
+ }
2009
+ .fr-dock-grip:focus-visible,
2010
+ .fr-dock-grip[data-dragging] {
2011
+ background: var(--ring);
2012
+ }
2013
+ @media (hover: hover) {
2014
+ .fr-dock-grip:hover {
2015
+ background: var(--ring);
2016
+ }
2017
+ }
2018
+ /* 0.53.1 Focus: the dock has the whole window; the page, the detail and the rail step aside (they stay mounted). */
2019
+ .fr-body[data-fr-dock="max"] > .fr-main,
2020
+ .fr-body[data-fr-dock="max"] > .fr-detail,
2021
+ .fr-body[data-fr-dock="max"] > .fr-dock-rail {
1984
2022
  display: none;
1985
2023
  }
2024
+ .fr-body[data-fr-dock="max"] > .fr-dock {
2025
+ width: auto;
2026
+ max-width: none;
2027
+ border-left: 0;
2028
+ }
1986
2029
  .fr-body[data-fr-dock="rail"] > .fr-dock {
1987
2030
  display: none !important;
1988
2031
  }
@@ -2054,6 +2097,7 @@
2054
2097
  .fr-detail,
2055
2098
  .fr-dock {
2056
2099
  width: auto;
2100
+ max-width: none;
2057
2101
  border-left: 0;
2058
2102
  }
2059
2103
  .fr-set {
@@ -2104,6 +2148,43 @@
2104
2148
  flex-direction: column;
2105
2149
  gap: 8px;
2106
2150
  }
2151
+ /* today-goals (G6): the goals for today, above what is owed */
2152
+ .td-goals {
2153
+ display: flex;
2154
+ flex-direction: column;
2155
+ gap: 8px;
2156
+ padding: 8px 12px;
2157
+ border: 1px solid var(--border);
2158
+ background: var(--card);
2159
+ }
2160
+ .td-goals__head { display: flex; align-items: center; }
2161
+ .td-goals__list, .td-goal__steps, .td-goal__ctx-list {
2162
+ list-style: none;
2163
+ margin: 0;
2164
+ padding: 0;
2165
+ display: flex;
2166
+ flex-direction: column;
2167
+ gap: 6px;
2168
+ }
2169
+ .td-goals__list { counter-reset: goal; }
2170
+ .td-goal { display: flex; flex-direction: column; gap: 4px; }
2171
+ .td-goal__head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
2172
+ .td-goal__text { color: var(--foreground); font-weight: 500; }
2173
+ .td-goal__text::before { counter-increment: goal; content: counter(goal) ". "; color: var(--muted-foreground); }
2174
+ .td-goal--closed .td-goal__text { color: var(--muted-foreground); text-decoration: line-through; }
2175
+ .td-goal__ctrls { display: inline-flex; gap: 4px; margin-left: auto; }
2176
+ .td-goal__body { display: flex; flex-direction: column; gap: 4px; padding-left: 16px; }
2177
+ .td-goal__step { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
2178
+ .td-goal__step-title { color: var(--foreground); }
2179
+ .td-goal__meta, .td-goal__ctx, .td-goal__none, .td-goals__note, .td-goal__prep-text {
2180
+ color: var(--muted-foreground);
2181
+ font-size: 12px;
2182
+ line-height: 16px;
2183
+ margin: 0;
2184
+ }
2185
+ .td-goal__prep { display: inline-flex; align-items: center; gap: 8px; }
2186
+ .td-goals__bar { display: flex; gap: 8px; }
2187
+ .td-goals__sub { margin: 4px 0 0; font-size: 12px; font-weight: 600; color: var(--muted-foreground); }
2107
2188
  .td-card {
2108
2189
  display: flex;
2109
2190
  flex-direction: column;
@@ -21,6 +21,7 @@ import { wsOf } from './data.js';
21
21
  import { state, setState, FIXTURE } from './state.js';
22
22
  import { COPY } from './today-copy.js';
23
23
  import { todayCard, acceptCard, rankNodes, scopeCtx, ensureFeed, feedOf, feedErrorOf, bannerOf, onFeed } from './today-cards.js';
24
+ import { goalsSection, goalsSig } from './today-goals.js';
24
25
 
25
26
  const SHOWN = 3; // cards drawn before "Show more": the first paint holds at most 17 controls (first-paint-today.test.js)
26
27
  let expanded = false;
@@ -117,7 +118,10 @@ function build(ctx0) {
117
118
  ? button({ label: expanded ? COPY.fewer : COPY.more(nodes.length - SHOWN), variant: 'ghost', attrs: { 'data-today': 'more' }, onClick: () => { expanded = !expanded; setState({}); } })
118
119
  : null;
119
120
  nodes.forEach((node) => node.setAttribute('role', 'listitem'));
120
- return { children: [head, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner]) + nodes.map((x) => x.outerHTML).join('') };
121
+ // today-goals (G6): the PM's goals sit above what is owed; they are joined to the cards of the first repository in scope
122
+ const goalsWs = sc.repos.length ? wsOf(sc.repos[0]) : null;
123
+ const goals = goalsSection({ ws: goalsWs, setState: ctx0.setState || setState });
124
+ return { children: [head, goals, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner]) + goalsSig(goalsWs) + nodes.map((x) => x.outerHTML).join('') };
121
125
  }
122
126
 
123
127
  /** The key of the control that has the focus inside the board, so it can be focused again after a rebuild. */
@@ -128,6 +132,7 @@ function focusKey(root) {
128
132
  const attr = ['data-today-act', 'data-today-more', 'data-today-add-note', 'data-today-accept', 'data-today-return'].find((k) => a.hasAttribute(k));
129
133
  if (card && attr) return { id: card.getAttribute('data-today-id'), attr };
130
134
  if (a.closest('[data-today-note]')) return { id: card && card.getAttribute('data-today-id'), note: true };
135
+ if (a.getAttribute('data-goals') === 'input') return { goalsInput: true, caret: a.selectionStart };
131
136
  if (a.hasAttribute('data-today-scope')) return { scope: a.getAttribute('data-today-scope') };
132
137
  if (a.hasAttribute('data-today') && a.getAttribute('data-today') === 'more') return { more: true };
133
138
  return null;
@@ -138,9 +143,10 @@ function refocus(root, k) {
138
143
  let t = null;
139
144
  if (k.note && card) t = card.querySelector('[data-today-note] input, [data-today-note] textarea, input[data-today-note], textarea[data-today-note]');
140
145
  else if (k.attr && card) t = card.querySelector('[' + k.attr + ']');
146
+ else if (k.goalsInput) t = root.querySelector('[data-goals="input"]');
141
147
  else if (k.scope) t = root.querySelector('[data-today-scope="' + k.scope + '"]');
142
148
  else if (k.more) t = root.querySelector('[data-today="more"]');
143
- if (t && !t.hidden) { try { t.focus({ preventScroll: true }); } catch { /* not focusable */ } }
149
+ if (t && !t.hidden) { try { t.focus({ preventScroll: true }); if (k.goalsInput && typeof k.caret === 'number') t.setSelectionRange(k.caret, k.caret); } catch { /* not focusable */ } }
144
150
  }
145
151
 
146
152
  /** Draw Today into `host`. Safe to call on every render: nothing changes unless what Today shows changed. */
@@ -6,8 +6,11 @@
6
6
  * the dock is a rail waits in the dock until the PM opens it.
7
7
  *
8
8
  * This module holds the parts of the dock that are new in the frame:
9
- * createDockRail({ open, label }) the 44px rail: one control that opens the dock
9
+ * createDockRail({ open, resize }) the 44px rail: one control that opens the dock. While the dock is open the rail
10
+ * is a thin grip on the dock's left edge that resizes it (0.53.1)
10
11
  * foldButton({ fold }) the control in the open dock's strip that folds it back to the rail
12
+ * focusButton({ toggle }) / paintFocus(b, on)
13
+ * Focus: the dock takes the whole window, and Exit focus gives the page back (0.53.1)
11
14
  * sessionMenuButton({ newSession, withOptions, openSkill, ws })
12
15
  * "+ Session": New session, Session with options, then the skills to start
13
16
  * from (R10: Skills start from the dock's +session menu; the Skills view goes)
@@ -47,9 +50,76 @@ export function setSessionDefaults(patch) {
47
50
 
48
51
  /* ── the rail and the fold control ────────────────────────────────────────────────────────────────────────────────── */
49
52
 
50
- export function createDockRail({ open } = {}) {
53
+ export function createDockRail({ open, resize } = {}) {
51
54
  const b = iconButton({ icon: 'bot', ariaLabel: COPY.dock.open, onClick: () => open && open(), attrs: { 'data-fr': 'dock-open', 'aria-keyshortcuts': 'Meta+J Control+J' } });
52
- return el('div', { class: 'fr-dock-rail', 'data-fr': 'dock-rail' }, b);
55
+ return el('div', { class: 'fr-dock-rail', 'data-fr': 'dock-rail' }, b, resize ? dockGrip(resize) : null);
56
+ }
57
+
58
+ /* ── the dock's width (0.53.1: the PM found the terminal too small) ───────────────────────────────────────────────────
59
+ * The grip sits in the rail's place while the dock is open, so the body keeps its four children and the dock stays the
60
+ * fourth. `resize` = { get() → px | null, set(px | null) }: null is the default width (the CSS clamp). The grip measures
61
+ * from the body's right edge, so the dock is as wide as the distance from the pointer to the window's edge. */
62
+ export const DOCK_MIN = 320;
63
+ export const DOCK_STEP = 32;
64
+ /** The widest the dock may be in a body `bodyW` wide: the page keeps 280px beside it. */
65
+ export const dockMax = (bodyW) => Math.max(DOCK_MIN, Math.round(bodyW) - 280);
66
+ export const clampDock = (px, bodyW) => Math.min(dockMax(bodyW), Math.max(DOCK_MIN, Math.round(px)));
67
+
68
+ function dockGrip(resize) {
69
+ const g = el('div', {
70
+ class: 'fr-dock-grip', 'data-fr': 'dock-grip', role: 'separator', 'aria-orientation': 'vertical',
71
+ 'aria-label': COPY.dock.resize, title: COPY.dock.resize, tabindex: '0',
72
+ });
73
+ grips.set(g, resize);
74
+ g.addEventListener('dblclick', () => resize.set(null));
75
+ g.addEventListener('keydown', (e) => {
76
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
77
+ e.preventDefault();
78
+ const b = g.closest('.fr-body');
79
+ const d = b && b.querySelector(':scope > .fr-dock');
80
+ const now = resize.get() || (d ? d.getBoundingClientRect().width : 0);
81
+ resize.set(clampDock(now + (e.key === 'ArrowLeft' ? DOCK_STEP : -DOCK_STEP), b ? b.getBoundingClientRect().width : window.innerWidth));
82
+ });
83
+ return g;
84
+ }
85
+
86
+ /* The drag is read on the document, once: a press on a grip starts it, and the moves and the release are the window's,
87
+ * so a drag that leaves the grip (it is 6px wide) keeps going. The grip itself is a separator with keys (above). */
88
+ const grips = new WeakMap(); // grip -> resize
89
+ let drag = null; // { g, resize, right }
90
+ document.addEventListener('pointerdown', (e) => {
91
+ const g = e.button === 0 && e.target && e.target.closest ? e.target.closest('[data-fr="dock-grip"]') : null;
92
+ const resize = g && grips.get(g);
93
+ const b = g && g.closest('.fr-body');
94
+ if (!resize || !b) return;
95
+ e.preventDefault();
96
+ drag = { g, resize, body: b, right: b.getBoundingClientRect().right };
97
+ g.setAttribute('data-dragging', '');
98
+ });
99
+ window.addEventListener('pointermove', (e) => {
100
+ if (drag) drag.resize.set(clampDock(drag.right - e.clientX, drag.body.getBoundingClientRect().width));
101
+ });
102
+ const endDrag = () => { if (drag) { drag.g.removeAttribute('data-dragging'); drag = null; } };
103
+ window.addEventListener('pointerup', endDrag);
104
+ window.addEventListener('pointercancel', endDrag);
105
+
106
+ const focusLabels = new WeakMap(); // the Focus control -> its label node
107
+ export function focusButton({ toggle } = {}) {
108
+ const label = el('span', null, COPY.dock.focus);
109
+ const b = button({ label, variant: 'ghost', size: 'sm',
110
+ attrs: { class: 'fr-dock-focus', 'data-fr': 'dock-focus', 'aria-pressed': 'false', title: COPY.dock.focusTitle, 'aria-keyshortcuts': 'Meta+Shift+J Control+Shift+J' },
111
+ onClick: () => toggle && toggle() });
112
+ focusLabels.set(b, label);
113
+ return b;
114
+ }
115
+ /** Show the Focus control's state: pressed while the dock has the whole window. */
116
+ export function paintFocus(b, on) {
117
+ if (!b) return;
118
+ const label = focusLabels.get(b);
119
+ const text = on ? COPY.dock.unfocus : COPY.dock.focus;
120
+ if (label && label.textContent !== text) label.textContent = text;
121
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
122
+ b.title = on ? COPY.dock.unfocusTitle : COPY.dock.focusTitle;
53
123
  }
54
124
 
55
125
  export function foldButton({ fold } = {}) {
@@ -36,6 +36,11 @@ export const COPY = {
36
36
  dock: {
37
37
  open: 'Open the agent dock (⌘J)',
38
38
  fold: 'Fold the agent dock (⌘J)',
39
+ focus: 'Focus',
40
+ unfocus: 'Exit focus',
41
+ focusTitle: 'Give the dock the whole window (⌘⇧J)',
42
+ unfocusTitle: 'Show the page beside the dock again (⌘⇧J)',
43
+ resize: 'Resize the agent dock. Drag, or use the left and right arrow keys. Double-click resets the width.',
39
44
  session: 'Session',
40
45
  sessionMenu: 'Start a session',
41
46
  newSession: 'New session',
@@ -719,13 +719,11 @@ const bottomBar = el('div', { 'data-tabs': 'bottom', 'data-term-chrome': '', cla
719
719
  button({ label: copyLabel, variant: 'outline', size: 'sm', onClick: () => copyActive() }),
720
720
  );
721
721
 
722
- /* pm-os-absorb Run 3 (R11): the capture box is gone, so in real mode the bar has no scratchpad toggle and paintBottom
723
- * lays it out: the session's name and `copy`, or, on a phone, the terminal's compose line. Fixture mode keeps the
724
- * prototype's bar (the parity lock holds until slice 3D lifts it). */
725
- const [, bottomTitle, bottomCopy] = bottomBar.children;
726
-
727
- /* The drawer had its toggle in the bar and has none now: a stored "open" is dropped once, at load, so no capture box comes back. */
728
- if (!FIXTURE && state.scratchOpen) setState({ scratchOpen: false });
722
+ /* pm-os-absorb Run 3 (R11) took the scratchpad toggle out of the bar, and with it the only way to the pad on a desktop.
723
+ * 0.53.1 puts it back: paintBottom lays the bar out as the scratchpad toggle, the session's name and `copy`, or, on a
724
+ * phone, the terminal's compose line (the same per-session pad). Fixture mode keeps the prototype's bar. */
725
+ const [bottomToggle, bottomTitle, bottomCopy] = bottomBar.children;
726
+ bottomToggle.setAttribute('data-scratch', 'toggle');
729
727
 
730
728
  /* The compose line: one message field and Send on the SAME per-session pad as the drawer (padKey), so the text is kept
731
729
  * on this machine and never crosses sessions. On a desktop ⌘K "Ask <session>" types to a session (pm/palette.js). */
@@ -751,7 +749,7 @@ function sendCompose() {
751
749
  onScratchInput('');
752
750
  }
753
751
 
754
- /** The bar in real mode: no scratchpad toggle; a phone's terminal gets the compose line in its place. */
752
+ /** The bar in real mode: the scratchpad toggle, the title and copy; a phone's terminal gets the compose line instead. */
755
753
  function paintBottom(act) {
756
754
  const compose = isMobile() && !!act && act.kind === 'term';
757
755
  bottomTitle.textContent = act ? act.title : '';
@@ -761,7 +759,7 @@ function paintBottom(act) {
761
759
  if (composeInput.control.value !== text) composeInput.control.value = text;
762
760
  composeSend.disabled = !text.trim();
763
761
  }
764
- setChildren(bottomBar, compose ? [composeLine] : (act ? [bottomTitle, bottomCopy] : [bottomTitle]));
762
+ setChildren(bottomBar, compose ? [composeLine] : (act ? [bottomToggle, bottomTitle, bottomCopy] : [bottomToggle, bottomTitle]));
765
763
  }
766
764
 
767
765
  /* ── clipboard + scratchpad actions (lines 2098–2118) ───────────────────────────────────────── */
@@ -1101,9 +1099,12 @@ function frameDock() {
1101
1099
  openSkill: (sk) => openTab({ id: 'skill:' + sk.id, kind: 'skill', title: sk.label || sk.skill || sk.id, skillId: sk.id, repo: state.product || null, slug: null, role: 'SKILL' }),
1102
1100
  }),
1103
1101
  fold: dockParts ? dockParts.fold : dockMod.foldButton({ fold: () => window.pmApp.frame.foldDock() }),
1102
+ focus: dockParts ? dockParts.focus : dockMod.focusButton({ toggle: () => window.pmApp.frame.toggleFocus() }),
1104
1103
  };
1105
1104
  }
1106
- return { session: dockParts.session, fold: isMobile() ? null : dockParts.fold };
1105
+ if (isMobile()) return { session: dockParts.session, fold: null, focus: null };
1106
+ dockMod.paintFocus(dockParts.focus, !!window.pmApp.frame.dockMax);
1107
+ return { session: dockParts.session, fold: dockParts.fold, focus: dockParts.focus };
1107
1108
  }
1108
1109
 
1109
1110
  /**
@@ -1290,6 +1291,7 @@ export function renderTabs(host) {
1290
1291
  newSessionOptsBtn,
1291
1292
  canSplit ? splitBtn : null,
1292
1293
  orientBtn,
1294
+ fr ? fr.focus : null,
1293
1295
  fr ? fr.fold : null),
1294
1296
  ]);
1295
1297
  } else if (ck) {
@@ -1304,7 +1306,7 @@ export function renderTabs(host) {
1304
1306
  ].filter(Boolean);
1305
1307
  extras[0].style.marginLeft = 'auto';
1306
1308
  // The switch sits after `+ session`, so the strip still ends the mockup's way in the fixture, which does not draw it.
1307
- setChildren(tabBar, [...visTabs.map((t) => cockpitTabNode(t, act, splitTab)), ...extras, fr ? fr.session : newSessionBtn, FIXTURE ? null : orientBtn, fr ? fr.fold : null]);
1309
+ setChildren(tabBar, [...visTabs.map((t) => cockpitTabNode(t, act, splitTab)), ...extras, fr ? fr.session : newSessionBtn, FIXTURE ? null : orientBtn, fr ? fr.focus : null, fr ? fr.fold : null]);
1308
1310
  } else {
1309
1311
  tabBar.setAttribute('style', BAR_STYLE);
1310
1312
  newSessionBtn.setAttribute('style', CHIP_STYLE);
@@ -1363,7 +1365,7 @@ export function renderTabs(host) {
1363
1365
  newSessionBlock(),
1364
1366
  visTabs.length === 0 && !state.startPrompt && !state.newSess ? emptyState : null,
1365
1367
  panesRow,
1366
- state.scratchOpen ? scratchBlock : null,
1368
+ state.scratchOpen && (FIXTURE || !isMobile()) ? scratchBlock : null,
1367
1369
  bottomBar,
1368
1370
  ]);
1369
1371
  }
@@ -56,6 +56,35 @@ export const COPY = {
56
56
  commandShown: (cmd) => `Run this in a terminal: ${cmd}`,
57
57
  noInitiative: 'This card names no initiative.',
58
58
  sessionGone: 'That session has ended.',
59
+ /** The goals for today (initiative today-goals, PRD G6). The word for what the session finds is "step", never a plan. */
60
+ goals: {
61
+ title: 'Goals for today',
62
+ toggle: (open, total) => (total ? `Goals for today · ${open} open of ${total}` : 'Set your goals for today'),
63
+ intro: 'List what you want done today. Prepare my day then finds the meetings, mentions and steps that move each goal.',
64
+ inputLabel: 'Add a goal',
65
+ inputHint: 'What do you want done today? Press Enter to add it.',
66
+ full: (max) => `A day has at most ${max} goals. Drop or remove one to add another.`,
67
+ prepareDay: 'Prepare my day',
68
+ prepareDayHint: 'Starts a session that reads your calendar and your Slack, Jira and Confluence mentions, and ties what it finds to your goals.',
69
+ started: 'The session started. The steps show here when it is done.',
70
+ coverage: { planned: 'has steps', 'needs-plan': 'no step yet', done: 'done', dropped: 'dropped' },
71
+ done: 'Done', drop: 'Drop', reopen: 'Reopen', remove: 'Remove',
72
+ you: 'You', agent: 'Agent', mention: 'mention',
73
+ noStep: 'No step found yet. Press Prepare my day, or open the initiative.',
74
+ prepare: 'Prepare',
75
+ queue: 'Queue it',
76
+ prepareShows: (key, branch) => `${key} on ${branch || 'a local branch'}. Queue it to approve this exact prompt.`,
77
+ queued: (key) => `Queued ${key}. It starts when background prepare is on and the budget and quiet hours allow.`,
78
+ also: 'Also on your plate',
79
+ worthAsking: 'Worth asking',
80
+ free: (min, until) => `${Math.floor(min / 60)} h ${min % 60} min free until ${until}.`,
81
+ comingUp: (title, at) => `Coming up: ${title} at ${at}.`,
82
+ notRead: (list) => `Not read: ${list}.`,
83
+ stale: (date) => `What was read is from ${date}. It is out of date, so only what Today found is used.`,
84
+ noBrief: 'Nothing was read yet today. Press Prepare my day to read your calendar and mentions.',
85
+ refused: (why) => `Nothing changed. ${why}`,
86
+ unreached: 'The workspace did not answer.',
87
+ },
59
88
  /** The Acceptance Gate card (D12): the PM's own act on a run. */
60
89
  accept: {
61
90
  review: 'Review',
@@ -0,0 +1,172 @@
1
+ /* pm/today-goals.js — the goals for today at the top of Today (initiative today-goals, PRD G5 and G6).
2
+ *
3
+ * goalsSection({ ws, setState }) → the section node, or null in fixture mode
4
+ * goalsSig(ws) → a string that changes whenever what the section shows changes
5
+ *
6
+ * The server joins the goals to what the morning-brief session read and to Today's cards (lib/goals.js planDay, GET /api/proactive/goals).
7
+ * This module draws that answer and sends the PM's presses: add, done, drop, reopen, remove, "Prepare my day" (runs the
8
+ * morning-brief schedule) and, on an agent step that names an initiative, Prepare then Queue it (the shipped background
9
+ * prepare: one approval is one prompt hash).
10
+ *
11
+ * FIRST PAINT. Folded, the section is one control (its toggle) and a list of the goals as text, so Today keeps inside its
12
+ * control budget (first-paint-today.test.js). The input and the buttons are drawn only when the PM opens it.
13
+ */
14
+
15
+ import { el } from './dom.js';
16
+ import { button, field, badge, toast } from './primitives.js';
17
+ import { pmGet, pmPost } from './http.js';
18
+ import { FIXTURE } from './state.js';
19
+ import { COPY } from './today-copy.js';
20
+
21
+ const G = COPY.goals;
22
+ const POLL_MS = 30000;
23
+ const cache = new Map(); // ws -> { data, error, at }
24
+ let open = false;
25
+ let draft = '';
26
+ const prepared = new Map(); // step ref -> { key, branch, item_id, prompt_sha, initiative_id, ws } (a plan shown, not yet queued)
27
+ let rerender = () => {};
28
+ let timer = null;
29
+
30
+ async function load(ws) {
31
+ const out = await pmGet('/api/proactive/goals' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
32
+ const prev = cache.get(ws);
33
+ const next = out && out.ok ? { data: out, error: null, at: Date.now() } : { data: prev ? prev.data : null, error: (out && out.error) || G.unreached, at: Date.now() };
34
+ cache.set(ws, next);
35
+ if (!prev || JSON.stringify(prev.data) !== JSON.stringify(next.data) || prev.error !== next.error) rerender();
36
+ }
37
+
38
+ function ensure(ws) {
39
+ if (!cache.has(ws)) { cache.set(ws, { data: null, error: null, at: 0 }); load(ws); }
40
+ if (!timer && typeof window !== 'undefined') {
41
+ timer = setInterval(() => { for (const w of cache.keys()) load(w); }, POLL_MS);
42
+ }
43
+ }
44
+
45
+ async function press(ws, body) {
46
+ const r = await pmPost('/api/proactive/goals', body);
47
+ if (!r || r.ok === false) { toast({ text: G.refused((r && r.error) || G.unreached), tone: 'destructive' }); return false; }
48
+ await load(ws);
49
+ return true;
50
+ }
51
+
52
+ async function prepareDay(ws) {
53
+ const r = await pmPost('/api/proactive/goals/plan', { ws });
54
+ toast({ text: r && r.ok ? G.started : G.refused((r && r.error) || G.unreached), tone: r && r.ok ? undefined : 'destructive' });
55
+ load(ws);
56
+ }
57
+
58
+ async function prepareStep(ws, st) {
59
+ const r = await pmPost('/api/proactive/prepare/plan', { ws, initiative_id: st.initiative_id });
60
+ if (!r || r.ok === false) { toast({ text: G.refused((r && r.error) || G.unreached), tone: 'destructive' }); return; }
61
+ if (r.refused) { toast({ text: G.refused(r.refused.why) }); return; }
62
+ prepared.set(st.ref, { key: r.item.key, branch: r.plan && r.plan.branch, item_id: r.item.id, prompt_sha: r.prompt_sha, initiative_id: st.initiative_id, ws: r.ws || ws });
63
+ rerender();
64
+ }
65
+
66
+ async function queueStep(ref) {
67
+ const p = prepared.get(ref);
68
+ if (!p) return;
69
+ const r = await pmPost('/api/proactive/prepare/queue', { confirm: true, ws: p.ws, initiative_id: p.initiative_id, item_id: p.item_id, prompt_sha: p.prompt_sha });
70
+ prepared.delete(ref);
71
+ toast({ text: r && r.ok ? G.queued(p.key) : G.refused((r && r.error) || G.unreached), tone: r && r.ok ? undefined : 'destructive' });
72
+ rerender();
73
+ }
74
+
75
+ const hhmm = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? String(iso) : `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
76
+
77
+ function stepNode(ws, st, opened) {
78
+ const who = st.owner === 'pm' ? G.you : G.agent;
79
+ const meta = [st.source, st.mention ? G.mention : null, st.effort].filter(Boolean).join(' · ');
80
+ const p = prepared.get(st.ref);
81
+ const acts = !opened || !st.prepare ? null
82
+ : p ? el('span', { class: 'td-goal__prep' }, el('span', { class: 'td-goal__prep-text' }, G.prepareShows(p.key, p.branch)),
83
+ button({ label: G.queue, size: 'sm', attrs: { 'data-goal-queue': st.ref }, onClick: () => queueStep(st.ref) }))
84
+ : button({ label: G.prepare, size: 'sm', variant: 'ghost', attrs: { 'data-goal-prepare': st.ref }, onClick: () => prepareStep(ws, st) });
85
+ return el('li', { class: 'td-goal__step', 'data-goal-step': st.ref },
86
+ badge({ text: who, tone: st.owner === 'pm' ? 'outline' : 'muted' }),
87
+ el('span', { class: 'td-goal__step-title' }, st.title),
88
+ meta ? el('span', { class: 'td-goal__meta' }, meta) : null,
89
+ acts);
90
+ }
91
+
92
+ function goalNode(ws, g, opened) {
93
+ const closed = g.status !== 'open';
94
+ const ctrls = opened ? el('span', { class: 'td-goal__ctrls' },
95
+ ...(closed
96
+ ? [button({ label: G.reopen, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'reopen' }, onClick: () => press(ws, { action: 'reopen', id: g.id }) })]
97
+ : [button({ label: G.done, size: 'sm', attrs: { 'data-goal-act': 'done' }, onClick: () => press(ws, { action: 'done', id: g.id }) }),
98
+ button({ label: G.drop, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'drop' }, onClick: () => press(ws, { action: 'drop', id: g.id }) })]),
99
+ button({ label: G.remove, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'remove' }, onClick: () => press(ws, { action: 'remove', id: g.id }) })) : null;
100
+ const ctx = (g.context || []).map((e) => el('li', { class: 'td-goal__ctx' }, `${hhmm(e.start)} ${e.title}${e.with ? ' · ' + e.with : ''}`));
101
+ const body = opened && !closed ? el('div', { class: 'td-goal__body' },
102
+ ctx.length ? el('ul', { class: 'td-goal__ctx-list' }, ...ctx) : null,
103
+ g.steps.length ? el('ul', { class: 'td-goal__steps' }, ...g.steps.map((st) => stepNode(ws, st, opened))) : el('p', { class: 'td-goal__none' }, G.noStep)) : null;
104
+ return el('li', { class: 'td-goal' + (closed ? ' td-goal--closed' : ''), 'data-goal': g.id, 'data-goal-coverage': g.coverage },
105
+ el('div', { class: 'td-goal__head' },
106
+ el('span', { class: 'td-goal__text' }, g.text),
107
+ badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'muted' }),
108
+ g.initiative_id ? el('span', { class: 'td-goal__meta' }, g.initiative_id) : null,
109
+ ctrls),
110
+ body);
111
+ }
112
+
113
+ function stepList(ws, title, steps, attr) {
114
+ if (!steps.length) return null;
115
+ return el('div', { class: 'td-goals__also', 'data-goals': attr },
116
+ el('p', { class: 'td-goals__sub' }, title),
117
+ el('ul', { class: 'td-goal__steps' }, ...steps.map((st) => stepNode(ws, st, true))));
118
+ }
119
+
120
+ /** The section. `ws` is the repository whose Today cards the goals are joined to. */
121
+ export function goalsSection({ ws, setState } = {}) {
122
+ if (FIXTURE) return null;
123
+ // like Today's own feeds, a read that lands re-renders only while the section is on the page: a hidden Today must not
124
+ // cause a whole-page render under whatever the PM is doing elsewhere
125
+ if (setState) rerender = () => { if (document.querySelector('[data-today="goals"]')) setState({}); };
126
+ ensure(ws);
127
+ const c = cache.get(ws) || {};
128
+ const d = c.data;
129
+ const plan = d && d.plan;
130
+ const goals = plan ? plan.goals : [];
131
+ const openN = goals.filter((g) => g.status === 'open').length;
132
+ const toggle = button({ label: G.toggle(openN, goals.length), variant: 'ghost', iconEnd: open ? 'chevron-down' : 'chevron-right',
133
+ attrs: { 'data-goals': 'toggle', 'aria-expanded': open ? 'true' : 'false' }, onClick: () => { open = !open; rerender(); } });
134
+ const list = goals.length ? el('ol', { class: 'td-goals__list', 'data-goals': 'list' }, ...goals.map((g) => goalNode(ws, g, open))) : null;
135
+ const kids = [el('div', { class: 'td-goals__head' }, toggle), list];
136
+ if (c.error) kids.push(el('p', { class: 'td-goals__note', role: 'status' }, G.refused(c.error)));
137
+ if (open) {
138
+ if (!goals.length) kids.push(el('p', { class: 'td-goals__note' }, G.intro));
139
+ const max = (d && d.max_goals) || 7;
140
+ if (goals.length < max) {
141
+ const f = field({ label: G.inputLabel, value: draft, placeholder: G.inputHint, hideLabel: true, attrs: { 'data-goals': 'input', maxlength: '140' },
142
+ onCommit: async (v) => {
143
+ draft = v;
144
+ if (!String(v).trim()) return;
145
+ if (await press(ws, { action: 'add', text: v })) { draft = ''; if (f.control.resetCommitted) f.control.resetCommitted(''); rerender(); }
146
+ } });
147
+ f.control.addEventListener('input', () => { draft = f.control.value; }); // a rebuild keeps what is typed
148
+ kids.push(f);
149
+ } else kids.push(el('p', { class: 'td-goals__note' }, G.full(max)));
150
+ kids.push(el('div', { class: 'td-goals__bar' },
151
+ button({ label: G.prepareDay, attrs: { 'data-goals': 'prepare-day', title: G.prepareDayHint }, disabled: !openN, onClick: () => prepareDay(ws) })));
152
+ if (plan) {
153
+ const lines = [];
154
+ if (!plan.brief) lines.push(G.noBrief);
155
+ else if (!plan.brief.fresh) lines.push(G.stale(plan.brief.date));
156
+ if (plan.time) {
157
+ lines.push(G.free(plan.time.free_minutes, plan.time.until));
158
+ if (plan.time.next) lines.push(G.comingUp(plan.time.next.title, hhmm(plan.time.next.start)));
159
+ }
160
+ if (plan.not_read && plan.not_read.length) lines.push(G.notRead(plan.not_read.map((x) => x.source).join(', ')));
161
+ if (lines.length) kids.push(el('p', { class: 'td-goals__note', 'data-goals': 'context' }, lines.join(' ')));
162
+ kids.push(stepList(ws, G.also, plan.also || [], 'also'));
163
+ kids.push(stepList(ws, G.worthAsking, plan.worth_asking || [], 'worth-asking'));
164
+ }
165
+ }
166
+ return el('section', { class: 'td-goals', 'data-today': 'goals', 'aria-label': G.title }, ...kids.filter(Boolean));
167
+ }
168
+
169
+ export function goalsSig(ws) {
170
+ const c = cache.get(ws) || {};
171
+ return JSON.stringify([open, c.data && c.data.plan, c.error, [...prepared.keys()]]);
172
+ }
@@ -24,6 +24,19 @@
24
24
  "ref": "contract-run2 done_criteria 14 (the plan-routes.test.js Origin cases)",
25
25
  "reason": "R9 asserted the route's own no-Origin text, which named the terminal route. After Run 2 the route runs pm-guard first, so R9 now asserts pm-guard's refusal; the 403 and the nothing-written check stay.",
26
26
  "names": ["R9 a POST with no Origin is 403 and names the terminal route; nothing is written"]
27
+ },
28
+ {
29
+ "id": "STG-SLACK",
30
+ "file": "workspace/proactive.test.js",
31
+ "run": "today-goals",
32
+ "ref": "planning/prd/today-goals-prd.md G2 (slack becomes a brief source)",
33
+ "reason": "These four assertions used slack as the example of an unknown brief source. today-goals G2 makes slack a known source, so each now uses teams as the unknown name; the refusal and its reason are asserted unchanged.",
34
+ "names": [
35
+ "G5 a non-object, a bad date and an unknown source are refused with the reason",
36
+ "G6 a confidence outside 0 to 1, a bad owner, a bad effort, a missing why_now and an unknown item source are each refused",
37
+ "G14 the new settings are checked: name length, source names, booleans",
38
+ "I6 an unknown brief source is refused with the allowed names"
39
+ ]
27
40
  }
28
41
  ],
29
42
  "entries": [