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.
- 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/c3-ib-more-test.js +23 -4
- package/workspace/c3-sessions-panel-test.js +69 -11
- package/workspace/c3-work-test.js +2 -2
- package/workspace/guide/console3-sessions.md +33 -9
- package/workspace/guide/jira.md +13 -1
- package/workspace/guide/my-work.md +4 -1
- package/workspace/lib/day-run.js +30 -1
- package/workspace/lib/engine/boards.js +51 -7
- package/workspace/lib/jira-status.js +46 -1
- package/workspace/lib/my-work.js +1 -1
- package/workspace/lib/proactive-cli.js +8 -2
- package/workspace/lib/routes-my-work.js +44 -17
- package/workspace/lib/routes-pm-items.js +8 -0
- package/workspace/lib/transcript-read.js +93 -10
- package/workspace/lib/work-assigned.js +23 -11
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +12 -3
- package/workspace/public/pm/c3-ib-sprints.js +142 -39
- package/workspace/public/pm/c3-initiatives.css +18 -0
- package/workspace/public/pm/c3-session-conv.js +164 -0
- package/workspace/public/pm/c3-session-term.js +63 -16
- package/workspace/public/pm/c3-settings.js +1 -1
- package/workspace/public/pm/console3.css +62 -0
- package/workspace/public/pm/pm-work-view.js +37 -11
- package/workspace/public/pm/tab-term.js +9 -0
- package/workspace/server.js +32 -12
- package/workspace/skills/day-task.skill +1 -0
- package/workspace/skills/jira-sync.skill +9 -3
- 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 };
|
package/workspace/lib/my-work.js
CHANGED
|
@@ -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) {
|
|
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
|
|
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))
|
|
164
|
+
if (run && dayRun.findTask(run, id)) found = { run, id };
|
|
162
165
|
}
|
|
163
|
-
return
|
|
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
|
|
179
|
-
if (!
|
|
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
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
261
|
-
if (!
|
|
262
|
-
|
|
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
|
-
|
|
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 =
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.215.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|