create-pmos 0.183.0 → 0.185.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/guide/jira.md +12 -1
- package/workspace/guide/my-work.md +30 -2
- package/workspace/lib/day-run.js +13 -2
- package/workspace/lib/engine/boards.js +27 -2
- package/workspace/lib/jira-cli.js +22 -1
- package/workspace/lib/jira-status.js +52 -5
- package/workspace/lib/my-work.js +2 -1
- package/workspace/lib/routes-boards.js +6 -2
- package/workspace/lib/routes-calendar-views.js +2 -2
- package/workspace/lib/routes-my-work.js +60 -1
- package/workspace/lib/routes-stage-tools.js +15 -0
- package/workspace/lib/work-assigned.js +30 -6
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/console3.css +9 -0
- package/workspace/public/pm/pm-work-view.js +139 -9
- package/workspace/server.js +6 -0
- package/workspace/skills/jira-sync.skill +4 -3
- package/workspace/test-lists/c3-work-agent.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.185.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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T05:02:49Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T05:02:49Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T05:02:49Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/jira.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Jira: tickets drafted from the PRD, and their status"
|
|
4
4
|
description: "How to draft Jira tickets from a PRD in your ticket template, push the ones that are ready, and see their status, sprint and progress on the initiative's Jira tickets tab."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-13
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Jira: tickets drafted from the PRD, and their status
|
|
@@ -110,6 +110,15 @@ pmos jira query [<initiative>]
|
|
|
110
110
|
pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
## The board's Sprints tab shows the whole sprint
|
|
114
|
+
|
|
115
|
+
After it reads the initiatives, the check reads every issue in the sprints their tickets are in. It reads every issue, not only the issues of an initiative and not only the issues assigned to you. The board's **Sprints** tab and the sprint burndown show all of them. An issue that is in no initiative of the board shows **No initiative**. The check writes `<state>/tracker/jira/_sprints.md` with:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
pmos jira query --sprints
|
|
119
|
+
pmos jira sync --sprints --from <file|-> [--query "<jql>"]
|
|
120
|
+
```
|
|
121
|
+
|
|
113
122
|
## Link an initiative to its Jira epics
|
|
114
123
|
|
|
115
124
|
Your team may already plan in Jira, with an epic for each piece of work. Then link the initiative to its epics. You do not have to draft or push tickets.
|
|
@@ -152,6 +161,8 @@ The morning brief, the day check and requirement gathering use it to tie a Jira
|
|
|
152
161
|
- `pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID]`
|
|
153
162
|
- `pmos jira query [<initiative>]`
|
|
154
163
|
- `pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
164
|
+
- `pmos jira query --sprints`
|
|
165
|
+
- `pmos jira sync --sprints --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
155
166
|
- `pmos jira which <KEY>`
|
|
156
167
|
|
|
157
168
|
Every command takes `--json` and `--root <dir>`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "My work: every Jira ticket and WBS item assigned to you, ranked, briefed to an agent, approved in Agenda"
|
|
4
|
-
timestamp: 2026-10-
|
|
4
|
+
timestamp: 2026-10-13
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# My work
|
|
@@ -77,7 +77,7 @@ The Work page holds all the work that is yours:
|
|
|
77
77
|
- every open Jira ticket and WBS item assigned to you, from the same read as My work;
|
|
78
78
|
- the items today's morning brief suggests for you. They are flagged, so they are in Today.
|
|
79
79
|
|
|
80
|
-
A row of assigned work shows its Jira key (a link to the ticket) or **WBS** and the repository, and the
|
|
80
|
+
A row of assigned work shows its Jira key (a link to the ticket) or **WBS** and the repository. When the read has a suggestion, the first chip on the row shows **Agent can:** and the suggestion. This chip is only a suggestion. No agent works on the item until you start it. A suggested row shows **Suggested** and the reason. An agent at work on a ticket puts it in Waiting; a ticket in progress, or with a draft ready for you, is flagged. A ticket that is done in Jira leaves the page.
|
|
81
81
|
|
|
82
82
|
Every action works on these rows. The first action writes the item to your own list, with its source. **Drop** takes it off your Work page only; Jira and the Board keep it, and it does not come back.
|
|
83
83
|
|
|
@@ -96,6 +96,34 @@ The six views follow the OmniFocus style. The **Work** tab badge counts Inbox pl
|
|
|
96
96
|
|
|
97
97
|
An item goes to the first view that fits these rules, in this order. Parked is Someday. Waiting on someone is Waiting. A defer date after today is Upcoming. Flagged is Today. Due today or earlier is Today. A later due date is Upcoming. An undated item whose defer date has arrived is Today. No date is Inbox.
|
|
98
98
|
|
|
99
|
+
### Let the agent draft an item
|
|
100
|
+
|
|
101
|
+
Open an assigned item that shows **Agent can:**. The case file shows **What the agent will do**.
|
|
102
|
+
|
|
103
|
+
1. To change the instruction, select **Change what the agent does** and type a new one.
|
|
104
|
+
2. Select **Let the agent draft this** and hold to confirm. This adds one job to today's day run, with the instruction.
|
|
105
|
+
|
|
106
|
+
The row chip then shows the agent's state:
|
|
107
|
+
|
|
108
|
+
| Chip | What it means |
|
|
109
|
+
|---|---|
|
|
110
|
+
| **Agent: queued** | The job waits for a free place in the day run. |
|
|
111
|
+
| **Agent: working** | An agent session drafts the item. Select **stop** in the case file to end it. |
|
|
112
|
+
| **Agent: draft ready** | The draft waits for you. |
|
|
113
|
+
| **Agent: posting** | You approved the draft. A session posts exactly that text. |
|
|
114
|
+
| **Posted to** the item | The draft went out. Select **Open what was posted** to see it. |
|
|
115
|
+
| **Agent stopped** | The job failed or you stopped it. The case file gives the reason. Select **Try again** to run it again. |
|
|
116
|
+
|
|
117
|
+
The page reads the state again every 15 seconds while an agent is queued, working or posting.
|
|
118
|
+
|
|
119
|
+
When the draft is ready, the case file quotes it. Select **Open the draft** to read all of it. Then choose one option:
|
|
120
|
+
|
|
121
|
+
- **Post it to** the ticket posts the exact text. Nothing goes out until you hold to confirm.
|
|
122
|
+
- **Send it back with a note** returns the draft to the agent. Type what to change and press Enter.
|
|
123
|
+
- **Keep it as a draft** leaves the draft as it is.
|
|
124
|
+
|
|
125
|
+
The agent only drafts. It never posts to Jira until you approve the draft.
|
|
126
|
+
|
|
99
127
|
### Defer and flag
|
|
100
128
|
|
|
101
129
|
A defer date means "not before this day". The item stays in Upcoming and shows **Not before** with the date. On that day it moves to Today by itself. A due date is when the work must be done. A defer date is when you start.
|
package/workspace/lib/day-run.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* addBriefed(run, spec) one task the PM briefed in My work (initiative my-work, PRD R5)
|
|
18
18
|
* addSend(run, spec, now) the PM's own text, written in the card, to post as written (today-act-prepare)
|
|
19
19
|
* toStart(run, limit) the tasks to start now, each with its mode (do | revise | send)
|
|
20
|
-
* started · deliver · decide · sent · fail · sessionEnded · sessionStalled
|
|
20
|
+
* started · deliver · decide · sent · fail · stop · sessionEnded · sessionStalled
|
|
21
21
|
* stage(run, brief) reading | working | review | done
|
|
22
22
|
* checkDue(run, {settings, now, events}) · checkStarted · checkDone
|
|
23
23
|
*
|
|
@@ -272,6 +272,17 @@ function fail(run, taskId, reason, now) {
|
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/** The PM stopped a task that has not delivered (initiative c3-work-agent): waiting or working → failed with `reason`, so it
|
|
276
|
+
* never starts and frees its place. The caller ends its session. A retry runs it again in a NEW session. */
|
|
277
|
+
function stop(run, taskId, reason, now) {
|
|
278
|
+
return withTask(run, taskId, (t) => {
|
|
279
|
+
if (t.status !== 'waiting' && t.status !== 'working') return `task ${taskId} is ${t.status} — only a waiting or working task can be stopped`;
|
|
280
|
+
const why = text(reason) || 'Stopped by the PM.';
|
|
281
|
+
t.status = 'failed'; t.error = why.slice(0, 240); t.failed_at = iso(now); keepSession(t); t.prev_session = null;
|
|
282
|
+
return null;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
275
286
|
/** A session ended. A task it was working on without delivering, or sending without reporting, has failed. */
|
|
276
287
|
function sessionEnded(run, sessionId, now) {
|
|
277
288
|
if (!run) return { ok: false, error: 'no day run today' };
|
|
@@ -388,5 +399,5 @@ function checkDone(run, { found = 0, resolved = 0 } = {}, now) {
|
|
|
388
399
|
}
|
|
389
400
|
|
|
390
401
|
module.exports = { MAX_PARALLEL, MAX_REVISIONS, MAX_CHECK_LOG, MAX_INTERVAL_MIN, STATUSES, KINDS, ACTIONS, TASK_RE,
|
|
391
|
-
newRun, readRun, findTask, modeOf, addTasks, addBriefed, addSend, toStart, started, deliver, decide, sent, fail, sessionEnded, sessionStalled, stage, intervalMin,
|
|
402
|
+
newRun, readRun, findTask, modeOf, addTasks, addBriefed, addSend, toStart, started, deliver, decide, sent, fail, stop, sessionEnded, sessionStalled, stage, intervalMin,
|
|
392
403
|
checkDue, checkStarted, checkDone };
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* filterRows(rows, query) → { total, offset, limit, rows, facets }
|
|
16
16
|
* health(rows) → 'ok' | 'warn' | 'bad'
|
|
17
17
|
* counts(rows, workflow) → { total, open, byCat, byStage, byStatus, risk, late }
|
|
18
|
-
* sprintsOf(snapshots, onBoard)
|
|
18
|
+
* sprintsOf(snapshots, onBoard, whole?) → { source, readAt, sprints }
|
|
19
19
|
* INI_TEMPLATES · isBuildStage · templateBuilds · boardSettings(board) the per-initiative workflows (console3-initiatives-parity)
|
|
20
20
|
* workflowOf(board, id, folded) → the stages an initiative runs: its own (ini-workflow), else the board's
|
|
21
21
|
* applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) → { ok, template, workflow, moved } | { ok:false, status, error }
|
|
@@ -523,8 +523,11 @@ function counts(rows, workflow) {
|
|
|
523
523
|
* The Jira sprints of a board from the cached status snapshots (lib/jira-status.js readSnapshot), read-only.
|
|
524
524
|
* snapshots: [{ initiative, fetchedAt, tickets: Map|[] of readings }]; onBoard(initiativeId) → boolean.
|
|
525
525
|
* A ticket with no sprint (the backlog) is in no sprint. source is 'none' when no snapshot of the board was read.
|
|
526
|
+
* whole: the whole-sprint read (lib/jira-status.js readSprints), { fetchedAt, tickets }. A sprint the board's tickets are in then
|
|
527
|
+
* lists EVERY issue Jira holds in it, whoever it is assigned to; an issue no initiative of the board holds has initiative null
|
|
528
|
+
* (jira-sprint-scope). Without it, a sprint lists only the board's own tickets.
|
|
526
529
|
*/
|
|
527
|
-
function sprintsOf(snapshots, onBoard = () => true) {
|
|
530
|
+
function sprintsOf(snapshots, onBoard = () => true, whole = null) {
|
|
528
531
|
const byName = new Map();
|
|
529
532
|
let readAt = null;
|
|
530
533
|
let any = false;
|
|
@@ -545,6 +548,28 @@ function sprintsOf(snapshots, onBoard = () => true) {
|
|
|
545
548
|
sp.counts[category]++;
|
|
546
549
|
}
|
|
547
550
|
}
|
|
551
|
+
const wt = whole && whole.tickets ? (whole.tickets instanceof Map ? [...whole.tickets.values()] : Array.isArray(whole.tickets) ? whole.tickets : []) : [];
|
|
552
|
+
let widened = false;
|
|
553
|
+
for (const sp of byName.values()) {
|
|
554
|
+
const all = wt.filter((t) => t && t.key && t.sprint && String(t.sprint.name) === sp.name);
|
|
555
|
+
if (!all.length) continue;
|
|
556
|
+
widened = true;
|
|
557
|
+
const own = new Map(sp.issues.map((i) => [i.key, i]));
|
|
558
|
+
const keys = new Set(all.map((t) => t.key));
|
|
559
|
+
if (!sp.start && typeof all[0].sprint.start === 'string' && all[0].sprint.start) sp.start = all[0].sprint.start;
|
|
560
|
+
if (!sp.end && typeof all[0].sprint.end === 'string' && all[0].sprint.end) sp.end = all[0].sprint.end;
|
|
561
|
+
sp.issues = [
|
|
562
|
+
...all.map((t) => {
|
|
563
|
+
const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
|
|
564
|
+
const o = own.get(t.key);
|
|
565
|
+
return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: o ? o.initiative : null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null };
|
|
566
|
+
}),
|
|
567
|
+
...sp.issues.filter((i) => !keys.has(i.key)), // a board ticket the sprint read has not seen yet keeps its own reading
|
|
568
|
+
];
|
|
569
|
+
sp.counts = { todo: 0, doing: 0, done: 0 };
|
|
570
|
+
for (const i of sp.issues) sp.counts[i.category]++;
|
|
571
|
+
}
|
|
572
|
+
if (widened && whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt; // the oldest read is the honest age
|
|
548
573
|
const order = { active: 0, future: 1, closed: 2 };
|
|
549
574
|
const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
|
|
550
575
|
for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys the agent created
|
|
11
11
|
* pmos jira query [<initiative>] the Jira search for the tickets an initiative pushed
|
|
12
12
|
* pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] a saved search → the status snapshot
|
|
13
|
+
* pmos jira query --sprints the Jira search for every issue in the sprints the tickets are in
|
|
14
|
+
* pmos jira sync --sprints --from <file|-> [--query "<jql>"] a saved whole-sprint search → the Sprints tab (jira-sprint-scope)
|
|
13
15
|
* pmos jira which <KEY> the initiative a Jira key belongs to
|
|
14
16
|
* every command: [--json] [--root <dir>]
|
|
15
17
|
*
|
|
@@ -36,11 +38,13 @@ const USAGE = [
|
|
|
36
38
|
' pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys created in Jira',
|
|
37
39
|
' pmos jira query [<initiative>] the Jira search for the pushed tickets',
|
|
38
40
|
' pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] save the status read from Jira',
|
|
41
|
+
' pmos jira query --sprints the Jira search for every issue in those tickets\' sprints',
|
|
42
|
+
' pmos jira sync --sprints --from <file|-> [--query "<jql>"] save every issue of the sprints, for the Sprints tab',
|
|
39
43
|
' pmos jira which <KEY> the initiative a Jira key belongs to',
|
|
40
44
|
' every command: [--json] [--root <dir>]',
|
|
41
45
|
'This command never calls Jira. Your agent reads and writes Jira with your own connection.',
|
|
42
46
|
];
|
|
43
|
-
const BOOL = new Set(['json', 'dry-run', 'help']);
|
|
47
|
+
const BOOL = new Set(['json', 'dry-run', 'help', 'sprints']);
|
|
44
48
|
const VALUE = new Set(['from', 'query', 'root', 'epic', 'updated']);
|
|
45
49
|
const FLAG_TEXT = { 'prd-changed': 'PRD changed', removed: 'Removed from PRD', blocked: 'Blocked', incomplete: 'Missing text', pushing: 'Selected, waiting for the push', edited: 'Edited, not in Jira yet' };
|
|
46
50
|
|
|
@@ -92,6 +96,23 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
92
96
|
const known = () => planItems.INIT_RE.test(initiative || '') && !!jiraTickets.recordFm(root, initiative);
|
|
93
97
|
|
|
94
98
|
try {
|
|
99
|
+
if (flags.sprints && verb !== 'query' && verb !== 'sync') return fail('--sprints goes with query or sync', 2);
|
|
100
|
+
if (verb === 'query' && flags.sprints) {
|
|
101
|
+
const q = jiraStatus.sprintQuery(root);
|
|
102
|
+
return out(q, [q.linked ? q.query : 'no Jira reading has a sprint yet — run the initiatives\' searches first']);
|
|
103
|
+
}
|
|
104
|
+
if (verb === 'sync' && flags.sprints) {
|
|
105
|
+
if (!flags.from) return need('--sprints --from <file|->');
|
|
106
|
+
let raw;
|
|
107
|
+
try { raw = JSON.parse(readInput(flags.from, cwd, stdin)); }
|
|
108
|
+
catch (e) { return fail(`could not read ${flags.from} as JSON — ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
109
|
+
const r = jiraStatus.syncSprints(root, raw, { query: flags.query || undefined, dryRun: !!flags['dry-run'], now });
|
|
110
|
+
if (!r.ok) return fail(r.error);
|
|
111
|
+
const lines = [`sprints · read ${r.read} ${r.read === 1 ? 'issue' : 'issues'}${r.ignored ? ` · ${r.ignored} in no sprint, ignored` : ''}${r.skipped ? ` · ${r.skipped} skipped (no key or summary)` : ''}${r.dryRun ? ' · dry run, nothing written' : ''}`];
|
|
112
|
+
for (const [name, n] of Object.entries(r.sprints)) lines.push(` ${name} · ${n} ${n === 1 ? 'issue' : 'issues'}`);
|
|
113
|
+
if (!r.dryRun) lines.push(`snapshot ${r.snapshot}`);
|
|
114
|
+
return out(r, lines);
|
|
115
|
+
}
|
|
95
116
|
if (verb === 'query') {
|
|
96
117
|
if (initiative) {
|
|
97
118
|
const q = jiraStatus.queryFor(root, initiative);
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* sync(root, initiative, raw, opts) → the snapshot, written to <state>/tracker/jira/<initiative>.md
|
|
14
14
|
* readSnapshot(root, initiative) → { fetchedAt, query, tickets: Map<key, reading> } | null
|
|
15
15
|
* which(root, key) → the initiative and ticket a key belongs to
|
|
16
|
+
* sprintQuery(root) → { linked, sprints, query } the JQL for EVERY issue in the sprints the initiatives' tickets
|
|
17
|
+
* are in, whoever holds them (jira-sprint-scope); linked false when no reading has a sprint
|
|
18
|
+
* syncSprints(root, raw, opts) → the whole-sprint snapshot, written to <state>/tracker/jira/_sprints.md
|
|
19
|
+
* readSprints(root) → { fetchedAt, query, tickets: Map<key, reading> } | null
|
|
16
20
|
*
|
|
17
21
|
* A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed, id?, start?, end?,
|
|
18
22
|
* completed? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
|
|
@@ -206,12 +210,13 @@ function snapshotRel(root, initiative) { return jiraTickets.relOf(root, snapshot
|
|
|
206
210
|
const cell = (s) => oneLine(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|') || '—';
|
|
207
211
|
const SPRINT_WORD = { active: 'Active sprint', future: 'Next sprint', closed: 'Closed sprint' };
|
|
208
212
|
function sprintLabel(s) { return s ? `${SPRINT_WORD[s.state] || 'Sprint'}: ${s.name}` : 'Backlog'; }
|
|
209
|
-
function renderSnapshot({ initiative, fetchedAt, query, readings, ids }) {
|
|
213
|
+
function renderSnapshot({ initiative, fetchedAt, query, readings, ids, title }) {
|
|
214
|
+
const name = title || initiative;
|
|
210
215
|
const rows = readings.map((r) => `| ${cell(ids.get(r.key) || '')} | ${cell(r.key)} | ${cell(r.summary)} | ${cell(r.status)} | ${cell(r.category === 'done' ? 'Done' : sprintLabel(r.sprint))} | ${r.subtasks.total ? `${r.subtasks.done} of ${r.subtasks.total}` : '—'} | ${cell(r.flagged ? r.reason : '')} |${r.stale ? ' not in the last read |' : ''}`);
|
|
211
216
|
return [
|
|
212
|
-
'---', 'type: Reference', `title: "Jira status: ${
|
|
213
|
-
`# Jira status: ${
|
|
214
|
-
`Read from Jira at ${fetchedAt} by the Jira status check, read-only. The Jira tickets tab of the initiative shows it. Written by \`pmos jira sync\`; do not edit by hand.`, '',
|
|
217
|
+
'---', 'type: Reference', `title: "Jira status: ${name}"`, `timestamp: ${fetchedAt}`, ...(title ? [] : [`initiative: ${initiative}`]), '---', '',
|
|
218
|
+
`# Jira status: ${name}`, '',
|
|
219
|
+
`Read from Jira at ${fetchedAt} by the Jira status check, read-only. ${title ? 'The Sprints tab and the sprint burndown of a board show it.' : 'The Jira tickets tab of the initiative shows it.'} Written by \`pmos jira sync\`; do not edit by hand.`, '',
|
|
215
220
|
query ? `Search: \`${query.replace(/`/g, "'")}\`` : 'Search: not recorded', '',
|
|
216
221
|
'| Ticket | Key | Summary | Status | Sprint | Subtasks | Blocked |', '|---|---|---|---|---|---|---|', ...rows, '',
|
|
217
222
|
'```json', JSON.stringify({ fetched_at: fetchedAt, query: query || null, tickets: readings }, null, 0), '```', '',
|
|
@@ -275,6 +280,48 @@ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
|
|
|
275
280
|
return { ...result, fetchedAt };
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
// ── the whole sprint (jira-sprint-scope) ─────────────────────────────────────────────────────────────
|
|
284
|
+
// An initiative's read holds only its own tickets, so a sprint drawn from those reads lists only the tickets PMOS knows,
|
|
285
|
+
// not the sprint. This second read asks Jira for every issue in the sprints those tickets are in, whoever holds them.
|
|
286
|
+
const SPRINTS_FILE = '_sprints';
|
|
287
|
+
const jqlName = (n) => `"${String(n).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
288
|
+
function sprintQuery(root) {
|
|
289
|
+
const seen = new Map();
|
|
290
|
+
for (const q of linkedInitiatives(root).filter((x) => x.linked)) {
|
|
291
|
+
const snap = readSnapshot(root, q.initiative);
|
|
292
|
+
if (!snap) continue;
|
|
293
|
+
for (const t of snap.tickets.values()) {
|
|
294
|
+
const sp = t && t.sprint;
|
|
295
|
+
if (!sp || !sp.name) continue;
|
|
296
|
+
const k = sp.id || sp.name;
|
|
297
|
+
if (!seen.has(k)) seen.set(k, { name: sp.name, state: sp.state || null, ...(sp.id ? { id: sp.id } : {}) });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const sprints = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
301
|
+
if (!sprints.length) return { ok: true, linked: false, sprints, query: null };
|
|
302
|
+
return { ok: true, linked: true, sprints, query: `sprint in (${sprints.map((s) => s.id || jqlName(s.name)).join(', ')}) ORDER BY key` };
|
|
303
|
+
}
|
|
304
|
+
/** Save a whole-sprint read. The read replaces the last one whole: an issue moved out of a sprint is gone from it. */
|
|
305
|
+
function syncSprints(root, raw, { query, dryRun = false, now } = {}) {
|
|
306
|
+
const n = normalize(raw);
|
|
307
|
+
if (n.error) return { ok: false, error: n.error };
|
|
308
|
+
const kept = n.issues.filter((i) => i.sprint && i.sprint.name);
|
|
309
|
+
const fetchedAt = nowIso(now);
|
|
310
|
+
const prev = readSprints(root);
|
|
311
|
+
const bySprint = {};
|
|
312
|
+
for (const i of kept) bySprint[i.sprint.name] = (bySprint[i.sprint.name] || 0) + 1;
|
|
313
|
+
const result = { ok: true, read: kept.length, ignored: n.issues.length - kept.length, skipped: n.skipped, sprints: bySprint,
|
|
314
|
+
was: prev ? prev.tickets.size : null, snapshot: snapshotRel(root, SPRINTS_FILE), dryRun: !!dryRun };
|
|
315
|
+
if (dryRun) return result;
|
|
316
|
+
const file = snapshotPath(root, SPRINTS_FILE);
|
|
317
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
318
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
319
|
+
fs.writeFileSync(tmp, renderSnapshot({ initiative: SPRINTS_FILE, title: 'every issue in the sprints', fetchedAt, query: query || (prev && prev.query) || null, readings: kept, ids: new Map() }));
|
|
320
|
+
fs.renameSync(tmp, file);
|
|
321
|
+
return { ...result, fetchedAt };
|
|
322
|
+
}
|
|
323
|
+
function readSprints(root) { return readSnapshot(root, SPRINTS_FILE); }
|
|
324
|
+
|
|
278
325
|
// ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
|
|
279
326
|
function which(root, key) {
|
|
280
327
|
const k = String(key || '').trim().toUpperCase();
|
|
@@ -288,4 +335,4 @@ function which(root, key) {
|
|
|
288
335
|
return { ok: true, key: k, initiatives: hits };
|
|
289
336
|
}
|
|
290
337
|
|
|
291
|
-
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel };
|
|
338
|
+
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE };
|
package/workspace/lib/my-work.js
CHANGED
|
@@ -194,7 +194,8 @@ 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
|
|
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,
|
|
198
|
+
sent: t.sent || null, started_at: t.started_at || null, revisions: Array.isArray(t.revisions) ? t.revisions.length : 0 } : null;
|
|
198
199
|
const base = { task, instruction: last.instruction, decided_at: last.at };
|
|
199
200
|
if (!t) return { state: 'agent', sub: 'waiting', ...base };
|
|
200
201
|
if (t.status === 'review') return { state: 'ready', sub: 'review', ...base };
|
|
@@ -489,7 +489,9 @@ const READS = {
|
|
|
489
489
|
if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
|
|
490
490
|
}
|
|
491
491
|
} catch { /* no snapshot yet */ }
|
|
492
|
-
|
|
492
|
+
let whole = null;
|
|
493
|
+
try { whole = jiraStatus.readSprints(r.w.root); } catch { whole = null; }
|
|
494
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id), whole) });
|
|
493
495
|
},
|
|
494
496
|
};
|
|
495
497
|
|
|
@@ -866,7 +868,9 @@ function meetingBoards(ctx) {
|
|
|
866
868
|
if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
|
|
867
869
|
}
|
|
868
870
|
} catch { /* no snapshot yet */ }
|
|
869
|
-
|
|
871
|
+
let whole = null;
|
|
872
|
+
try { whole = jiraStatus.readSprints(w.root); } catch { whole = null; }
|
|
873
|
+
sprints = B.sprintsOf(snaps, (id) => ids.has(id), whole);
|
|
870
874
|
}
|
|
871
875
|
return { ...head, health: B.health(rows), counts: { total: c.total, open: c.open, risk: c.risk, late: c.late },
|
|
872
876
|
rows: rows.filter((r) => r.status === 'late' || r.status === 'at risk').map((r) => ({ id: r.id, title: r.title, status: r.status, end: r.end || null, owner: r.owner || null })),
|
|
@@ -122,13 +122,13 @@ function readers(root, runtimeDir) {
|
|
|
122
122
|
try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
|
|
123
123
|
return out;
|
|
124
124
|
},
|
|
125
|
-
/* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts are not
|
|
125
|
+
/* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts and the _sprints.md whole-sprint read are not) */
|
|
126
126
|
readJira() {
|
|
127
127
|
const fs = require('fs');
|
|
128
128
|
const status = require('./jira-status.js');
|
|
129
129
|
const out = new Map();
|
|
130
130
|
let names = [];
|
|
131
|
-
try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')); } catch { return out; }
|
|
131
|
+
try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md') && !n.startsWith('_')); } catch { return out; }
|
|
132
132
|
for (const n of names) {
|
|
133
133
|
const id = n.slice(0, -3);
|
|
134
134
|
try { const p = require('./briefing-real.js').jiraProgress(status.readSnapshot(root, id)); if (p) out.set(id, p); } catch { /* one unreadable snapshot is left out */ }
|
|
@@ -7,6 +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? }
|
|
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
|
|
13
|
+
* POST /api/my-work/stop { item } → { ok, item } ends the item's working or queued task and offers the suggestion again
|
|
10
14
|
*
|
|
11
15
|
* STATE. The latest read is <runtime>/my-work/read.json and the presses are <runtime>/my-work/actions.jsonl, append-only.
|
|
12
16
|
* Both stay in the per-user runtime state and never land in a repository: they hold paraphrases of Jira comments. The
|
|
@@ -23,6 +27,7 @@ const guard = require('./pm-guard.js');
|
|
|
23
27
|
const myWork = require('./my-work.js');
|
|
24
28
|
const boardFold = require('./board-fold.js');
|
|
25
29
|
const offloop = require('./offloop.js');
|
|
30
|
+
const dayRun = require('./day-run.js');
|
|
26
31
|
const { localDate } = require('./brief.js');
|
|
27
32
|
|
|
28
33
|
const dirOf = (ctx) => path.join(ctx.runtimeDir, 'my-work');
|
|
@@ -92,7 +97,7 @@ async function handle(req, res, url, ctx) {
|
|
|
92
97
|
ctx.json(res, await listForAsync(ctx, now));
|
|
93
98
|
return true;
|
|
94
99
|
}
|
|
95
|
-
if (!['/api/my-work/read', '/api/my-work/act', '/api/my-work/refresh'].includes(p)) return false;
|
|
100
|
+
if (!['/api/my-work/read', '/api/my-work/act', '/api/my-work/refresh', '/api/my-work/decide', '/api/my-work/stop'].includes(p)) return false;
|
|
96
101
|
if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
|
|
97
102
|
const got = await ctx.readJson(req);
|
|
98
103
|
if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
|
|
@@ -116,6 +121,8 @@ async function handle(req, res, url, ctx) {
|
|
|
116
121
|
return true;
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
if (p === '/api/my-work/decide' || p === '/api/my-work/stop') { taskPress(ctx, res, p.slice('/api/my-work/'.length), body, now); return true; }
|
|
125
|
+
|
|
119
126
|
// act
|
|
120
127
|
const c = myWork.checkAct(body, now);
|
|
121
128
|
if (!c.ok) { ctx.json(res, { ok: false, error: c.error }, 400); return true; }
|
|
@@ -137,4 +144,56 @@ async function handle(req, res, url, ctx) {
|
|
|
137
144
|
return true;
|
|
138
145
|
}
|
|
139
146
|
|
|
147
|
+
/* ── the agent's task of an item (initiative c3-work-agent) ─────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
const DAY_MS = 86400000;
|
|
150
|
+
const nextDay = (d) => new Date(Date.parse(d + 'T00:00:00Z') + DAY_MS).toISOString().slice(0, 10);
|
|
151
|
+
|
|
152
|
+
/** 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. */
|
|
153
|
+
function runOfTask(ctx, brief, today) {
|
|
154
|
+
const id = myWork.taskSpec({ id: brief.item }, brief).id;
|
|
155
|
+
for (let d = String(brief.at).slice(0, 10), n = 0; d <= today && n < 400; d = nextDay(d), n++) {
|
|
156
|
+
let run = null; try { run = ctx.readDayRun ? ctx.readDayRun(d) : null; } catch { run = null; }
|
|
157
|
+
if (run && dayRun.findTask(run, id)) return { run, id };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** decide (approve | revise | retry) or stop, on the task of the item's last brief. */
|
|
163
|
+
function taskPress(ctx, res, verb, body, now) {
|
|
164
|
+
const item = String((body && body.item) || '');
|
|
165
|
+
if (!myWork.ID_RE.test(item)) { ctx.json(res, { ok: false, error: 'item must be an item id like jira:ATL-491' }, 400); return; }
|
|
166
|
+
const list = listFor(ctx, now);
|
|
167
|
+
const it = list.items.find((x) => x.id === item);
|
|
168
|
+
if (!it) { ctx.json(res, { ok: false, error: `no item ${item} is assigned to you` }, 404); return; }
|
|
169
|
+
const brief = loadActions(ctx).filter((a) => a.item === item).pop();
|
|
170
|
+
if (!brief || brief.action !== 'brief') { ctx.json(res, { ok: false, error: 'no agent works on this item — let the agent draft it first' }, 409); return; }
|
|
171
|
+
const found = runOfTask(ctx, brief, list.today);
|
|
172
|
+
if (verb === 'decide') {
|
|
173
|
+
if (!found) { ctx.json(res, { ok: false, error: 'the agent has not started this item yet' }, 409); return; }
|
|
174
|
+
const r = dayRun.decide(found.run, found.id, { action: body.action, note: body.note }, now);
|
|
175
|
+
if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, 400); return; }
|
|
176
|
+
const run = ctx.startDayTasks ? ctx.startDayTasks(r.run, now) : r.run;
|
|
177
|
+
if (!ctx.writeDayRun || !ctx.writeDayRun(run)) { ctx.json(res, { ok: false, error: 'could not save — nothing changed' }, 500); return; }
|
|
178
|
+
const after = listFor(ctx, Date.now()).items.find((x) => x.id === item) || null;
|
|
179
|
+
ctx.json(res, { ok: true, item: after, task: dayRun.findTask(run, found.id) });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// stop: only a task that has not delivered yet; its session ends and the item offers the suggestion again
|
|
183
|
+
if (!(it.state === 'agent' && ['waiting', 'working', 'failed'].includes(it.sub))) { ctx.json(res, { ok: false, error: `the item is ${it.state === 'ready' ? 'a draft waiting for you' : it.state} — there is nothing to stop` }, 409); return; }
|
|
184
|
+
if (found) {
|
|
185
|
+
const t = dayRun.findTask(found.run, found.id);
|
|
186
|
+
if (t && (t.status === 'waiting' || t.status === 'working')) {
|
|
187
|
+
if (t.session_id && ctx.endSession) { try { ctx.endSession(t.session_id); } catch { /* the session already ended */ } }
|
|
188
|
+
const f = dayRun.stop(found.run, found.id, 'You stopped it.', now);
|
|
189
|
+
if (!f.ok) { ctx.json(res, { ok: false, error: f.error }, 409); return; }
|
|
190
|
+
if (!ctx.writeDayRun || !ctx.writeDayRun(f.run)) { ctx.json(res, { ok: false, error: 'could not save — nothing changed' }, 500); return; }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try { fs.mkdirSync(dirOf(ctx), { recursive: true }); fs.appendFileSync(actionsFile(ctx), JSON.stringify({ action: 'reopen', item, at: new Date(now).toISOString() }) + '\n'); }
|
|
194
|
+
catch { ctx.json(res, { ok: false, error: 'could not save your press' }, 500); return; }
|
|
195
|
+
const after = listFor(ctx, Date.now()).items.find((x) => x.id === item) || null;
|
|
196
|
+
ctx.json(res, { ok: true, item: after });
|
|
197
|
+
}
|
|
198
|
+
|
|
140
199
|
module.exports = { prefixes: ['/api/my-work'], handle, listFor, listForAsync };
|
|
@@ -164,6 +164,21 @@ function burndownFor(ctx, w, ids, board, sprint) {
|
|
|
164
164
|
if (snap.readAt && (!readAt || snap.readAt < readAt)) readAt = snap.readAt; // the oldest read is the honest age
|
|
165
165
|
}
|
|
166
166
|
if (!tickets.length) return { ok: true, available: false, reason: 'no Jira read for these initiatives yet' };
|
|
167
|
+
// jira-sprint-scope: a sprint burns down every issue in it, not only the initiatives' own tickets.
|
|
168
|
+
let whole = null;
|
|
169
|
+
try { whole = require('./jira-status.js').readSprints(w.root); } catch { whole = null; }
|
|
170
|
+
if (whole && whole.tickets.size) {
|
|
171
|
+
const names = new Set(tickets.filter((t) => t.sprint && t.sprint.name).map((t) => t.sprint.name));
|
|
172
|
+
const own = new Map(tickets.map((t) => [t.key, t]));
|
|
173
|
+
const add = [...whole.tickets.values()].filter((t) => t.sprint && names.has(t.sprint.name));
|
|
174
|
+
if (add.length) {
|
|
175
|
+
const keys = new Set(add.map((t) => t.key));
|
|
176
|
+
const merged = [...add.map((t) => ({ ...t, initiative: own.has(t.key) ? own.get(t.key).initiative : null })), ...tickets.filter((t) => !keys.has(t.key))];
|
|
177
|
+
tickets.length = 0;
|
|
178
|
+
tickets.push(...merged);
|
|
179
|
+
if (whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
167
182
|
return { ok: true, ...S.burndown(tickets, { sprint: sprint || null }), readAt };
|
|
168
183
|
}
|
|
169
184
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* addable(virtuals, itemLines) → [item] the ones the PM's own ledger has never held (not added, not dropped)
|
|
7
7
|
* createdEvent(v) → event the `created` line that puts one in the PM's ledger on its first press
|
|
8
8
|
* overlay(item, byId) → item a ledger copy with the source's current status, suggestion and state
|
|
9
|
+
* agentOf(x) → agent what the agent does on a my-work item (initiative c3-work-agent), or null
|
|
9
10
|
*
|
|
10
11
|
* PURE: no file, process or clock. A virtual item has the Work item's fields (title, due, priority, waiting_on, flagged) and
|
|
11
12
|
* says where it came from: origin jira | wbs | brief, plus key, url, status, suggest, why, my_work_state, repo. Its `source`
|
|
@@ -13,17 +14,40 @@
|
|
|
13
14
|
|
|
14
15
|
const TIER_PRIORITY = { P1: 'P1', P2: 'P2', P3: 'P3' };
|
|
15
16
|
const AGENT = 'an agent';
|
|
16
|
-
const SHOWN = ['origin', 'key', 'url', 'status', 'suggest', 'why', 'my_work_state', 'repo', 'initiative'];
|
|
17
|
+
const SHOWN = ['origin', 'key', 'url', 'status', 'suggest', 'why', 'my_work_state', 'repo', 'initiative', 'agent'];
|
|
18
|
+
|
|
19
|
+
/* The agent on a my-work item (initiative c3-work-agent): its state, read from lib/my-work.js stateOf.
|
|
20
|
+
* suggest nothing started (the suggestion, when there is one, is the offer) queued · working · sending · failed the day task
|
|
21
|
+
* ready the draft waits for the PM sent the PM posted it null the PM took the item, or there is nothing to offer */
|
|
22
|
+
const SUB_STATE = { waiting: 'queued', working: 'working', sending: 'sending', failed: 'failed' };
|
|
23
|
+
function agentOf(x) {
|
|
24
|
+
if (!x) return null;
|
|
25
|
+
const t = x.task || null;
|
|
26
|
+
let state = null;
|
|
27
|
+
if (x.state === 'brief') state = x.suggest ? 'suggest' : null;
|
|
28
|
+
else if (x.state === 'agent') state = SUB_STATE[x.sub] || 'queued';
|
|
29
|
+
else if (x.state === 'ready') state = 'ready';
|
|
30
|
+
else if (x.state === 'done' && x.sub === 'sent') state = 'sent';
|
|
31
|
+
if (!state) return null;
|
|
32
|
+
return {
|
|
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,
|
|
35
|
+
sent: t && t.sent ? { link: t.sent.link || null, at: t.sent.at || null } : null,
|
|
36
|
+
error: t ? t.error || null : null, revisions: t ? t.revisions || 0 : 0, started_at: t ? t.started_at || null : null, decided_at: x.decided_at || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
17
39
|
|
|
18
40
|
function fromMyWork(items) {
|
|
19
41
|
const out = [];
|
|
20
42
|
for (const x of items || []) {
|
|
21
|
-
if (!x || !x.id || !x.title
|
|
43
|
+
if (!x || !x.id || !x.title) continue;
|
|
44
|
+
// a posted item is done in my-work: it adds no new row, but the PM's own copy of it still shows that it was posted
|
|
45
|
+
if (x.state === 'done' || x.category === 'done') { if (x.state === 'done' && x.sub === 'sent') out.push({ id: String(x.id), overlay_only: true, agent: agentOf(x) }); continue; }
|
|
22
46
|
const v = {
|
|
23
47
|
id: String(x.id), title: String(x.title), source: x.source === 'wbs' ? 'wbs' : 'jira', origin: x.source === 'wbs' ? 'wbs' : 'jira', assigned: true,
|
|
24
48
|
due: x.due || null, priority: TIER_PRIORITY[x.wsjf && x.wsjf.tier] || null, waiting_on: null, job: null,
|
|
25
49
|
key: x.key || null, url: x.url || null, status: x.status || null, suggest: x.suggest || null, my_work_state: x.state || null,
|
|
26
|
-
repo: x.repo || null, initiative: x.initiative || null,
|
|
50
|
+
repo: x.repo || null, initiative: x.initiative || null, agent: agentOf(x),
|
|
27
51
|
};
|
|
28
52
|
if (x.state === 'agent') v.waiting_on = AGENT;
|
|
29
53
|
if (x.state === 'ready' || x.category === 'doing') v.flagged = true;
|
|
@@ -49,11 +73,11 @@ function fromBrief(brief, todayIso) {
|
|
|
49
73
|
/** The virtual items whose id the ledger never held: an item the PM added, pressed or dropped is the ledger's from then on. */
|
|
50
74
|
function addable(virtuals, itemLines) {
|
|
51
75
|
const held = new Set((itemLines || []).filter((l) => l && l.id).map((l) => l.id));
|
|
52
|
-
return (virtuals || []).filter((v) => !held.has(v.id));
|
|
76
|
+
return (virtuals || []).filter((v) => !v.overlay_only && !held.has(v.id));
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
function createdEvent(v) {
|
|
56
|
-
const { id, source, ...fields } = v;
|
|
80
|
+
const { id, source, agent, ...fields } = v; // the agent state is read on every list, never stored
|
|
57
81
|
return { event: 'created', id, fields, source };
|
|
58
82
|
}
|
|
59
83
|
|
|
@@ -66,4 +90,4 @@ function overlay(item, byId) {
|
|
|
66
90
|
return out;
|
|
67
91
|
}
|
|
68
92
|
|
|
69
|
-
module.exports = { fromMyWork, fromBrief, addable, createdEvent, overlay, AGENT };
|
|
93
|
+
module.exports = { fromMyWork, fromBrief, addable, createdEvent, overlay, agentOf, AGENT };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.185.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.185.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1879,6 +1879,15 @@
|
|
|
1879
1879
|
.c3 .wkq{display:flex;flex-wrap:wrap;gap:var(--sp-2);align-items:center;margin-top:var(--sp-2)}
|
|
1880
1880
|
.c3 .wkq-f{display:inline-flex;gap:6px;align-items:center;font-size:var(--fs-2)}
|
|
1881
1881
|
.c3 .wkq input[type=date],.c3 .wkq-f input[type=text]{font:inherit;font-size:var(--fs-2);padding:4px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground);border-radius:6px;min-width:0;max-width:150px}
|
|
1882
|
+
/* c3-work-agent: what the agent will do, the note when the PM sends a draft back, the draft sheet */
|
|
1883
|
+
.c3 .wk-agent-ask{display:flex;gap:6px;align-items:center;margin-top:6px}
|
|
1884
|
+
.c3 .wk-agent-ask input{flex:1;min-width:0;font:inherit;font-size:var(--fs-2);padding:4px 8px;border:1px solid var(--border);background:var(--background);color:var(--foreground);border-radius:6px}
|
|
1885
|
+
.c3 .wk-agent-ask[hidden]{display:none}
|
|
1886
|
+
.c3 .wk-agent-sheet{display:grid;gap:var(--sp-3)}
|
|
1887
|
+
.c3 .wk-agent-will{font-size:var(--fs-2)}
|
|
1888
|
+
.c3 .wk-agent-sum{margin:0}
|
|
1889
|
+
.c3 .wk-agent-body{--tc:var(--acc)}
|
|
1890
|
+
.c3 .wk-agent-body .sit{white-space:pre-line}
|
|
1882
1891
|
.c3 .tag.wk-off{background:var(--accw);color:var(--acc)}
|
|
1883
1892
|
.c3 .tag.wk-nb{background:var(--muted);color:var(--foreground)}
|
|
1884
1893
|
.c3 .tag.wk-on{background:var(--acc);color:var(--background)}
|
|
@@ -34,6 +34,7 @@ import { makeXp, attachExpand } from './c3-expand.js';
|
|
|
34
34
|
import { attachSwipe } from './c3-swipe.js';
|
|
35
35
|
import { toast } from './c3-toast.js';
|
|
36
36
|
import { openSheet } from './c3-sheet.js';
|
|
37
|
+
import { c3btn } from './page-agent.js';
|
|
37
38
|
import { onBriefing, loadBriefing } from './shell-ticker.js';
|
|
38
39
|
import { reviewSection } from './c3-brief.js';
|
|
39
40
|
import { VIEWS, REVIEW_DAYS, workPlace, arrives, buildWork, addIso, daysBetween, untouchedDays } from './work-place.js';
|
|
@@ -165,7 +166,7 @@ export const COPY = Object.freeze({
|
|
|
165
166
|
outcome: 'What came out of it (optional)', notYet: 'Not yet:', laterToday: 'Later today', tomorrow: 'Tomorrow', nextWeek: 'Next week',
|
|
166
167
|
looksDone: 'Looks done?', confirm: 'Confirm done', flag: 'Flag', unflag: 'Unflag', notBefore: 'Not before', someday: 'Someday', bringBack: 'Bring back',
|
|
167
168
|
waitingField: 'Waiting on', waitingWho: 'Who?', stopWaiting: 'Stop waiting', flagged: 'Flagged',
|
|
168
|
-
notBeforeChip: (d) => 'Not before ' + d, advances: (t) => 'Advances team task ' + t, suggested: 'Suggested', agentSuggests: (t) => 'Agent: ' + t, moved: (n) => `Moved ${n}×`,
|
|
169
|
+
notBeforeChip: (d) => 'Not before ' + d, advances: (t) => 'Advances team task ' + t, suggested: 'Suggested', agentSuggests: (t) => 'Agent can: ' + t, moved: (n) => `Moved ${n}×`,
|
|
169
170
|
overdue: (n) => (n === 1 ? 'Overdue since yesterday' : `${n} days overdue`), dueToday: 'Due today', dueOn: (d) => 'Due ' + d,
|
|
170
171
|
waitingSince: (w, since, ago) => `Waiting for ${w}` + (since ? ` since ${since}` : '') + (ago ? ` (${ago})` : ''), repeats: (r) => 'Repeats ' + String(r).toLowerCase(),
|
|
171
172
|
/* Review (PRD R7) */
|
|
@@ -284,7 +285,7 @@ export function mountPmWork(host, ctx) {
|
|
|
284
285
|
if (ctx && ctx.FIXTURE) return;
|
|
285
286
|
ensureC3Css();
|
|
286
287
|
inst = { host, ctx: ctx || {}, root: el('div', { class: 'c3 calwrap wk', 'data-pm-work': '' }), data: null, base: null, ov: newOv(), lens: 'today',
|
|
287
|
-
opens: new Set(), picks: new Map(), editing: new Set(), planning: new Set(), runs: new Map(), trays: new Set(), cases: new Set(), timers: new Set(), hidden: new Set(),
|
|
288
|
+
opens: new Set(), picks: new Map(), asks: new Map(), editing: new Set(), planning: new Set(), runs: new Map(), trays: new Set(), cases: new Set(), timers: new Set(), hidden: new Set(),
|
|
288
289
|
focEdit: new Set(), rv: { ids: null, handled: new Set(), changing: false }, decisions: 0, drafts: null, draftsSig: '', draftsHost: el('div', { 'data-pm-review-drafts': '' }) };
|
|
289
290
|
inst.body = el('div', { class: 'calbody pmw-body' });
|
|
290
291
|
inst.bar = el('div', { class: 'calbar' });
|
|
@@ -328,6 +329,9 @@ async function load(inst) {
|
|
|
328
329
|
inst.data = inst.base && DEMO() ? applyOv(inst.base, inst.ov) : inst.base;
|
|
329
330
|
if (!inst.data) say({ text: (r && r.error) || COPY.readError, tone: 'error' });
|
|
330
331
|
if (insts.get(inst.host) === inst) draw(inst);
|
|
332
|
+
/* an agent that is queued, working or posting changes on its own: read again until it stops (c3-work-agent) */
|
|
333
|
+
const busy = inst.data && !DEMO() && (inst.data.items || []).some((i) => i.agent && AGENT_BUSY.includes(i.agent.state));
|
|
334
|
+
if (busy && !inst.agentPoll) { inst.agentPoll = true; later(inst, () => { inst.agentPoll = false; load(inst); }, AGENT_POLL_MS); }
|
|
331
335
|
}
|
|
332
336
|
|
|
333
337
|
async function press(inst, path, body, { quiet } = {}) {
|
|
@@ -628,6 +632,7 @@ function itemCase(inst, item, lens) {
|
|
|
628
632
|
const ix = item.ix || {}; const mock = item.mock_row === true;
|
|
629
633
|
const j = item.job && jobById(inst, item.job);
|
|
630
634
|
const L = lensOptions(inst, item, lens);
|
|
635
|
+
if (!mock && item.agent && item.kind !== 'offline') return agentCase(inst, item, L);
|
|
631
636
|
const jm = j && (j.mock_name || j.want);
|
|
632
637
|
const evidence = ix.chg ? ix.chg.slice() : (() => {
|
|
633
638
|
const c = [];
|
|
@@ -691,12 +696,131 @@ function startRun(inst, item, intent) {
|
|
|
691
696
|
}
|
|
692
697
|
|
|
693
698
|
|
|
699
|
+
/* ── the agent on a Jira or WBS item (initiative c3-work-agent, planning/prd/c3-work-agent-prd.md) ───────────────────
|
|
700
|
+
* item.agent = { state: suggest|queued|working|ready|sending|sent|failed, suggest, instruction, draft: { summary, body }, sent, error, … }
|
|
701
|
+
* from lib/work-assigned.js. The suggestion is an option the PM picks; the day task does the work; nothing goes outward
|
|
702
|
+
* until the PM holds Post (POST /api/my-work/decide approve). Stop ends a task that has not delivered (POST /api/my-work/stop). */
|
|
703
|
+
const AGENT_BUSY = ['queued', 'working', 'sending'];
|
|
704
|
+
const AGENT_POLL_MS = 15000;
|
|
705
|
+
const hhmm = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
|
|
706
|
+
const minsAgo = (iso) => { const t = Date.parse(iso); if (!Number.isFinite(t)) return ''; const m = Math.max(0, Math.round((Date.now() - t) / 60000)); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : Math.floor(m / 60) + ' h ago'; };
|
|
707
|
+
const whereOf = (item) => (item.origin === 'jira' ? 'Jira' : 'its source');
|
|
708
|
+
const AG = {
|
|
709
|
+
can: (t) => 'Agent can: ' + t, queued: 'Agent: queued', working: (ago) => 'Agent: working' + (ago ? ' · started ' + ago : ''), ready: 'Agent: draft ready',
|
|
710
|
+
sending: 'Agent: posting', sent: (w, at) => 'Posted to ' + w + (at ? ' ' + at : ''), failed: (why) => 'Agent stopped' + (why ? ': ' + why : ''),
|
|
711
|
+
sitSuggest: (w) => `The agent can draft this for you. You read it here first. Nothing goes to ${w} until you post it.`,
|
|
712
|
+
sitQueued: 'Queued. The agent starts as soon as a session is free. You can close this page.',
|
|
713
|
+
sitWorking: 'The agent is working on it. You can close this page: the item keeps its state.',
|
|
714
|
+
sitReady: (at, w) => `Draft ready${at ? ' at ' + at : ''}. Nothing went to ${w}.`,
|
|
715
|
+
sitSending: (k) => `Posting to ${k}. A session does exactly the step you approved and nothing else.`,
|
|
716
|
+
sitSent: (at, w) => `You posted it to ${w}${at ? ' at ' + at : ''}.`,
|
|
717
|
+
sitFailed: (why) => `The agent stopped${why ? ': ' + why : '.'} Nothing was sent.`,
|
|
718
|
+
willDo: 'What the agent will do: ', change: 'Change what the agent does', askLabel: 'What the agent will do',
|
|
719
|
+
draftIt: 'Let the agent draft this', tryAgain: 'Try again', post: (k) => 'Post it to ' + k, back: 'Send it back with a note', keepDraft: 'Keep it as a draft',
|
|
720
|
+
noteLabel: 'Tell the agent what to change', openDraft: 'Open the draft', draftSheet: (k) => 'Draft for ' + k, stop: 'stop',
|
|
721
|
+
handed: (w) => `Handed to the agent. Nothing goes to ${w} until you post it.`, stopped: 'Stopped. Nothing was kept.', kept: 'Kept as a draft. Post it when you are ready.',
|
|
722
|
+
posting: 'Approved. A session posts exactly this text.', sentBack: 'Sent back with your note. The agent drafts it again.', retried: 'The agent tries again.',
|
|
723
|
+
needNote: 'Say what to change first: type a note, then send it back.', link: 'Open what was posted',
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
function agentChip(item) {
|
|
727
|
+
const a = item.agent; if (!a || item.mock_row) return null;
|
|
728
|
+
const st = (cls, ic, text, spin) => el('span', { class: cls, 'data-pm-agent-state': a.state, 'data-pm-suggest': item.id }, spin ? el('span', { class: 'c3spin' }, c3ic(ic, 'sm')) : c3ic(ic, 'sm'), text);
|
|
729
|
+
if (a.state === 'suggest') return st('chip2', 'spark', AG.can(a.instruction || a.suggest || ''));
|
|
730
|
+
if (a.state === 'queued') return st('st', 'clock', AG.queued);
|
|
731
|
+
if (a.state === 'working') return st('st c3strip', 'spark', AG.working(minsAgo(a.started_at)), true);
|
|
732
|
+
if (a.state === 'ready') return st('st ok', 'check', AG.ready);
|
|
733
|
+
if (a.state === 'sending') return st('st c3strip', 'out', AG.sending, true);
|
|
734
|
+
if (a.state === 'sent') return st('st ok', 'out', AG.sent(whereOf(item), a.sent && a.sent.at ? hhmm(a.sent.at) : ''));
|
|
735
|
+
if (a.state === 'failed') return st('tag warn', 'alert', AG.failed(a.error));
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function agentPress(inst, path, body, text) {
|
|
740
|
+
const r = await pmPost(path, body);
|
|
741
|
+
if (!r || !r.ok) { say({ text: (r && r.error) || COPY.saveError, tone: 'error' }); return false; }
|
|
742
|
+
inst.picks.delete(body.item);
|
|
743
|
+
say({ text, timeout: 4000 });
|
|
744
|
+
await load(inst);
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function agentCase(inst, item, L) {
|
|
749
|
+
const a = item.agent; const w = whereOf(item); const k = item.key || item.title;
|
|
750
|
+
const o = (id, l, f, run, extra) => ({ id, l, rec: 0, fx: f, run, ...(extra || {}) });
|
|
751
|
+
const own = L.opts.slice(0, 2).map((x) => ({ ...x, rec: 0 }));
|
|
752
|
+
const ask = () => inst.asks.get(item.id) || a.instruction || a.suggest || '';
|
|
753
|
+
let sit = ''; let opts = []; let quote = ''; const chg = []; let outbox = false; let cta = ''; let agentAsk = null; let after = null;
|
|
754
|
+
const extras = [];
|
|
755
|
+
const strip = (spin, ic, text) => el('div', { class: 'c3ag', 'data-pm-agent-strip': item.id }, el('div', { class: 'fold a', role: 'status' }, spin ? el('span', { class: 'c3spin' }, c3ic(ic, 'sm')) : c3ic(ic, 'sm'), ' ' + text + ' ',
|
|
756
|
+
c3btn('chip2 lk', { 'data-pm-agent-stop': item.id }, [c3ic('stop', 'sm'), AG.stop], () => agentPress(inst, '/api/my-work/stop', { item: item.id }, AG.stopped))));
|
|
757
|
+
if (a.state === 'suggest' || a.state === 'failed') {
|
|
758
|
+
sit = a.state === 'failed' ? AG.sitFailed(a.error) : AG.sitSuggest(w);
|
|
759
|
+
const brief = () => agentPress(inst, '/api/my-work/act', { action: 'brief', item: item.id, instruction: ask(), ws: inst.ctx.ws }, AG.handed(w));
|
|
760
|
+
const retry = () => agentPress(inst, '/api/my-work/decide', { item: item.id, action: 'retry' }, AG.retried);
|
|
761
|
+
opts = [o('agent', a.state === 'failed' ? AG.tryAgain : AG.draftIt, [fx('spark', 'Drafts it here in PMOS', 'ok'), fx('lock', 'Nothing goes to ' + w, 'ok')], a.state === 'failed' ? retry : brief), ...own];
|
|
762
|
+
cta = 'Slide to let the agent draft';
|
|
763
|
+
if (a.state === 'suggest') {
|
|
764
|
+
const editing = inst.editing.has('ask:' + item.id);
|
|
765
|
+
const input = el('input', { type: 'text', value: ask(), 'aria-label': AG.askLabel, 'data-pm-agent-ask': item.id, autocomplete: 'off' });
|
|
766
|
+
const save = () => { const v = input.value.trim(); if (v) inst.asks.set(item.id, v); inst.editing.delete('ask:' + item.id); draw(inst); };
|
|
767
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) save(); });
|
|
768
|
+
extras.push(el('div', { class: 'c3ag', 'data-pm-agent-will': item.id }, el('div', { class: 'dim mono wk-agent-will' }, c3ic('spark', 'sm'), ' ' + AG.willDo + (editing ? '' : ask())),
|
|
769
|
+
editing ? el('div', { class: 'form wk-agent-ask' }, input, button({ label: COPY.save, variant: 'outline', size: 'sm', onClick: save })) : null));
|
|
770
|
+
if (!editing) agentAsk = c3btn('chip2 lk', { 'data-pm-agent-change': item.id }, [c3ic('edit', 'sm'), AG.change], () => { inst.editing.add('ask:' + item.id); draw(inst); });
|
|
771
|
+
}
|
|
772
|
+
} else if (a.state === 'queued') {
|
|
773
|
+
sit = AG.sitQueued; extras.push(strip(false, 'clock', 'Queued' + (a.decided_at ? ' at ' + hhmm(a.decided_at) : '')));
|
|
774
|
+
} else if (a.state === 'working') {
|
|
775
|
+
sit = AG.sitWorking; extras.push(strip(true, 'spark', 'Working' + (a.started_at ? ' · started ' + minsAgo(a.started_at) : '')));
|
|
776
|
+
} else if (a.state === 'ready') {
|
|
777
|
+
const d = a.draft || {};
|
|
778
|
+
quote = d.body || ''; sit = AG.sitReady('', w);
|
|
779
|
+
if (d.summary) chg.push(['doc', d.summary, 'draft']);
|
|
780
|
+
if (a.revisions) chg.push(['reply', 'Redrafted after your note' + (a.revisions > 1 ? 's (' + a.revisions + ')' : ''), '']);
|
|
781
|
+
outbox = true; cta = 'Hold to post';
|
|
782
|
+
const note = el('input', { type: 'text', placeholder: AG.noteLabel, 'aria-label': AG.noteLabel, 'data-pm-agent-note-in': item.id, autocomplete: 'off' });
|
|
783
|
+
const back = () => { const v = note.value.trim(); if (!v) { say({ text: AG.needNote, timeout: 3500 }); note.focus(); return; } agentPress(inst, '/api/my-work/decide', { item: item.id, action: 'revise', note: v }, AG.sentBack); };
|
|
784
|
+
note.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) back(); });
|
|
785
|
+
opts = [
|
|
786
|
+
o('post', AG.post(k), [fx('lock', 'Outward: exactly this text', 'warn'), fx('out', 'Goes to ' + w + ' as you', 'warn')], () => agentPress(inst, '/api/my-work/decide', { item: item.id, action: 'approve' }, AG.posting), { outward: true }),
|
|
787
|
+
o('back', AG.back, [fx('reply', 'The agent drafts it again', 'ok')], back),
|
|
788
|
+
o('keep', AG.keepDraft, [fx('check', 'Nothing leaves PMOS', 'ok')], () => say({ text: AG.kept, timeout: 3000 })),
|
|
789
|
+
];
|
|
790
|
+
const noteBox = el('div', { class: 'form wk-agent-ask', 'data-pm-agent-note': item.id }, note); noteBox.hidden = inst.picks.get(item.id) !== 'back';
|
|
791
|
+
extras.push(el('div', { class: 'chips2' }, c3btn('chip2 lk', { 'data-pm-agent-draft': item.id }, [c3ic('eye', 'sm'), AG.openDraft], () => openDraft(item))));
|
|
792
|
+
after = noteBox;
|
|
793
|
+
} else if (a.state === 'sending') {
|
|
794
|
+
sit = AG.sitSending(k);
|
|
795
|
+
} else if (a.state === 'sent') {
|
|
796
|
+
sit = AG.sitSent(a.sent && a.sent.at ? hhmm(a.sent.at) : '', w);
|
|
797
|
+
opts = L.opts.map((x) => ({ ...x }));
|
|
798
|
+
if (a.draft && a.draft.body) extras.push(el('div', { class: 'chips2' }, c3btn('chip2 lk', { 'data-pm-agent-draft': item.id }, [c3ic('eye', 'sm'), AG.openDraft], () => openDraft(item)),
|
|
799
|
+
a.sent && a.sent.link ? el('a', { class: 'chip2 lk', href: a.sent.link, target: '_blank', rel: 'noopener noreferrer' }, c3ic('out', 'sm'), AG.link) : null));
|
|
800
|
+
}
|
|
801
|
+
if (opts.length) opts[0].rec = 1;
|
|
802
|
+
const model = { id: 'task:' + item.id, sit, quote, chg, conf: 0, cta, opts: opts.map((x) => ({ id: x.id, l: x.l, rec: x.rec, fx: x.fx, outward: !!x.outward })), why: '', outbox,
|
|
803
|
+
outboxReason: OUT_REASON, agentAsk, agentExtras: () => extras, agentAfterOpts: after };
|
|
804
|
+
const run = {}; opts.forEach((x) => { if (x.run) run[x.id] = x.run; });
|
|
805
|
+
return { model, run };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function openDraft(item) {
|
|
809
|
+
const a = item.agent || {}; const d = a.draft || {};
|
|
810
|
+
const body = el('div', { class: 'wk-agent-sheet' },
|
|
811
|
+
el('div', { class: 'chips' }, el('span', { class: 'tag' }, a.state === 'sent' ? 'posted' : 'draft'), item.key ? el('span', { class: 'chip2' }, c3ic('org', 'sm'), (item.origin === 'jira' ? 'Jira ' : '') + item.key) : null),
|
|
812
|
+
d.summary ? el('p', { class: 'dim wk-agent-sum' }, d.summary) : null,
|
|
813
|
+
el('div', { class: 'case wk-agent-body' }, el('p', { class: 'sit' }, d.body || '')),
|
|
814
|
+
el('div', { class: 'fold' }, c3ic('info', 'sm'), ' ' + (a.state === 'sent' ? 'This is the text that was posted.' : 'Nothing is in ' + whereOf(item) + ' yet. Post it from the item, after you read it.')));
|
|
815
|
+
openSheet({ title: AG.draftSheet(item.key || item.title), icon: 'doc', body });
|
|
816
|
+
}
|
|
817
|
+
|
|
694
818
|
/** The case file element of an item: c3-case.js plus what is Work's own (offline work, the shared actions, the steps, the agent tray, the edit fields). */
|
|
695
819
|
function caseFile(inst, item, lens) {
|
|
696
820
|
const { model, run } = itemCase(inst, item, lens);
|
|
697
821
|
const root = renderCase(model, {
|
|
698
|
-
picked: inst.picks.get(item.id), outbox:
|
|
699
|
-
onPick: (id) => inst.picks.set(item.id, id),
|
|
822
|
+
picked: inst.picks.get(item.id), outbox: !!model.outbox,
|
|
823
|
+
onPick: (id) => { inst.picks.set(item.id, id); const n = root.querySelector('[data-pm-agent-note]'); if (n) { n.hidden = id !== 'back'; if (!n.hidden) n.querySelector('input').focus(); } },
|
|
700
824
|
onConfirm: ({ option }) => {
|
|
701
825
|
const fn = run[option.id];
|
|
702
826
|
if (fn) fn(rowOf(inst, item));
|
|
@@ -711,6 +835,8 @@ function caseFile(inst, item, lens) {
|
|
|
711
835
|
const rs = inst.runs.get(item.id);
|
|
712
836
|
const agent = item.mock_row && item.agent_move && (item.ix || {}).move;
|
|
713
837
|
if (item.kind === 'offline') extras.push(offlineBlock(inst, item));
|
|
838
|
+
if (model.agentExtras) extras.push(...model.agentExtras());
|
|
839
|
+
if (model.agentAfterOpts) { const sl = root.querySelector('.c3slide'); if (sl) sl.after(model.agentAfterOpts); }
|
|
714
840
|
if (rs && rs.phase === 'work') {
|
|
715
841
|
extras.push(el('div', { class: 'c3ag', 'data-ag': item.id }, el('div', { class: 'fold a', role: 'status' }, el('span', { class: 'c3spin' }, c3ic('spark', 'sm')), rs.steps[Math.min(rs.i, rs.steps.length - 1)] + ' · ' + (Math.min(rs.i, rs.steps.length - 1) + 1) + ' of ' + rs.steps.length,
|
|
716
842
|
el('button', { type: 'button', class: 'chip2 lk', 'data-pm-agent-stop': item.id, onclick: () => { inst.runs.delete(item.id); say({ text: 'Stopped. Nothing was kept.', timeout: 2500 }); draw(inst); } }, c3ic('stop', 'sm'), 'stop'))));
|
|
@@ -725,7 +851,7 @@ function caseFile(inst, item, lens) {
|
|
|
725
851
|
c3ib('Speak is off on this line: nothing on this page records. Type the reply here.', 'mic', { 'data-off': '', onclick: () => say({ text: 'The microphone needs the app. Nothing was recorded: type the reply here.', timeout: 4200 }) }, 'sm'),
|
|
726
852
|
c3ib('Close the reply', 'x', { onClick: () => { inst.trays.delete(item.id); draw(inst); } }, 'sm')));
|
|
727
853
|
}
|
|
728
|
-
const showSteps = !(rs && rs.phase) && item.kind !== 'offline' && (inst.planning.has(item.id) || (item.steps || []).length || (!agent && (!item.mock_row || item.src === 'You')));
|
|
854
|
+
const showSteps = !(rs && rs.phase) && item.kind !== 'offline' && !(item.agent && !item.mock_row && item.agent.state !== 'sent') && (inst.planning.has(item.id) || (item.steps || []).length || (!agent && (!item.mock_row || item.src === 'You')));
|
|
729
855
|
if (showSteps) extras.push(stepsBlock(inst, item));
|
|
730
856
|
const anchor = root.querySelector('.c3ag') || root.querySelector('.sit');
|
|
731
857
|
if (anchor) anchor.after(...extras); else root.prepend(...extras);
|
|
@@ -734,8 +860,9 @@ function caseFile(inst, item, lens) {
|
|
|
734
860
|
const form = editForm(inst, item); form.hidden = !inst.editing.has(item.id);
|
|
735
861
|
const edit = el('button', { type: 'button', class: 'chip2 lk', 'aria-expanded': String(inst.editing.has(item.id)), 'data-pm-edit-toggle': item.id,
|
|
736
862
|
onclick: () => { const on = !inst.editing.has(item.id); if (on) inst.editing.add(item.id); else inst.editing.delete(item.id); form.hidden = !on; edit.setAttribute('aria-expanded', String(on)); } }, c3ic('edit', 'sm'), 'Edit details');
|
|
737
|
-
const ft = root.querySelector('.cft');
|
|
738
|
-
if (
|
|
863
|
+
const ft = root.querySelector('.cft') || root.appendChild(el('div', { class: 'cft' }));
|
|
864
|
+
if (model.agentAsk) ft.append(model.agentAsk);
|
|
865
|
+
ft.append(edit);
|
|
739
866
|
root.append(form);
|
|
740
867
|
return root;
|
|
741
868
|
}
|
|
@@ -805,7 +932,8 @@ function workChips(inst, item, o = {}) {
|
|
|
805
932
|
const label = item.origin === 'jira' ? 'Jira ' + (item.key || '') : 'WBS' + (item.repo ? ' ' + item.repo : '') + (item.key ? ' ' + item.key : '');
|
|
806
933
|
const attrs = { class: 'tag', 'data-pm-origin': item.origin, title: item.status || '' };
|
|
807
934
|
c.push(item.url ? el('a', { ...attrs, href: item.url, target: '_blank', rel: 'noopener', onclick: (e) => e.stopPropagation() }, label.trim()) : el('span', attrs, label.trim()));
|
|
808
|
-
|
|
935
|
+
// the agent's state is the row's first chip (itemRow), so quiet pages keep it in sight
|
|
936
|
+
if (item.suggest && !item.agent) c.push(el('span', { class: 'chip2', 'data-pm-suggest': item.id }, c3ic('spark', 'sm'), COPY.agentSuggests(item.suggest)));
|
|
809
937
|
} else if (item.origin === 'brief') {
|
|
810
938
|
c.push(el('span', { class: 'tag', 'data-pm-origin': 'brief', title: item.why || '' }, c3ic('spark', 'sm'), COPY.suggested));
|
|
811
939
|
if (item.why) c.push(el('span', { class: 'chip2', 'data-pm-why': item.id }, item.why));
|
|
@@ -833,7 +961,9 @@ function itemRow(inst, item, lens, o = {}) {
|
|
|
833
961
|
if (rs && rs.phase === 'work') chips.push(el('span', { class: 'st c3strip', 'data-sxs': item.id }, el('span', { class: 'c3spin' }, c3ic('spark', 'sm')), rs.steps[Math.min(rs.i, rs.steps.length - 1)] + ' · ' + (Math.min(rs.i, rs.steps.length - 1) + 1) + ' of ' + rs.steps.length));
|
|
834
962
|
else if (rs && rs.phase === 'done') chips.push(el('span', { class: 'st ok' }, c3ic('check', 'sm'), 'agent: result ready'));
|
|
835
963
|
else if (inst.planning.has(item.id)) chips.push(el('span', { class: 'st' }, el('span', { class: 'c3spin' }, c3ic('spark', 'sm')), 'planning the steps'));
|
|
836
|
-
|
|
964
|
+
const ac = !mock && agentChip(item);
|
|
965
|
+
if (ac) chips.push(ac);
|
|
966
|
+
if (item.waiting_on && !(ac && item.waiting_on === 'an agent')) {
|
|
837
967
|
const since = item.waiting_since ? String(item.waiting_since).slice(0, 10) : '';
|
|
838
968
|
chips.push(el('span', { class: 'chip2', 'data-pm-waiting-chip': item.id }, c3ic('wait', 'sm'), COPY.waitingSince(item.waiting_on, since ? dayLabel(since) : '', since ? ago(since, today) : '')));
|
|
839
969
|
}
|
package/workspace/server.js
CHANGED
|
@@ -8988,6 +8988,12 @@ async function handleRequest(req, res) {
|
|
|
8988
8988
|
headlessBusy: (id) => { const x = sessions.get(String(id || '')); return !!(x && x.headless && x.child); },
|
|
8989
8989
|
// initiative my-work: every registered repository, the day runs, and the two presses that start sessions
|
|
8990
8990
|
workspaceList: () => [...workspaces.values()], readDayRun, myWorkBrief, myWorkRead,
|
|
8991
|
+
// c3-work-agent: the PM's decision on the agent's draft and Stop, on the day run that holds the item's task
|
|
8992
|
+
writeDayRun, startDayTasks, endSession: (id) => {
|
|
8993
|
+
const x = sessions.get(String(id || '')); if (!x) return;
|
|
8994
|
+
if (x.child) stopHeadless(x);
|
|
8995
|
+
else if (x.pty) { if (x.tmux) tmuxKillSession(x.tmux); try { x.pty.kill(); } catch { /* already ended */ } }
|
|
8996
|
+
},
|
|
8991
8997
|
// workspace-data-freshness: the source table, the facts rebuild and Refresh now's skill sessions
|
|
8992
8998
|
freshness, factsRefresh: (root) => factsAsync.refresh(root, { force: true }), startSkill: startSkillSession,
|
|
8993
8999
|
// c3-initiative-boards: the PMOS checkout this server is embedded in, whose default board keeps its derived ladder
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: jira-sync
|
|
3
3
|
label: "Jira status check"
|
|
4
|
-
description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and save them in PMOS for the initiative's Jira tickets tab. It writes nothing to Jira."
|
|
4
|
+
description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and every issue in the sprints those tickets are in, and save them in PMOS for the initiative's Jira tickets tab and the board's Sprints tab. It writes nothing to Jira."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
output:
|
|
@@ -35,10 +35,11 @@ Procedure:
|
|
|
35
35
|
- `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
|
|
36
36
|
The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
|
|
37
37
|
4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
|
|
38
|
+
4a. Read the whole sprints, after every initiative is synced. Run `pmos jira query --sprints`. It prints one Jira search for every issue in the sprints the tickets above are in, whoever the issue is assigned to. Do this even when the task names one initiative. If it prints "no Jira reading has a sprint yet", skip this step. Otherwise run that search with the same fields as step 2, fetch every page, save the result in the scratch folder in the same shape as step 3 (for example `<scratch>/jira-sprints.json`), run `pmos jira sync --sprints --from <the file> --query "<the JQL>"`, and delete the file. Do not record people from this search (step 5 is for the initiatives' own issues).
|
|
38
39
|
5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue (People adds each new person at once): {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
|
|
39
40
|
6. Finish with the three sections of the output contract, one short line each:
|
|
40
|
-
- Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded.
|
|
41
|
-
- Not reached: each initiative whose search failed, with the reason.
|
|
41
|
+
- Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded, and how many issues the sprint read saved per sprint.
|
|
42
|
+
- Not reached: each initiative whose search failed, with the reason, and the sprint read if it failed.
|
|
42
43
|
- Needs you: tickets newly blocked, tickets the search did not return, and any refusal.
|
|
43
44
|
|
|
44
45
|
Output contract — produce EXACTLY these sections, the same shape every run:
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Initiative c3-work-agent: the agent on a Jira or WBS item in Work (the suggestion as an option, queued, working, the draft, Send back, Stop, Post).",
|
|
3
|
+
"modules": ["work-agent.test.js"],
|
|
4
|
+
"panels": ["pm-c3-work-agent-test.js"],
|
|
5
|
+
"seconds": { "work-agent.test.js": 8, "pm-c3-work-agent-test.js": 30 }
|
|
6
|
+
}
|