create-pmos 0.59.0 → 0.59.2
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/guide/layout.md +2 -0
- package/workspace/guide/today.md +10 -0
- package/workspace/lib/brief.js +33 -1
- package/workspace/lib/day-run.js +33 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/mobile.js +5 -4
- package/workspace/public/pm/tab-term.js +50 -6
- package/workspace/public/pm/today-day.js +2 -1
- package/workspace/server.js +95 -12
- package/workspace/skills/day-check.skill +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.59.
|
|
3
|
+
"version": "0.59.2",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T14:07:26Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T14:07:26Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T14:07:26Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -95,6 +95,8 @@ The bar at the top has Back (when you are deeper than a tab), Repository, the na
|
|
|
95
95
|
|
|
96
96
|
Every row has a visible ⋯ button. Every control is at least 44 pixels high. Nothing works only when you hover.
|
|
97
97
|
|
|
98
|
+
When the keyboard opens or closes, or you turn the phone, the terminal fits itself to the new space. The session is told its new size when the keyboard stops moving, not at every step, so an agent redraws its screen once. Scrolling the page does not resize the terminal.
|
|
99
|
+
|
|
98
100
|
## The older layout
|
|
99
101
|
|
|
100
102
|
The address options `?layout=legacy` and `?fixture=cockpit` keep the older layouts. The rest of this page describes them. In the cockpit, the workspace has four columns: the repository rail, the list, the detail and the tabs. With `?layout=legacy` it has three columns: navigation, detail and tabs.
|
package/workspace/guide/today.md
CHANGED
|
@@ -20,6 +20,16 @@ If the morning-brief session stops before it submits a brief, the Read step says
|
|
|
20
20
|
|
|
21
21
|
Each agent job runs in its own session, so you can open any job and follow or steer it. The workspace types the job's prompt into the session when the agent is ready to read, and presses Enter for you. When you approve or revise a job and its session is still open, the workspace uses that same session again. Prepare again works the same way: when the morning-brief session is still open and has finished its read, or has printed nothing for two minutes, the workspace types the read into that session and runs it again.
|
|
22
22
|
|
|
23
|
+
An agent job can stop with its session still open, for example when the agent asks a question or stops without a draft. When a job's session prints nothing for five minutes and has no draft, the job fails with that reason, so the other jobs can start. Press **Open session** to see what the agent waits on, or **Run it again** to start the job in a new session. **Prepare again** also starts each of these jobs again in a new session. If the first session still delivers its draft, the job takes it.
|
|
24
|
+
|
|
25
|
+
### What Prepare again, Check now and deleting a session change
|
|
26
|
+
|
|
27
|
+
- **Prepare again** reads every source again. For each source it reads, the new read replaces that source's items, digests and, for the calendar, the events. The notes of a meeting stay with the meeting. A source it cannot read this time keeps what it gave before. Your goals and every job stay: a job that has a draft, an approval or a result keeps it, and a new item becomes a new job.
|
|
28
|
+
- **Check now** adds what is new since the last read. It also looks once at each open item: it takes off an item that is done or no longer true, and updates an item whose facts changed, for example a new ask or a new due date. It replaces nothing else, and every job stays.
|
|
29
|
+
- **Deleting a session** does not delete its job. A job that was working fails with "the session ended without a draft", so press **Run it again** to start it in a new session. A job that has a draft keeps it. A job that was sending after your approval fails too, so check the place it was for before you run it again: the workspace cannot tell whether it sent.
|
|
30
|
+
|
|
31
|
+
Each job session is named "Job ·" and the first words of the job, so the session list stays short.
|
|
32
|
+
|
|
23
33
|
**Agent results**, at the top of the left column, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under it, above the calendar. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
|
|
24
34
|
|
|
25
35
|
### One day for every repository
|
package/workspace/lib/brief.js
CHANGED
|
@@ -387,5 +387,37 @@ function mergeBrief(stored, incoming, { now = Date.now() } = {}) {
|
|
|
387
387
|
} };
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/**
|
|
391
|
+
* A second full read of the same day (Prepare again) over the stored brief. Pure. The new read wins for every source
|
|
392
|
+
* it READ; what came from a source it could not read this time is kept, so a source that fails once does not wipe
|
|
393
|
+
* what it gave before.
|
|
394
|
+
* items the incoming ones, plus the stored ones from a source the incoming brief did not read (and not resolved).
|
|
395
|
+
* calendar the incoming events when it read the calendar (notes of an event with the same id are kept), else the
|
|
396
|
+
* stored events.
|
|
397
|
+
* digests the incoming ones, plus the stored ones from a source it did not read.
|
|
398
|
+
* places a service's list is replaced when the incoming brief lists that service.
|
|
399
|
+
* resolved kept.
|
|
400
|
+
* @returns {{ok:true, brief:object, kept:number}|{ok:false, error:string}}
|
|
401
|
+
*/
|
|
402
|
+
function rereadBrief(stored, incoming) {
|
|
403
|
+
if (!incoming || typeof incoming !== 'object') return { ok: false, error: 'nothing to read again' };
|
|
404
|
+
if (!stored || typeof stored !== 'object' || stored.date !== incoming.date) return { ok: true, brief: { ...incoming, resolved: [] }, kept: 0 };
|
|
405
|
+
const read = (k) => k === 'derived' || (incoming.sources || {})[k] === 'read';
|
|
406
|
+
const gone = new Set(stored.resolved || []);
|
|
407
|
+
const inItems = [...(incoming.priorities || []), ...(incoming.worth_asking || [])];
|
|
408
|
+
const ids = new Set(inItems.map((x) => x.id));
|
|
409
|
+
const keep = [...(stored.priorities || []), ...(stored.worth_asking || [])].filter((x) => !read(x.source) && !ids.has(x.id) && !gone.has(x.id));
|
|
410
|
+
const items = sortItems([...inItems, ...keep]).slice(0, MAX_ITEMS);
|
|
411
|
+
const oldEv = new Map((stored.calendar || []).map((e) => [e.id, e]));
|
|
412
|
+
const calendar = read('calendar')
|
|
413
|
+
? (incoming.calendar || []).map((e) => { const o = oldEv.get(e.id); return o && o.notes ? { ...e, notes: { ...o.notes, ...(e.notes || {}) } } : e; })
|
|
414
|
+
: (stored.calendar || []);
|
|
415
|
+
const digests = [...(incoming.digests || []), ...(stored.digests || []).filter((d) => !read(d.source))].slice(0, MAX_DIGESTS);
|
|
416
|
+
const listed = new Set((incoming.places || []).map((x) => x.source));
|
|
417
|
+
const places = [...(stored.places || []).filter((x) => !listed.has(x.source)), ...(incoming.places || [])];
|
|
418
|
+
return { ok: true, kept: keep.length, brief: { ...incoming, calendar, priorities: items.filter((x) => !x.worth_asking), worth_asking: items.filter((x) => x.worth_asking),
|
|
419
|
+
digests, places, resolved: [...gone].slice(-MAX_RESOLVED) } };
|
|
420
|
+
}
|
|
421
|
+
|
|
390
422
|
module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, KINDS, DIGEST_SOURCES, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, MAX_DIGESTS, MAX_PLACES,
|
|
391
|
-
isUrl, validateBrief, mergeBrief, greeting, localDate };
|
|
423
|
+
isUrl, validateBrief, mergeBrief, rereadBrief, greeting, localDate };
|
package/workspace/lib/day-run.js
CHANGED
|
@@ -15,13 +15,17 @@
|
|
|
15
15
|
* newRun(date, now) · readRun(raw, date)
|
|
16
16
|
* addTasks(run, brief) agent priorities (kind approve|auto) → waiting tasks
|
|
17
17
|
* toStart(run, limit) the tasks to start now, each with its mode (do | revise | send)
|
|
18
|
-
* started · deliver · decide · sent · fail · sessionEnded
|
|
18
|
+
* started · deliver · decide · sent · fail · sessionEnded · sessionStalled
|
|
19
19
|
* stage(run, brief) reading | working | review | done
|
|
20
20
|
* checkDue(run, {settings, now, events}) · checkStarted · checkDone
|
|
21
21
|
*
|
|
22
22
|
* Status of a task: waiting → working → review (approve) | done (auto)
|
|
23
23
|
* review --approve--> sending → sent review|done --revise--> waiting (with the note)
|
|
24
24
|
* working|sending --fail or session ended--> failed --retry--> waiting
|
|
25
|
+
* working|sending --session quiet too long--> failed (stalled) --retry--> waiting, in a NEW session
|
|
26
|
+
*
|
|
27
|
+
* A stalled task keeps its session in `stalled_session`, so the PM can open it. When that session still delivers its
|
|
28
|
+
* draft (or reports sent) the task takes it, as if it had never stalled.
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
31
|
const MAX_PARALLEL = 5;
|
|
@@ -134,6 +138,7 @@ function started(run, taskId, sessionId, now) {
|
|
|
134
138
|
/** The task session's ONE draft. An `auto` task is then done; an `approve` task waits for the PM. */
|
|
135
139
|
function deliver(run, taskId, d, now) {
|
|
136
140
|
return withTask(run, taskId, (t) => {
|
|
141
|
+
revive(t);
|
|
137
142
|
if (t.status !== 'working') return `task ${taskId} is ${t.status} — only a working task can deliver a draft`;
|
|
138
143
|
const b = d && typeof d === 'object' ? d : {};
|
|
139
144
|
const summary = text(b.summary); const body = typeof b.body === 'string' ? b.body.trim() : '';
|
|
@@ -183,7 +188,9 @@ function decide(run, taskId, op, now) {
|
|
|
183
188
|
return null;
|
|
184
189
|
}
|
|
185
190
|
if (t.status !== 'failed') return `task ${taskId} is ${t.status} — only a failed task can be run again`;
|
|
191
|
+
const stalled = !!t.stalled_session;
|
|
186
192
|
t.status = 'waiting'; keepSession(t); t.error = null; t.decided = { action: 'retry', at };
|
|
193
|
+
if (stalled) { t.prev_session = null; delete t.stalled_session; delete t.stalled_status; }
|
|
187
194
|
return null;
|
|
188
195
|
});
|
|
189
196
|
}
|
|
@@ -191,6 +198,7 @@ function decide(run, taskId, op, now) {
|
|
|
191
198
|
/** The send session reports the approved step done, with the link to what it posted. */
|
|
192
199
|
function sent(run, taskId, s, now) {
|
|
193
200
|
return withTask(run, taskId, (t) => {
|
|
201
|
+
revive(t);
|
|
194
202
|
if (t.status !== 'sending') return `task ${taskId} is ${t.status} — only an approved task being sent can be reported sent`;
|
|
195
203
|
const link = s && s.link;
|
|
196
204
|
if (link !== undefined && link !== null && link !== '' && !isUrl(link)) return 'the link must be an http(s) address of at most 500 characters';
|
|
@@ -226,6 +234,29 @@ function sessionEnded(run, sessionId, now) {
|
|
|
226
234
|
return { ok: true, run: next, changed };
|
|
227
235
|
}
|
|
228
236
|
|
|
237
|
+
/** A stalled task whose session reports after all goes back to the step it stalled in. */
|
|
238
|
+
function revive(t) {
|
|
239
|
+
if (t.status !== 'failed' || !t.stalled_session || !t.stalled_status) return;
|
|
240
|
+
t.status = t.stalled_status; t.session_id = t.stalled_session; t.error = null;
|
|
241
|
+
delete t.stalled_session; delete t.stalled_status; delete t.failed_at;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A task session is open but has printed nothing for a long time and delivered nothing: it waits on a question, or its
|
|
246
|
+
* agent stopped without reporting. The task fails with `why`, which frees its place, and a retry starts a NEW session.
|
|
247
|
+
*/
|
|
248
|
+
function sessionStalled(run, sessionId, why, now) {
|
|
249
|
+
if (!run) return { ok: false, error: 'no day run today' };
|
|
250
|
+
const next = clone(run);
|
|
251
|
+
let changed = false;
|
|
252
|
+
for (const t of next.tasks) {
|
|
253
|
+
if (!sessionId || t.session_id !== sessionId || (t.status !== 'working' && t.status !== 'sending')) continue;
|
|
254
|
+
t.stalled_status = t.status; t.stalled_session = sessionId; t.session_id = null; t.prev_session = null;
|
|
255
|
+
t.status = 'failed'; t.error = text(why).slice(0, 240) || 'the session stopped without a draft'; t.failed_at = iso(now); changed = true;
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, run: next, changed };
|
|
258
|
+
}
|
|
259
|
+
|
|
229
260
|
/** Where the run is: reading (no brief for the day yet), working, review (drafts wait for the PM) or done. */
|
|
230
261
|
function stage(run, brief) {
|
|
231
262
|
if (!run) return null;
|
|
@@ -299,5 +330,5 @@ function checkDone(run, { found = 0, resolved = 0 } = {}, now) {
|
|
|
299
330
|
}
|
|
300
331
|
|
|
301
332
|
module.exports = { MAX_PARALLEL, MAX_REVISIONS, MAX_CHECK_LOG, MAX_INTERVAL_MIN, STATUSES, KINDS, ACTIONS, TASK_RE,
|
|
302
|
-
newRun, readRun, findTask, modeOf, addTasks, toStart, started, deliver, decide, sent, fail, sessionEnded, stage, intervalMin,
|
|
333
|
+
newRun, readRun, findTask, modeOf, addTasks, toStart, started, deliver, decide, sent, fail, sessionEnded, sessionStalled, stage, intervalMin,
|
|
303
334
|
checkDue, checkStarted, checkDone };
|
package/workspace/package.json
CHANGED
|
@@ -505,10 +505,11 @@ export function mountMobile(context) {
|
|
|
505
505
|
if (theme) theme.addEventListener('click', toggleTheme);
|
|
506
506
|
document.addEventListener('click', onChromeClick);
|
|
507
507
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
508
|
+
/* `resize` only. A visual-viewport SCROLL moves the page under the keyboard and changes no box,
|
|
509
|
+
* so there is nothing to re-fit — but it fires on every frame of a touch scroll, and each one used
|
|
510
|
+
* to become a synthetic window `resize` that every resize listener on the page answered. That was
|
|
511
|
+
* the terminal stuttering under a finger on a phone. */
|
|
512
|
+
if (window.visualViewport) window.visualViewport.addEventListener('resize', refit);
|
|
512
513
|
window.addEventListener('orientationchange', () => setTimeout(refit, 250));
|
|
513
514
|
|
|
514
515
|
// Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
|
|
@@ -678,7 +678,25 @@ function syncSize(rec) {
|
|
|
678
678
|
if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
|
|
679
679
|
const cols = rec.term.cols, rows = rec.term.rows;
|
|
680
680
|
if (!cols || !rows) return;
|
|
681
|
-
if (rec.sentCols === cols && rec.sentRows === rows) return;
|
|
681
|
+
if (rec.sentCols === cols && rec.sentRows === rows) { clearTimeout(rec.sizeTimer); rec.sizeTimer = null; return; }
|
|
682
|
+
/* The FIRST statement on an attach goes out at once: until it does, the shell is drawing into the
|
|
683
|
+
* 80×24 it was spawned with. A CHANGE after that waits for the grid to settle. A phone's soft
|
|
684
|
+
* keyboard slides in over about a quarter of a second and the pane is re-fitted on every frame of
|
|
685
|
+
* it; each `resize` the PTY gets makes a full-screen agent CLI repaint everything it shows, so
|
|
686
|
+
* stating every intermediate grid buried the session under a dozen redraws of screens that were
|
|
687
|
+
* gone before they arrived. The local fit is not delayed — only what the shell is told. */
|
|
688
|
+
if (rec.sentCols == null) { sendSize(rec); return; }
|
|
689
|
+
clearTimeout(rec.sizeTimer);
|
|
690
|
+
rec.sizeTimer = setTimeout(() => { rec.sizeTimer = null; sendSize(rec); }, RESIZE_SETTLE_MS);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** How long the grid must hold still before a change is stated to the PTY. See syncSize. */
|
|
694
|
+
const RESIZE_SETTLE_MS = 120;
|
|
695
|
+
|
|
696
|
+
function sendSize(rec) {
|
|
697
|
+
if (!rec.term || !rec.sessionId || rec.attachedId !== rec.sessionId) return;
|
|
698
|
+
const cols = rec.term.cols, rows = rec.term.rows;
|
|
699
|
+
if (!cols || !rows || (rec.sentCols === cols && rec.sentRows === rows)) return;
|
|
682
700
|
if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
|
|
683
701
|
}
|
|
684
702
|
|
|
@@ -694,19 +712,40 @@ function note(rec, line, force) {
|
|
|
694
712
|
* The single entry point. Coalesced into one animation frame so the six triggers below cost one
|
|
695
713
|
* measure per frame rather than six. A pane that is not in the document, or has no box yet, is
|
|
696
714
|
* skipped — fitting a zero-sized element is how a terminal ends up 1×1 and stays there.
|
|
715
|
+
*
|
|
716
|
+
* A pane whose box has not moved since it was last fitted is NOT re-measured. Every state change
|
|
717
|
+
* in the app lands here (the `subscribe` below), and a fit is a getComputedStyle round-trip on the
|
|
718
|
+
* terminal's parent per pane — on a phone, that was a forced style pass on every render for a grid
|
|
719
|
+
* that could not have changed. The grid depends on the box and on the cell size; the box is the key
|
|
720
|
+
* here, and the one event that moves the cell size without moving the box (the web font arriving)
|
|
721
|
+
* passes `force`.
|
|
697
722
|
*/
|
|
698
723
|
let refitQueued = false;
|
|
724
|
+
let refitForce = false;
|
|
699
725
|
|
|
700
|
-
export function refitVisible() {
|
|
726
|
+
export function refitVisible(force) {
|
|
727
|
+
if (force === true) refitForce = true;
|
|
701
728
|
if (refitQueued) return;
|
|
702
729
|
refitQueued = true;
|
|
703
730
|
const run = () => {
|
|
704
731
|
refitQueued = false;
|
|
732
|
+
const forced = refitForce;
|
|
733
|
+
refitForce = false;
|
|
705
734
|
for (const rec of panes.values()) {
|
|
706
735
|
if (rec.kind !== 'real' || !rec.fit || !rec.host) continue;
|
|
707
736
|
if (!rec.host.isConnected) continue;
|
|
708
|
-
|
|
709
|
-
|
|
737
|
+
const w = rec.host.offsetWidth, h = rec.host.offsetHeight;
|
|
738
|
+
if (!w || !h) continue;
|
|
739
|
+
if (!forced && rec.fitW === w && rec.fitH === h) { syncSize(rec); continue; }
|
|
740
|
+
try {
|
|
741
|
+
// Measured first, so a pane whose renderer cannot measure yet is retried next time rather
|
|
742
|
+
// than remembered as fitted at a grid it never reached.
|
|
743
|
+
const dims = rec.fit.proposeDimensions();
|
|
744
|
+
if (dims && dims.cols > 0 && dims.rows > 0) {
|
|
745
|
+
if (dims.cols !== rec.term.cols || dims.rows !== rec.term.rows) rec.fit.fit();
|
|
746
|
+
rec.fitW = w; rec.fitH = h;
|
|
747
|
+
}
|
|
748
|
+
} catch { /* a terminal mid-teardown */ }
|
|
710
749
|
// …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
|
|
711
750
|
// grid MOVES, so a pane that was measured before it had a session would otherwise never tell
|
|
712
751
|
// the shell how wide it is. See the `attached` handler.
|
|
@@ -724,7 +763,8 @@ if (typeof window !== 'undefined') {
|
|
|
724
763
|
subscribe(() => refitVisible());
|
|
725
764
|
window.addEventListener('resize', () => refitVisible());
|
|
726
765
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) refitVisible(); });
|
|
727
|
-
|
|
766
|
+
// The face changes the CELL, not the box, so this one re-measures every pane regardless.
|
|
767
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible(true)).catch(() => {});
|
|
728
768
|
}
|
|
729
769
|
|
|
730
770
|
/* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
|
|
@@ -931,6 +971,7 @@ export function disposeTerm(tab) {
|
|
|
931
971
|
* pane for the id and detaches from the orphan instead. */
|
|
932
972
|
if (rec.kind !== 'real') return;
|
|
933
973
|
if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
|
|
974
|
+
if (rec.sizeTimer) { clearTimeout(rec.sizeTimer); rec.sizeTimer = null; }
|
|
934
975
|
if (rec.sessionId) {
|
|
935
976
|
send({ t: 'detach', id: rec.sessionId });
|
|
936
977
|
SOCK.attached.delete(rec.sessionId);
|
|
@@ -1058,6 +1099,9 @@ export function termProbe() {
|
|
|
1058
1099
|
*/
|
|
1059
1100
|
export function __handleForTest(m) { return handle(m); }
|
|
1060
1101
|
|
|
1061
|
-
|
|
1102
|
+
/* `fit` is the name mobile.js asks for when the soft keyboard or a rotation moves the box. Without it
|
|
1103
|
+
* that module fell back to dispatching a synthetic window `resize`, which every resize listener on
|
|
1104
|
+
* the page answered — not only this one. */
|
|
1105
|
+
if (typeof window !== 'undefined') window.pmTerm = { refitVisible, fit: () => refitVisible(), termProbe, panes, requestSpawn };
|
|
1062
1106
|
|
|
1063
1107
|
export default mountTerm;
|
|
@@ -553,7 +553,8 @@ function jobCard(ctx, j) {
|
|
|
553
553
|
acts.push(extLink(D.openConversation, j.link, { 'data-day-link': 'conversation' }));
|
|
554
554
|
if (t && t.draft) acts.push(extLink(D.openDraft, t.draft.link, { 'data-day-link': 'draft' }));
|
|
555
555
|
if (t && t.sent) acts.push(extLink(D.openSent, t.sent.link, { 'data-day-link': 'sent' }));
|
|
556
|
-
|
|
556
|
+
const sid = t && (t.session_id || t.stalled_session);
|
|
557
|
+
if (sid) acts.push(button({ label: D.openSession, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'session' }, onClick: () => openSession({ id: sid, name: 'day-task' }) }));
|
|
557
558
|
let form = null;
|
|
558
559
|
if (t && ui.revising.has(t.id) && canRevise) {
|
|
559
560
|
const f = field({ label: D.noteLabel, kind: 'textarea', value: ui.notes.get(t.id) || '', placeholder: D.noteHint, hideLabel: true, attrs: { maxlength: '500', 'data-day-input': 'note-' + t.id } });
|
package/workspace/server.js
CHANGED
|
@@ -453,6 +453,13 @@ function profileOverlay(profile) {
|
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
const SCROLLBACK = 200_000; // bytes of ring buffer per session
|
|
456
|
+
const SCROLLBACK_SLACK = 32_768; // how far the ring may run over before a trim (appendBuf)
|
|
457
|
+
// PTY output goes on the wire at most once per this window per session (queueOutput). node-pty
|
|
458
|
+
// hands over whatever one read() returned, which for an agent CLI repainting its screen is many
|
|
459
|
+
// small chunks — each one a JSON frame, a WebSocket message and a parse in the browser. The first
|
|
460
|
+
// chunk after a quiet spell is sent at once, so a keystroke's echo is never delayed by it.
|
|
461
|
+
const OUTPUT_COALESCE_MS = 4;
|
|
462
|
+
const OUTPUT_COALESCE_MAX = 64 * 1024; // …and flushed at once past this, so a flood stays bounded
|
|
456
463
|
// How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
|
|
457
464
|
// 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
|
|
458
465
|
// a 2s cadence puts the worst-case error in "quiet 31s" at one tick — under the rounding the UI
|
|
@@ -2836,7 +2843,11 @@ function trimBuf(buf, limit) {
|
|
|
2836
2843
|
|
|
2837
2844
|
function appendBuf(s, data) {
|
|
2838
2845
|
s.buf += data;
|
|
2839
|
-
|
|
2846
|
+
// Trimmed with SLACK, not on every byte past the limit. Once a busy session's ring is full, a trim
|
|
2847
|
+
// per chunk flattens and scans the whole 200 KB string for each few bytes an agent CLI repaints —
|
|
2848
|
+
// a spinner alone is dozens of chunks a second. Letting the ring run SCROLLBACK_SLACK over and then
|
|
2849
|
+
// cutting it back to SCROLLBACK does the same work once per 32 KB of output.
|
|
2850
|
+
if (s.buf.length > SCROLLBACK + SCROLLBACK_SLACK) s.buf = trimBuf(s.buf, SCROLLBACK);
|
|
2840
2851
|
s.bufDirty = true; // the periodic writer skips a session that is not dirty
|
|
2841
2852
|
// Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
|
|
2842
2853
|
// from reading the bytes — which is why it is the only one of the five that cannot misread.
|
|
@@ -2844,6 +2855,46 @@ function appendBuf(s, data) {
|
|
|
2844
2855
|
s.parsed = null; // buffer moved; the cached parse is stale
|
|
2845
2856
|
}
|
|
2846
2857
|
|
|
2858
|
+
/**
|
|
2859
|
+
* Put PTY output on the wire as at most ONE frame per OUTPUT_COALESCE_MS per session.
|
|
2860
|
+
*
|
|
2861
|
+
* Leading edge, then a window: a chunk that arrives when nothing has been sent recently goes out at
|
|
2862
|
+
* once — a keystroke's echo is never held back — and opens a window in which further chunks are
|
|
2863
|
+
* gathered and sent together when it closes. A burst (an agent CLI repainting its screen) therefore
|
|
2864
|
+
* costs one frame per window instead of one per node-pty read, and a lone echo costs nothing.
|
|
2865
|
+
*
|
|
2866
|
+
* The bytes reach the ring (`appendBuf`) at the same moment they reach the clients, never before:
|
|
2867
|
+
* a client that attaches in between gets `buf` without the pending bytes and then the flush, so it
|
|
2868
|
+
* sees every byte exactly once and in order. Anything that writes to `buf` directly while output may
|
|
2869
|
+
* be pending (the exit note) calls `flushOutput` first for the same reason.
|
|
2870
|
+
*/
|
|
2871
|
+
function queueOutput(s, data) {
|
|
2872
|
+
if (!data) return;
|
|
2873
|
+
if (s._out) s._out += data; else s._out = data;
|
|
2874
|
+
if (!s._outTimer) { sendOutput(s); s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS); return; }
|
|
2875
|
+
if (s._out.length >= OUTPUT_COALESCE_MAX) sendOutput(s);
|
|
2876
|
+
}
|
|
2877
|
+
function closeOutputWindow(s) {
|
|
2878
|
+
s._outTimer = null;
|
|
2879
|
+
if (!s._out) return;
|
|
2880
|
+
// Output kept arriving through the window: send it, and keep gathering for one more window.
|
|
2881
|
+
sendOutput(s);
|
|
2882
|
+
s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS);
|
|
2883
|
+
}
|
|
2884
|
+
function flushOutput(s) {
|
|
2885
|
+
if (s._outTimer) { clearTimeout(s._outTimer); s._outTimer = null; }
|
|
2886
|
+
sendOutput(s);
|
|
2887
|
+
}
|
|
2888
|
+
function sendOutput(s) {
|
|
2889
|
+
const data = s._out;
|
|
2890
|
+
if (!data) return;
|
|
2891
|
+
s._out = '';
|
|
2892
|
+
appendBuf(s, data);
|
|
2893
|
+
if (!s.clients || !s.clients.size) return;
|
|
2894
|
+
const frame = JSON.stringify({ t: 'data', id: s.id, data });
|
|
2895
|
+
for (const c of s.clients) if (c.readyState === 1) c.send(frame);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2847
2898
|
// Platform-correct guidance when a spawn fails. On macOS `posix_spawnp failed` is almost always
|
|
2848
2899
|
// node-pty's prebuilt `spawn-helper` missing its executable bit (prebuild extraction drops it) —
|
|
2849
2900
|
// the binary is fine, just not +x. The server auto-restores it at startup and `npm run doctor`
|
|
@@ -3077,8 +3128,7 @@ function attachPty(s, { seed = false } = {}) {
|
|
|
3077
3128
|
onOutput: (pane, text) => {
|
|
3078
3129
|
if (paneId && pane !== paneId) return; // only this session's pane
|
|
3079
3130
|
if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
|
|
3080
|
-
|
|
3081
|
-
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id: s.id, data: text }));
|
|
3131
|
+
queueOutput(s, text);
|
|
3082
3132
|
},
|
|
3083
3133
|
});
|
|
3084
3134
|
p.onData((d) => parser.feed(d));
|
|
@@ -3086,6 +3136,7 @@ function attachPty(s, { seed = false } = {}) {
|
|
|
3086
3136
|
// on the client PTY's own window size.
|
|
3087
3137
|
client.resize(s.cols || 80, s.rows || 24);
|
|
3088
3138
|
p.onExit(({ exitCode }) => {
|
|
3139
|
+
flushOutput(s); // whatever the pane said last lands before a reconnect or the exit note
|
|
3089
3140
|
s.pty = null;
|
|
3090
3141
|
// Our PTY is the tmux CLIENT: it exits when the pane process ends AND on a mere detach. If
|
|
3091
3142
|
// the tmux session is still alive, the process lives on — reconnect (bounded, to never spin).
|
|
@@ -3327,11 +3378,9 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3327
3378
|
announceStateHealth(s);
|
|
3328
3379
|
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
3329
3380
|
announceModel(s);
|
|
3330
|
-
p.onData((d) =>
|
|
3331
|
-
appendBuf(s, d);
|
|
3332
|
-
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
3333
|
-
});
|
|
3381
|
+
p.onData((d) => queueOutput(s, d));
|
|
3334
3382
|
p.onExit(({ exitCode }) => {
|
|
3383
|
+
flushOutput(s); // the last bytes the process wrote come before the exit note, in the ring and on the wire
|
|
3335
3384
|
s.state = 'exited'; s.pty = null;
|
|
3336
3385
|
const fast = Date.now() - startedMs < 800; // exited almost immediately → surface a hint
|
|
3337
3386
|
s.note = fast
|
|
@@ -3707,7 +3756,8 @@ function readDayRun(date) {
|
|
|
3707
3756
|
return run;
|
|
3708
3757
|
}
|
|
3709
3758
|
function writeDayRun(run) {
|
|
3710
|
-
|
|
3759
|
+
// Written to a side file and renamed into place, so a reader (the page, the CLI, a suite) never sees a half-written run.
|
|
3760
|
+
try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); const f = dayRunFile(run.date); const tmp = `${f}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(run) + '\n'); fs.renameSync(tmp, f); return true; }
|
|
3711
3761
|
catch (e) { warnWriteFail('day run (goals/<date>.run.json)', e); return false; }
|
|
3712
3762
|
}
|
|
3713
3763
|
function briefOfDay(date) {
|
|
@@ -3726,6 +3776,20 @@ function briefSessionDone(run, date, now) {
|
|
|
3726
3776
|
if (now - Math.max(s.lastOutputAt || 0, Date.parse(bs.started_at || 0) || 0) >= BRIEF_IDLE_MS) return 'idle';
|
|
3727
3777
|
return null;
|
|
3728
3778
|
}
|
|
3779
|
+
// A day-task session that is open but has printed nothing for TASK_IDLE_MS, without a draft, waits on a question or
|
|
3780
|
+
// stopped without reporting. Its task fails with that reason, so its place frees and Retry or Prepare again starts a new
|
|
3781
|
+
// session. A working agent redraws its spinner many times a second, so a quiet of minutes is not work in progress.
|
|
3782
|
+
const TASK_IDLE_MS = Number(process.env.PMOS_DAY_TASK_IDLE_MS || 5 * 60 * 1000);
|
|
3783
|
+
const TASK_STALLED_WHY = 'the session has printed nothing for five minutes and has not delivered a draft. Open the session to see what it waits on, or run the job again.';
|
|
3784
|
+
function stallQuietTasks(run, now) {
|
|
3785
|
+
for (const t of run.tasks) {
|
|
3786
|
+
if ((t.status !== 'working' && t.status !== 'sending') || !t.session_id) continue;
|
|
3787
|
+
const s = sessions.get(t.session_id);
|
|
3788
|
+
if (!s || s.state === 'exited') continue;
|
|
3789
|
+
if (now - Math.max(s.lastOutputAt || 0, Date.parse(t.started_at || 0) || 0) >= TASK_IDLE_MS) run = dayRunLib.sessionStalled(run, t.session_id, TASK_STALLED_WHY, now).run;
|
|
3790
|
+
}
|
|
3791
|
+
return run;
|
|
3792
|
+
}
|
|
3729
3793
|
// The skill's own body is the prompt, as fireSchedule hands it over; the line after it names this run's task and mode.
|
|
3730
3794
|
function daySkillPrompt(run, skillId) {
|
|
3731
3795
|
const w = getWs(run.ws);
|
|
@@ -3733,6 +3797,13 @@ function daySkillPrompt(run, skillId) {
|
|
|
3733
3797
|
if (!task || task.kind !== 'agent' || !task.prompt) return { error: `the ${skillId} skill is not installed for this repository` };
|
|
3734
3798
|
return { w, task, prompt: skillStore.retargetFiling(task.prompt, filingContract(w)) };
|
|
3735
3799
|
}
|
|
3800
|
+
/** A short session name for a job, so the session list stays readable: "Job · " and the first words of its title. */
|
|
3801
|
+
function jobSessionName(title) {
|
|
3802
|
+
const t = String(title || '').replace(/\s+/g, ' ').trim();
|
|
3803
|
+
if (t.length <= 28) return 'Job · ' + (t || 'untitled');
|
|
3804
|
+
const cut = t.slice(0, 28); const sp = cut.lastIndexOf(' ');
|
|
3805
|
+
return 'Job · ' + (sp > 12 ? cut.slice(0, sp) : cut).replace(/[\s,.;:·—-]+$/, '') + '…';
|
|
3806
|
+
}
|
|
3736
3807
|
/** Start one task session; a start that fails marks the task failed with the reason. Returns the new run. */
|
|
3737
3808
|
function startDayTask(run, t, now) {
|
|
3738
3809
|
const sk = daySkillPrompt(run, 'day-task');
|
|
@@ -3746,7 +3817,7 @@ function startDayTask(run, t, now) {
|
|
|
3746
3817
|
}
|
|
3747
3818
|
if (!sk.error && !s) {
|
|
3748
3819
|
const prompt = `${sk.prompt}\n\n${line}`;
|
|
3749
|
-
s = spawnSession({ name:
|
|
3820
|
+
s = spawnSession({ name: jobSessionName(t.title), agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-task', initialInput: prompt + '\r',
|
|
3750
3821
|
modelAsk: { scheduleTier: null, skillTier: sk.task.tier || null, skill: sk.task.skill } });
|
|
3751
3822
|
}
|
|
3752
3823
|
const live = s && s.state !== 'exited';
|
|
@@ -3797,6 +3868,7 @@ function dayTick() {
|
|
|
3797
3868
|
const ids = new Set([...run.tasks.filter((t) => (t.status === 'working' || t.status === 'sending') && t.session_id).map((t) => t.session_id),
|
|
3798
3869
|
run.brief_session && !run.brief_session.ended_at ? run.brief_session.id : null, run.checks.session_id].filter(Boolean));
|
|
3799
3870
|
for (const id of ids) if (!daySessionAlive(id)) run = dayRunLib.sessionEnded(run, id, now).run;
|
|
3871
|
+
run = stallQuietTasks(run, now);
|
|
3800
3872
|
const brief = briefOfDay(date);
|
|
3801
3873
|
const a = dayRunLib.addTasks(run, brief);
|
|
3802
3874
|
if (a.ok) run = a.run;
|
|
@@ -7482,6 +7554,15 @@ async function handleRequest(req, res) {
|
|
|
7482
7554
|
const open = readGoalsDay(pdate).goals.filter((g) => g.status === 'open');
|
|
7483
7555
|
let run = readDayRun(pdate) || dayRunLib.newRun(pdate, pnow);
|
|
7484
7556
|
run.ws = w.id;
|
|
7557
|
+
// Prepare again also runs again every job whose session went quiet, each in a new session: the PM pressed it to
|
|
7558
|
+
// get the day moving, and a job stuck on a silent session would otherwise hold the day at "working".
|
|
7559
|
+
if (run.tasks.length) {
|
|
7560
|
+
run = stallQuietTasks(run, pnow);
|
|
7561
|
+
for (const t of run.tasks.filter((x) => x.status === 'failed' && x.stalled_session)) {
|
|
7562
|
+
const d = dayRunLib.decide(run, t.id, { action: 'retry' }, pnow); if (d.ok) run = d.run;
|
|
7563
|
+
}
|
|
7564
|
+
run = startDayTasks(run, pnow);
|
|
7565
|
+
}
|
|
7485
7566
|
if (run.brief_session && !run.brief_session.ended_at) {
|
|
7486
7567
|
// A session that finished its read (or sits idle) is used again: the skill's prompt is typed into it and run.
|
|
7487
7568
|
const done = daySessionAlive(run.brief_session.id) ? briefSessionDone(run, pdate, pnow) : null;
|
|
@@ -7652,7 +7733,7 @@ async function handleRequest(req, res) {
|
|
|
7652
7733
|
}
|
|
7653
7734
|
if (p === '/api/proactive/brief') {
|
|
7654
7735
|
// The brief is submitted by a scheduled skill in an agent session and only CHECKED and stored here. One file per day;
|
|
7655
|
-
// a second submission for the same day replaces
|
|
7736
|
+
// a second submission for the same day replaces what it read, and keeps what came from a source it could not read. Nothing is sent, started or changed.
|
|
7656
7737
|
const dir = path.join(STATE, 'briefs');
|
|
7657
7738
|
if (req.method === 'POST') {
|
|
7658
7739
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
@@ -7661,8 +7742,10 @@ async function handleRequest(req, res) {
|
|
|
7661
7742
|
if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
|
|
7662
7743
|
// `merge: true` (a daytime check, G22) merges into the stored brief of that day; with none stored it is stored as it is.
|
|
7663
7744
|
let out = { ...v.brief, resolved: [] }; let merged = null;
|
|
7745
|
+
let stored = null; try { stored = JSON.parse(fs.readFileSync(path.join(dir, v.brief.date + '.json'), 'utf8')); } catch { /* none yet */ }
|
|
7746
|
+
// A second full read of the day (Prepare again) keeps what came from a source it could not read this time.
|
|
7747
|
+
if (body.merge !== true && stored) { const rr = briefLib.rereadBrief(stored, v.brief); if (rr.ok) out = rr.brief; }
|
|
7664
7748
|
if (body.merge === true) {
|
|
7665
|
-
let stored = null; try { stored = JSON.parse(fs.readFileSync(path.join(dir, v.brief.date + '.json'), 'utf8')); } catch { /* none yet */ }
|
|
7666
7749
|
const m = briefLib.mergeBrief(stored, v.brief, { now: Date.now() });
|
|
7667
7750
|
if (!m.ok) return json(res, { ok: false, error: m.error }, 400);
|
|
7668
7751
|
out = m.brief; merged = { found: m.added, resolved: m.removed };
|
|
@@ -8461,7 +8544,7 @@ module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost },
|
|
|
8461
8544
|
stripLegacyClientScreen,
|
|
8462
8545
|
// the scrollback ring's front trim, exported pure so the suite can assert that a cut never lands
|
|
8463
8546
|
// inside an escape sequence or between the halves of a surrogate pair.
|
|
8464
|
-
trimBuf, SCROLLBACK,
|
|
8547
|
+
trimBuf, SCROLLBACK, queueOutput, flushOutput, OUTPUT_COALESCE_MS,
|
|
8465
8548
|
// the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
|
|
8466
8549
|
// asserted without a server that has no clients (the suite always has both).
|
|
8467
8550
|
shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: day-check
|
|
3
3
|
label: "Day check"
|
|
4
|
-
description: "The daytime check of today's run: read only what changed since the last check, and merge new
|
|
4
|
+
description: "The daytime check of today's run: read only what changed since the last check, check the open items once, and merge new, changed and resolved items, meeting notes and updated digests into the stored brief. Read-only on every source; it sends nothing."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
output:
|
|
@@ -14,7 +14,7 @@ output:
|
|
|
14
14
|
|
|
15
15
|
Rules that always hold:
|
|
16
16
|
1. READ-ONLY on every source. Never send, reply, forward, post, react, comment, create, edit, accept, decline, transition, label, archive, mark read or delete anything in mail, Slack, Jira, Confluence, GitHub, Drive, Figma, a website, calendar, Wispr Flow or any other source.
|
|
17
|
-
2. ONLY WHAT CHANGED. Do not read the day again. Read only what is new since the last check. The time of the last check is in `pmos proactive day`.
|
|
17
|
+
2. ONLY WHAT CHANGED. Do not read the day again. Read only what is new since the last check, and look once at each open item of the stored brief. The time of the last check is in `pmos proactive day`.
|
|
18
18
|
3. SETTINGS DECIDE THE SOURCES AND THE SCOPES, as in the morning brief. Read a source only if it is on. In Slack, Jira and Confluence, read a place with scope watch since the last check, look in a place with scope search only for mentions of the PM and the words of the goals, and do not touch a place with scope ignore.
|
|
19
19
|
4. LIGHT ON CONTENT. Never copy a message body, an attachment, a token or a credential. Paraphrase in the limits of each field. Skip anything marked private, personal, confidential or HR.
|
|
20
20
|
5. NOTHING INVENTED. Add an item only for something you read. A check that finds nothing is a correct check.
|
|
@@ -28,10 +28,12 @@ Procedure:
|
|
|
28
28
|
- Mentions of the PM since the last check, in Slack, Jira and Confluence, in every place that is not ignored. Keep where, who, the link to the conversation itself and a short paraphrase of what is asked. To tie a Jira issue to an initiative, run `pmos jira which <KEY>` (it reads local files only) and use the initiative it names. A status change of a ticket that `pmos jira which` names is not an item: the Jira status check shows it on the initiative's Jira tickets tab.
|
|
29
29
|
- Places with scope watch: read every message or comment since the last check. Update the digest of the place (decisions, open questions, what touches a goal). A digest you submit replaces the stored digest of that place, so carry over what still holds.
|
|
30
30
|
- GitHub, Drive, Figma and websites, if on: only what changed since the last check and matters to a goal or a meeting.
|
|
31
|
+
- The open items of the stored brief: look once at each item that has a link, in a source that is on. Note an item that is now done or no longer true, and an item whose facts changed: a new ask, a new due date, a new owner, or an answer that changes what the PM must do.
|
|
31
32
|
- Wispr Flow: notes for meetings that ended since the last check. Read the summary, the decisions and the actions. Read the related mail for the same meeting only if mail is on, and keep the subject, the sender and a gist.
|
|
32
33
|
3. Decide what the brief needs.
|
|
33
34
|
- New items: a thing the PM should act on that is not in the stored brief. Fill the fields as the morning-brief skill does: link, where, who, about, ask, reply_needed, how and kind (you, approve or auto, with the same meaning). Set "mention": true on an item that comes from a mention. Only give an item a source that is "read".
|
|
34
35
|
- Resolved items: an item in the stored brief that is now done, answered or no longer true, for example a thread the PM answered, an issue that was closed, a meeting that was cancelled. List its id in "resolved". Never resolve an item only because it is old.
|
|
36
|
+
- Changed items: an open item whose facts changed. Submit it again with the SAME "title" and "source" as in the stored brief, because the workspace derives its id from them, and with the fields updated. The workspace replaces the stored item. Do not submit an item that did not change.
|
|
35
37
|
- Event notes: for a meeting that ended, add "notes": {"wispr": {"link", "summary", "decisions", "actions"}, "mail": {"subject", "from", "gist", "link"}} to that event, and mark the "after" steps that are now done with "done": true. Give the event by the same "start", "end" and "title" it has in the stored brief, because the workspace derives its id from them.
|
|
36
38
|
- Updated digests: only for the places you read.
|
|
37
39
|
4. Write one JSON file in the scratch folder of this session, in the shape of the morning brief, with only what is new or changed, and a list of resolved ids:
|