create-pmos 0.504.0 → 0.506.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.
- package/package.json +45 -27
- 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/changelog.json +44 -0
- package/workspace/fixtures/decision-pairs.js +94 -0
- package/workspace/guide/ask-the-past.md +46 -0
- package/workspace/guide/claude-proposes.md +29 -2
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/notes.md +8 -1
- package/workspace/guide/what-you-missed.md +42 -0
- package/workspace/lib/ai-cli.js +63 -0
- package/workspace/lib/ai-guard.js +314 -0
- package/workspace/lib/briefing-premium-agent.js +3 -0
- package/workspace/lib/decision-ledger.js +236 -0
- package/workspace/lib/decision-memory.js +237 -0
- package/workspace/lib/drift-cli.js +98 -0
- package/workspace/lib/drift-facts.js +151 -0
- package/workspace/lib/drift-runs.js +149 -0
- package/workspace/lib/drift.js +227 -0
- package/workspace/lib/grill-cli.js +22 -1
- package/workspace/lib/grill-runs.js +20 -3
- package/workspace/lib/grill.js +15 -8
- package/workspace/lib/past-chat.js +32 -0
- package/workspace/lib/past-cli.js +147 -0
- package/workspace/lib/past-repeat.js +126 -0
- package/workspace/lib/past.js +353 -0
- package/workspace/lib/pmos-verbs.js +9 -0
- package/workspace/lib/proposal-runs.js +20 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-drift.js +62 -0
- package/workspace/lib/settings-store.js +6 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-home-rows.css +8 -0
- package/workspace/public/pm/cozy-live-pill.css +10 -0
- package/workspace/public/pm/cozy-live-pill.js +13 -5
- package/workspace/public/pm/cozy-rec-tab.js +5 -2
- package/workspace/public/pm/grill-words.js +4 -2
- package/workspace/public/pm/home-app.js +14 -2
- package/workspace/public/pm/home-drift.js +76 -0
- package/workspace/public/pm/home-rec-row.js +11 -2
- package/workspace/public/pm/rec-state.js +30 -4
- package/workspace/server.js +19 -0
- package/workspace/skills/grill.skill +4 -2
- package/workspace/test-files.json +1 -1
- package/workspace/test-lists/ask-the-past.json +5 -0
- package/workspace/test-lists/decision-memory.json +6 -0
- package/workspace/test-lists/weekly-drift-check.json +5 -0
|
@@ -101,6 +101,10 @@ const DEFS = Object.freeze({
|
|
|
101
101
|
// and the monthly credit cap in US dollars. At the cap Claude pauses. `pmos grill mode|cap` reads and sets them.
|
|
102
102
|
'grill.mode': one('checks', 'computer', ['full', 'exits', 'off'], 'full'),
|
|
103
103
|
'grill.cap': { section: 'checks', scope: 'computer', type: 'dollars', min: 0, max: 500, def: 10 },
|
|
104
|
+
// The AI jobs' kill switch (lib/ai-guard.js): 'ai.on' false stops every AI job; 'ai.off' lists the jobs that are off one by one.
|
|
105
|
+
// `pmos ai off [job]` and `pmos ai on [job]` set them; PMOS_AI=off wins over both.
|
|
106
|
+
'ai.on': bool('checks', 'computer', true),
|
|
107
|
+
'ai.off': { section: 'checks', scope: 'computer', type: 'jobs', max: 40, def: [] },
|
|
104
108
|
});
|
|
105
109
|
|
|
106
110
|
/* The environment variables that win over a setting, here or in a setting's own store. `key` names the setting the page
|
|
@@ -112,6 +116,7 @@ const ENV = Object.freeze([
|
|
|
112
116
|
{ key: 'rec.final', env: 'PMOS_NOTES_FINAL', off: 'off', effect: 'The pass after Stop is off on this computer.' },
|
|
113
117
|
{ key: 'notes.wispr', env: 'PMOS_NOTES_WISPR', off: 'off', effect: 'Wispr Flow notes are not fetched on this computer.' },
|
|
114
118
|
{ key: 'notes.engine', env: 'PMOS_NOTES_QWEN', off: 'off', effect: 'Qwen3-ASR is not used on this computer.' },
|
|
119
|
+
{ key: 'ai.on', env: 'PMOS_AI', off: 'off', effect: 'Every AI job is off on this computer: nothing is filed under ✦.' },
|
|
115
120
|
]);
|
|
116
121
|
|
|
117
122
|
const WS_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
@@ -163,6 +168,7 @@ function validate(key, value) {
|
|
|
163
168
|
}
|
|
164
169
|
// ['*'] alone is every workspace (the product picker's All, lib/briefing-premium-products.js)
|
|
165
170
|
case 'ids': return value === null || Array.isArray(value) && (value.length === 1 && value[0] === '*' || value.length <= d.max && value.every((x) => typeof x === 'string' && WS_ID.test(x)) && new Set(value).size === value.length) ? null : `${key} must list at most ${d.max} workspace ids, each once, or ['*'] for every one`;
|
|
171
|
+
case 'jobs': return Array.isArray(value) && value.length <= d.max && value.every((x) => typeof x === 'string' && /^[a-z][a-z0-9-]{1,40}$/.test(x)) && new Set(value).size === value.length ? null : `${key} must list at most ${d.max} job ids, each once`;
|
|
166
172
|
// Dress for the day (pm/cozy-seasons.js): a day's key is its charm key, as in DAYS there
|
|
167
173
|
case 'country': return value === '' || typeof value === 'string' && /^[A-Z]{2}$/.test(value) ? null : `${key} must be a two-letter country code, or empty for this computer's`;
|
|
168
174
|
case 'colors': return Array.isArray(value) && value.length === 2 && value.every((c) => DRESS_COLORS.includes(c)) ? null : `${key} must be two of ${DRESS_COLORS.join(', ')}`;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.506.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.506.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
.hr-rec-card:not(.paused) .hr-rdot::after { content: ""; position: absolute; inset: -1.5px; border-radius: inherit; background: var(--cz-dot-red); animation: hr-crit 2.4s ease-out infinite; pointer-events: none; }
|
|
24
24
|
@keyframes hr-crit { 0% { transform: scale(1); opacity: .4; } 60% { transform: scale(2.2); opacity: 0; } 100% { transform: scale(2.2); opacity: 0; } }
|
|
25
25
|
@media (prefers-reduced-motion: reduce) { .hr-rec-card .hr-rdot::after { animation: none; } }
|
|
26
|
+
/* the icon-only Stop beside the pinned row (finish from anywhere, 2026-11-03): quiet red square, solid red once pressed ("Press again") */
|
|
27
|
+
.hr-rec { display: flex; align-items: center; }
|
|
28
|
+
.hr-stop { flex: none; width: 40px; height: 40px; margin-right: 10px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--cz-red); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
|
29
|
+
.hr-stop svg { width: 16px; height: 16px; fill: currentColor; }
|
|
30
|
+
.hr-stop:hover { background: var(--cz-well); }
|
|
31
|
+
.hr-stop.armed { background: var(--cz-dot-red); color: var(--cz-onsolid); }
|
|
32
|
+
.hr-stop:disabled { opacity: .5; cursor: default; }
|
|
33
|
+
.hr-stop:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
|
|
26
34
|
|
|
27
35
|
/* ── the Mentions Viewer: the round solid press and the 30-day question (kept from Mentions A+) ── */
|
|
28
36
|
.hr-rp { width: 44px; height: 44px; border-radius: 999px; border: 0; background: var(--cz-solid); color: var(--cz-onsolid); display: inline-grid; place-items: center; cursor: pointer; flex: none; }
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
.lv-row + .lv-row::before { content: ''; position: absolute; left: 38px; right: 12px; top: 0; height: 1px; background: var(--cz-hair); }
|
|
36
36
|
.lv-row:hover { background: var(--cz-well); }
|
|
37
37
|
.lv-row.cur { background: var(--cz-bluebg); }
|
|
38
|
+
/* the recording's row and its icon-only Stop (finish from anywhere, 2026-11-03): quiet until pressed once, then solid red ("Press again") */
|
|
39
|
+
.lv-rw { position: relative; display: flex; align-items: center; }
|
|
40
|
+
.lv-rw > .lv-row { flex: 1; min-width: 0; }
|
|
41
|
+
.lv-rw + .lv-row::before, .lv-row + .lv-rw::before { content: ''; position: absolute; left: 38px; right: 12px; top: 0; height: 1px; background: var(--cz-hair); }
|
|
42
|
+
.lv-stop { flex: none; width: 36px; height: 36px; margin-right: 4px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--cz-red); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
|
43
|
+
.lv-stop svg { width: 16px; height: 16px; fill: currentColor; }
|
|
44
|
+
.lv-stop:hover { background: var(--cz-well); }
|
|
45
|
+
.lv-stop.armed { background: var(--cz-dot-red); color: var(--cz-onsolid); }
|
|
46
|
+
.lv-stop:disabled { opacity: .5; cursor: default; }
|
|
47
|
+
.lv-stop:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
|
|
38
48
|
.lv-mk { width: 24px; flex: none; display: inline-flex; justify-content: center; align-items: center; }
|
|
39
49
|
.lv-t { flex: 1; min-width: 0; font-size: var(--fs-3); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
40
50
|
.lv-t small { display: block; font-size: var(--fs-2); font-weight: 600; color: var(--cz-ink3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* opts.go(item) → true the page opens the item itself (Home: the session's chat, the meeting); else the row's link is followed
|
|
17
17
|
* opts.open() → id the session open on this page now: its row is tinted
|
|
18
18
|
* Reads GET /api/live every 10 s while the page is shown, and on the cz:live event (Home sends it after the PM opens or answers a
|
|
19
|
-
* session); the recording comes from pm/rec-state.js.
|
|
19
|
+
* session); the recording comes from pm/rec-state.js. The recording's row has one icon-only Stop beside it (Wawan, 2026-11-03: finish
|
|
20
|
+
* from anywhere): the first press asks "Press again", the second ends the meeting (rec-state.js stopPress). Nothing else is written.
|
|
20
21
|
* Adler (batch 17 item 4-B, AdM1 "Live recording pill", AdH5): while a meeting records, his S face with headphones follows the clock, now in
|
|
21
22
|
* the recording's row. The red dot stays: the face never replaces the row's mark. Paused, he is not shown (nobody is listening). */
|
|
22
|
-
import { onRecording } from './rec-state.js';
|
|
23
|
+
import { onRecording, stopPress } from './rec-state.js';
|
|
23
24
|
import { ownTab, bring } from './rec-tab-core.js';
|
|
24
25
|
import { liveItems, keepOrder, pillLabel, rowLabel, clock, elapsed } from './live-model.js';
|
|
25
26
|
import { wedgeHtml } from './ctx-wedge.js';
|
|
@@ -27,6 +28,7 @@ import { adler } from './cozy-adler.js'; // the same context pie as Home's row
|
|
|
27
28
|
|
|
28
29
|
const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
29
30
|
const SHEET = new URL('./cozy-live-pill.css', import.meta.url).href;
|
|
31
|
+
const STOP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>';
|
|
30
32
|
const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
|
|
31
33
|
const PHONE = '(max-width: 760px)';
|
|
32
34
|
const mac = () => { try { return /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ''); } catch { return false; } };
|
|
@@ -49,7 +51,11 @@ export function mountLivePill(doc = document, opts = {}) {
|
|
|
49
51
|
nav.append(scrim, tray, pill);
|
|
50
52
|
pill.addEventListener('click', () => set(!L.open));
|
|
51
53
|
scrim.addEventListener('click', () => set(false));
|
|
52
|
-
tray.addEventListener('click', (e) => {
|
|
54
|
+
tray.addEventListener('click', (e) => {
|
|
55
|
+
const s = e.target.closest('[data-lv-stop]');
|
|
56
|
+
if (s) { e.preventDefault(); const p = stopPress(s, s.dataset.lvStop); if (p) p.then((r) => { if (r && r.ok) set(false); }); return; }
|
|
57
|
+
const a = e.target.closest('a[data-lv]'); if (a) go(a, e);
|
|
58
|
+
});
|
|
53
59
|
tray.addEventListener('keydown', keys);
|
|
54
60
|
draw();
|
|
55
61
|
return true;
|
|
@@ -77,11 +83,13 @@ export function mountLivePill(doc = document, opts = {}) {
|
|
|
77
83
|
function rowsHtml(list) {
|
|
78
84
|
return list.map((x, i) => {
|
|
79
85
|
const was = L.marks.get(x.id), once = L.open && was && was !== x.mark;
|
|
80
|
-
|
|
86
|
+
const row = `<a class="lv-row${x.open ? ' cur' : ''}" href="${esc(x.href)}" data-lv="${esc(x.id)}" aria-label="${esc(rowLabel(x))}" aria-keyshortcuts="${i + 1}">`
|
|
81
87
|
+ `<span class="lv-mk">${x.kind === 'rec' ? `<i class="lv-m rec${x.paused ? ' paused' : ''}"></i>` : markHtml(x.mark, once)}</span>`
|
|
82
88
|
+ `<span class="lv-t">${esc(x.name)}<small>${esc(x.sub)}</small></span>${wedgeHtml(x.ctx)}`
|
|
83
89
|
+ (x.kind === 'rec' && !x.paused ? `<span class="lv-ad">${adler('thinking', 18, { label: false, prop: 'headphones', id: 'lvrec' })}</span>` : '')
|
|
84
90
|
+ `<span class="lv-tm${x.kind === 'rec' && !x.paused ? ' rec' : x.mark === 'stop' ? ' stop' : ''}">${esc(x.time)}</span></a>`;
|
|
91
|
+
// the recording: one icon-only Stop beside its row, so the meeting can be finished from any page
|
|
92
|
+
return x.kind === 'rec' ? `<div class="lv-rw">${row}<button type="button" class="lv-stop" data-lv-stop="${esc(x.id.slice(4))}" aria-label="Stop the recording" title="Stop the recording">${STOP}</button></div>` : row;
|
|
85
93
|
}).join('');
|
|
86
94
|
}
|
|
87
95
|
// the closed pill: the number of live items, and a red corner dot while a meeting records (grey while paused); no marks, no clock
|
|
@@ -103,7 +111,7 @@ export function mountLivePill(doc = document, opts = {}) {
|
|
|
103
111
|
pill.setAttribute('aria-label', pillLabel(now, { mac: mac() }));
|
|
104
112
|
pill.title = tipOf(now);
|
|
105
113
|
const h = pillHtml(now); if (pill.__h !== h) { pill.__h = h; pill.innerHTML = h; }
|
|
106
|
-
if (L.open) {
|
|
114
|
+
if (L.open && !tray.querySelector('.lv-stop.armed, .lv-stop:disabled')) { // a Stop waiting for its second press keeps the list as it is
|
|
107
115
|
const f = doc.activeElement && tray.contains(doc.activeElement) ? doc.activeElement.dataset.lv : null;
|
|
108
116
|
tray.innerHTML = rowsHtml(list);
|
|
109
117
|
if (f) { const a = [...tray.querySelectorAll('a[data-lv]')].find((n) => n.dataset.lv === f); if (a) a.focus(); }
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* alert amber "Not hearing the meeting" with Share again, when the microphone is heard and the meeting is not.
|
|
16
16
|
* ask Stop asks first. Record on the next meeting, pressed on another page, asks here once: "Stop X and record Y?".
|
|
17
17
|
* Stop ends the meeting, and the tab then shows its notes (the Viewer, /premium-viewer.html?open=meeting).
|
|
18
|
+
* Finish from anywhere (Wawan, 2026-11-03): a tab that shows the meeting but does not hold the recorder ("Recording in another tab.", or the
|
|
19
|
+
* tab reloaded) still has the red Stop. The server ends the meeting, and the tab that holds the recorder hears 'ended' and goes to the notes.
|
|
18
20
|
*
|
|
19
21
|
* Slots: S2 and S3 load here if their files exist (a missing file is ignored):
|
|
20
22
|
* cozy-rec-now.js mountNow({ stage, side, bar }, ctx) ctx = { ws, note(), mode(), on(evt, fn), focus(), event, rec }
|
|
@@ -118,7 +120,7 @@ function drawStage() {
|
|
|
118
120
|
const left = S.row && S.row.resume_until ? Math.max(1, Math.ceil((Date.parse(S.row.resume_until) - Date.now()) / 60000)) : null;
|
|
119
121
|
own.innerHTML = `<div class="rt-wait"><h2>Paused</h2><p>Nothing is kept until you resume${left ? '. ' + left + (left === 1 ? ' minute' : ' minutes') + ' left to resume' : ''}.</p></div>`; return;
|
|
120
122
|
}
|
|
121
|
-
if (S.mode === 'idle') { own.innerHTML = `<div class="rt-wait"><h2>${esc(title())}</h2><p>${esc(S.msg || (S.row && S.row.state === 'recording' ? 'Recording in another tab.' : 'Not recording yet.'))}</p>${S.row && S.row.state !== 'recording' ? btn('start', 'Start recording', 'rec', 'red big') : ''}</div>`; return; }
|
|
123
|
+
if (S.mode === 'idle') { own.innerHTML = `<div class="rt-wait"><h2>${esc(title())}</h2><p>${esc(S.msg || (S.row && S.row.state === 'recording' ? 'Recording in another tab.' : 'Not recording yet.'))}</p>${S.row && S.row.state !== 'recording' ? btn('start', 'Start recording', 'rec', 'red big') : S.row ? btn('stop', 'Stop the recording', 'stop', 'red big') : ''}</div>`; return; }
|
|
122
124
|
own.innerHTML = '<div class="rt-wait"><h2>Stopped</h2><p>The notes are being written.</p></div>';
|
|
123
125
|
}
|
|
124
126
|
function drawLine() {
|
|
@@ -198,7 +200,7 @@ async function act(a, t) {
|
|
|
198
200
|
case 'shots': { C.shots(!C.screen().paused); return draw(); }
|
|
199
201
|
case 'stop': S.ask = { kind: 'stop' }; return drawAsk();
|
|
200
202
|
case 'askno': S.ask = null; drawAsk(); return draw();
|
|
201
|
-
case 'stopgo': S.ask = null; drawAsk(); return run(async () => { const r = await C.stop(ID); S.msg = r.ok ? '' : r.error; });
|
|
203
|
+
case 'stopgo': S.ask = null; drawAsk(); return run(async () => { const r = await C.stop(ID); S.msg = r.ok ? '' : r.error; if (r.ok) post({ id: ID, state: 'ended', title: title(), elapsedMs: elapsed(), silent: false }); });
|
|
202
204
|
case 'switchgo': {
|
|
203
205
|
const to = S.ask && S.ask.body; S.ask = null; drawAsk();
|
|
204
206
|
return run(async () => {
|
|
@@ -215,6 +217,7 @@ document.addEventListener('click', (e) => { const t = e.target.closest('[data-a]
|
|
|
215
217
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && S.ask) { S.ask = null; drawAsk(); } });
|
|
216
218
|
|
|
217
219
|
/* Record on the next meeting, pressed on another page while this one records: ask here once (rec-tab-core.js asks over the channel) */
|
|
220
|
+
listen((d) => { if (d.id === ID && d.state === 'ended' && !READING) { READING = true; read().catch(() => {}).finally(() => { READING = false; }); } }); // finished on another page
|
|
218
221
|
listen((d) => { if (d.id === ID && d.switchTo && d.switchTo.body) { S.ask = { kind: 'switch', title: clip(d.switchTo.title || 'the next meeting', 80), body: d.switchTo.body }; drawAsk(); } });
|
|
219
222
|
|
|
220
223
|
/* ── the slots: S2 (Now panel) and S3 (Present) mount here when their files are on the page ─────────────────── */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* pm/grill-words.js — the words and marks every page uses for Claude's checks (Grill and Forecast screens; canvas GrillResult, GrillLenses,
|
|
2
2
|
* FcIntel, FcPhone). Pure: Node can import it.
|
|
3
3
|
*
|
|
4
|
-
* ANGLE { [angle]: { label, icon (a 24-unit path) } }: premortem · redteam · voices · reframe · scope · whatif
|
|
4
|
+
* ANGLE { [angle]: { label, icon (a 24-unit path) } }: premortem · redteam · voices · reframe · scope · whatif · decision · past
|
|
5
5
|
* findingOf(p) a grill proposal (lib/grill.js finding) → { angle, label, icon, title, why, next, simulated }
|
|
6
6
|
* the title and why without the angle prefix and the "Next:" tail lib/grill.js writes into them
|
|
7
7
|
* whenWord(iso, now) 'overnight' · 'today' · 'yesterday' · 'on Oct 30'
|
|
@@ -16,6 +16,8 @@ export const ANGLE = Object.freeze({
|
|
|
16
16
|
reframe: { label: 'Reframe', icon: 'M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4M9 9h6v6H9z' },
|
|
17
17
|
scope: { label: 'Scope', icon: 'M4 6h16M7 12h10M10 18h4' },
|
|
18
18
|
whatif: { label: 'What-if', icon: 'M4 18c4 0 4-12 8-12s4 12 8 12M4 12c4 0 4-6 8-6' },
|
|
19
|
+
decision: { label: 'Earlier decision', icon: 'M6 3h9l3 3v15H6zM9 9h6M9 13h6M9 17h3' },
|
|
20
|
+
past: { label: 'You decided this before', icon: 'M3 12a9 9 0 1 0 3-6.7M3 4v4h4M12 7v5l3 2' },
|
|
19
21
|
});
|
|
20
22
|
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
21
23
|
|
|
@@ -44,7 +46,7 @@ export function costWord(usd) {
|
|
|
44
46
|
if (usd == null || !Number.isFinite(n)) return '';
|
|
45
47
|
return n < 1 ? Math.max(1, Math.round(n * 100)) + '¢' : '$' + n.toFixed(2);
|
|
46
48
|
}
|
|
47
|
-
const WHY = { exit: 'before it leaves its stage', forecast: 'when its forecast turned', move: 'after its scope or dates moved', monthly: 'in the monthly look' };
|
|
49
|
+
const WHY = { exit: 'before it leaves its stage', forecast: 'when its forecast turned', move: 'after its scope or dates moved', plan: 'when its PRD changed', monthly: 'in the monthly look' };
|
|
48
50
|
export const whyWord = (r) => (r && WHY[r.why]) || '';
|
|
49
51
|
export function runLine(r, now = Date.now()) {
|
|
50
52
|
if (!r) return '';
|
|
@@ -58,6 +58,7 @@ import * as CW from './ctx-wedge.js'; // how full a session's context is, and
|
|
|
58
58
|
import * as SO from './sort-order.js'; // the sort button and ONE order with the Board (batch 14 item 8, canvas SoAHomePhone)
|
|
59
59
|
import * as PO from './prog-order.js'; // a program's order on Home: the grip, hold-then-drag on a phone, ✦ Claude's order from the dates (canvas RoAPhone)
|
|
60
60
|
import * as IMP from './home-impact.js'; // Your impact: the weekly check in Home's own chat (batch 16 item 7, GmP1Ledger)
|
|
61
|
+
import * as DRIFT from './home-drift.js'; // What you might have missed: the weekly drift ✦ in Home's own chat (weekly-drift-check)
|
|
61
62
|
import * as LPV from './loose-props.js'; // Claude's clean-up of "Not in an initiative": the ✦ on its header and the Viewer (batch 13 item 3)
|
|
62
63
|
import * as IM from './impact.js';
|
|
63
64
|
import { mountPull } from './cozy-pull.js'; // pull to refresh on Your day, Adler peeking up (batch 17 item 4-B, AdP2) // Your impact (batch 16 item 7): Adler's sprout on the greeting, the week in the Viewer, the Friday chapter
|
|
@@ -214,6 +215,7 @@ async function load() {
|
|
|
214
215
|
draw();
|
|
215
216
|
NEED.refresh(); // an open ask: its agent finished, or it left Home while it was open
|
|
216
217
|
loadImpact();
|
|
218
|
+
loadDrift();
|
|
217
219
|
readImpact();
|
|
218
220
|
loadGrillNews();
|
|
219
221
|
// the boards of the other products Home reads, each its own closed group: read after the list is drawn, so a slow one never holds it up
|
|
@@ -347,7 +349,7 @@ function homeListHtml() {
|
|
|
347
349
|
S.lo = { sel, ses: S.ses, restOpen: (k) => S.rest.has(k), pin: S.pin, phone: false }; // the folded rail's card draws Home's rows with these
|
|
348
350
|
return TD.listHtml(M, todayModel(), {
|
|
349
351
|
sel, selHead: S.head, ses: S.ses, pOpen, here: isPhone() ? null : TD.groupOfOpen(M, sel) || S.head, restOpen: (k) => S.rest.has(k), cap: S.cap, pin: S.pin, avHtml, ready: S.ready, err: S.D.err.length ? S.D.err[0] : '',
|
|
350
|
-
pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') : S.menIn ? '<div class="ls-stale" inert>' + LS.inert(MEN.rowHtml(S.D, {})) + '</div>' : LS.menShape(), // Mentions on its own clock, seen but not pressed until Home is fresh (SpG)
|
|
352
|
+
pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') + (S.drift ? DRIFT.rowHtml(S.drift.c, { esc, sel: S.sel === 'drift' }) : '') : S.menIn ? '<div class="ls-stale" inert>' + LS.inert(MEN.rowHtml(S.D, {})) + '</div>' : LS.menShape(), // Mentions on its own clock, seen but not pressed until Home is fresh (SpG)
|
|
351
353
|
last: S.ready ? '' : LS.last(LAST_KEY), keep: S.ready && S.filter === 'today' && !S.ini ? (b) => LS.keepLast(LAST_KEY, b) : null,
|
|
352
354
|
imFace: IM.faceHtml(S.im, { joy: S.imJoy }), grill: S.ready ? grillNews() : null,
|
|
353
355
|
});
|
|
@@ -1072,7 +1074,7 @@ function programTarget(id, task) {
|
|
|
1072
1074
|
function homeTarget() {
|
|
1073
1075
|
return { key: 'chat:main', ws: WS || undefined, title: 'Home', scope: { text: 'All work', icon: I.home }, rich: false, placeholder: 'Message Claude', role: CLAUDE,
|
|
1074
1076
|
empty: 'Ask anything about your work. Claude reads your repositories and works in a real Claude Code session.', also: alsoOf(null),
|
|
1075
|
-
intro: S.impact ? S.impact.intro : undefined };
|
|
1077
|
+
intro: S.sel === 'drift' && S.drift ? S.drift.intro : S.impact ? S.impact.intro : S.drift ? S.drift.intro : undefined };
|
|
1076
1078
|
}
|
|
1077
1079
|
/* Your impact (batch 16 item 7; batch 23 item 4, IcA): while last week's check is open, Home's own chat opens on Claude's thank-you card.
|
|
1078
1080
|
* Off in Settings, or nothing to check: no card. Read after the list is drawn, so it never holds Home up. */
|
|
@@ -1084,6 +1086,15 @@ async function loadImpact() {
|
|
|
1084
1086
|
if ((was || S.impact) && cur && cur.key === 'chat:main') { chat.open(homeTarget()); }
|
|
1085
1087
|
if (was || S.impact) drawList();
|
|
1086
1088
|
}
|
|
1089
|
+
/* What you might have missed (weekly-drift-check): last week's drift ✦ in Home's own chat. A quiet week, or the check off: no card, no row. */
|
|
1090
|
+
async function loadDrift() {
|
|
1091
|
+
const c = await DRIFT.readCheck(pmGet).catch(() => null);
|
|
1092
|
+
const was = !!S.drift;
|
|
1093
|
+
S.drift = c ? { c, intro: DRIFT.checkIntro(c, { esc, pmPost }) } : null;
|
|
1094
|
+
const cur = chat.current && chat.current();
|
|
1095
|
+
if ((was || S.drift) && cur && cur.key === 'chat:main') { chat.open(homeTarget()); }
|
|
1096
|
+
if (was || S.drift) drawList();
|
|
1097
|
+
}
|
|
1087
1098
|
// the switch in Settings › General (pm/cozy-settings.js) turns the check off and on at once
|
|
1088
1099
|
window.addEventListener('pmos:impact', () => { loadImpact(); readImpact(); }); // the switch: the weekly check row and Adler's sprout follow at once
|
|
1089
1100
|
function openTarget(t) {
|
|
@@ -1792,6 +1803,7 @@ $('#hmList').onclick = (e) => { // delegated: every press on the list is a <bu
|
|
|
1792
1803
|
if (S.ses && !b.closest('.hv-spp, .hv-sps, .hv-rcs')) sesPill(null, false); // a press anywhere else in the list closes it
|
|
1793
1804
|
if (d.hmSel) { if (S.ses) sesPill(null, false); if (S.day) S.day = false; select(d.hmSel); return; }
|
|
1794
1805
|
if ('hrImpact' in d) { if (S.day) S.day = false; S.sel = 'impact'; if (isPhone()) S.phoneChat = true; openTarget(homeTarget()); drawList(); return; } // Your impact: Home's own chat, on the weekly check
|
|
1806
|
+
if ('hrDrift' in d) { if (S.day) S.day = false; S.sel = 'drift'; if (isPhone()) S.phoneChat = true; openTarget(homeTarget()); drawList(); return; } // What you might have missed: Home's own chat, on the weekly ✦
|
|
1795
1807
|
if ('hrMentions' in d) { if (S.day) S.day = false; S.sel = 'mentions'; drawList(); MEN.openMentions(menCtx()); return; }
|
|
1796
1808
|
if ('hmImpact' in d) { if (S.day) day(false); IM.openWeek(); return; }
|
|
1797
1809
|
if (d.hmGn) { openGrillNews(d.hmGn); return; }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* pm/home-drift.js — "What you might have missed" in Home's chat (initiative weekly-drift-check; lib/routes-drift.js).
|
|
2
|
+
* Each Monday PMOS checks last week's records for quiet drift. When it found something, Claude shows at most three items as one ✦ card in
|
|
3
|
+
* Home's own chat, with the records each came from. A quiet week shows nothing: no card and no row. One quiet ⋯ per item holds "Not right,
|
|
4
|
+
* drop it"; a dropped item says so with Undo. Nothing else is a button: the check runs by itself. It reuses the Your impact card's look
|
|
5
|
+
* (pm/home-impact.css, canvas IcA).
|
|
6
|
+
*
|
|
7
|
+
* readCheck(pmGet) → the check of GET /api/drift/check, else null
|
|
8
|
+
* rowHtml(c, { esc, sel }) → the one row under Mentions until the card is seen: a tap opens Home's chat on it
|
|
9
|
+
* checkIntro(c, { esc, pmPost }) → the intro of Home's chat: { role, mood, text, card: { html, press } }
|
|
10
|
+
* Writes: POST /api/drift/drop { id }, /undo { id }, /looked { week }. Nothing leaves PMOS. */
|
|
11
|
+
|
|
12
|
+
const SV = (d, w = 18) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
|
|
13
|
+
const P = {
|
|
14
|
+
stalled: 'M12 7v5l3 2M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z',
|
|
15
|
+
slipped: 'M4 12h12M12 6l6 6-6 6',
|
|
16
|
+
jira: 'M8 7h11M8 12h11M8 17h11M4 7h.01M4 12h.01M4 17h.01',
|
|
17
|
+
spark: 'M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z',
|
|
18
|
+
no: 'M6 6l12 12M18 6L6 18', dots: 'M5 12h.01M12 12h.01M19 12h.01', lock: 'M7 11V8a5 5 0 0 1 10 0v3M5 11h14v9H5z',
|
|
19
|
+
};
|
|
20
|
+
const WORD = ['', 'One thing', 'Two things', 'Three things'];
|
|
21
|
+
|
|
22
|
+
export async function readCheck(pmGet) {
|
|
23
|
+
const r = await pmGet('/api/drift/check');
|
|
24
|
+
return r && r.ok && r.on && r.check && r.check.items && r.check.items.length ? r.check : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function rowHtml(c, { esc, sel }) {
|
|
28
|
+
if (!c || c.looked) return '';
|
|
29
|
+
return `<div class="hv-card hr-card hr-men-card"><div class="hm-r hv-r hr-men${sel ? ' sel' : ''}" data-id="drift">`
|
|
30
|
+
+ `<button type="button" class="hm-rm" data-hr-drift${sel ? ' aria-current="true"' : ''} aria-label="What you might have missed last week">`
|
|
31
|
+
+ `<span class="hv-mk hr-at" aria-hidden="true">${SV(P.spark, 16)}</span><span class="hm-rt"><b>What you might have missed</b><small class="hr-sub">Last week</small></span>`
|
|
32
|
+
+ '<span class="hv-xc" aria-hidden="true"></span></button></div></div>';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function checkIntro(c, { esc, pmPost }) {
|
|
36
|
+
const st = { c, msg: '', menu: null, told: !!c.looked };
|
|
37
|
+
const row = (x) => {
|
|
38
|
+
if (x.dropped) {
|
|
39
|
+
return `<li class="im-q im-gone"><span class="im-gt">Dropped: ${esc(x.title)}</span>`
|
|
40
|
+
+ `<button type="button" class="im-undo" data-dr-undo="${esc(x.id)}">Undo</button></li>`;
|
|
41
|
+
}
|
|
42
|
+
const open = st.menu === x.id;
|
|
43
|
+
const facts = (x.claims || []).map((k) => k.text).filter(Boolean).join(' · ');
|
|
44
|
+
return `<li class="im-q"><span class="im-k" aria-hidden="true">${SV(P[x.kind] || P.spark)}</span>`
|
|
45
|
+
+ `<span class="im-tx"><span class="im-t">${esc(x.title)}</span><span class="im-w">${esc(x.say)}</span>`
|
|
46
|
+
+ (facts ? `<span class="im-w">${esc(facts)}</span>` : '') + (x.product ? `<span class="im-w">${esc(x.product)}</span>` : '') + '</span>'
|
|
47
|
+
+ `<span class="im-mw"><button type="button" class="ut-ib im-more" data-dr-more="${esc(x.id)}" aria-haspopup="menu" aria-expanded="${open}" aria-label="More" title="More">${SV(P.dots, 16)}</button>`
|
|
48
|
+
+ (open ? `<span class="im-pop" role="menu"><button type="button" role="menuitem" data-dr-drop="${esc(x.id)}">${SV(P.no, 15)} Not right, drop it</button></span>` : '')
|
|
49
|
+
+ '</span></li>';
|
|
50
|
+
};
|
|
51
|
+
const html = () => {
|
|
52
|
+
if (!st.told) { st.told = true; st.c.looked = true; Promise.resolve(pmPost('/api/drift/looked', { week: st.c.week })).catch(() => {}); }
|
|
53
|
+
return `<div class="im-card" role="group" aria-label="What you might have missed last week">`
|
|
54
|
+
+ `<ul class="im-list">${st.c.items.map(row).join('')}</ul>`
|
|
55
|
+
+ `<p class="im-lock">${SV(P.lock, 13)} Read from your records. Nothing was changed or sent.</p>`
|
|
56
|
+
+ `<p class="im-msg" role="status">${esc(st.msg)}</p></div>`;
|
|
57
|
+
};
|
|
58
|
+
const press = async (b) => {
|
|
59
|
+
const d = b.dataset;
|
|
60
|
+
if (d.drMore) { st.menu = st.menu === d.drMore ? null : d.drMore; return true; }
|
|
61
|
+
let r = null;
|
|
62
|
+
if (d.drDrop) r = await pmPost('/api/drift/drop', { id: d.drDrop });
|
|
63
|
+
else if (d.drUndo) r = await pmPost('/api/drift/undo', { id: d.drUndo });
|
|
64
|
+
else return false;
|
|
65
|
+
st.menu = null;
|
|
66
|
+
if (!r || !r.ok) { st.msg = (r && r.error) || 'Not saved.'; return true; }
|
|
67
|
+
const x = st.c.items.find((y) => y.id === (d.drDrop || d.drUndo));
|
|
68
|
+
if (x) x.dropped = !!d.drDrop;
|
|
69
|
+
st.msg = '';
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
const n = c.items.length;
|
|
73
|
+
const unread = c.notRead && c.notRead.length ? ' I could not read ' + c.notRead.join(', ') + ', so this may not be all.' : '';
|
|
74
|
+
return { role: { key: 'claude', name: 'Claude', initial: 'C' }, mood: 'honest',
|
|
75
|
+
text: `✦ ${WORD[n] || n + ' things'} quietly drifted last week. The facts come from your records. Whether they matter is yours to judge.${unread}`, card: { html, press } };
|
|
76
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* While a meeting records, a row sits above @Mentions: a red outline, a red dot, the meeting's title and "Recording · 12:40", and the
|
|
4
4
|
* clock ticks. It stays while the PM opens other items. A press opens the live meeting (data-hm-recopen, which home-app.js already opens
|
|
5
5
|
* in place). Paused: "Paused · 12:40", quieter, and the clock stands still. With no recording there is no row.
|
|
6
|
+
* Beside it, one icon-only Stop (Wawan, 2026-11-03: finish from anywhere): the first press asks "Press again", the second ends the meeting
|
|
7
|
+
* (rec-state.js stopPress), wherever the recorder runs.
|
|
6
8
|
*
|
|
7
9
|
* mountRecRow({ notes, refresh, redraw }) → { html() }
|
|
8
10
|
* notes() the notes Home already reads (premium-meeting.js recCore().notes()), the fallback while rec-state.js is not on the page
|
|
@@ -10,7 +12,7 @@
|
|
|
10
12
|
* redraw() Home draws its list again: called when the recording starts, pauses, resumes, stops or changes its title
|
|
11
13
|
*
|
|
12
14
|
* The state comes from rec-state.js (onRecording, the meetings workstream) when the page has it, else from the notes, read by the same rule
|
|
13
|
-
* (a note recording or paused; meeting_ms is the meeting time heard).
|
|
15
|
+
* (a note recording or paused; meeting_ms is the meeting time heard). Only the Stop writes (POST /api/notes/stop, through rec-state.js). */
|
|
14
16
|
import { sheet } from './home-mentions.js';
|
|
15
17
|
|
|
16
18
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
@@ -36,7 +38,8 @@ export function recRowHtml(st, now = Date.now()) {
|
|
|
36
38
|
return `<div class="hv-card hr-card hr-rec-card${st.paused ? ' paused' : ''}"><div class="hm-r hv-r hr-rec" data-id="rec:${esc(st.id)}">`
|
|
37
39
|
+ `<button type="button" class="hm-rm" data-hm-recopen="${esc(st.id)}" aria-label="${esc(st.title)}. ${word}. Open the meeting">`
|
|
38
40
|
+ `<span class="hv-mk"><span class="hr-rdot" aria-hidden="true"></span></span><span class="hm-rt"><b>${esc(st.title)}</b></span>`
|
|
39
|
-
+ `<span class="hv-st hr-rst">${word} · <span data-hr-clock>${clockOf(elapsed(st, now))}</span></span><span class="hv-xc" aria-hidden="true"></span></button
|
|
41
|
+
+ `<span class="hv-st hr-rst">${word} · <span data-hr-clock>${clockOf(elapsed(st, now))}</span></span><span class="hv-xc" aria-hidden="true"></span></button>`
|
|
42
|
+
+ `<button type="button" class="hr-stop" data-hr-stop="${esc(st.id)}" aria-label="Stop the recording" title="Stop the recording"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg></button></div></div>`;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export function mountRecRow({ notes, refresh, redraw } = {}) {
|
|
@@ -50,6 +53,12 @@ export function mountRecRow({ notes, refresh, redraw } = {}) {
|
|
|
50
53
|
return st;
|
|
51
54
|
};
|
|
52
55
|
const state = () => live || fromNotes();
|
|
56
|
+
document.addEventListener('click', (e) => {
|
|
57
|
+
const b = e.target && e.target.closest ? e.target.closest('[data-hr-stop]') : null;
|
|
58
|
+
if (!b) return;
|
|
59
|
+
e.preventDefault(); e.stopPropagation();
|
|
60
|
+
import('./rec-state.js').then((M) => { const p = M.stopPress(b, b.dataset.hrStop); if (p) p.then((r) => { if (r && r.ok && refresh) Promise.resolve(refresh()).then(() => redraw && redraw()); }); }).catch(() => {});
|
|
61
|
+
}, true);
|
|
53
62
|
const changed = (st) => { const k = st.on ? [st.id, st.paused, st.title].join('|') : ''; if (k === seen) return false; seen = k; return true; };
|
|
54
63
|
import('./rec-state.js').then((M) => {
|
|
55
64
|
M.onRecording((st) => { live = st && typeof st === 'object' ? st : null; if (changed(state()) && redraw) redraw(); });
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* recState({ fresh }) → Promise<State> the recording now, read from GET /api/notes (kept 5 s unless fresh)
|
|
5
5
|
* onRecording(fn) → unsubscribe fn(State) now and on each change; while someone listens the page reads again every 10 s
|
|
6
6
|
* announce() a page that just started, paused, resumed or stopped a recording asks for a fresh read
|
|
7
|
+
* finish(id) → { ok, error } end the meeting from any page (Wawan, 2026-11-03: "finish recording from anywhere").
|
|
8
|
+
* The server ends it; the tab that holds the recorder hears 'ended' and closes the mic.
|
|
9
|
+
* stopPress(el, id) → Promise|undefined the icon-only Stop on a snippet: the first press arms it (it asks "Press again to
|
|
10
|
+
* stop"), a second press within 4 s finishes the meeting
|
|
7
11
|
* EVENT = 'cz:recording' the same State, as a CustomEvent on document, on each change
|
|
8
12
|
* window.czRecording = { state: recState, on: onRecording }
|
|
9
13
|
*
|
|
@@ -16,10 +20,10 @@
|
|
|
16
20
|
* here this page holds the recorder (premium-meeting.js sets window.__premiumHolding), or is the recording tab
|
|
17
21
|
* silent the recording tab hears the microphone but not the meeting ("Not hearing the meeting")
|
|
18
22
|
* The recording tab also sends its state over a BroadcastChannel (rec-tab-core.js post) on each change and every 5 s: a page reads again
|
|
19
|
-
* at once, so its snippet never runs a stale clock. The channel carries state
|
|
23
|
+
* at once, so its snippet never runs a stale clock. The channel carries state, and 'ended' when another page finished the meeting.
|
|
20
24
|
* The answer comes from the notes the server keeps, so a page that did not start the recording still knows of it. Nothing is written. */
|
|
21
|
-
import { pmGet } from './http.js';
|
|
22
|
-
import { tabUrl, isTab, listen } from './rec-tab-core.js';
|
|
25
|
+
import { pmGet, pmPost } from './http.js';
|
|
26
|
+
import { tabUrl, isTab, listen, post } from './rec-tab-core.js';
|
|
23
27
|
|
|
24
28
|
export const EVENT = 'cz:recording';
|
|
25
29
|
const OFF = Object.freeze({ on: false, paused: false, id: '', title: '', startedAt: 0, elapsedMs: 0, readAt: 0, href: '', here: false, silent: false });
|
|
@@ -66,6 +70,28 @@ export function onRecording(fn) {
|
|
|
66
70
|
/** A recording was started, paused, resumed or stopped on this page: read again now. */
|
|
67
71
|
export function announce() { return recState({ fresh: true }); }
|
|
68
72
|
|
|
73
|
+
/** End the meeting from this page, whichever tab holds the recorder. */
|
|
74
|
+
export async function finish(id) {
|
|
75
|
+
if (!id) return { ok: false, error: 'No meeting is recording.' };
|
|
76
|
+
const r = await pmPost('/api/notes/stop', { id: String(id) });
|
|
77
|
+
const ok = !!(r && r.ok === true);
|
|
78
|
+
if (ok) post({ id: String(id), state: 'ended', title: '', elapsedMs: 0, silent: false }); // the recording tab stops listening at once
|
|
79
|
+
await announce();
|
|
80
|
+
return ok ? { ok: true } : { ok: false, error: (r && r.error) || 'The meeting was not stopped.' };
|
|
81
|
+
}
|
|
82
|
+
const ARM_MS = 4000;
|
|
83
|
+
export function stopPress(el, id) {
|
|
84
|
+
if (!el || el.disabled) return undefined;
|
|
85
|
+
const say = (t) => { el.setAttribute('aria-label', t); el.title = t; };
|
|
86
|
+
if (el.dataset.armed !== '1') {
|
|
87
|
+
el.dataset.armed = '1'; el.classList.add('armed'); say('Press again to stop the recording');
|
|
88
|
+
clearTimeout(el.__arm); el.__arm = setTimeout(() => { delete el.dataset.armed; el.classList.remove('armed'); say('Stop the recording'); }, ARM_MS);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
clearTimeout(el.__arm); el.disabled = true;
|
|
92
|
+
return finish(id).then((r) => { if (!r.ok) { el.disabled = false; delete el.dataset.armed; el.classList.remove('armed'); say(r.error); } return r; });
|
|
93
|
+
}
|
|
94
|
+
|
|
69
95
|
if (typeof window !== 'undefined' && !isTab()) listen((d) => { heard = { id: d.id, silent: !!d.silent }; recState({ fresh: true }); });
|
|
70
96
|
|
|
71
|
-
if (typeof window !== 'undefined') window.czRecording = { state: recState, on: onRecording, EVENT };
|
|
97
|
+
if (typeof window !== 'undefined') window.czRecording = { state: recState, on: onRecording, finish, EVENT };
|
package/workspace/server.js
CHANGED
|
@@ -3073,6 +3073,25 @@ const impactRuns = require('./lib/impact-runs.js').create({
|
|
|
3073
3073
|
warn: (what, e) => warnWriteFail(what, e),
|
|
3074
3074
|
});
|
|
3075
3075
|
const impactTick = () => { try { impactRuns.tick({}); } catch (e) { warnWriteFail('impact tick', e); } };
|
|
3076
|
+
// weekly-drift-check (What would I have missed): each Monday the server reads every open product's records for quiet drift (lib/drift-runs.js).
|
|
3077
|
+
// Structured signals only, no session and no model. Its only write is the person's own ledger <runtime>/drift/drift.jsonl, through the shared
|
|
3078
|
+
// guard (lib/ai-guard.js). Off with PMOS_DRIFT=off or `pmos ai off drift`. Nothing is sent.
|
|
3079
|
+
const driftRuns = require('./lib/drift-runs.js').create({
|
|
3080
|
+
runtime: () => STATE,
|
|
3081
|
+
products: () => {
|
|
3082
|
+
const IR = require('./lib/initiative-rows.js'), out = [];
|
|
3083
|
+
for (const w of workspaces.values()) {
|
|
3084
|
+
const r = IR.rowsFor(w.root);
|
|
3085
|
+
if (r && r.pending) return null; // the rows are being read in the background: the next tick runs the week
|
|
3086
|
+
out.push({ w: { id: w.id, name: w.name || w.id, root: w.root }, stateDir: stateDirFor(w), rows: r && r.ok ? r.rows : null });
|
|
3087
|
+
}
|
|
3088
|
+
return out;
|
|
3089
|
+
},
|
|
3090
|
+
open: (now) => !proactiveLib.inQuietHours(now, proSettings().quiet),
|
|
3091
|
+
env: process.env,
|
|
3092
|
+
every: TICK_MS, // its own tick, as often as the server's; only the server that owns the schedules runs it
|
|
3093
|
+
mayRun: () => { try { return scheduleOwner.mayRun(); } catch { return true; } },
|
|
3094
|
+
warn: (what, e) => warnWriteFail(what, e) });
|
|
3076
3095
|
// batch 16 item 6 ("Claude runs the OKRs"): Claude reads each goal with its recipe (lib/metric-read-runs.js) when its cadence comes round, files the
|
|
3077
3096
|
// value with its source and time, says honestly what it could not read and drafts the fix. A broken limit opens one alert: a bell line only when
|
|
3078
3097
|
// the goal's notify is push. At most 2 runs at once, one per product. PMOS_METRIC_READ=off.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: grill
|
|
3
3
|
label: "Claude grills an initiative"
|
|
4
|
-
description: "One short background run for one initiative, started by Claude itself before a stage ends, when the forecast turns at risk, when scope or dates move a lot, or monthly on running work with late tasks. It picks the angles that fit the stage and the evidence (premortem, red team, voices, reframe, scope, what-if) and files each finding with a source and a next step. Everything waits under the initiative's ✦ for the PM's ✓. It writes no task, document or stage and sends nothing."
|
|
4
|
+
description: "One short background run for one initiative, started by Claude itself before a stage ends, when the forecast turns at risk, when scope or dates move a lot, when its PRD changes, or monthly on running work with late tasks. It picks the angles that fit the stage and the evidence (premortem, red team, voices, reframe, scope, what-if, earlier decision) and files each finding with a source and a next step. Everything waits under the initiative's ✦ for the PM's ✓. It writes no task, document or stage and sends nothing."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
output:
|
|
@@ -28,11 +28,13 @@ The angles. Pick the ones that fit the stage and why this run runs; you do not n
|
|
|
28
28
|
- `reframe`: the problem is framed wrong, or a smaller problem would win more.
|
|
29
29
|
- `scope`: what to cut, split or defer to keep the date (good when scope or dates moved).
|
|
30
30
|
- `whatif`: one other order or date for the work, and what it would change (good when the forecast turned).
|
|
31
|
+
- `decision`: the plan or the PRD goes against an earlier decision that still stands. Check this angle on EVERY run, first when the PRD changed. The decisions that stand are listed after this procedure, one line each with its `path:line`. Read the ones the plan touches for their words. Quote both sides verbatim: the decision's own words, and the plan's words at `path:line`. Never push back with a superseded decision: the server refuses it and names the one that replaced it. Say what you recommend: `keep` (keep the decision, change the plan), `change` (the plan is right: a new decision amends the old one), or `drop` (the old decision no longer holds). File it only when you would bet on it (`confidence` high or medium). A plan that only repeats a decision is not a finding.
|
|
31
32
|
|
|
32
33
|
Procedure:
|
|
33
34
|
1. Read the initiative: `pmos initiative export <id>`, its PRD and stage documents, `pmos task list <id>`, and the stored reads (`pmos source status`, `pmos source show <source>`). For Voices, look in the stored research and meeting notes.
|
|
34
35
|
2. Choose 2 to 4 angles. For each, find what the evidence says.
|
|
35
|
-
3. File
|
|
36
|
+
3. File an earlier decision with `pmos grill find decision --json -` and `{ "decision": "D79", "quote": "<the decision's words>", "plan": { "label": "Checkout PRD", "ref": "planning/prd/checkout-prd.md:42", "quote": "<the plan's words at that line>" }, "conflict": "<how the plan goes against it, one line>", "recommend": "keep", "next": "<one next step>", "confidence": "high" }`. The server checks both quotes and files it under the ✦ only after the real supersede pairs replayed.
|
|
37
|
+
4. File each other finding: `pmos grill find <angle> --json -` with `{ "title": "<the finding, one short line>", "why": "<the evidence, one line>", "next": "<one next step>", "sources": [{ "label": "Kickoff notes, Oct 3", "ref": "<path or link>" }], "rank": 1 }`.
|
|
36
38
|
|
|
37
39
|
Output contract: produce EXACTLY these sections in your final message:
|
|
38
40
|
- Filed (the finding ids and their angles, or "nothing: the plan holds")
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Decision memory (initiative decision-memory): the decision ledger, the grill's earlier-decision angle, the replay of the real supersede pairs through lib/ai-guard.js, and the plan trigger. No browser, no model.",
|
|
3
|
+
"modules": [
|
|
4
|
+
"decision-memory.test.js"
|
|
5
|
+
]
|
|
6
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "weekly-drift-check: What would I have missed. The signals, the cap and silence, facts from ledger files with path:line citations, the replay, the weekly run through the shared guard, pmos drift, the routes and Home's card (lib/drift.js, lib/drift-facts.js, lib/drift-runs.js, lib/drift-cli.js, lib/routes-drift.js, public/pm/home-drift.js).",
|
|
3
|
+
"modules": ["drift.test.js"],
|
|
4
|
+
"seconds": { "drift.test.js": 2 }
|
|
5
|
+
}
|