create-pmos 0.213.0 → 0.215.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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-ib-more-test.js +23 -4
  7. package/workspace/c3-sessions-panel-test.js +69 -11
  8. package/workspace/c3-work-test.js +2 -2
  9. package/workspace/guide/console3-sessions.md +33 -9
  10. package/workspace/guide/jira.md +13 -1
  11. package/workspace/guide/my-work.md +4 -1
  12. package/workspace/lib/day-run.js +30 -1
  13. package/workspace/lib/engine/boards.js +51 -7
  14. package/workspace/lib/jira-status.js +46 -1
  15. package/workspace/lib/my-work.js +1 -1
  16. package/workspace/lib/proactive-cli.js +8 -2
  17. package/workspace/lib/routes-my-work.js +44 -17
  18. package/workspace/lib/routes-pm-items.js +8 -0
  19. package/workspace/lib/transcript-read.js +93 -10
  20. package/workspace/lib/work-assigned.js +23 -11
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/c3-demo-boards.js +12 -3
  24. package/workspace/public/pm/c3-ib-sprints.js +142 -39
  25. package/workspace/public/pm/c3-initiatives.css +18 -0
  26. package/workspace/public/pm/c3-session-conv.js +164 -0
  27. package/workspace/public/pm/c3-session-term.js +63 -16
  28. package/workspace/public/pm/c3-settings.js +1 -1
  29. package/workspace/public/pm/console3.css +62 -0
  30. package/workspace/public/pm/pm-work-view.js +37 -11
  31. package/workspace/public/pm/tab-term.js +9 -0
  32. package/workspace/server.js +32 -12
  33. package/workspace/skills/day-task.skill +1 -0
  34. package/workspace/skills/jira-sync.skill +9 -3
  35. package/workspace/test-files.json +1 -1
@@ -355,6 +355,38 @@ function readSprintNotes(root) {
355
355
  function frozenNotes(root) { const r = readSprintNotes(root); return r ? r.notes.filter((x) => x.state === 'closed') : []; }
356
356
  const count = (v, max) => (Number.isInteger(v) && v >= 0 && v <= (max == null ? 100000 : max) ? v : null);
357
357
  const text = (v, n) => clip(oneLine(v), n);
358
+ const HITS = ['met', 'partly', 'missed'];
359
+ const HEALTH = ['ok', 'warn', 'bad'];
360
+ const strs = (v, max, n) => (Array.isArray(v) ? v.map((x) => text(x, n)).filter(Boolean).slice(0, max) : []);
361
+ const iniOrNull = (v) => { const ini = v == null || v === '' ? null : String(v).trim(); return ini == null || INI_RE.test(ini) ? { ini } : null; };
362
+ /** The brief of an active or future sprint → { brief } | { error }. A plan is what the PM can do to move the ticket to done. */
363
+ function briefOf(b, name) {
364
+ const health = String(b.health || '').toLowerCase();
365
+ if (!HEALTH.includes(health)) return { error: `${name}: brief.health is ok, warn or bad` };
366
+ const verdict = text(b.verdict, 600);
367
+ if (!verdict) return { error: `${name}: brief.verdict is empty — say where the sprint stands` };
368
+ const issues = [];
369
+ for (const i of Array.isArray(b.issues) ? b.issues.slice(0, 8) : []) {
370
+ const title = i && text(i.title, 120);
371
+ if (!title) return { error: `${name}: an issue has no title` };
372
+ const sev = String(i.sev || '').toLowerCase();
373
+ if (!['bad', 'warn'].includes(sev)) return { error: `${name}: issue "${title}" needs sev bad (blocked) or warn (watch)` };
374
+ const plan = strs(i.plan, 5, 240);
375
+ if (!plan.length) return { error: `${name}: issue "${title}" needs a plan — the steps that move it to done` };
376
+ const io = iniOrNull(i.initiative);
377
+ if (!io) return { error: `${name}: issue "${title}" has a bad initiative id` };
378
+ issues.push({ sev, title, keys: text(i.keys, 80), initiative: io.ini, why: text(i.why, 300), by: text(i.by, 60), plan, doneWhen: text(i.doneWhen, 200) });
379
+ }
380
+ const done = [];
381
+ for (const d of Array.isArray(b.done) ? b.done.slice(0, 8) : []) {
382
+ const title = d && text(d.title, 120);
383
+ if (!title) return { error: `${name}: a done entry has no title` };
384
+ const io = iniOrNull(d.initiative);
385
+ if (!io) return { error: `${name}: done "${title}" has a bad initiative id` };
386
+ done.push({ title, keys: text(d.keys, 80), initiative: io.ini, line: text(d.line, 200), waiting: d.waiting === true });
387
+ }
388
+ return { brief: { health, verdict, issues, done, asks: strs(b.asks, 4, 240) } };
389
+ }
358
390
  /** One sprint's note from the agent → { note } | { error }. Nothing is guessed: a missing required field refuses the note. */
359
391
  function noteOne(x) {
360
392
  if (!x || typeof x !== 'object') return { error: 'a sprint note is not an object' };
@@ -381,6 +413,19 @@ function noteOne(x) {
381
413
  themes.push({ title, line: text(t.line, 200), tickets, done: Math.min(count(t.done) || 0, tickets), blocked: Math.min(count(t.blocked) || 0, tickets), people: text(t.people, 80), initiative: ini });
382
414
  }
383
415
  const note = { name, state, goal, summary, themes };
416
+ // The sprint brief (sprint-goal-summary, mockup-c3-sprint-brief): where the sprint stands, the issues to resolve with a proposed
417
+ // plan each, what is done and the asks for the PM. Written by the agent from the tickets; optional, so a note without one still loads.
418
+ if (state !== 'closed' && x.brief && typeof x.brief === 'object') {
419
+ const r = briefOf(x.brief, name);
420
+ if (r.error) return r;
421
+ note.brief = r.brief;
422
+ }
423
+ if (state === 'closed') {
424
+ const hit = String(x.hit || '').toLowerCase();
425
+ if (hit) { if (!HITS.includes(hit)) return { error: `${name}: hit is met, partly or missed` }; note.hit = hit; }
426
+ const gotInWay = text(x.got, 300);
427
+ if (gotInWay) note.got = gotInWay;
428
+ }
384
429
  const id = isStr(x.id) || Number.isInteger(x.id) ? String(x.id) : '';
385
430
  if (/^\d{1,12}$/.test(id)) note.id = id;
386
431
  if (state === 'closed' && x.closed && typeof x.closed === 'object') {
@@ -460,4 +505,4 @@ function which(root, key) {
460
505
  }
461
506
 
462
507
  module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE,
463
- sprintsToSummarise, saveSprintNotes, readSprintNotes, sameSprint, NOTES_FILE };
508
+ sprintsToSummarise, saveSprintNotes, readSprintNotes, sameSprint, noteOne, NOTES_FILE };
@@ -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
  }
@@ -50,7 +50,36 @@ const cp = require('child_process');
50
50
  // refusal stands (an honest floor, not a silent truncation — the pane pid still had no record).
51
51
  const MAX_WALK_DEPTH = 2;
52
52
 
53
- const HOME = () => process.env.PMOS_CLAUDE_HOME || path.join(os.homedir(), '.claude');
53
+ /*
54
+ * Every folder Claude Code may keep its session records in. A PM with profiles runs Claude with CLAUDE_CONFIG_DIR set
55
+ * (`alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'`), so its records are in ~/.claude-work/sessions, not
56
+ * ~/.claude/sessions: reading only ~/.claude made every such session "wrote no session record". The folders: the server's
57
+ * own CLAUDE_CONFIG_DIR, ~/.claude, each ~/.claude-* folder that has a `sessions` folder, and each PMOS profile's
58
+ * CLAUDE_CONFIG_DIR. PMOS_CLAUDE_HOME, when set, is the only one (tests). A pid has at most one live record, so looking in
59
+ * several folders does not weaken the identity proof: the record still has to name a cwd inside the workspace root.
60
+ */
61
+ const HOMES_MS = 30000;
62
+ let homesSnap = null;
63
+ function claudeHomes(now = Date.now()) {
64
+ if (process.env.PMOS_CLAUDE_HOME) return [process.env.PMOS_CLAUDE_HOME];
65
+ if (homesSnap && now - homesSnap.at < HOMES_MS) return homesSnap.list;
66
+ const hd = os.homedir();
67
+ const list = [];
68
+ const add = (d) => { if (d && typeof d === 'string' && path.isAbsolute(d) && !list.includes(path.resolve(d))) list.push(path.resolve(d)); };
69
+ add(process.env.CLAUDE_CONFIG_DIR);
70
+ add(path.join(hd, '.claude'));
71
+ try {
72
+ for (const n of fs.readdirSync(hd).sort()) {
73
+ if (/^\.claude-./.test(n) && fs.existsSync(path.join(hd, n, 'sessions'))) add(path.join(hd, n));
74
+ }
75
+ } catch { /* no home listing: ~/.claude alone */ }
76
+ try {
77
+ const r = require('./profiles').readProfiles(hd, { detect: true });
78
+ for (const pf of (r && r.profiles) || []) add(pf && pf.env && pf.env.CLAUDE_CONFIG_DIR);
79
+ } catch { /* profiles unreadable: the folders above */ }
80
+ homesSnap = { at: now, list };
81
+ return list;
82
+ }
54
83
  // A transcript can grow large; cap what we read so a long session cannot stall a broadcast.
55
84
  const MAX_BYTES = 8 * 1024 * 1024;
56
85
  // Synthetic assistant records are the CLI's own bookkeeping, not model turns — never counted,
@@ -128,6 +157,15 @@ function descendants(pid, listChildren, maxDepth = MAX_WALK_DEPTH) {
128
157
  * consider, pane or descendant, identically.
129
158
  */
130
159
  function resolvePid(pid, root, home) {
160
+ if (Array.isArray(home)) {
161
+ let first = null;
162
+ for (const h of home) {
163
+ const r = resolvePid(pid, root, h);
164
+ if (!r.noRecord) return r; // the folder that holds this pid's record answers
165
+ first = first || r;
166
+ }
167
+ return first || { noRecord: true };
168
+ }
131
169
  let reg;
132
170
  try {
133
171
  reg = JSON.parse(fs.readFileSync(path.join(home, 'sessions', `${pid}.json`), 'utf8'));
@@ -150,7 +188,7 @@ function resolvePid(pid, root, home) {
150
188
  * process levels for a descendant that resolves in-root — see the child-walk note at the head.
151
189
  * `listChildren` is injectable for tests; it defaults to a real `ps` snapshot.
152
190
  */
153
- function findTranscript(pid, root, home = HOME(), listChildren = defaultChildren) {
191
+ function findTranscript(pid, root, home = claudeHomes(), listChildren = defaultChildren) {
154
192
  if (!pid) return { missing: "can't identify this session's transcript — no process id" };
155
193
  const direct = resolvePid(pid, root, home);
156
194
  if (direct.sessionId) return direct;
@@ -321,8 +359,8 @@ function readHarness(file, clean = (x) => x) {
321
359
  * · a `user` record with string content (or text blocks) that is not meta, a sidechain or a compact
322
360
  * summary, and is not the CLI's own command bookkeeping — a slash command shows as `/name`;
323
361
  * · an `assistant` record's text blocks, from a real model turn (never `<synthetic>`);
324
- * · tool calls fold into one activity line between them ("Used Bash, Read ×2"), never their input,
325
- * which can hold anything.
362
+ * · tool calls fold into one activity line between them ("Used Bash, Read ×2"); each call keeps a short
363
+ * target (callTarget: the file, pattern or first command line), never the whole input, which can hold anything.
326
364
  * Every string goes through `clean` (server.js's scrub, D08). The newest `limit` items are returned. */
327
365
  const CONV_LIMIT = 80;
328
366
  const CONV_TEXT_MAX = 8000;
@@ -343,7 +381,29 @@ function userText(r) {
343
381
  return t.trim();
344
382
  }
345
383
 
346
- function readConversation(file, clean = (x) => x, limit = CONV_LIMIT) {
384
+ /* What one tool call worked on, in a few words: the file, the pattern, the first line of the command, the address. Only these
385
+ * named fields are read, never the whole input, and the result is at most TARGET_MAX characters (a path inside `root` is shown
386
+ * relative to it). The caller scrubs it (D08). Anything else gives ''. */
387
+ const TARGET_MAX = 120;
388
+ const CALLS_MAX = 40;
389
+ function callTarget(input, root) {
390
+ if (!input || typeof input !== 'object') return '';
391
+ const str = (v) => (typeof v === 'string' ? v : '');
392
+ const rel = (p) => { const r = root && path.isAbsolute(p) ? path.relative(root, p) : ''; return r && !r.startsWith('..') && !path.isAbsolute(r) ? r : p; };
393
+ let t = '';
394
+ const file = str(input.file_path) || str(input.notebook_path);
395
+ if (file) t = rel(file);
396
+ else if (str(input.command)) t = str(input.command).split('\n')[0];
397
+ else if (str(input.pattern)) t = str(input.pattern) + (str(input.path) ? ' in ' + rel(str(input.path)) : '');
398
+ else if (str(input.url)) t = str(input.url);
399
+ else if (str(input.query)) t = str(input.query);
400
+ else if (str(input.description)) t = str(input.description);
401
+ else if (str(input.path)) t = rel(str(input.path));
402
+ t = t.replace(/\s+/g, ' ').trim();
403
+ return t.length > TARGET_MAX ? t.slice(0, TARGET_MAX - 1) + '…' : t;
404
+ }
405
+
406
+ function readConversation(file, clean = (x) => x, limit = CONV_LIMIT, root = null) {
347
407
  let raw;
348
408
  try {
349
409
  const st = fs.statSync(file);
@@ -362,13 +422,15 @@ function readConversation(file, clean = (x) => x, limit = CONV_LIMIT) {
362
422
  const out = [];
363
423
  const cap = (t) => (t.length > CONV_TEXT_MAX ? t.slice(0, CONV_TEXT_MAX) + ' …' : t);
364
424
  const push = (role, text, at) => { out.push({ role, text: clean(cap(text)), at: at || null }); };
365
- const tool = (name, at) => {
425
+ const tool = (name, input, at) => {
366
426
  const last = out[out.length - 1];
367
427
  const n = clean(prettyTool(name));
428
+ const call = { tool: n, target: clean(callTarget(input, root)) };
368
429
  if (last && last.role === 'tool') {
369
430
  last.tools[n] = (last.tools[n] || 0) + 1;
431
+ if (last.calls.length < CALLS_MAX) last.calls.push(call);
370
432
  last.at = at || last.at;
371
- } else out.push({ role: 'tool', tools: { [n]: 1 }, at: at || null });
433
+ } else out.push({ role: 'tool', tools: { [n]: 1 }, calls: [call], at: at || null });
372
434
  };
373
435
  for (const line of raw.split('\n')) {
374
436
  if (!line) continue;
@@ -391,14 +453,35 @@ function readConversation(file, clean = (x) => x, limit = CONV_LIMIT) {
391
453
  // one model turn can stream its text in several records: join them into one bubble
392
454
  if (last && last.role === 'assistant' && last.msgId && last.msgId === m.id) last.text = clean(cap(last.text + '\n\n' + c.text.trim()));
393
455
  else { push('assistant', c.text.trim(), r.timestamp); out[out.length - 1].msgId = m.id || null; }
394
- } else if (c.type === 'tool_use' && c.name) tool(c.name, r.timestamp);
456
+ } else if (c.type === 'tool_use' && c.name) tool(c.name, c.input, r.timestamp);
395
457
  }
396
458
  }
397
459
  const messages = out.slice(-limit).map((x) => (x.role === 'tool'
398
- ? { role: 'tool', tools: x.tools, at: x.at }
460
+ ? { role: 'tool', tools: x.tools, calls: x.calls, at: x.at }
399
461
  : { role: x.role, text: x.text, at: x.at }));
400
462
  return { messages };
401
463
  }
402
464
 
403
- module.exports = { findTranscript, readHarness, readConversation, CONV_LIMIT, prettyTool, slugFor, resolvePid, descendants,
465
+ /*
466
+ * Claude Code's permission prompt, read from the pane's visible screen (tmux capture-pane -p, plain text). The transcript has
467
+ * the open call but not the question or its choices, which exist only on the screen. Returns { question, options:[{n, label}] }
468
+ * when the screen's last "Do you want to …" line is followed by numbered options, else null. The page types an option's
469
+ * number to answer, which is the key Claude Code's own menu takes. Box-drawing borders are dropped; labels are scrubbed.
470
+ */
471
+ const ASK_RE = /^Do you want to .+\?$/;
472
+ function permissionAsk(screen, clean = (x) => x) {
473
+ const lines = String(screen || '').split('\n').map((l) => l.replace(/[│┃]/g, ' ').replace(/\s+$/, '').trim());
474
+ let at = -1;
475
+ for (let i = lines.length - 1; i >= 0; i--) if (ASK_RE.test(lines[i])) { at = i; break; }
476
+ if (at < 0) return null;
477
+ const options = [];
478
+ for (const l of lines.slice(at + 1, at + 12)) {
479
+ const m = /^(?:[❯›>]\s*)?(\d)[.)]\s+(.+)$/.exec(l);
480
+ if (m) { if (options.length < 6 && !options.some((o) => o.n === m[1])) options.push({ n: m[1], label: clean(m[2].trim()).slice(0, 160) }); }
481
+ else if (options.length && l) break;
482
+ }
483
+ return options.length >= 2 ? { question: clean(lines[at]).slice(0, 200), options } : null;
484
+ }
485
+
486
+ module.exports = { permissionAsk, claudeHomes, callTarget, findTranscript, readHarness, readConversation, CONV_LIMIT, prettyTool, slugFor, resolvePid, descendants,
404
487
  MAX_BYTES, MAX_WALK_DEPTH, PS_SNAPSHOT_MS, defaultChildren };
@@ -31,7 +31,7 @@ function agentOf(x) {
31
31
  if (!state) return null;
32
32
  return {
33
33
  state, suggest: x.suggest || null, instruction: x.instruction || null,
34
- draft: t && (t.draft || t.body) ? { summary: t.draft || null, body: t.body || null } : null,
34
+ draft: t && (t.draft || t.body) ? { summary: t.draft || null, body: t.body || null, edited_at: t.edited_at || null } : null,
35
35
  sent: t && t.sent ? { link: t.sent.link || null, at: t.sent.at || null } : null,
36
36
  error: t ? t.error || null : null, session: t && t.session ? String(t.session) : null, asks: t ? t.asks || null : null, revisions: t ? t.revisions || 0 : 0, started_at: t ? t.started_at || null : null, decided_at: x.decided_at || null,
37
37
  };
@@ -77,7 +77,7 @@ function taskAgent(taskId, suggest, run) {
77
77
  const base = { suggest, instruction: null, draft: null, sent: null, error: null, session: null, asks: null, revisions: 0, started_at: null, decided_at: null };
78
78
  if (!t) return { state: 'suggest', ...base };
79
79
  return { ...base, state: TASK_STATE[t.status] || 'queued', instruction: t.instruction || null,
80
- draft: t.draft ? { summary: t.draft.summary || null, body: t.draft.body || null } : null,
80
+ draft: t.draft ? { summary: t.draft.summary || null, body: t.draft.body || null, edited_at: t.draft.edited_at || null } : null,
81
81
  sent: t.sent ? { link: t.sent.link || null, at: t.sent.at || null } : null, error: t.error || null,
82
82
  session: t.stalled_session ? String(t.stalled_session) : null, asks: t.asks || null, revisions: Array.isArray(t.revisions) ? t.revisions.length : 0,
83
83
  started_at: t.started_at || null, decided_at: (t.decided && t.decided.at) || null };
@@ -100,17 +100,29 @@ function localDraftSpec(item, instruction) {
100
100
  const localAgent = (item, run) => taskAgent(localTaskId(item), localSuggest(item), run);
101
101
  /** The newest of `runs` (newest first) that holds task `taskId`, or null. An item the PM added keeps its draft across days. */
102
102
  const runWith = (runs, taskId) => (runs || []).find((r) => r && Array.isArray(r.tasks) && r.tasks.some((t) => t && t.id === taskId)) || null;
103
+ /** The local dates of the last `days` days, newest first. */
104
+ function recentDates(nowMs, days = 7) {
105
+ const pad = (n) => String(n).padStart(2, '0');
106
+ const out = [];
107
+ for (let i = 0; i < days; i++) { const d = new Date(nowMs); d.setDate(d.getDate() - i); out.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`); }
108
+ return out;
109
+ }
103
110
  /** The day runs of the last `days` days, newest first, through `readDayRun(date)`. */
104
111
  function recentRuns(readDayRun, nowMs, days = 7) {
105
- const out = [];
106
- if (typeof readDayRun !== 'function') return out;
107
- const pad = (n) => String(n).padStart(2, '0');
108
- for (let i = 0; i < days; i++) {
109
- const d = new Date(nowMs); d.setDate(d.getDate() - i);
110
- let r = null; try { r = readDayRun(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`); } catch { r = null; }
111
- if (r) out.push(r);
112
+ if (typeof readDayRun !== 'function') return [];
113
+ return recentDates(nowMs, days).map((d) => { try { return readDayRun(d); } catch { return null; } }).filter(Boolean);
114
+ }
115
+ /** The brief item `id` the PM owns, from the newest brief of the last `days` days that holds it (`readBrief(date)` → brief or
116
+ * null), or null. An item of yesterday's brief that the PM kept in Work keeps its agent offer (amendment 12). */
117
+ function findBriefItem(readBrief, id, nowMs, days = 7) {
118
+ if (typeof readBrief !== 'function') return null;
119
+ for (const d of recentDates(nowMs, days)) {
120
+ let b = null; try { b = readBrief(d); } catch { b = null; }
121
+ if (!b || b.date !== d) continue;
122
+ const x = [...(Array.isArray(b.priorities) ? b.priorities : []), ...(Array.isArray(b.items) ? b.items : [])].find((y) => y && y.id === id);
123
+ if (x) return x.owner === 'pm' && !x.worth_asking ? x : null;
112
124
  }
113
- return out;
125
+ return null;
114
126
  }
115
127
 
116
128
  function fromBrief(brief, todayIso, run = null) {
@@ -148,4 +160,4 @@ function overlay(item, byId) {
148
160
  return out;
149
161
  }
150
162
 
151
- module.exports = { fromMyWork, fromBrief, addable, createdEvent, overlay, agentOf, briefAgent, briefSuggest, briefDraftSpec, taskAgent, localTaskId, localOffer, localSuggest, localDraftSpec, localAgent, runWith, recentRuns, AGENT };
163
+ module.exports = { fromMyWork, fromBrief, addable, createdEvent, overlay, agentOf, briefAgent, briefSuggest, briefDraftSpec, taskAgent, localTaskId, localOffer, localSuggest, localDraftSpec, localAgent, runWith, recentRuns, recentDates, findBriefItem, AGENT };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.213.0",
3
+ "version": "0.215.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.213.0",
9
+ "version": "0.215.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.213.0",
3
+ "version": "0.215.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -391,11 +391,20 @@ export function handle(S, method, path, p, body) {
391
391
  const themes = [{ title: own[0] ? own[0].title : 'Sprint work', line: 'Most items are in progress or in review.', tickets: issues.filter((i) => i.initiative === ini0).length || 1, done: issues.filter((i) => i.initiative === ini0 && i.category === 'done').length, blocked: 1, people: 'Ana, Budi', initiative: ini0 },
392
392
  { title: 'Fixes and upkeep', line: 'Small fixes outside any initiative.', tickets: 2, done: 1, blocked: 0, people: 'Chen', initiative: null }];
393
393
  const act = { name: key + ' Sprint ' + num, state: 'active', issues, counts, goal: { from: 'tickets', text: 'Get the first initiative ready for internal users and clear the blocked work.', jira: 'Q4 delivery' },
394
- summary: 'Most of the sprint is on the first initiative. One item is blocked. Two small fixes sit outside any initiative.', themes, writtenAt: TODAY + 'T06:30:00Z', summarisedTickets: issues.length, closed: null, frozen: false };
394
+ summary: 'Most of the sprint is on the first initiative. One item is blocked. Two small fixes sit outside any initiative.', themes, writtenAt: TODAY + 'T06:30:00Z', summarisedTickets: issues.length, closed: null, frozen: false,
395
+ brief: { health: 'warn', verdict: 'At risk. Halfway through, 21 of 48 points are done. The team finishes about 27 points a sprint, so about 12 points will carry over unless scope is cut now. The blocked QA plan is the exposed part.',
396
+ issues: [{ sev: 'bad', title: 'QA test plan for the beta is blocked', keys: key + '-1373', initiative: ini0, why: 'Flagged in Jira for 4 days: no QA tester is free this sprint. The beta cannot start without it.', by: 'Decide today',
397
+ plan: ['Ask the QA lead whether another team can lend a tester for 3 days this week.', 'If yes, the tester writes the plan by Thursday and the beta keeps its date.', 'If no, move the beta by two weeks and tell the pilot customers this week.'], doneWhen: 'The ticket leaves Blocked with a named tester and a date.' },
398
+ { sev: 'warn', title: 'Code expiry has sat in review for 3 days', keys: key + '-1371', initiative: ini0, why: 'Review is the slowest step this sprint.', by: 'Raise at stand-up', plan: ['Ask at stand-up for a second reviewer this week.'], doneWhen: 'The ticket is merged.' }],
399
+ done: [{ title: 'Rate limiting approach chosen', keys: key + '-1370', initiative: ini0, line: 'Unblocks the build next sprint.', waiting: false }, { title: 'Footer shows the right year', keys: key + '-1379', initiative: null, line: 'Waiting for the next release to reach Prod.', waiting: true }],
400
+ asks: ['Decide how the beta gets QA: borrow a tester, or move the beta date.', 'Cut about 12 points now, starting with the lowest-value tickets.'] },
401
+ points: { committed: 48, done: 21, unestimated: 2 }, flow: [{ status: 'To Do', category: 'todo', n: counts.todo }, { status: 'In Progress', category: 'doing', n: counts.doing }, { status: 'Done', category: 'done', n: counts.done }].filter((f) => f.n),
402
+ pace: { day: 7, days: 14, daysLeft: 8, avg: 27, hitRate: 0.77, forecast: 36 } };
395
403
  const fut = { ...extra(1, 'future', key + ' Sprint ' + (num + 1), 'todo', 3), goal: { from: 'tickets', text: 'Finish the first initiative and start the beta.', jira: null }, summary: 'Planned so far: three follow-up items.', themes: [], writtenAt: TODAY + 'T06:30:00Z', closed: null, frozen: false };
396
404
  const old = { ...extra(0, 'closed', key + ' Sprint ' + (num - 1), 'done', 5), goal: { from: 'tickets', text: 'Start the first initiative.', jira: null }, summary: 'Goal met. Five items shipped; two moved to the next sprint.', themes: [{ title: 'Sprint work', line: '', tickets: 5, done: 5, blocked: 0, people: '', initiative: ini0 }],
397
- writtenAt: '2026-09-29T06:30:00Z', closed: { done: 5, total: 7, carried: 2, carriedTo: key + ' Sprint ' + num, points: '21 of 26' }, frozen: true };
398
- return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [act, fut, old] });
405
+ writtenAt: '2026-09-29T06:30:00Z', closed: { done: 5, total: 7, carried: 2, carriedTo: key + ' Sprint ' + num, points: '21 of 26' }, frozen: true, hit: 'met', got: 'The code entry screen waited 5 days on design.' };
406
+ const velocity = [{ name: key + ' Sprint ' + (num - 4), committed: 33, done: 24 }, { name: key + ' Sprint ' + (num - 3), committed: 36, done: 28 }, { name: key + ' Sprint ' + (num - 2), committed: 34, done: 26 }, { name: key + ' Sprint ' + (num - 1), committed: 38, done: 30 }];
407
+ return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [act, fut, old], velocity });
399
408
  }
400
409
  return fail(404, 'No route ' + path);
401
410
  }