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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +24 -63
- package/workspace/bin/pmos.js +4 -2
- package/workspace/guide/command-palette.md +1 -1
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +4 -0
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/lib/board-fold.js +2 -2
- package/workspace/lib/breakdown-export.js +1 -6
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/initiative-rows.js +7 -3
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/plan-tree.js +15 -2
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +2 -2
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +93 -1
- package/workspace/lib/report-draft.js +1 -2
- package/workspace/lib/routes-timeline.js +1 -1
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +11 -0
- package/workspace/public/pm/app.js +39 -8
- package/workspace/public/pm/components.css +82 -1
- package/workspace/public/pm/dest-today.js +8 -2
- package/workspace/public/pm/dock.js +73 -3
- package/workspace/public/pm/frame-copy.js +5 -0
- package/workspace/public/pm/panel-tabs.js +14 -12
- package/workspace/public/pm/today-copy.js +29 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/replaced-tests.json +13 -0
- package/workspace/server.js +134 -4
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- 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
|
-
|
|
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,
|
|
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)
|
|
723
|
-
*
|
|
724
|
-
*
|
|
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:
|
|
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:
|
|
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": [
|