create-pmos 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/first-paint-audit.js +5 -2
  7. package/workspace/guide/docs.md +1 -1
  8. package/workspace/guide/index.md +2 -1
  9. package/workspace/guide/jira.md +141 -0
  10. package/workspace/guide/layout.md +2 -2
  11. package/workspace/lib/jira-cli.js +194 -0
  12. package/workspace/lib/jira-status.js +239 -0
  13. package/workspace/lib/jira-tickets.js +668 -0
  14. package/workspace/lib/pm-guard.js +2 -0
  15. package/workspace/lib/pmos-verbs.js +3 -0
  16. package/workspace/lib/report-draft.js +19 -0
  17. package/workspace/package-lock.json +1 -1
  18. package/workspace/package.json +1 -1
  19. package/workspace/public/pm/components.css +206 -0
  20. package/workspace/public/pm/dest-docs.js +1 -1
  21. package/workspace/public/pm/frame-copy.js +1 -0
  22. package/workspace/public/pm/initiative-sheet.js +7 -3
  23. package/workspace/public/pm/initiatives-copy.js +1 -0
  24. package/workspace/public/pm/sheet-jira.js +353 -0
  25. package/workspace/public/pm/topbar.js +5 -4
  26. package/workspace/server.js +69 -0
  27. package/workspace/skills/day-check.skill +1 -1
  28. package/workspace/skills/gather-requirements.skill +1 -1
  29. package/workspace/skills/jira-push.skill +38 -0
  30. package/workspace/skills/jira-sync.skill +42 -0
  31. package/workspace/skills/morning-brief.skill +1 -1
  32. package/workspace/skills/report-biweekly.skill +1 -1
  33. package/workspace/skills/tracker-refresh.skill +1 -0
  34. package/workspace/test-lists/jira-wbs.json +6 -0
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  /* pm/topbar.js — the frame's one top bar (pm-os-absorb Run 3, slice 3A; PRD R9, R10).
2
2
  *
3
- * Repository ▾ · Today Initiatives Board Reports · ⌘K · Sync (only when unsynced) · Settings
3
+ * Repository ▾ · Today Initiatives Board Reports Docs · ⌘K · Sync (only when unsynced) · Settings
4
4
  *
5
5
  * The bar is built once and updated on every render: app.js calls `bar.update(model)` and the bar changes only what
6
6
  * the model changed, so a focused control keeps its focus across a repaint. It holds no state of its own except the
@@ -8,7 +8,7 @@
8
8
  * runs `git status` against the remote-tracking ref the last fetch left).
9
9
  *
10
10
  * createTopbar({ go, openPalette, repoMenu, sync }) → { node, update(model) }
11
- * go(id) open destination id (today, initiatives, board, reports, settings)
11
+ * go(id) open destination id (today, initiatives, board, reports, docs, settings)
12
12
  * openPalette() open ⌘K
13
13
  * repoMenu(anchor) open the Repository menu under `anchor`
14
14
  * sync(repo) run Sync for `repo`; resolves to the server's answer
@@ -22,8 +22,9 @@ import { el, setChildren } from './dom.js';
22
22
  import { button, needsDot, needsCount } from './primitives.js';
23
23
  import { COPY } from './frame-copy.js';
24
24
 
25
- /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. */
26
- export const BAR_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports']);
25
+ /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
26
+ * PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"), so the bar holds 8 controls. */
27
+ export const BAR_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs']);
27
28
 
28
29
  /** Is this sync reading "not in sync"? Behind, ahead, records not yet recorded, or `remote_stale`: no fetch in the last
29
30
  * few minutes, so another machine's push would be invisible here and "in sync" cannot be claimed. An unreadable status
@@ -134,6 +134,12 @@ const docCheckSessions = new Map();
134
134
  // The session each Use … version press started (workspace id|path|kind → { session, use }), so the line says "Updating…"
135
135
  // before the session has opened its sync, and a second press on the same copy refuses.
136
136
  const docSyncSessions = new Map();
137
+ const jiraTickets = require('./lib/jira-tickets.js'); // jira-wbs — the ticket draft, the PM's selection, the push plan
138
+ const jiraStatus = require('./lib/jira-status.js'); // …and the status snapshot an agent's read writes
139
+ // The session each Push or Check now press started (workspace id|initiative → { session, at }), so a second press
140
+ // while it runs refuses and the tab says "Pushing…" or "Checking…".
141
+ const jiraPushSessions = new Map();
142
+ const jiraCheckSessions = new Map();
137
143
  const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
138
144
  const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
139
145
  const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
@@ -2287,6 +2293,9 @@ const SEED_SETS = {
2287
2293
  // bi-weekly draft (Friday 16:00) fires every other week from whichever Friday it was set up, and a weekly refresh is
2288
2294
  // never out of step with it. A refresh that finds no change writes no line.
2289
2295
  tracker: [{ name: 'Tracker refresh', skill: 'tracker-refresh', cadence: 'weekly', day: 'Fri', time: '15:30' }],
2296
+ // jira-wbs (J14): the agent step that reads the status of every pushed Jira ticket into each initiative's snapshot,
2297
+ // read-only. Daily at 06:30, so the morning brief (06:45) and every report read a status at most a day old.
2298
+ jira: [{ name: 'Jira status check', skill: 'jira-sync', cadence: 'daily', time: '06:30' }],
2290
2299
  };
2291
2300
  function readSchedules() {
2292
2301
  try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
@@ -6550,6 +6559,66 @@ async function handleRequest(req, res) {
6550
6559
  if (started) docCheckSessions.set(w.id, s.id);
6551
6560
  return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
6552
6561
  }
6562
+ // ── Jira tickets: the initiative sheet's Jira tickets tab (jira-wbs, PRD J3 to J12, proposed D88) ─────────────────
6563
+ // GET reads the ticket draft, the PRD it came from and the last status snapshot, and decides every flag in
6564
+ // lib/jira-tickets.js. The POSTs write the draft file (draft, edit, epic, prd), or start ONE agent session: Push starts
6565
+ // `jira-push` for the tickets the PM selected (a PM-only press, pm-guard act `push`, which first records the
6566
+ // selection), and Check now starts `jira-sync` for this initiative. No route here reads or writes Jira: the session,
6567
+ // holding the PM's own Atlassian connection, does (D53, D88).
6568
+ if (p === '/api/jira/tickets' && req.method === 'GET') {
6569
+ const w = getWs(url.searchParams.get('ws'));
6570
+ const root = w ? w.root : SELF_ROOT;
6571
+ const id = String(url.searchParams.get('initiative') || '');
6572
+ if (!planItems.INIT_RE.test(id)) return json(res, { ok: false, error: 'name an initiative' }, 400);
6573
+ let v;
6574
+ try { v = jiraTickets.view(root, id, { snapshot: jiraStatus.readSnapshot(root, id) }); }
6575
+ catch (e) { return json(res, { ok: false, error: String((e && e.message) || e).slice(0, 200) }, 500); }
6576
+ if (!v.ok) return json(res, v, v.status || 400);
6577
+ const key = `${w ? w.id : ''}|${id}`;
6578
+ const running = (m) => { const x = m.get(key); return x && sessions.get(x.session) && sessions.get(x.session).state !== 'exited' ? x : null; };
6579
+ return json(res, { ...v, ws: w ? w.id : null, pushing: running(jiraPushSessions), checking: running(jiraCheckSessions) });
6580
+ }
6581
+ if (/^\/api\/jira\/(draft|edit|epic|prd|push|check)$/.test(p) && req.method === 'POST') {
6582
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6583
+ const act = p.slice('/api/jira/'.length);
6584
+ const g = act === 'push' ? pmGuard.requirePmPress(req, body, { act: 'push' }) : pmGuard.requirePagePress(req, body);
6585
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
6586
+ const w = body.ws ? workspaces.get(String(body.ws)) : getWs(null);
6587
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
6588
+ const id = String(body.initiative || '');
6589
+ if (!planItems.INIT_RE.test(id) || !jiraTickets.recordFm(w.root, id)) return json(res, { ok: false, error: 'no such initiative' }, 404);
6590
+ const answer = (r) => json(res, r, r.ok ? 200 : (r.status || 400));
6591
+ const key = `${w.id}|${id}`;
6592
+ const busy = (m) => { const x = m.get(key); return !!(x && sessions.get(x.session) && sessions.get(x.session).state !== 'exited'); };
6593
+ try {
6594
+ if (act === 'draft') return answer(jiraTickets.draft(w.root, id));
6595
+ if (act === 'edit') return answer(jiraTickets.edit(w.root, id, String(body.id || ''), body.patch));
6596
+ if (act === 'epic') return answer(jiraTickets.setEpic(w.root, id, { mode: body.mode, key: body.key }));
6597
+ if (act === 'prd') return answer(jiraTickets.resolvePrd(w.root, id, String(body.id || ''), String(body.use || '')));
6598
+ const start = (skill, name, origin, ask, map) => {
6599
+ const task = taskById(skill, w.root);
6600
+ if (!task || !task.prompt) return json(res, { ok: false, error: `the ${skill} skill is not installed` }, 500);
6601
+ const s = spawnSession({ name, agent: task.agent || 'claude', ws: w.id, origin,
6602
+ initialInput: task.prompt + ask + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
6603
+ const started = s.state !== 'exited';
6604
+ if (started) map.set(key, { session: s.id, at: new Date().toISOString() });
6605
+ return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
6606
+ };
6607
+ if (act === 'push') {
6608
+ if (busy(jiraPushSessions)) return json(res, { ok: false, error: 'a push is already running for this initiative' }, 409);
6609
+ const ids = Array.isArray(body.ids) ? body.ids.map(String).slice(0, 200) : [];
6610
+ const r = jiraTickets.approve(w.root, id, ids);
6611
+ if (!r.ok) return answer(r);
6612
+ return start('jira-push', 'Push to Jira', 'jira:push', `\n\nThis push:\n- initiative: ${id}\n- tickets: ${r.approved.join(', ')}`, jiraPushSessions);
6613
+ }
6614
+ if (busy(jiraCheckSessions)) return json(res, { ok: false, error: 'a check is already running for this initiative' }, 409);
6615
+ const q = jiraStatus.queryFor(w.root, id);
6616
+ if (!q.ok || !q.linked) return json(res, { ok: false, error: 'no ticket of this initiative is in Jira yet — push one first' }, 409);
6617
+ return start('jira-sync', 'Jira status check', 'jira:check', `\n\nThis check:\n- initiative: ${id} (this one only)`, jiraCheckSessions);
6618
+ } catch (e) {
6619
+ return json(res, { ok: false, error: String((e && e.message) || e).slice(0, 200) }, 500);
6620
+ }
6621
+ }
6553
6622
  if (p === '/api/initiative/link' && req.method === 'POST') {
6554
6623
  // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
6555
6624
  // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
@@ -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
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: jira-sync
3
+ label: "Jira status check"
4
+ description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, and save them in PMOS for the initiative's Jira tickets tab. It writes nothing to Jira."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Checked
9
+ - Not reached
10
+ - Needs you
11
+ ---
12
+
13
+ /jira-sync — bring the status of each initiative's Jira tickets up to date. You read Jira with the PM's own Atlassian connection. PMOS saves what you read and shows it on the initiative's Jira tickets tab. You change nothing in Jira.
14
+
15
+ Rules that always hold:
16
+ 1. READ-ONLY on Jira. Use only the Jira search tool. Never create, edit, transition, assign, comment on, link, close or delete an issue. Never write to Confluence or to any other source.
17
+ 2. THE ONLY WRITE is `pmos jira sync`. Never edit a repository file. Never run `pmos item`. Never write a ledger by hand. Never commit; the PM's Sync carries the files.
18
+ 3. LIGHT ON CONTENT. Ask Jira only for these fields: summary, status, the sprint field, subtasks and the Flagged field. Never ask for or copy a description, a comment, an attachment, a person or a token.
19
+ 4. TEMPORARY FILES OUTSIDE THE REPOSITORY. Save each search result in this session's scratch folder, never in the repository, and delete it after the sync.
20
+ 5. NOTHING INVENTED. Give `pmos jira sync` exactly what Jira returned. If Jira cannot be reached, list the initiative under "Not reached" and write nothing for it.
21
+
22
+ Procedure:
23
+ 1. If the task names one initiative ("This check: initiative …"), check that one only: run `pmos jira query <initiative>`. Otherwise run `pmos jira query`: each line is an initiative id, a tab and the Jira search (JQL) to run for it. If no initiative has a ticket in Jira, say so in one line and stop.
24
+ 2. For each initiative, run its search with the Jira search tool and the fields in rule 3. The sprint is usually a custom field named "Sprint" (often `customfield_10020`); ask for it by name. Fetch every page.
25
+ 3. Save the result as JSON in the scratch folder, for example `<scratch>/jira-<initiative>.json`, as a list with one object per issue:
26
+ `{"key", "summary", "status", "category", "sprint", "subtasks", "flagged", "reason", "url"}`
27
+ - `category`: the status category, `new`, `indeterminate` or `done`.
28
+ - `sprint`: `{"name": "Sprint 42", "state": "active"}` for the active sprint, `"future"` for a planned one, or `null` when the issue is in the backlog.
29
+ - `subtasks`: `{"done": 2, "total": 5}`.
30
+ - `flagged`: `true` when the issue is flagged; `reason`: the flag's comment or the blocking status, in a few words.
31
+ - `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
32
+ The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
33
+ 4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
34
+ 5. Finish with the three sections of the output contract, one short line each:
35
+ - Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change".
36
+ - Not reached: each initiative whose search failed, with the reason.
37
+ - Needs you: tickets newly blocked, tickets the search did not return, and any refusal.
38
+
39
+ Output contract — produce EXACTLY these sections, the same shape every run:
40
+ - Checked
41
+ - Not reached
42
+ - Needs you
@@ -29,7 +29,7 @@ Procedure:
29
29
  Then run `pmos proactive goals`. It lists the PM's goals for today, each with its id (g-xxxxxxxx), its status and, if set, an initiative. The goals are what the PM wants done today: plan for them first.
30
30
  Then run `pmos proactive learn lessons`. Each line is a preference the PM approved, written from their own feedback. Use it only to decide how confident to be and where an item goes. A lesson never asks you to read another source, to switch a setting on, or to write anything.
31
31
  2. Read the sources that are on and reachable.
32
- - Calendar: today's events. Wispr Flow: today's and tomorrow's meeting notes. Jira: issues assigned to the PM or due soon. Confluence: pages changed since yesterday. Mail, only if on: subjects and senders of threads waiting on the PM, no bodies.
32
+ - Calendar: today's events. Wispr Flow: today's and tomorrow's meeting notes. Jira: issues assigned to the PM or due soon. 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. Confluence: pages changed since yesterday. Mail, only if on: subjects and senders of threads waiting on the PM, no bodies.
33
33
  - GitHub, if on: pull requests that wait on the PM for review, pull requests of the PM that have a review or a failed check, and issues assigned to the PM. Keep the repository, the number, the title and the state.
34
34
  - Drive, if on: files shared with the PM or changed since yesterday that a meeting or a goal names. Keep the name, the owner and the link.
35
35
  - Figma, if on: files the PM owns or was asked to look at, with new comments since yesterday. Keep the file, the commenter and a short paraphrase of the comment.
@@ -28,7 +28,7 @@ Procedure:
28
28
  2. Read the definition: `<state>/reports/definitions/biweekly.md`.
29
29
  - If it exists, write your notes to its purpose, audience, length and tone. The script applies its sections.
30
30
  - If it does NOT exist, do not stop. Look for earlier biweekly reports in the repository and under <state>/reports/ to learn the shape the PM already uses. Write a PROPOSED definition (purpose, sources, sections, length, tone) to `<state>/reports/definitions/biweekly.md` with this frontmatter, one key per line and nothing indented: `type: Reference`, `title`, `description`, `cadence: biweekly`, `sections: [headline, timeline, initiatives, new, decided, needs, sources]`, `generated: {by: claude-code/report-biweekly, at: "…"}`, `status: draft` — and then use it for this draft. Never set `status: stable` or add `verified` yourself: only the PM confirms a definition. Never set `audience`: only the PM names who reads the report, and the script writes no asks until the PM does.
31
- 3. Do not read a tracker. Do not run `tracker-refresh`. That skill is scheduled before this one and writes its evidence into the plan items from local files. If it did not run, the script lists the tracker under "Sources reached and not reached". Repeat that line in your final message.
31
+ 3. Do not read a tracker. Do not run `tracker-refresh` or the Jira status check (`jira-sync`). Both are scheduled before this one and write their evidence in local files. If one did not run, the script lists its source under "Sources reached and not reached". Repeat that line in your final message.
32
32
  4. Run `pmos report draft biweekly`. The script has no model: it folds the ledgers since the last issued biweekly report and writes the whole draft to `<state>/reports/biweekly/YYYY-MM-DD.md` (the slot is today's date), with one empty note marker for each block. Read what it prints.
33
33
  - It prints the path: go on.
34
34
  - It refuses because a draft for the slot exists: do NOT overwrite it. Do not pass `--redraft` here, and do not pass `--now`. Write your notes into the file that exists.
@@ -25,6 +25,7 @@ Rules that always hold (D71):
25
25
  7. NEVER REOPEN, NEVER WITHDRAW. Never move an item that is Done or Withdrawn. Never withdraw an item: if a file says the tracker closed it as "won't do", list it under "Left as they are" and say why.
26
26
  8. A FILE THAT PMOS WRITES IS NEVER EVIDENCE. Never use a plan-items ledger, any `*.jsonl` file, anything under `<state>/runs/` or anything under `<state>/reports/` as evidence. Those files hold the state you are about to check, so they cannot confirm it.
27
27
  9. AN OLD FILE DOES NOT UNDO A NEWER MOVE. Move an item only when the file is dated later than the item's last move.
28
+ 10. JIRA TICKETS ARE NOT YOURS. The Jira tickets PMOS pushed are shown on each initiative's Jira tickets tab, from the snapshot the Jira status check writes in `<state>/tracker/jira/`. Do not read or move them, and never use a file in `<state>/tracker/jira/` as evidence for a plan item.
28
29
 
29
30
  Procedure:
30
31
  1. Find the state directory: `pmos/state/` if it exists, otherwise `planning/`. Call it <state>.
@@ -0,0 +1,6 @@
1
+ {
2
+ "_comment": "jira-wbs: `pmos jira` — Jira tickets drafted from the PRD in the PM's template, pushed by the PM's selection, their status read back (lib/jira-tickets.js, lib/jira-status.js, lib/jira-cli.js), and the initiative sheet's Jira tickets tab with its routes (public/pm/sheet-jira.js), on synthetic repositories with no network.",
3
+ "modules": ["jira-wbs.test.js"],
4
+ "panels": ["pm-jira-test.js"],
5
+ "seconds": { "jira-wbs.test.js": 3, "pm-jira-test.js": 40 }
6
+ }