create-pmos 0.214.0 → 0.216.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.214.0",
3
+ "version": "0.216.0",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@e529926
1
+ kernel-source: pmos@da1c3aa
2
2
  assembled: 2026-10-14
3
3
  mode: vendor
@@ -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-14T03:12:52Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T04:16:13Z"}
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-14T03:12:52Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T04:16:13Z"}
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-14T03:12:52Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T04:16:13Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -37,6 +37,17 @@ async function openPanel(page) {
37
37
  if (got && await page.$('[data-c3-all-sessions]')) await page.click('[data-c3-all-sessions]');
38
38
  }
39
39
  async function waitFor(page, fn, arg, ms = 15000) { try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; } }
40
+ /* the session sheet opens on the conversation (c3-session-conv.js); Terminal shows the live terminal */
41
+ const sheetOf = (i) => `[data-c3-session="${i}"]`;
42
+ async function toTerm(page, i) {
43
+ if (!await waitFor(page, (s) => !!document.querySelector(s), sheetOf(i))) return false;
44
+ await page.click('[data-c3-view="term"]');
45
+ return waitFor(page, (x) => !!document.querySelector(`[data-c3-session-term="${x}"] .xterm`), i);
46
+ }
47
+ async function openTerm(page, i) {
48
+ await page.evaluate((x) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: x } })), i);
49
+ return toTerm(page, i);
50
+ }
40
51
  const get = (p) => fetch(BASE + p).then((r) => r.json());
41
52
  // the agent CLI's home for this run: the conversation test writes a session record and a transcript here, as claude would
42
53
  const CLAUDE_HOME = path.join(T.STATE_DIR, 'claude-home');
@@ -104,23 +115,30 @@ function startShell(name) {
104
115
  /* ── C: Open the session stays on this page ── */
105
116
  const url0 = page.url();
106
117
  await page.click(`[data-blank-panel="${id}"] [data-open-session]`);
107
- ok('C1 the session opens in a terminal sheet', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
118
+ ok('C1 the session opens in a sheet, on its conversation', await waitFor(page, (i) => { const c = document.querySelector(`[data-c3-session="${i}"] [data-c3-conv]`); return !!c && !c.hidden && !document.querySelector('.xterm'); }, id));
119
+ ok('C1b a shell with no Claude in it says so and offers Start Claude here', await waitFor(page, () => /No Claude in this shell yet/.test((document.querySelector('[data-cv="none"]') || {}).textContent || '') && !!document.querySelector('[data-cv="start-claude"]')));
120
+ ok('C1c the view control has Conversation and Terminal, Conversation pressed', await page.evaluate(() => [...document.querySelectorAll('[data-c3-view]')].map((b) => b.dataset.c3View + ':' + b.getAttribute('aria-pressed')).join() === 'conv:true,term:false'));
121
+ ok('C1d Terminal shows the live terminal in the same sheet', await toTerm(page, id) && await page.evaluate(() => document.querySelector('[data-c3-conv]').hidden === true));
108
122
  ok('C2 the page stays on console3', page.url() === url0, page.url());
109
123
  await sleep(800);
110
124
  await page.click(`[data-c3-session-term="${id}"] .xterm`);
111
- await page.keyboard.type('echo c3-typed-$((40+2))\n');
125
+ // a claude that only echoes, so Start Claude here can be checked without a real agent
126
+ await page.keyboard.type('claude() { echo c3-start-$((3*3)); }; echo c3-typed-$((40+2))\n');
112
127
  ok('C2b what is typed in the sheet reaches the shell', !!(await until(async () => /c3-typed-42/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
113
128
  await page.keyboard.press('Escape');
114
129
  await sleep(300);
115
130
  ok('C3 Esc in the terminal goes to the shell, not the sheet', await page.evaluate((i) => !!document.querySelector(`[data-c3-session-term="${i}"]`), id));
116
131
  await page.keyboard.press('Control+u'); // the shell read that Esc as a Meta prefix: this key takes it, so the next line starts clean
132
+ await page.click('[data-c3-view="conv"]');
133
+ ok('C3b Conversation comes back, the terminal stays mounted but hidden', await waitFor(page, (i) => { const c = document.querySelector('[data-c3-conv]'); const t = document.querySelector(`[data-c3-session-term="${i}"]`); return !!c && !c.hidden && !!t && !!t.querySelector('.xterm') && t.closest('[data-c3-view-pane]').hidden; }, id));
134
+ await page.click('[data-cv="start-claude"]');
135
+ ok('C3c Start Claude here types claude into the shell', !!(await until(async () => /c3-start-9/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
117
136
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
118
137
  ok('C4 the close button closes the terminal sheet', await waitFor(page, () => !document.querySelector('[data-c3-session-term]')));
119
138
  ok('C5 closing the sheet leaves the session running', await until(async () => ((await get('/api/sessions')).sessions || []).some((s) => s.id === id && s.state !== 'exited')));
120
139
 
121
140
  /* ── G: the scratchpad and the size control on the terminal sheet (c3-term-pad-size) ── */
122
- await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
123
- const g0 = await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id);
141
+ const g0 = await openTerm(page, id);
124
142
  ok('G0 the terminal sheet opens for the pad checks', g0 || !!(await page.$('[data-c3-size-seg]')));
125
143
  const sizes = await page.$$eval('[data-c3-size-seg] [data-c3-size]', (b) => b.map((x) => x.dataset.c3Size));
126
144
  ok('G1 the size control has 4 options', sizes.join() === 'normal,half,two-thirds,whole', sizes.join());
@@ -137,8 +155,7 @@ function startShell(name) {
137
155
  await page.click('[data-c3-size="half"]');
138
156
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
139
157
  await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
140
- await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
141
- await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id);
158
+ await openTerm(page, id);
142
159
  await sleep(200);
143
160
  ok('G4 the size choice is kept after closing and opening again', Math.abs((await wid()) - vw / 2) < 3, String(await wid()));
144
161
  ok('G4b it is kept in localStorage', await page.evaluate(() => localStorage.getItem('pmos.workspace.c3.term-size') === 'half'));
@@ -158,7 +175,7 @@ function startShell(name) {
158
175
  await page.fill('[data-c3-pad="text"]', 'flushed-on-close');
159
176
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
160
177
  ok('G8 closing the sheet saves a pad edit at once', !!(await until(async () => (await get('/api/scratchpad?session=' + id)).text === 'flushed-on-close', 2000)));
161
- await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
178
+ await openTerm(page, id);
162
179
  await waitFor(page, () => !!document.querySelector('[data-c3-pad="text"]'));
163
180
  ok('G9 the pad loads its text when the terminal opens again', await waitFor(page, () => document.querySelector('[data-c3-pad="text"]').value === 'flushed-on-close'));
164
181
  /* the grip above the pad resizes it: a drag up makes the pad taller and the terminal shorter; the height is kept */
@@ -183,9 +200,9 @@ function startShell(name) {
183
200
  /* ── D: End asks first, then ends ── */
184
201
  // Sessions opens the terminal of the session opened last while it runs (PM, 2026-10-13); its "All sessions" opens the panel
185
202
  await page.click('[data-c3pg="agent-button"]');
186
- ok('D0 Sessions opens the terminal of the session opened last', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
203
+ ok('D0 Sessions opens the session opened last', await waitFor(page, (s) => !!document.querySelector(s + ' [data-c3-conv]'), sheetOf(id)));
187
204
  await page.click('[data-c3-all-sessions]');
188
- ok('D0a All sessions in the terminal sheet opens the panel', await waitFor(page, (s) => !!document.querySelector(s), sel) && !(await page.$('[data-c3-session-term]')));
205
+ ok('D0a All sessions in the session sheet opens the panel', await waitFor(page, (s) => !!document.querySelector(s), sel) && !(await page.$('[data-c3-session]')));
189
206
  ok('D0b it opens on the session opened last: its row is open and marked', await waitFor(page, (i) => { const li = document.querySelector(`[data-c3t="mine"] [data-run="${i}"]`); const end = document.querySelector(`[data-end-session="${i}"]`); return !!li && li.hasAttribute('data-last-open') && !!end && end.offsetParent !== null; }, id)
190
207
  && await page.evaluate((i) => localStorage.getItem('pmos.workspace.c3.last-session') === i, id));
191
208
  if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
@@ -232,6 +249,47 @@ function startShell(name) {
232
249
  return me && tool && !!bot && !!bot.querySelector('li b') && !!bot.querySelector('li code') && !/not shown/.test(f.textContent);
233
250
  }, feedSel, 10000));
234
251
 
252
+ /* ── S: the session sheet's conversation, from the same transcript ── */
253
+ await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
254
+ const cvSel = sheetOf(id) + ' [data-c3-conv]';
255
+ ok('S1 the sheet shows the prompt, a folded tool line and the reply', await waitFor(page, (s) => {
256
+ const f = document.querySelector(s); if (!f) return false;
257
+ const me = [...f.querySelectorAll('[data-chat="me"]')].some((n) => n.textContent === 'What changed today?');
258
+ const tool = f.querySelector('details[data-chat="tool"]');
259
+ const bot = f.querySelector('[data-chat="bot"]');
260
+ return me && !!tool && !tool.open && /Used Bash/.test(tool.textContent) && !!bot && !!bot.querySelector('li b') && !/not shown/.test(f.textContent);
261
+ }, cvSel, 10000));
262
+ ok('S2 the tool line lists each call with its target', await page.evaluate((s) => [...document.querySelectorAll(s + ' details[data-chat="tool"] li')].map((li) => li.querySelector('b').textContent + ' ' + ((li.querySelector('span') || {}).textContent || '')).join() === 'Bash git log', cvSel));
263
+ ok('S3 the status reads Your turn and the meta names the model', await page.evaluate(() => (document.querySelector('[data-cv="status"]') || {}).textContent === 'Your turn' && /claude-test/.test((document.querySelector('[data-cv="meta"]') || {}).textContent || '')));
264
+ await page.fill('[data-cv="msg"]', 'echo c3-conv-$((5*5))');
265
+ await page.press('[data-cv="msg"]', 'Enter');
266
+ ok('S4 a sent message shows at once', await waitFor(page, () => [...document.querySelectorAll('[data-c3-conv] [data-chat="me"]')].some((n) => /c3-conv-/.test(n.textContent)), null, 3000));
267
+ ok('S4b the send box types into the session and presses Enter', !!(await until(async () => /^c3-conv-25/m.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
268
+ // Claude asks before a tool: the transcript holds an open call and the pane shows Claude Code's prompt
269
+ fs.appendFileSync(path.join(proj, 'conv-test-1.jsonl'), rec({ type: 'assistant', message: { id: 'msg_3', model: 'claude-test', role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'rm notes.txt' } }] } }) + '\n');
270
+ await page.fill('[data-cv="msg"]', "clear; printf 'Do you want to proceed?\\n> 1. Yes\\n 2. No, and tell Claude what to do differently\\n'");
271
+ await page.press('[data-cv="msg"]', 'Enter');
272
+ ok('S5 the allow prompt shows as a card with its options', await waitFor(page, () => {
273
+ const c = document.querySelector('[data-c3-conv] [data-cv="ask"]'); if (!c) return false;
274
+ const o = [...c.querySelectorAll('[data-cv-opt]')].map((b) => b.dataset.cvOpt + ':' + b.textContent).join('|');
275
+ return /Do you want to proceed\?/.test(c.textContent) && o === '1:Yes|2:No, and tell Claude what to do differently' && !!c.querySelector('[data-cv="ask-term"]');
276
+ }, null, 20000));
277
+ if (!results[results.length - 1].cond) { const c = await get('/api/sessions/conversation?id=' + id); console.log(' conversation:', JSON.stringify({ state: c.state, turn: c.turn, openCall: c.openCall, ask: c.ask }), JSON.stringify(((await get('/api/sessions/transcript?id=' + id + '&max=600')).text || '').slice(-300))); }
278
+ ok('S5b the status reads Needs you', await page.evaluate(() => (document.querySelector('[data-cv="status"]') || {}).textContent === 'Needs you'));
279
+ await page.click('[data-cv-opt="2"]', { timeout: 3000 }).catch(() => {});
280
+ await sleep(500);
281
+ await page.fill('[data-cv="msg"]', '; echo c3-opt-$((2*4))');
282
+ await page.press('[data-cv="msg"]', 'Enter');
283
+ ok('S5c an option button types its number into the session', !!(await until(async () => /2; echo c3-opt/.test((await get('/api/sessions/transcript?id=' + id + '&max=6000')).text || ''), 8000)));
284
+ await page.click('[data-cv="ask-term"]').catch(() => {});
285
+ ok('S6 Answer in the terminal switches to the terminal', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`) && document.querySelector('[data-c3-view="term"]').getAttribute('aria-pressed') === 'true', id));
286
+ await page.click('[data-shell="sheet"] [aria-label="Close"]');
287
+ await waitFor(page, () => !document.querySelector('[data-c3-session]'));
288
+ // the panel row was open before the sheet: open it again for End
289
+ // (it opens on the session opened last, with its row open)
290
+ await openPanel(page);
291
+ if (!await waitFor(page, (i) => { const b = document.querySelector(`[data-end-session="${i}"]`); return !!b && b.offsetParent !== null; }, id, 5000)) await page.click(sel + ' .nb');
292
+
235
293
  await page.click(`[data-end-session="${id}"]`);
236
294
  await page.click(`[data-end-yes="${id}"]`);
237
295
  const gone = async () => !((await get('/api/sessions')).sessions || []).some((s) => s.id === id);
@@ -255,9 +313,9 @@ function startShell(name) {
255
313
  const made = await until(async () => ((await get('/api/sessions')).sessions || []).find((s) => !before.has(s.id) && s.name === 'c3 new shell'), 10000);
256
314
  ok('F3 Start starts the session on the server', !!made);
257
315
  ok('F3b it starts on the board chosen in the sheet', !!made && made.ws === opts.find((o) => o.on).v, JSON.stringify(made && made.ws));
258
- ok('F4 its terminal opens in a sheet on console3', !!made && await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), made.id) && page.url() === url0, page.url());
316
+ ok('F4 it opens in a sheet on console3', !!made && await waitFor(page, (s) => !!document.querySelector(s + ' [data-c3-conv]'), sheetOf(made.id)) && page.url() === url0, page.url());
259
317
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
260
- await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
318
+ await waitFor(page, () => !document.querySelector('[data-c3-session]'));
261
319
  await page.evaluate(() => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: {} })));
262
320
  ok('F5 an open-session request with no session opens the new-session sheet, not the classic app', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
263
321
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
@@ -87,8 +87,8 @@ const W = '[data-pm-work]';
87
87
  if (!ready) return finish(1);
88
88
 
89
89
  /* the six views and their counts (R1) */
90
- const readBar = () => st((w) => [...document.querySelectorAll(w + ' [data-pm-lenses] [data-lens]')].map((b) => [b.getAttribute('data-lens'), b.textContent.replace(/\d+$/, '').trim()]), W);
91
- // A redraw puts a new bar in the header and the quiet layout moves it beside Add work 50 ms later; read it once it has settled.
90
+ // only the bar on screen: a redraw puts a new bar in the header, hidden there (.calbar.qt-moved), until the quiet layout moves it beside Add work
91
+ const readBar = () => st((w) => [...document.querySelectorAll(w + ' [data-pm-lenses] [data-lens]')].filter((b) => b.offsetParent !== null).map((b) => [b.getAttribute('data-lens'), b.textContent.replace(/\d+$/, '').trim()]), W);
92
92
  await until(async () => (await readBar()).length === VIEWS.length, 10000, 100);
93
93
  const bar = await readBar();
94
94
  ok('the bar has the six views in order: Inbox, Today, Upcoming, Waiting, Someday, Review', JSON.stringify(bar.map((b) => b[0])) === JSON.stringify(VIEWS) && JSON.stringify(bar.map((b) => b[1])) === JSON.stringify(LABELS), JSON.stringify(bar));
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Sessions in console3: start, open, message, read and end your sessions"
4
- description: "How the Sessions panel in console3 groups your sessions, how to start a session, how to open a session's terminal without leaving console3, how to send a message to a session, and how to end a session."
5
- timestamp: 2026-10-13
4
+ description: "How the Sessions panel in console3 groups your sessions, how to start a session, how to open a session as a conversation or as its terminal without leaving console3, how to send a message to a session, and how to end a session."
5
+ timestamp: 2026-10-14
6
6
  ---
7
7
 
8
8
  # Sessions in console3: start, open, message, read and end your sessions
9
9
 
10
10
  Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
11
11
 
12
- When the session that you opened last is still running, the **Sessions** button opens its terminal directly. To see all your sessions, press **All sessions** at the top of the terminal sheet. If that session has ended, the button opens the panel.
12
+ When the session that you opened last is still running, the **Sessions** button opens that session directly, on its conversation. To see all your sessions, press **All sessions** at the top of the session sheet. If that session has ended, the button opens the panel.
13
13
 
14
14
  The panel opens on the session that you opened last. Its row is open, so you can read the conversation and send a message without searching for it. A session counts as opened when you open its row or its terminal.
15
15
 
@@ -32,7 +32,7 @@ You start a session in console3. You do not need the classic app.
32
32
  - **Automatic**: the server selects the tree.
33
33
  - A listed tree: the session runs in that working tree.
34
34
  - **New branch**: type the branch name. The session runs in its own working copy of the new branch.
35
- 6. Press **Start**. The session starts, and its terminal opens in a sheet on the same page. If you selected a different board, the dock changes to that board.
35
+ 6. Press **Start**. The session starts, and it opens in a sheet on the same page. If you selected a different board, the dock changes to that board.
36
36
 
37
37
  If the server cannot start the session, the sheet shows the reason.
38
38
 
@@ -47,7 +47,31 @@ If the server finds that the process of a session stopped (for example, after a
47
47
  1. Press the row to open it.
48
48
  2. Press **Open the session**.
49
49
 
50
- The terminal of the session opens in a panel on the same page. You can type in it. When the terminal has focus, Esc and Tab go to the terminal. To close the panel, press its close button. The session continues to run.
50
+ The session opens in a sheet on the same page, on its conversation. To close the sheet, press its close button. The session continues to run.
51
+
52
+ Two buttons at the top of the sheet change the view:
53
+
54
+ - **Conversation**: your messages and the replies of Claude, with a box to send a message. This is the view that opens first.
55
+ - **Terminal**: the live terminal of the session. You can type in it. When the terminal has focus, Esc and Tab go to the terminal. Use it for the things that the conversation does not show, for example a menu of Claude Code or the full output of a command.
56
+
57
+ Press **Conversation** to go back. The terminal stays connected while the sheet is open, so nothing restarts when you change the view.
58
+
59
+ ### The conversation
60
+
61
+ The conversation comes from the transcript that Claude Code writes on this computer. It shows the words of Claude as Claude wrote them. Nothing is summarised, and nothing is sent to a different place.
62
+
63
+ - Your messages are on the right. The replies of Claude are on the left. Lists, bold text and code show as formatted text.
64
+ - A grey line, for example "Used Bash, Read ×2", shows a run of tool calls. Press it to see each call and its target, for example the file or the command.
65
+ - While Claude works, a line says so. Press the stop button next to the message box to stop Claude. It types Esc into the session.
66
+ - When Claude asks before it uses a tool, a card shows the question and its options, for example **Yes** and **No**. Press an option to answer. You can also press **Answer in the terminal**.
67
+ - Type a message in the box and press Enter to send it. Shift Enter starts a new line. **/compact** and **/clear** put that command in the box.
68
+ - The tag above the conversation shows the state: **Working**, **Needs you**, **Your turn**, **Shell** or **Ended**.
69
+
70
+ A shell where Claude has not started says "No Claude in this shell yet". Press **Start Claude here** to start Claude in that shell. Its conversation then shows in this view.
71
+
72
+ If PMOS cannot find the transcript of a Claude session, the view says so, and it does not show the transcript of a different session. Read the session in the terminal. PMOS looks for transcripts in `~/.claude`, in each `~/.claude-*` profile folder, and in the folder that `CLAUDE_CONFIG_DIR` names.
73
+
74
+ At **2/3** and **Whole**, the sheet also shows a list of your running sessions at the left. Press a session to open it.
51
75
 
52
76
  ### The scratchpad
53
77
 
@@ -58,11 +82,11 @@ Below the terminal is a scratchpad. It keeps text for this session only. The tex
58
82
  - To make the pad taller or shorter, drag the grip on the line above it. Or select the grip and press the up arrow or the down arrow. The terminal gets the remaining room. The page remembers the height. To go back to the default height, double-click the grip.
59
83
  - Esc in the pad closes the panel. Esc in the terminal goes to the terminal.
60
84
 
61
- ### The size of the terminal
85
+ ### The size of the sheet
62
86
 
63
- Four buttons in the header of the panel set its width: **Normal**, **Half**, **2/3** and **Whole**. Half is half of the window. 2/3 is two thirds of the window. Whole is the full window. The terminal fits the new size. The page remembers your choice for the next terminal you open. On a phone the panel is already full width, so the buttons are not shown.
87
+ Four buttons in the header of the sheet set its width: **Normal**, **Half**, **2/3** and **Whole**. Half is half of the window. 2/3 is two thirds of the window. Whole is the full window. The terminal fits the new size. The page remembers your choice for the next session you open. On a phone the panel is already full width, so the buttons are not shown.
64
88
 
65
- The **Open the session** link in the sessions dock opens the same panel.
89
+ The **Open the session** link in the sessions dock opens the same sheet.
66
90
 
67
91
  ## Send a message to a session
68
92
 
@@ -78,7 +102,7 @@ The panel shows the conversation of a Claude session:
78
102
 
79
103
  - Your messages are on the right.
80
104
  - The replies of Claude are on the left. Lists, bold text and code show as formatted text.
81
- - A grey line, for example "Used Bash, Read ×2", shows the tools that Claude used. The input of a tool does not show.
105
+ - A grey line, for example "Used Bash, Read ×2", shows the tools that Claude used. The session sheet also shows the target of each call.
82
106
  - While Claude works, a grey line says so.
83
107
 
84
108
  The panel reads the conversation from the transcript that Claude Code writes on this computer. Nothing is sent to a different place. The panel updates while it is open.
@@ -100,7 +100,7 @@ An item goes to the first view that fits these rules, in this order. Parked is S
100
100
 
101
101
  Open an item that shows **Agent can:**. The case file shows **What the agent will do**.
102
102
 
103
- The offer is on Jira and WBS items with a suggestion, and on each item the morning brief suggests for you (for example a Slack reply or an email). On a brief item the offer is the reply or the note that the item asks for, and the draft goes back to the place the item came from, only when you post it.
103
+ The offer is on Jira and WBS items with a suggestion, and on each item the morning brief suggests for you (for example a Slack reply or an email), also on the days after, while you keep the item in Work. On a brief item the offer is the reply or the note that the item asks for, and the draft goes back to the place the item came from, only when you post it.
104
104
 
105
105
  An item you added yourself shows **Agent can draft a first version**. The agent drafts what the item asks for (a document, a plan or a recommendation), and the draft stays on the item. It has no place to post to: read it, keep it, or send it back with a note.
106
106
 
@@ -121,11 +121,14 @@ The row chip then shows the agent's state:
121
121
 
122
122
  The page reads the state again every 15 seconds while an agent is queued, working or posting.
123
123
 
124
- When the draft is ready, the case file quotes it. Select **Open the draft** to read all of it. Then choose one option:
124
+ When the draft is ready, the case file shows it as a document, with its headings, lists and tables. Select **Open the draft** to read all of it. Then choose one option:
125
125
 
126
126
  - **Post it to** the ticket posts the exact text. Nothing goes out until you hold to confirm.
127
127
  - **Send it back with a note** returns the draft to the agent. Type what to change and press Enter.
128
128
  - **Keep it as a draft** leaves the draft as it is.
129
+ - **Mark it done** closes the item. Nothing is posted.
130
+
131
+ To change the text yourself before you post it, select **Edit the draft**, change the text and select **Save the draft**. The case file then says **Edited by you**, and **Post it to** the ticket posts exactly your text.
129
132
 
130
133
  The agent only drafts. It never posts to Jira until you approve the draft.
131
134
 
@@ -249,6 +249,35 @@ function decide(run, taskId, op, now) {
249
249
  });
250
250
  }
251
251
 
252
+ /** The PM's own edit of a draft that waits for them (initiative c3-work-agent, amendment 13). The edited text replaces the
253
+ * body, so an approve posts exactly what the PM saved. The agent's first text stays in `agent_body`. */
254
+ function edit(run, taskId, body, now) {
255
+ const b = typeof body === 'string' ? body.trim() : '';
256
+ return withTask(run, taskId, (t) => {
257
+ if (t.status !== 'review' || !t.draft) return `task ${taskId} is ${t.status} — only a draft waiting for you can be edited`;
258
+ if (!b) return 'the draft cannot be empty — keep some text, or send it back with a note';
259
+ if (b.length > 8000) return 'the draft is over 8000 characters';
260
+ if (b === t.draft.body) return null;
261
+ if (t.draft.agent_body === undefined) t.draft.agent_body = t.draft.body;
262
+ t.draft.body = b; t.draft.edited_at = iso(now);
263
+ return null;
264
+ });
265
+ }
266
+
267
+ /** Approve a draft of an earlier day's run (amendment 13). The job CLI reads today's run only, so the approved task joins
268
+ * today's run as `sending`, with its draft as it is. `task` is the task of the earlier run. */
269
+ function carry(run, task, now) {
270
+ if (!run) return { ok: false, error: 'no day run today' };
271
+ if (!task || !TASK_RE.test(String(task.id || ''))) return { ok: false, error: 'no task to carry' };
272
+ if (task.status !== 'review' || !task.draft) return { ok: false, error: `task ${task.id} is ${task.status} — only a draft waiting for you can be approved` };
273
+ const next = clone(run);
274
+ if (findTask(next, task.id)) return { ok: false, error: `task ${task.id} is already in today's run` };
275
+ const t = clone(task);
276
+ Object.assign(t, { status: 'sending', session_id: null, prev_session: null, started_at: null, error: null, decided: { action: 'approve', at: iso(now) } });
277
+ next.tasks.push(t);
278
+ return { ok: true, run: next, task: task.id };
279
+ }
280
+
252
281
  /** The send session reports the approved step done, with the link to what it posted. */
253
282
  function sent(run, taskId, s, now) {
254
283
  return withTask(run, taskId, (t) => {
@@ -413,5 +442,5 @@ function checkDone(run, { found = 0, resolved = 0 } = {}, now) {
413
442
  }
414
443
 
415
444
  module.exports = { MAX_PARALLEL, MAX_REVISIONS, MAX_CHECK_LOG, MAX_INTERVAL_MIN, STATUSES, KINDS, ACTIONS, TASK_RE,
416
- newRun, readRun, findTask, modeOf, addTasks, addBriefed, addSend, toStart, started, deliver, decide, sent, fail, stop, withdraw, sessionEnded, sessionStalled, stage, intervalMin,
445
+ newRun, readRun, findTask, modeOf, addTasks, addBriefed, addSend, toStart, started, deliver, decide, edit, carry, sent, fail, stop, withdraw, sessionEnded, sessionStalled, stage, intervalMin,
417
446
  checkDue, checkStarted, checkDone };
@@ -194,7 +194,7 @@ function stateOf(item, presses, runs) {
194
194
  if (!last || last.action === 'reopen') return { state: 'brief', sub: null, task: null, instruction: null };
195
195
  if (last.action === 'handle') return { state: 'yours', sub: null, task: null, instruction: null, decided_at: last.at };
196
196
  const t = taskOf(runs, last);
197
- const task = t ? { id: t.id, status: t.status, error: t.error || null, draft: t.draft ? t.draft.summary : null, body: t.draft ? t.draft.body || null : null,
197
+ const task = t ? { id: t.id, status: t.status, error: t.error || null, draft: t.draft ? t.draft.summary : null, body: t.draft ? t.draft.body || null : null, edited_at: t.draft ? t.draft.edited_at || null : null,
198
198
  sent: t.sent || null, started_at: t.started_at || null, revisions: Array.isArray(t.revisions) ? t.revisions.length : 0,
199
199
  session: t.stalled_session || null, asks: t.asks || null } : null;
200
200
  const base = { task, instruction: last.instruction, decided_at: last.at };
@@ -296,13 +296,19 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
296
296
  else {
297
297
  // an item the PM added in Work (initiative c3-work-agent, amendment 11): the PM's item is the ask
298
298
  const c = t.context || {};
299
- say(`from Work: ${t.title}${c.due ? ' · due ' + c.due : ''}${c.from ? ' · with ' + c.from : ''}`);
299
+ say(`from Work: ${t.title}${c.due ? ' · due ' + c.due : ''}${c.from ? ' · with ' + c.from : ''}${t.link ? ' · link ' + t.link : ''}`);
300
+ for (const k of ['where', 'about', 'ask', 'how', 'why_now']) if (c[k] && c[k] !== t.title) say(`${k.replace('_', ' ')}: ${c[k]}`);
300
301
  }
301
302
  // the PM's ask, as Work or Your day gave it, including a changed ask (c3-work-agent amendments 10 and 11)
302
303
  if (t.instruction && !t.my_work) say(`instruction from the PM (this is the ask): ${t.instruction}`);
303
304
  if (t.kind === 'approve') say('kind approve: deliver ONE draft; nothing goes out until the PM approves it.');
304
305
  else say('kind auto: deliver ONE draft; nothing leaves this machine.');
305
- if (t.draft) { say(`draft (${t.draft.at}): ${t.draft.summary}${t.draft.link ? ' · ' + t.draft.link : ''}`); say(t.draft.body); }
306
+ if (t.draft) {
307
+ say(`draft (${t.draft.at}): ${t.draft.summary}${t.draft.link ? ' · ' + t.draft.link : ''}`);
308
+ // the PM edited the draft (c3-work-agent amendment 13): the body below is the PM's text, to post as it is
309
+ if (t.draft.edited_at) say(`edited by the PM (${t.draft.edited_at}): the text below is the PM's own. Post it exactly as written.`);
310
+ say(t.draft.body);
311
+ }
306
312
  t.revisions.forEach((rv, i) => say(`revision note ${i + 1} (${rv.at}): ${rv.note}`));
307
313
  if (t.decided) say(`decided: ${t.decided.action} at ${t.decided.at}${t.decided.note ? ' — ' + t.decided.note : ''}`);
308
314
  if (t.sent) say(`sent ${t.sent.at}${t.sent.link ? ' · ' + t.sent.link : ''}`);
@@ -7,9 +7,10 @@
7
7
  * POST /api/my-work/act { action: brief|handle|reopen, item, instruction?, ws }
8
8
  * → { ok, item, task? } the PM's press; a brief adds one day task (R5)
9
9
  * POST /api/my-work/refresh { ws } → { ok, session } Read again: starts the my-work-read skill
10
- * POST /api/my-work/decide { item, action: approve|revise|retry, note? }
10
+ * POST /api/my-work/decide { item, action: approve|revise|retry|edit, note?, body? }
11
11
  * → { ok, item, task } the PM's decision on the agent's draft (initiative c3-work-agent):
12
- * approve posts exactly the draft, revise sends it back with the note, retry runs a failed task again
12
+ * approve posts exactly the draft, revise sends it back with the note, retry runs a failed task
13
+ * again, edit saves the PM's own text as the draft (approve with body: edit, then approve)
13
14
  * POST /api/my-work/stop { item } → { ok, item } ends the item's working or queued task and offers the suggestion again
14
15
  *
15
16
  * STATE. The latest read is <runtime>/my-work/read.json and the presses are <runtime>/my-work/actions.jsonl, append-only.
@@ -156,11 +157,13 @@ const nextDay = (d) => new Date(Date.parse(d + 'T00:00:00Z') + DAY_MS).toISOStri
156
157
  /** The day run that holds the task of the item's last brief: the brief's day (UTC, as the press records it) up to today. */
157
158
  function runOfTask(ctx, brief, today) {
158
159
  const id = myWork.taskSpec({ id: brief.item }, brief).id;
160
+ // the newest: an approved draft of an earlier day joins today's run (amendment 13)
161
+ let found = null;
159
162
  for (let d = String(brief.at).slice(0, 10), n = 0; d <= today && n < 400; d = nextDay(d), n++) {
160
163
  let run = null; try { run = ctx.readDayRun ? ctx.readDayRun(d) : null; } catch { run = null; }
161
- if (run && dayRun.findTask(run, id)) return { run, id };
164
+ if (run && dayRun.findTask(run, id)) found = { run, id };
162
165
  }
163
- return null;
166
+ return found;
164
167
  }
165
168
 
166
169
  /** decide (approve | revise | retry) or stop, on the task of the item's last brief. */
@@ -175,12 +178,10 @@ function taskPress(ctx, res, verb, body, now) {
175
178
  const found = runOfTask(ctx, brief, list.today);
176
179
  if (verb === 'decide') {
177
180
  if (!found) { ctx.json(res, { ok: false, error: 'the agent has not started this item yet' }, 409); return; }
178
- const r = dayRun.decide(found.run, found.id, { action: body.action, note: body.note }, now);
179
- if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, 400); return; }
180
- const run = ctx.startDayTasks ? ctx.startDayTasks(r.run, now) : r.run;
181
- if (!ctx.writeDayRun || !ctx.writeDayRun(run)) { ctx.json(res, { ok: false, error: 'could not save — nothing changed' }, 500); return; }
181
+ const d = decideDraft(ctx, found.run, found.id, body, now);
182
+ if (!d.ok) { ctx.json(res, { ok: false, error: d.error }, d.status || 400); return; }
182
183
  const after = listFor(ctx, Date.now()).items.find((x) => x.id === item) || null;
183
- ctx.json(res, { ok: true, item: after, task: dayRun.findTask(run, found.id) });
184
+ ctx.json(res, { ok: true, item: after, task: dayRun.findTask(d.run, found.id) });
184
185
  return;
185
186
  }
186
187
  // stop: only a task that has not delivered yet; its session ends and the item offers the suggestion again
@@ -200,6 +201,32 @@ function taskPress(ctx, res, verb, body, now) {
200
201
  ctx.json(res, { ok: true, item: after });
201
202
  }
202
203
 
204
+ /**
205
+ * The PM's decision on a draft in `run` (amendment 13): edit saves the PM's text as the draft; approve posts the draft,
206
+ * with the PM's text first when the press carries `body`; revise and retry as lib/day-run.js decide. A draft of an earlier
207
+ * day is approved in today's run, because the job CLI reads today's run only. Returns { ok, run } (the run that holds the
208
+ * task now) or { ok: false, error, status }.
209
+ */
210
+ function decideDraft(ctx, run, taskId, body, now) {
211
+ const save = (r) => (ctx.writeDayRun && ctx.writeDayRun(r) ? { ok: true, run: r } : { ok: false, status: 500, error: 'could not save — nothing changed' });
212
+ let cur = run;
213
+ if (body.action === 'edit' || (body.action === 'approve' && typeof body.body === 'string')) {
214
+ const e = dayRun.edit(cur, taskId, body.body, now);
215
+ if (!e.ok) return { ok: false, error: e.error };
216
+ if (body.action === 'edit') return save(e.run);
217
+ cur = e.run;
218
+ }
219
+ if (body.action === 'approve' && cur.date !== localDate(now)) {
220
+ const t = dayRun.findTask(cur, taskId);
221
+ if (cur !== run) { const w = save(cur); if (!w.ok) return w; }
222
+ const b = ctx.myWorkBrief(t, String(body.ws || cur.ws || ''), (today, spec, at) => dayRun.carry(today, spec, at));
223
+ return b.ok ? { ok: true, run: b.run } : { ok: false, status: b.status || 500, error: b.error };
224
+ }
225
+ const r = dayRun.decide(cur, taskId, { action: body.action, note: body.note }, now);
226
+ if (!r.ok) return { ok: false, error: r.error };
227
+ return save(ctx.startDayTasks ? ctx.startDayTasks(r.run, now) : r.run);
228
+ }
229
+
203
230
  /* ── the agent on an item the morning brief suggests for the PM (c3-work-agent amendment 10) and on an item the PM added
204
231
  * in Work (amendment 11). Item `brief:<id>`: its task is `t-<id>` in today's day run, added as Your day's "Have the agent
205
232
  * draft it" adds it. Item `item-<hex>`: its task is `t-<hex>`, in the newest day run of the last 7 days that holds it. */
@@ -208,13 +235,13 @@ const LOCAL_ITEM = /^item-[0-9a-f]{12}$/;
208
235
 
209
236
  /** The item the press names, as the agent sees it: its task id, the offer, the spec a press adds and its agent field. */
210
237
  function targetOf(ctx, itemId, now) {
211
- const today = localDate(now);
212
238
  const bm = BRIEF_ITEM.exec(itemId);
213
239
  if (bm) {
214
- let brief = null; try { brief = JSON.parse(fs.readFileSync(path.join(ctx.runtimeDir, 'briefs', today + '.json'), 'utf8')); } catch { brief = null; }
215
- const x = brief && brief.date === today ? [...(brief.priorities || []), ...(brief.items || [])].find((y) => y && y.id === bm[1]) : null;
216
- if (!x || x.owner !== 'pm') return { error: 'no item of yours with that id in today\'s brief' };
217
- const run = ctx.readDayRun ? ctx.readDayRun(today) : null;
240
+ // today's brief, or an earlier one of the last 7 days when the PM kept the item in Work (amendment 12)
241
+ const readBriefOf = (d) => { try { return JSON.parse(fs.readFileSync(path.join(ctx.runtimeDir, 'briefs', d + '.json'), 'utf8')); } catch { return null; } };
242
+ const x = assigned.findBriefItem(readBriefOf, bm[1], now);
243
+ if (!x) return { error: 'no item of yours with that id in a brief of the last 7 days' };
244
+ const run = assigned.runWith(assigned.recentRuns(ctx.readDayRun, now), 't-' + x.id);
218
245
  return { postable: true, taskId: 't-' + x.id, suggest: assigned.briefSuggest(x), spec: (own) => assigned.briefDraftSpec(x, own), run, agent: (r) => assigned.briefAgent(x, r) };
219
246
  }
220
247
  // an item the PM added in Work (amendment 11): its draft is kept across days, so the newest run that holds its task decides
@@ -257,9 +284,9 @@ function agentItemPress(ctx, res, verb, body, itemId, now) {
257
284
  after(b.run, b.task); return;
258
285
  }
259
286
  if (verb === 'decide') {
260
- const r = dayRun.decide(run, taskId, { action: body.action, note: body.note }, now);
261
- if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, 400); return; }
262
- next = ctx.startDayTasks ? ctx.startDayTasks(r.run, now) : r.run;
287
+ const d = decideDraft(ctx, run, taskId, body, now);
288
+ if (!d.ok) { ctx.json(res, { ok: false, error: d.error }, d.status || 400); return; }
289
+ after(d.run, dayRun.findTask(d.run, taskId)); return;
263
290
  } else {
264
291
  // stop: only a task that has not delivered; its session ends and the task leaves the run, so the item offers the draft again
265
292
  if (t.status !== 'waiting' && t.status !== 'working' && t.status !== 'failed') { ctx.json(res, { ok: false, error: `the agent's task is ${t.status} — there is nothing to stop` }, 409); return; }
@@ -349,6 +349,14 @@ module.exports = {
349
349
  // an item the PM added: the agent can draft a first version of it (c3-work-agent amendment 11)
350
350
  const runs = assigned.recentRuns(ctx.readDayRun, c.now.getTime());
351
351
  for (const it of v.items) if (!it.agent && assigned.localOffer(it)) it.agent = assigned.localAgent(it, assigned.runWith(runs, assigned.localTaskId(it)));
352
+ // an item of an earlier brief the PM kept in Work keeps its offer (amendment 12): it is no longer in today's brief
353
+ const readBriefOf = (d) => { try { return JSON.parse(fs.readFileSync(path.join(ctx.runtimeDir, 'briefs', d + '.json'), 'utf8')); } catch { return null; } };
354
+ for (const it of v.items) {
355
+ const m = /^brief:([0-9a-f]{12})$/.exec(String(it.id || ''));
356
+ if (!m || it.agent || it.done || !ctx.runtimeDir) continue;
357
+ const x = assigned.findBriefItem(readBriefOf, m[1], c.now.getTime());
358
+ if (x) { it.agent = assigned.briefAgent(x, assigned.runWith(runs, 't-' + x.id)); if (!it.place) it.place = x.source || null; }
359
+ }
352
360
  ctx.json(res, v);
353
361
  return true;
354
362
  }