create-pmos 0.57.1 → 0.59.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/index.md +1 -0
- package/workspace/guide/jira.md +141 -0
- package/workspace/guide/layout.md +4 -1
- package/workspace/lib/jira-cli.js +194 -0
- package/workspace/lib/jira-status.js +239 -0
- package/workspace/lib/jira-tickets.js +668 -0
- package/workspace/lib/kit-update.js +161 -0
- package/workspace/lib/pm-guard.js +2 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/report-draft.js +19 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +223 -1
- package/workspace/public/pm/dest-settings.js +107 -3
- package/workspace/public/pm/frame-copy.js +22 -0
- package/workspace/public/pm/initiative-sheet.js +7 -3
- package/workspace/public/pm/initiatives-copy.js +1 -0
- package/workspace/public/pm/sheet-jira.js +353 -0
- package/workspace/server.js +115 -0
- package/workspace/skills/day-check.skill +1 -1
- package/workspace/skills/gather-requirements.skill +1 -1
- package/workspace/skills/jira-push.skill +38 -0
- package/workspace/skills/jira-sync.skill +42 -0
- package/workspace/skills/morning-brief.skill +1 -1
- package/workspace/skills/report-biweekly.skill +1 -1
- package/workspace/skills/tracker-refresh.skill +1 -0
- package/workspace/test-lists/jira-wbs.json +6 -0
- package/workspace/test-lists/workspace-kit-update.json +5 -0
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* About 70% of the width on a desktop and the whole screen on a phone. Top to bottom:
|
|
4
4
|
* line 1 the title, the horizon, the health reason (only when not on track), and a meta line: lane · anchor · id
|
|
5
5
|
* line 2 the stage stepper, then "Waiting on you: approval [Open gate ▸]" or "Waiting on agent: build [Hand to agent ▸]"
|
|
6
|
-
* tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files, History
|
|
6
|
+
* tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files, History,
|
|
7
|
+
* Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira)
|
|
7
8
|
* ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
|
|
8
9
|
* Nothing on this sheet says "plan". Everything it shows is read from GET /api/initiatives/<id>; every write goes through
|
|
9
10
|
* initiatives-api.js. The Breakdown tab mounts with a tab id of the form `initiative:<slug>`, so it never meets a panel-4
|
|
@@ -17,6 +18,7 @@ import { el } from './dom.js';
|
|
|
17
18
|
import { sheet, button, iconButton, tabs, row, badge, menu, toast, empty, field, ensureComponentsCss } from './primitives.js';
|
|
18
19
|
import { icon, ensureSprite } from './icons.js';
|
|
19
20
|
import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
|
|
21
|
+
import { mountJira, unmountJira } from './sheet-jira.js';
|
|
20
22
|
import { openDoc } from './panel-initiatives.js';
|
|
21
23
|
import { openNewSession } from './panel-newsess.js';
|
|
22
24
|
import { wsOf, readDoc, saveDoc } from './data.js';
|
|
@@ -73,7 +75,7 @@ export function openSheet(repo, slug) {
|
|
|
73
75
|
const s = sheet({
|
|
74
76
|
mode: 'auto', title: slug, sub: '', body: [crumb, stepperHost, waitingHost, tabsHost, panel],
|
|
75
77
|
attrs: { class: 'in-sheet', 'data-in-sheet': 'initiative', 'data-in-id': slug },
|
|
76
|
-
onClose: () => { closed = true; unmountBreakdown(panel); if (current === s) current = null; },
|
|
78
|
+
onClose: () => { closed = true; unmountBreakdown(panel); unmountJira(panel); if (current === s) current = null; },
|
|
77
79
|
});
|
|
78
80
|
current = s;
|
|
79
81
|
|
|
@@ -219,10 +221,11 @@ export function openSheet(repo, slug) {
|
|
|
219
221
|
function drawTabs() {
|
|
220
222
|
tabsHost.replaceChildren(tabs({
|
|
221
223
|
variant: 'segmented', full: true, ariaLabel: COPY.sheet.tabs, value: tab,
|
|
222
|
-
items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }],
|
|
224
|
+
items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }],
|
|
223
225
|
onChange: (id) => {
|
|
224
226
|
if (id === tab) return;
|
|
225
227
|
if (tab === 'breakdown') unmountBreakdown(panel);
|
|
228
|
+
if (tab === 'jira') unmountJira(panel);
|
|
226
229
|
tab = id;
|
|
227
230
|
drawTabs(); drawPanel();
|
|
228
231
|
const t = tabsHost.querySelector('[data-tab="' + id + '"]');
|
|
@@ -234,6 +237,7 @@ export function openSheet(repo, slug) {
|
|
|
234
237
|
function drawPanel() {
|
|
235
238
|
if (!data) return;
|
|
236
239
|
if (tab === 'breakdown') { mountBreakdown(panel, { id: tabId, ws, slug }, { embedded: true }); return; }
|
|
240
|
+
if (tab === 'jira') { mountJira(panel, { ws, slug }); return; }
|
|
237
241
|
if (tab === 'files') {
|
|
238
242
|
const files = data.files || [];
|
|
239
243
|
panel.replaceChildren(files.length ? el('div', { class: 'in-sheet__rows', role: 'list' }, files.map((f) => row({
|
|
@@ -120,6 +120,7 @@ export const COPY = {
|
|
|
120
120
|
breakdown: 'Breakdown',
|
|
121
121
|
files: 'Files',
|
|
122
122
|
history: 'History',
|
|
123
|
+
jira: 'Jira tickets',
|
|
123
124
|
waitingYou: (act) => `Waiting on you: ${act}`,
|
|
124
125
|
waitingAgent: (act) => `Waiting on agent: ${act}`,
|
|
125
126
|
nothingOwed: 'Nothing is owed.',
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/* pm/sheet-jira.js — the initiative sheet's Jira tickets tab (initiative jira-wbs, PRD J3 to J12, proposed D88).
|
|
2
|
+
*
|
|
3
|
+
* mountJira(panel, { ws, slug }) draws the tab into the sheet's panel; unmountJira(panel) stops its polling. It follows
|
|
4
|
+
* the mockup the PM reviewed (version 9):
|
|
5
|
+
* locked until the handoff is done in a document repository that hands off (the server decides: gate.open)
|
|
6
|
+
* list the epic, "n of m in Jira · Checked … · Check now", then ONE list: a row per ticket with its summary,
|
|
7
|
+
* "TD-01 · ATL-491 · Task · High · S", its status and sprint in Jira, subtasks n of m and one flag. A
|
|
8
|
+
* checkbox on each ticket that can be pushed; one primary button, Push N to Jira.
|
|
9
|
+
* detail any row opens the ticket: its fields (selects), its Jira status with Open in Jira, the PRD's version
|
|
10
|
+
* beside the ticket's when the PRD changed, and the full text. Edit text edits the summary and sections.
|
|
11
|
+
* Everything it shows comes from GET /api/jira/tickets; every write is a POST under /api/jira/. Push is a PM-only press
|
|
12
|
+
* on the server (pm-guard act `push`): it records the selection and starts one agent session. Nothing here talks to
|
|
13
|
+
* Jira: that session does, with the PM's own connection.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { el } from './dom.js';
|
|
17
|
+
import { button, row, field, toast, empty, signal, ensureComponentsCss } from './primitives.js';
|
|
18
|
+
import { ensureSprite } from './icons.js';
|
|
19
|
+
import { pmGet, pmPost } from './http.js';
|
|
20
|
+
import { mdRender } from './markdown.js';
|
|
21
|
+
|
|
22
|
+
export const COPY = Object.freeze({
|
|
23
|
+
reading: 'Reading…',
|
|
24
|
+
readError: (why) => `The Jira tickets could not be read: ${why}`,
|
|
25
|
+
locked: 'Jira tickets open when the handoff is done. Mark the Handoff item done in the Breakdown.',
|
|
26
|
+
noItems: 'The PRD has no ticket items yet. Add a "User stories" or "Tickets" section to it, one heading per ticket, such as "### TD-01 · Title".',
|
|
27
|
+
noDraft: (n) => `No tickets yet. The PRD has ${n} ticket ${n === 1 ? 'item' : 'items'}.`,
|
|
28
|
+
draft: 'Draft tickets',
|
|
29
|
+
drafted: (n) => `Drafted ${n} ${n === 1 ? 'ticket' : 'tickets'}. Read them before you push.`,
|
|
30
|
+
newInPrd: (n) => `${n} new ${n === 1 ? 'item' : 'items'} in the PRD.`,
|
|
31
|
+
addNew: 'Add to tickets',
|
|
32
|
+
epic: 'Epic',
|
|
33
|
+
epicNew: (t) => `Create new: ${t}`,
|
|
34
|
+
epicExisting: 'Use an existing epic',
|
|
35
|
+
epicLink: 'Epic link or key',
|
|
36
|
+
epicPaste: 'Paste the epic link or key',
|
|
37
|
+
epicIs: (k) => `Epic ${k}`,
|
|
38
|
+
epicLater: 'Epic created at the first push',
|
|
39
|
+
inJira: (n, m) => `${n} of ${m} in Jira`,
|
|
40
|
+
checked: (iso) => 'Checked ' + ago(iso),
|
|
41
|
+
never: 'Not checked yet',
|
|
42
|
+
checkNow: 'Check now',
|
|
43
|
+
checking: 'Checking…',
|
|
44
|
+
checkStarted: 'Check started. It reads Jira and changes nothing there.',
|
|
45
|
+
push: (n) => `Push ${n} to Jira`,
|
|
46
|
+
pushing: 'Pushing…',
|
|
47
|
+
pushStarted: (n) => `Pushing ${n} ${n === 1 ? 'ticket' : 'tickets'}. An agent session creates them with your Jira connection.`,
|
|
48
|
+
pick: 'Push',
|
|
49
|
+
pickLabel: (id) => `Select ${id} for the push`,
|
|
50
|
+
notInJira: 'Not in Jira',
|
|
51
|
+
notChecked: 'Not checked yet',
|
|
52
|
+
backlog: 'Backlog',
|
|
53
|
+
sprint: { active: 'Active sprint', future: 'Next sprint', closed: 'Closed sprint' },
|
|
54
|
+
subtasks: (d, t) => `Subtasks ${d} of ${t}`,
|
|
55
|
+
stale: 'Not in the last check',
|
|
56
|
+
flag: {
|
|
57
|
+
'prd-changed': 'PRD changed', removed: 'Removed from PRD', blocked: 'Blocked', incomplete: 'Missing text',
|
|
58
|
+
pushing: 'Waiting for the push', edited: 'Edited, not in Jira yet',
|
|
59
|
+
},
|
|
60
|
+
newRow: 'New in PRD',
|
|
61
|
+
open: (s) => `Open ${s}`,
|
|
62
|
+
back: 'All tickets',
|
|
63
|
+
editText: 'Edit text',
|
|
64
|
+
save: 'Save',
|
|
65
|
+
cancel: 'Cancel',
|
|
66
|
+
saved: 'Saved.',
|
|
67
|
+
pushOne: 'Push to Jira',
|
|
68
|
+
updateOne: 'Update in Jira',
|
|
69
|
+
type: 'Type',
|
|
70
|
+
priority: 'Priority',
|
|
71
|
+
estimate: 'Estimate',
|
|
72
|
+
depends: 'Depends on',
|
|
73
|
+
none: 'None',
|
|
74
|
+
summary: 'Summary',
|
|
75
|
+
openJira: 'Open in Jira',
|
|
76
|
+
missing: (list) => `Missing: ${list.join(', ')}. Add it with Edit text before you push.`,
|
|
77
|
+
removedNote: 'This item is no longer in the PRD. Jira is not changed.',
|
|
78
|
+
prdTitle: 'The PRD changed since this ticket was drafted.',
|
|
79
|
+
thisTicket: 'This ticket',
|
|
80
|
+
prdNow: 'The PRD now',
|
|
81
|
+
usePrd: 'Use the PRD version',
|
|
82
|
+
keep: 'Keep the ticket as it is',
|
|
83
|
+
usedPrd: 'The ticket now matches the PRD.',
|
|
84
|
+
kept: 'The ticket is kept as it is.',
|
|
85
|
+
empty: '—',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function ago(iso) {
|
|
89
|
+
if (!iso) return 'never';
|
|
90
|
+
const m = Math.round((Date.now() - Date.parse(iso)) / 60000);
|
|
91
|
+
if (m < 1) return 'just now';
|
|
92
|
+
if (m < 60) return `${m} min ago`;
|
|
93
|
+
if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
|
|
94
|
+
return `${Math.round(m / 1440)} days ago`;
|
|
95
|
+
}
|
|
96
|
+
const POLL_MS = 5000;
|
|
97
|
+
const live = new WeakMap(); // panel → the mounted instance
|
|
98
|
+
|
|
99
|
+
/** Where the ticket is in Jira, in words: "In Progress · Active sprint: Sprint 4". */
|
|
100
|
+
function whereText(t) {
|
|
101
|
+
if (!t.inJira) return COPY.notInJira;
|
|
102
|
+
if (!t.jira) return COPY.notChecked;
|
|
103
|
+
const j = t.jira;
|
|
104
|
+
const sprint = j.category === 'done' ? null : (j.sprint ? `${COPY.sprint[j.sprint.state] || 'Sprint'}: ${j.sprint.name}` : COPY.backlog);
|
|
105
|
+
return [j.status, sprint].filter(Boolean).join(' · ');
|
|
106
|
+
}
|
|
107
|
+
function flagSignal(t) {
|
|
108
|
+
if (!t.flag) return null;
|
|
109
|
+
const tone = t.flag === 'blocked' || t.flag === 'prd-changed' ? 'destructive' : t.flag === 'pushing' ? 'muted' : 'warning';
|
|
110
|
+
const text = t.flag === 'blocked' && t.jira && t.jira.reason ? `${COPY.flag.blocked}: ${t.jira.reason}` : COPY.flag[t.flag];
|
|
111
|
+
return signal({ text, tone, attrs: { 'data-jira-flag': t.flag } });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function unmountJira(panel) {
|
|
115
|
+
const inst = live.get(panel);
|
|
116
|
+
if (!inst) return;
|
|
117
|
+
inst.gone = true;
|
|
118
|
+
clearTimeout(inst.timer);
|
|
119
|
+
live.delete(panel);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function mountJira(panel, { ws, slug }) {
|
|
123
|
+
unmountJira(panel);
|
|
124
|
+
ensureComponentsCss();
|
|
125
|
+
ensureSprite();
|
|
126
|
+
const inst = { gone: false, timer: null, data: null, picked: new Set(), open: null, editing: false };
|
|
127
|
+
live.set(panel, inst);
|
|
128
|
+
const root = el('div', { class: 'in-jira', 'data-jira': slug });
|
|
129
|
+
panel.replaceChildren(root);
|
|
130
|
+
root.replaceChildren(empty({ text: COPY.reading }));
|
|
131
|
+
|
|
132
|
+
const post = (verb, body) => pmPost('/api/jira/' + verb, { ws, initiative: slug, ...body });
|
|
133
|
+
const say = (out, okText) => {
|
|
134
|
+
if (out && out.ok) { if (okText) toast({ text: okText }); return true; }
|
|
135
|
+
toast({ text: (out && out.error) || 'That did not work.', tone: 'destructive' });
|
|
136
|
+
return false;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
async function load() {
|
|
140
|
+
const out = await pmGet('/api/jira/tickets?ws=' + encodeURIComponent(ws || '') + '&initiative=' + encodeURIComponent(slug));
|
|
141
|
+
if (inst.gone) return;
|
|
142
|
+
if (!out || !out.ok) { root.replaceChildren(empty({ text: COPY.readError((out && out.error) || 'no answer') })); return; }
|
|
143
|
+
inst.data = out;
|
|
144
|
+
const can = new Set(out.tickets.filter((t) => t.selectable).map((t) => t.id));
|
|
145
|
+
for (const id of [...inst.picked]) if (!can.has(id)) inst.picked.delete(id);
|
|
146
|
+
draw();
|
|
147
|
+
clearTimeout(inst.timer);
|
|
148
|
+
if (out.pushing || out.checking) inst.timer = setTimeout(load, POLL_MS);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function draw() {
|
|
152
|
+
const d = inst.data;
|
|
153
|
+
if (!d) return;
|
|
154
|
+
if (!d.gate.open) { root.replaceChildren(empty({ text: COPY.locked, attrs: { 'data-jira-locked': '' } })); return; }
|
|
155
|
+
const t = inst.open && d.tickets.find((x) => x.id === inst.open);
|
|
156
|
+
if (t) { root.replaceChildren(...detail(t)); return; }
|
|
157
|
+
inst.open = null;
|
|
158
|
+
root.replaceChildren(...list());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ── the list ───────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
162
|
+
|
|
163
|
+
function draftButton(label, variant) {
|
|
164
|
+
return button({ label, variant, size: 'sm', attrs: { 'data-jira-draft': '' }, onClick: async () => {
|
|
165
|
+
const out = await post('draft');
|
|
166
|
+
if (say(out, out && out.ok ? COPY.drafted(out.changes.filter((c) => c.what === 'added').length || out.tickets) : null)) load();
|
|
167
|
+
} });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function epicLine() {
|
|
171
|
+
const d = inst.data;
|
|
172
|
+
const e = d.epic;
|
|
173
|
+
if (e.locked) return el('span', { class: 'in-jira-epic__text', 'data-jira-epic': e.key || '' }, e.key ? COPY.epicIs(e.key) : COPY.epicLater);
|
|
174
|
+
const kids = [field({
|
|
175
|
+
label: COPY.epic, kind: 'select', value: e.mode, attrs: { 'data-jira-epic-mode': '' },
|
|
176
|
+
options: [{ value: 'new', label: COPY.epicNew(d.title) }, { value: 'existing', label: COPY.epicExisting }],
|
|
177
|
+
onCommit: async (v) => {
|
|
178
|
+
if (v === 'new') { if (say(await post('epic', { mode: 'new' }))) load(); return; }
|
|
179
|
+
inst.data.epic = { ...e, mode: 'existing' }; draw();
|
|
180
|
+
},
|
|
181
|
+
})];
|
|
182
|
+
if (e.mode === 'existing') {
|
|
183
|
+
kids.push(field({
|
|
184
|
+
label: COPY.epicLink, value: e.key || '', placeholder: COPY.epicPaste, attrs: { 'data-jira-epic-key': '' },
|
|
185
|
+
onCommit: async (v) => { if (say(await post('epic', { mode: 'existing', key: v }))) load(); },
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
return el('div', { class: 'in-jira-epic' }, kids);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function checkLine() {
|
|
192
|
+
const d = inst.data;
|
|
193
|
+
const kids = [el('span', { class: 'in-jira-count', 'data-jira-count': '' }, COPY.inJira(d.inJira, d.tickets.length)),
|
|
194
|
+
el('span', { class: 'in-jira-when' }, d.checkedAt ? COPY.checked(d.checkedAt) : COPY.never)];
|
|
195
|
+
if (d.checking) kids.push(signal({ text: COPY.checking, attrs: { 'data-jira-checking': '' } }));
|
|
196
|
+
else if (d.inJira) {
|
|
197
|
+
kids.push(button({ label: COPY.checkNow, variant: 'ghost', size: 'sm', icon: 'refresh-cw', attrs: { 'data-jira-check': '' }, onClick: async () => {
|
|
198
|
+
if (say(await post('check'), COPY.checkStarted)) load();
|
|
199
|
+
} }));
|
|
200
|
+
}
|
|
201
|
+
return el('div', { class: 'in-jira-status-line' }, kids);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ticketRow(t) {
|
|
205
|
+
const lead = [];
|
|
206
|
+
if (t.selectable) {
|
|
207
|
+
lead.push(field({
|
|
208
|
+
label: COPY.pick, kind: 'check', value: inst.picked.has(t.id),
|
|
209
|
+
attrs: { 'aria-label': COPY.pickLabel(t.id), 'data-jira-pick': t.id },
|
|
210
|
+
onCommit: (on) => { if (on) inst.picked.add(t.id); else inst.picked.delete(t.id); drawPush(); },
|
|
211
|
+
}));
|
|
212
|
+
} else lead.push(el('span', { class: 'in-jira-pick-gap', 'aria-hidden': 'true' }));
|
|
213
|
+
const meta = [t.id, t.key, t.type, t.priority, t.estimate].filter(Boolean);
|
|
214
|
+
const sub = t.jira && t.jira.subtasks && t.jira.subtasks.total ? COPY.subtasks(t.jira.subtasks.done, t.jira.subtasks.total) : null;
|
|
215
|
+
const trail = [el('span', { class: 'in-jira-where', 'data-jira-where': '' }, whereText(t))];
|
|
216
|
+
if (sub) trail.push(el('span', { class: 'in-jira-sub', 'data-jira-sub': '' }, sub));
|
|
217
|
+
return row({
|
|
218
|
+
title: t.summary, meta, signals: [flagSignal(t)], lead, trail,
|
|
219
|
+
onOpen: () => { inst.open = t.id; inst.editing = false; draw(); },
|
|
220
|
+
openLabel: COPY.open(t.summary), done: !!(t.jira && t.jira.category === 'done'),
|
|
221
|
+
attrs: { role: 'listitem', 'data-jira-ticket': t.id }, openAttrs: { 'data-jira-open': t.id },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let pushHost = null;
|
|
226
|
+
function drawPush() {
|
|
227
|
+
if (!pushHost) return;
|
|
228
|
+
const d = inst.data;
|
|
229
|
+
const n = inst.picked.size;
|
|
230
|
+
if (d.pushing) { pushHost.replaceChildren(signal({ text: COPY.pushing, attrs: { 'data-jira-pushing': '' } })); return; }
|
|
231
|
+
pushHost.replaceChildren(button({
|
|
232
|
+
label: COPY.push(n), variant: 'primary', disabled: n === 0, attrs: { 'data-jira-push': '' },
|
|
233
|
+
onClick: () => pushIds([...inst.picked]),
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
async function pushIds(ids) {
|
|
237
|
+
if (!ids.length) return;
|
|
238
|
+
const out = await post('push', { ids });
|
|
239
|
+
if (say(out, COPY.pushStarted(ids.length))) { inst.picked.clear(); load(); }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function list() {
|
|
243
|
+
const d = inst.data;
|
|
244
|
+
if (!d.drafted) {
|
|
245
|
+
if (!d.items) return [empty({ text: COPY.noItems, attrs: { 'data-jira-empty': '' } })];
|
|
246
|
+
return [empty({ text: COPY.noDraft(d.items), attrs: { 'data-jira-empty': '' } }), el('div', { class: 'in-jira-actions' }, draftButton(COPY.draft, 'primary'))];
|
|
247
|
+
}
|
|
248
|
+
const out = [el('div', { class: 'in-jira-head' }, epicLine(), checkLine())];
|
|
249
|
+
if (d.newInPrd.length) {
|
|
250
|
+
out.push(el('div', { class: 'in-jira-note', 'data-jira-new': '' },
|
|
251
|
+
el('span', { class: 'in-jira-note__text' }, COPY.newInPrd(d.newInPrd.length) + ' ' + d.newInPrd.map((n) => n.id).join(', ')),
|
|
252
|
+
draftButton(COPY.addNew, 'outline')));
|
|
253
|
+
}
|
|
254
|
+
out.push(el('div', { class: 'in-jira-list', role: 'list' }, d.tickets.map(ticketRow)));
|
|
255
|
+
pushHost = el('div', { class: 'in-jira-actions' });
|
|
256
|
+
out.push(pushHost);
|
|
257
|
+
drawPush();
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ── the detail ─────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
262
|
+
|
|
263
|
+
const md = (text) => {
|
|
264
|
+
const s = String(text || '').trim();
|
|
265
|
+
if (!s) return [el('p', { class: 'in-jira-body__empty' }, COPY.empty)];
|
|
266
|
+
try { return mdRender(s, {}).blocks; } catch { return [el('p', null, s)]; }
|
|
267
|
+
};
|
|
268
|
+
const edit = async (id, patch) => { if (say(await post('edit', { id, patch }), COPY.saved)) load(); };
|
|
269
|
+
|
|
270
|
+
function fields(t) {
|
|
271
|
+
const d = inst.data;
|
|
272
|
+
const opts = (list, withNone) => [...(withNone ? [{ value: '', label: COPY.none }] : []), ...list.map((v) => ({ value: v, label: v }))];
|
|
273
|
+
return el('div', { class: 'in-jira-fields' },
|
|
274
|
+
field({ label: COPY.type, kind: 'select', value: t.type, options: opts(d.types), onCommit: (v) => edit(t.id, { type: v }), attrs: { 'data-jira-field': 'type' } }),
|
|
275
|
+
field({ label: COPY.priority, kind: 'select', value: t.priority, options: opts(d.priorities), onCommit: (v) => edit(t.id, { priority: v }), attrs: { 'data-jira-field': 'priority' } }),
|
|
276
|
+
field({ label: COPY.estimate, kind: 'select', value: t.estimate || '', options: opts(d.estimates, true), onCommit: (v) => edit(t.id, { estimate: v || null }), attrs: { 'data-jira-field': 'estimate' } }),
|
|
277
|
+
field({ label: COPY.depends, value: (t.depends || []).join(', '), placeholder: COPY.none, onCommit: (v) => edit(t.id, { depends: v }), attrs: { 'data-jira-field': 'depends' } }));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function jiraBox(t) {
|
|
281
|
+
if (!t.inJira) return null;
|
|
282
|
+
const j = t.jira;
|
|
283
|
+
const kids = [el('span', { class: 'in-jira-box__key' }, t.key), el('span', { class: 'in-jira-box__where' }, whereText(t))];
|
|
284
|
+
if (j && j.subtasks && j.subtasks.total) kids.push(el('span', { class: 'in-jira-box__sub' }, COPY.subtasks(j.subtasks.done, j.subtasks.total)));
|
|
285
|
+
if (j && j.stale) kids.push(el('span', { class: 'in-jira-box__sub' }, COPY.stale));
|
|
286
|
+
if (j && j.url) kids.push(el('a', { class: 'in-jira-box__link', href: j.url, target: '_blank', rel: 'noopener noreferrer', 'data-jira-link': '' }, COPY.openJira));
|
|
287
|
+
return el('div', { class: 'in-jira-box', 'data-jira-box': '' }, kids);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function prdBox(t) {
|
|
291
|
+
if (!t.prd) return null;
|
|
292
|
+
const d = inst.data;
|
|
293
|
+
const names = d.sectionNames;
|
|
294
|
+
const rows = [];
|
|
295
|
+
if (t.prd.summary !== t.summary) rows.push({ name: COPY.summary, mine: t.summary, theirs: t.prd.summary });
|
|
296
|
+
for (const s of names) {
|
|
297
|
+
const a = String(t.sections[s.key] || '').trim(); const b = String(t.prd.sections[s.key] || '').trim();
|
|
298
|
+
if (a !== b) rows.push({ name: s.name, mine: a, theirs: b });
|
|
299
|
+
}
|
|
300
|
+
const side = (label, key) => el('div', { class: 'in-jira-diff__side' },
|
|
301
|
+
el('span', { class: 'in-jira-diff__label' }, label),
|
|
302
|
+
...rows.map((r) => el('div', { class: 'in-jira-diff__part' }, el('span', { class: 'in-jira-diff__name' }, r.name), ...md(r[key]))));
|
|
303
|
+
return el('div', { class: 'in-jira-diff', 'data-jira-prd': '' },
|
|
304
|
+
el('p', { class: 'in-jira-diff__title' }, COPY.prdTitle),
|
|
305
|
+
el('div', { class: 'in-jira-diff__sides' }, side(COPY.thisTicket, 'mine'), side(COPY.prdNow, 'theirs')),
|
|
306
|
+
el('div', { class: 'in-jira-actions' },
|
|
307
|
+
button({ label: COPY.usePrd, variant: 'outline', size: 'sm', attrs: { 'data-jira-use': 'prd' }, onClick: async () => { if (say(await post('prd', { id: t.id, use: 'prd' }), COPY.usedPrd)) load(); } }),
|
|
308
|
+
button({ label: COPY.keep, variant: 'ghost', size: 'sm', attrs: { 'data-jira-use': 'keep' }, onClick: async () => { if (say(await post('prd', { id: t.id, use: 'keep' }), COPY.kept)) load(); } })));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function body(t) {
|
|
312
|
+
const d = inst.data;
|
|
313
|
+
if (inst.editing) {
|
|
314
|
+
const summary = field({ label: COPY.summary, kind: 'title', value: t.summary, attrs: { 'data-jira-edit': 'summary' } });
|
|
315
|
+
const areas = d.sectionNames.map((s) => field({ label: s.name, kind: 'textarea', value: t.sections[s.key] || '', attrs: { 'data-jira-edit': s.key, class: 'in-jira-area' } }));
|
|
316
|
+
return el('div', { class: 'in-jira-edit' }, summary, ...areas,
|
|
317
|
+
el('div', { class: 'in-jira-actions' },
|
|
318
|
+
button({ label: COPY.save, variant: 'primary', attrs: { 'data-jira-save': '' }, onClick: async () => {
|
|
319
|
+
const sections = {};
|
|
320
|
+
d.sectionNames.forEach((s, i) => { sections[s.key] = areas[i].control.value; });
|
|
321
|
+
const out = await post('edit', { id: t.id, patch: { summary: summary.control.value, sections } });
|
|
322
|
+
if (say(out, COPY.saved)) { inst.editing = false; load(); }
|
|
323
|
+
} }),
|
|
324
|
+
button({ label: COPY.cancel, variant: 'ghost', onClick: () => { inst.editing = false; draw(); } })));
|
|
325
|
+
}
|
|
326
|
+
return el('div', { class: 'in-jira-body', 'data-jira-body': '' }, d.sectionNames.map((s) => el('section', { class: 'in-jira-body__part' },
|
|
327
|
+
el('h4', { class: 'in-jira-body__name' }, s.name), ...md(t.sections[s.key]))));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function detail(t) {
|
|
331
|
+
const d = inst.data;
|
|
332
|
+
const actions = [];
|
|
333
|
+
if (!inst.editing) actions.push(button({ label: COPY.editText, variant: 'outline', size: 'sm', attrs: { 'data-jira-edit-open': '' }, onClick: () => { inst.editing = true; draw(); } }));
|
|
334
|
+
if (t.selectable && !inst.editing && !d.pushing) {
|
|
335
|
+
actions.push(button({ label: t.inJira ? COPY.updateOne : COPY.pushOne, variant: 'primary', size: 'sm', attrs: { 'data-jira-push-one': t.id }, onClick: () => pushIds([t.id]) }));
|
|
336
|
+
}
|
|
337
|
+
const notes = [];
|
|
338
|
+
if (t.missing.length) notes.push(el('p', { class: 'in-jira-warn', 'data-jira-missing': '' }, COPY.missing(t.missing)));
|
|
339
|
+
if (t.removed) notes.push(el('p', { class: 'in-jira-warn' }, COPY.removedNote));
|
|
340
|
+
return [
|
|
341
|
+
el('div', { class: 'in-jira-detail-top' },
|
|
342
|
+
button({ label: COPY.back, variant: 'ghost', size: 'sm', icon: 'arrow-left', attrs: { 'data-jira-back': '' }, onClick: () => { inst.open = null; inst.editing = false; draw(); } }),
|
|
343
|
+
el('span', { class: 'in-jira-detail-top__gap' }), ...actions),
|
|
344
|
+
el('div', { class: 'in-jira-detail', 'data-jira-detail': t.id },
|
|
345
|
+
el('h3', { class: 'in-jira-detail__title' }, t.summary),
|
|
346
|
+
el('span', { class: 'in-jira-detail__meta' }, [t.id, t.key].filter(Boolean).join(' · ')),
|
|
347
|
+
...notes, fields(t), jiraBox(t), prdBox(t), body(t)),
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
load();
|
|
352
|
+
return { reload: load };
|
|
353
|
+
}
|
package/workspace/server.js
CHANGED
|
@@ -112,6 +112,7 @@ const modelLib = require('./lib/models'); // tier routing — one
|
|
|
112
112
|
const memoryLib = require('./lib/memory'); // curated repository memory — one ledger code path, shared with the agents' CLI (D72)
|
|
113
113
|
const audit = require('./lib/audit'); // the workflow audit — the sweep the agents' CLI runs, read-only here (D77)
|
|
114
114
|
const syncLib = require('./lib/sync'); // press-only fetch / integrate / commit records / push (D70)
|
|
115
|
+
const kitUpdate = require('./lib/kit-update.js'); // workspace-kit-update: press-only check and update of the kit and the app
|
|
115
116
|
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
116
117
|
const startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
|
|
117
118
|
const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
|
|
@@ -134,6 +135,12 @@ const docCheckSessions = new Map();
|
|
|
134
135
|
// The session each Use … version press started (workspace id|path|kind → { session, use }), so the line says "Updating…"
|
|
135
136
|
// before the session has opened its sync, and a second press on the same copy refuses.
|
|
136
137
|
const docSyncSessions = new Map();
|
|
138
|
+
const jiraTickets = require('./lib/jira-tickets.js'); // jira-wbs — the ticket draft, the PM's selection, the push plan
|
|
139
|
+
const jiraStatus = require('./lib/jira-status.js'); // …and the status snapshot an agent's read writes
|
|
140
|
+
// The session each Push or Check now press started (workspace id|initiative → { session, at }), so a second press
|
|
141
|
+
// while it runs refuses and the tab says "Pushing…" or "Checking…".
|
|
142
|
+
const jiraPushSessions = new Map();
|
|
143
|
+
const jiraCheckSessions = new Map();
|
|
137
144
|
const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
|
|
138
145
|
const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
|
|
139
146
|
const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
|
|
@@ -1862,6 +1869,7 @@ function readLedger(w, basename) {
|
|
|
1862
1869
|
// D70 — the lifecycle ledger: the latest line for an initiative wins; `active` reopens. The same rule
|
|
1863
1870
|
// as scripts/workflow-state.js#readLifecycle (a parity case in workspace/test.js keeps them in step).
|
|
1864
1871
|
const syncing = new Set(); // repositories with a sync in flight — one at a time
|
|
1872
|
+
let kitBusy = null; // workspace-kit-update: the one kit or app update in flight ('kit' | 'app'), or null
|
|
1865
1873
|
// D70 as amended 2026-10-06: the same Sync, run without the press at startup and when work sits unpushed and quiet.
|
|
1866
1874
|
// Every rule it must pass is in lib/auto-sync.js; a scratch server (its own PMOS_STATE_DIR) does not auto-sync.
|
|
1867
1875
|
const autoSync = require('./lib/auto-sync').createAutoSync({
|
|
@@ -2287,6 +2295,9 @@ const SEED_SETS = {
|
|
|
2287
2295
|
// bi-weekly draft (Friday 16:00) fires every other week from whichever Friday it was set up, and a weekly refresh is
|
|
2288
2296
|
// never out of step with it. A refresh that finds no change writes no line.
|
|
2289
2297
|
tracker: [{ name: 'Tracker refresh', skill: 'tracker-refresh', cadence: 'weekly', day: 'Fri', time: '15:30' }],
|
|
2298
|
+
// jira-wbs (J14): the agent step that reads the status of every pushed Jira ticket into each initiative's snapshot,
|
|
2299
|
+
// read-only. Daily at 06:30, so the morning brief (06:45) and every report read a status at most a day old.
|
|
2300
|
+
jira: [{ name: 'Jira status check', skill: 'jira-sync', cadence: 'daily', time: '06:30' }],
|
|
2290
2301
|
};
|
|
2291
2302
|
function readSchedules() {
|
|
2292
2303
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
@@ -6550,6 +6561,66 @@ async function handleRequest(req, res) {
|
|
|
6550
6561
|
if (started) docCheckSessions.set(w.id, s.id);
|
|
6551
6562
|
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6552
6563
|
}
|
|
6564
|
+
// ── Jira tickets: the initiative sheet's Jira tickets tab (jira-wbs, PRD J3 to J12, proposed D88) ─────────────────
|
|
6565
|
+
// GET reads the ticket draft, the PRD it came from and the last status snapshot, and decides every flag in
|
|
6566
|
+
// lib/jira-tickets.js. The POSTs write the draft file (draft, edit, epic, prd), or start ONE agent session: Push starts
|
|
6567
|
+
// `jira-push` for the tickets the PM selected (a PM-only press, pm-guard act `push`, which first records the
|
|
6568
|
+
// selection), and Check now starts `jira-sync` for this initiative. No route here reads or writes Jira: the session,
|
|
6569
|
+
// holding the PM's own Atlassian connection, does (D53, D88).
|
|
6570
|
+
if (p === '/api/jira/tickets' && req.method === 'GET') {
|
|
6571
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6572
|
+
const root = w ? w.root : SELF_ROOT;
|
|
6573
|
+
const id = String(url.searchParams.get('initiative') || '');
|
|
6574
|
+
if (!planItems.INIT_RE.test(id)) return json(res, { ok: false, error: 'name an initiative' }, 400);
|
|
6575
|
+
let v;
|
|
6576
|
+
try { v = jiraTickets.view(root, id, { snapshot: jiraStatus.readSnapshot(root, id) }); }
|
|
6577
|
+
catch (e) { return json(res, { ok: false, error: String((e && e.message) || e).slice(0, 200) }, 500); }
|
|
6578
|
+
if (!v.ok) return json(res, v, v.status || 400);
|
|
6579
|
+
const key = `${w ? w.id : ''}|${id}`;
|
|
6580
|
+
const running = (m) => { const x = m.get(key); return x && sessions.get(x.session) && sessions.get(x.session).state !== 'exited' ? x : null; };
|
|
6581
|
+
return json(res, { ...v, ws: w ? w.id : null, pushing: running(jiraPushSessions), checking: running(jiraCheckSessions) });
|
|
6582
|
+
}
|
|
6583
|
+
if (/^\/api\/jira\/(draft|edit|epic|prd|push|check)$/.test(p) && req.method === 'POST') {
|
|
6584
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6585
|
+
const act = p.slice('/api/jira/'.length);
|
|
6586
|
+
const g = act === 'push' ? pmGuard.requirePmPress(req, body, { act: 'push' }) : pmGuard.requirePagePress(req, body);
|
|
6587
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6588
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : getWs(null);
|
|
6589
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
|
|
6590
|
+
const id = String(body.initiative || '');
|
|
6591
|
+
if (!planItems.INIT_RE.test(id) || !jiraTickets.recordFm(w.root, id)) return json(res, { ok: false, error: 'no such initiative' }, 404);
|
|
6592
|
+
const answer = (r) => json(res, r, r.ok ? 200 : (r.status || 400));
|
|
6593
|
+
const key = `${w.id}|${id}`;
|
|
6594
|
+
const busy = (m) => { const x = m.get(key); return !!(x && sessions.get(x.session) && sessions.get(x.session).state !== 'exited'); };
|
|
6595
|
+
try {
|
|
6596
|
+
if (act === 'draft') return answer(jiraTickets.draft(w.root, id));
|
|
6597
|
+
if (act === 'edit') return answer(jiraTickets.edit(w.root, id, String(body.id || ''), body.patch));
|
|
6598
|
+
if (act === 'epic') return answer(jiraTickets.setEpic(w.root, id, { mode: body.mode, key: body.key }));
|
|
6599
|
+
if (act === 'prd') return answer(jiraTickets.resolvePrd(w.root, id, String(body.id || ''), String(body.use || '')));
|
|
6600
|
+
const start = (skill, name, origin, ask, map) => {
|
|
6601
|
+
const task = taskById(skill, w.root);
|
|
6602
|
+
if (!task || !task.prompt) return json(res, { ok: false, error: `the ${skill} skill is not installed` }, 500);
|
|
6603
|
+
const s = spawnSession({ name, agent: task.agent || 'claude', ws: w.id, origin,
|
|
6604
|
+
initialInput: task.prompt + ask + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
|
|
6605
|
+
const started = s.state !== 'exited';
|
|
6606
|
+
if (started) map.set(key, { session: s.id, at: new Date().toISOString() });
|
|
6607
|
+
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6608
|
+
};
|
|
6609
|
+
if (act === 'push') {
|
|
6610
|
+
if (busy(jiraPushSessions)) return json(res, { ok: false, error: 'a push is already running for this initiative' }, 409);
|
|
6611
|
+
const ids = Array.isArray(body.ids) ? body.ids.map(String).slice(0, 200) : [];
|
|
6612
|
+
const r = jiraTickets.approve(w.root, id, ids);
|
|
6613
|
+
if (!r.ok) return answer(r);
|
|
6614
|
+
return start('jira-push', 'Push to Jira', 'jira:push', `\n\nThis push:\n- initiative: ${id}\n- tickets: ${r.approved.join(', ')}`, jiraPushSessions);
|
|
6615
|
+
}
|
|
6616
|
+
if (busy(jiraCheckSessions)) return json(res, { ok: false, error: 'a check is already running for this initiative' }, 409);
|
|
6617
|
+
const q = jiraStatus.queryFor(w.root, id);
|
|
6618
|
+
if (!q.ok || !q.linked) return json(res, { ok: false, error: 'no ticket of this initiative is in Jira yet — push one first' }, 409);
|
|
6619
|
+
return start('jira-sync', 'Jira status check', 'jira:check', `\n\nThis check:\n- initiative: ${id} (this one only)`, jiraCheckSessions);
|
|
6620
|
+
} catch (e) {
|
|
6621
|
+
return json(res, { ok: false, error: String((e && e.message) || e).slice(0, 200) }, 500);
|
|
6622
|
+
}
|
|
6623
|
+
}
|
|
6553
6624
|
if (p === '/api/initiative/link' && req.method === 'POST') {
|
|
6554
6625
|
// Recording that an artifact corresponds to an external page. This writes a DECLARATION and
|
|
6555
6626
|
// makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
|
|
@@ -6853,6 +6924,50 @@ async function handleRequest(req, res) {
|
|
|
6853
6924
|
return json(res, { ws: w.id, name: w.name, ...out });
|
|
6854
6925
|
} finally { syncing.delete(w.root); }
|
|
6855
6926
|
}
|
|
6927
|
+
/* ── workspace-kit-update: Settings › Updates. GET reads versions from disk only. Each POST is the PM's press, and is
|
|
6928
|
+
* the only way the workspace reaches the npm registry (lib/kit-update.js; no-network.test.js holds that). ── */
|
|
6929
|
+
if (p === '/api/kit/status' && req.method === 'GET') {
|
|
6930
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6931
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6932
|
+
return json(res, { ok: true, ws: w.id, name: w.name, busy: kitBusy, ...kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname }) });
|
|
6933
|
+
}
|
|
6934
|
+
if ((p === '/api/kit/check' || p === '/api/kit/update' || p === '/api/kit/upgrade-app') && req.method === 'POST') {
|
|
6935
|
+
let body = {};
|
|
6936
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6937
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6938
|
+
const g = pmGuard.requirePagePress(req, body);
|
|
6939
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6940
|
+
// A named workspace only: getWs falls back to the home repository, and an update must never land on a repository
|
|
6941
|
+
// the page did not show.
|
|
6942
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : null;
|
|
6943
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
|
|
6944
|
+
const st = () => kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname });
|
|
6945
|
+
if (p === '/api/kit/check') {
|
|
6946
|
+
const r = await kitUpdate.latest();
|
|
6947
|
+
const now = st();
|
|
6948
|
+
if (!r.ok) return json(res, { ok: false, error: r.error, ws: w.id, ...now }, 502);
|
|
6949
|
+
return json(res, { ok: true, ws: w.id, ...now, latest: r.version, ...kitUpdate.compare(now, r.version) });
|
|
6950
|
+
}
|
|
6951
|
+
if (kitBusy) return json(res, { ok: false, error: kitBusy === 'app' ? 'The workspace is already updating' : 'A kit update is already running' }, 409);
|
|
6952
|
+
if (p === '/api/kit/update') {
|
|
6953
|
+
kitBusy = 'kit';
|
|
6954
|
+
try {
|
|
6955
|
+
const r = await kitUpdate.updateKit(w.root, { version: body.version });
|
|
6956
|
+
boardCache.invalidate(w.root);
|
|
6957
|
+
return json(res, { ws: w.id, ...r, ...st() }, r.ok ? 200 : 502);
|
|
6958
|
+
} finally { kitBusy = null; }
|
|
6959
|
+
}
|
|
6960
|
+
// The app update stops this server and starts a new one. It is refused from a phone: the new server starts on
|
|
6961
|
+
// loopback only, so the phone that asked would lose the page. It is refused from a checkout, which git updates.
|
|
6962
|
+
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, BIND.publicHost)) {
|
|
6963
|
+
return json(res, { ok: false, error: 'Update the workspace from the computer it runs on' }, 403);
|
|
6964
|
+
}
|
|
6965
|
+
if (st().app.fromCheckout) return json(res, { ok: false, error: 'This workspace runs from a PMOS checkout. Update it with git pull' }, 409);
|
|
6966
|
+
const r = kitUpdate.upgradeApp(w.root, { version: body.version, logFile: path.join(STATE, 'app-update.log') });
|
|
6967
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
|
|
6968
|
+
kitBusy = 'app';
|
|
6969
|
+
return json(res, { ok: true, ws: w.id, from: APP_VERSION, to: body.version, log: r.log });
|
|
6970
|
+
}
|
|
6856
6971
|
/* ── next-step actions: prepare is read-only; go starts exactly what was shown; review records the PM's word ── */
|
|
6857
6972
|
if (p === '/api/next-step/prepare' && req.method === 'POST') {
|
|
6858
6973
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
@@ -25,7 +25,7 @@ Procedure:
|
|
|
25
25
|
1. Run `pmos proactive day`. It shows the stage, the tasks, and the check state (when the last check was, and why this one runs). Run `pmos proactive brief` to read the stored brief: its items with their ids, its events with their ids, and its digests. Run `pmos proactive settings` for the sources, the scopes and the websites. Run `pmos proactive goals` for the goal ids.
|
|
26
26
|
2. Read what changed, from the sources that are on and reachable:
|
|
27
27
|
- Calendar: today's events. Note a new event, a moved one, a cancelled one, and a new link or agenda in one.
|
|
28
|
-
- Mentions of the PM since the last check, in Slack, Jira and Confluence, in every place that is not ignored. Keep where, who, the link to the conversation itself and a short paraphrase of what is asked.
|
|
28
|
+
- Mentions of the PM since the last check, in Slack, Jira and Confluence, in every place that is not ignored. Keep where, who, the link to the conversation itself and a short paraphrase of what is asked. To tie a Jira issue to an initiative, run `pmos jira which <KEY>` (it reads local files only) and use the initiative it names. A status change of a ticket that `pmos jira which` names is not an item: the Jira status check shows it on the initiative's Jira tickets tab.
|
|
29
29
|
- Places with scope watch: read every message or comment since the last check. Update the digest of the place (decisions, open questions, what touches a goal). A digest you submit replaces the stored digest of that place, so carry over what still holds.
|
|
30
30
|
- GitHub, Drive, Figma and websites, if on: only what changed since the last check and matters to a goal or a meeting.
|
|
31
31
|
- Wispr Flow: notes for meetings that ended since the last check. Read the summary, the decisions and the actions. Read the related mail for the same meeting only if mail is on, and keep the subject, the sender and a gist.
|
|
@@ -29,7 +29,7 @@ Procedure (each step ends with a check that shows it is done):
|
|
|
29
29
|
Done when: the target path is named, and an existing file is still intact.
|
|
30
30
|
2. Read the previous note in that folder to learn the cutoff. With no previous note, look back 3 days.
|
|
31
31
|
Done when: you can state the cutoff as a date and time.
|
|
32
|
-
3. Read the initiatives in `<state>/initiatives/` (or `planning/initiatives/`) so you can say which one an item touches. Match by what the item is about, not by keyword.
|
|
32
|
+
3. Read the initiatives in `<state>/initiatives/` (or `planning/initiatives/`) so you can say which one an item touches. Match by what the item is about, not by keyword. For a Jira issue, run `pmos jira which <KEY>` first: when it names an initiative, use it. A status change of such an issue is not a requirement; the Jira status check records it.
|
|
33
33
|
Done when: you have the list of initiative ids that an item can name.
|
|
34
34
|
4. For each source you can reach, read what changed since the cutoff: mail, chat, Jira, Confluence, calendar events with their notes, meeting notes.
|
|
35
35
|
Done when: each source kind is either read or marked not reached.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jira-push
|
|
3
|
+
label: "Push to Jira"
|
|
4
|
+
description: "Create or update in Jira the tickets the PM selected on an initiative's Jira tickets tab, with the PM's own Atlassian connection, and record their keys in PMOS. Only selected tickets are sent."
|
|
5
|
+
run: agent
|
|
6
|
+
agent: claude
|
|
7
|
+
output:
|
|
8
|
+
- Created
|
|
9
|
+
- Updated
|
|
10
|
+
- Not sent
|
|
11
|
+
- Needs you
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
/jira-push — send the tickets the PM selected to Jira. PMOS drafted them from the PRD in the PM's ticket template, and the PM selected the ones that are ready. You create or update exactly what `pmos jira pending` lists, with the PM's own Atlassian connection, and you tell PMOS the keys.
|
|
15
|
+
|
|
16
|
+
Rules that always hold:
|
|
17
|
+
1. SELECTED TICKETS ONLY. Send to Jira only what `pmos jira pending` lists. A ticket it does not list was not selected, or changed after it was selected: leave it. Never select a ticket yourself: only the PM selects, with Push on the tab or `pmos jira approve` in their own terminal.
|
|
18
|
+
2. CREATE, UPDATE AND LINK ONLY. Create the epic and the tickets `pmos jira pending` lists under create, and update the summary, description, issue type and priority of the tickets it lists under update. Add an "is blocked by" link for each key in `blocked_by`. Never transition, assign, comment on, close or delete an issue.
|
|
19
|
+
3. THE TEXT AS GIVEN. Use the summary, description, issue type, priority and labels exactly as `pmos jira pending <initiative> --json` gives them. The description is markdown: keep its headings and lists. Do not rewrite it.
|
|
20
|
+
4. RECORD EVERY KEY. After each create, run `pmos jira record` with the key Jira returned, so a second run never creates the same ticket twice. After updates, record them with `--updated`.
|
|
21
|
+
5. NO REPOSITORY EDITS. The only writes in PMOS are `pmos jira record` and, at the end, `pmos jira sync`. Never edit the ticket draft or any file by hand. Never commit.
|
|
22
|
+
|
|
23
|
+
Procedure:
|
|
24
|
+
1. Take the initiative from the task ("This push: initiative …"), else ask the PM. Run `pmos jira pending <initiative> --json`. If it lists nothing to create or update, say so under "Needs you" and stop.
|
|
25
|
+
2. Find the Jira project: the project of the epic key when the plan names one. Otherwise ask the PM for the project key before you create anything.
|
|
26
|
+
3. If the plan's epic has no key, create the epic from `epic.create`. Then run `pmos jira record <initiative> --epic <KEY>`.
|
|
27
|
+
4. For each ticket under `create`: create it in that project with its summary, description, issue type, priority and labels, as a child of the epic (the parent field). If the project has no such issue type or priority, use the nearest one and say which under "Needs you". Then run `pmos jira record <initiative> <ticket>=<KEY>`, for example `pmos jira record checkout TD-01=ATL-491`.
|
|
28
|
+
5. For each ticket under `update`: update its summary, description, issue type and priority in Jira. Then run `pmos jira record <initiative> --updated <ticket>[,<ticket>]`.
|
|
29
|
+
6. For each pushed ticket with `blocked_by`: link it as "is blocked by" each key there. A `blocked_by` entry with no key is a ticket not in Jira yet: skip it and say so under "Not sent".
|
|
30
|
+
7. Run `pmos jira pending <initiative>` again. It must list nothing to create or update. If it does, report what is left.
|
|
31
|
+
8. Bring the status back now: run the steps of the Jira status check for this one initiative (`pmos jira query <initiative>`, the search, `pmos jira sync <initiative> --from <file>`).
|
|
32
|
+
9. Finish with the four sections: Created (ticket, key, summary), Updated (ticket, key), Not sent (anything you skipped and why), Needs you (any refusal or choice you made).
|
|
33
|
+
|
|
34
|
+
Output contract — produce EXACTLY these sections, the same shape every run:
|
|
35
|
+
- Created
|
|
36
|
+
- Updated
|
|
37
|
+
- Not sent
|
|
38
|
+
- Needs you
|