create-pmos 0.57.1 → 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.
@@ -0,0 +1,668 @@
1
+ 'use strict';
2
+ /* jira-tickets.js — Jira tickets drafted from the PRD in the PM's template, selected by the PM, then pushed (initiative
3
+ * jira-wbs, PRD J1 to J10 and J12, proposed D88).
4
+ *
5
+ * items(root, initiative) → { items, sources, prd } J1: the ticket items of the PRD and story files
6
+ * fromItem(item, prd) → a ticket J2: one item in the PM's template
7
+ * draft(root, initiative, opts) → the merged draft, written J3: new items added, unedited ones redrafted
8
+ * view(root, initiative) → what the tab draws J4, J5, J10, J12
9
+ * edit(root, initiative, id, patch) → the ticket, written J5
10
+ * resolvePrd(root, initiative, id, use) → 'prd' | 'keep' J10
11
+ * setEpic(root, initiative, { mode, key }) → the epic choice J8
12
+ * approve(root, initiative, ids) → the selected hashes J6 (the caller has checked it is the PM)
13
+ * pushPlan(root, initiative) → what to create or update J7: approved content only
14
+ * record(root, initiative, keys) → keys and pushed hashes J9
15
+ *
16
+ * THE DRAFT FILE is `<state>/tracker/jira/<initiative>-tickets.md`. PMOS owns the text of these tickets and Jira owns
17
+ * their status (D88). A ticket is:
18
+ *
19
+ * ## TD-01 · Carry the real runtime version on every GA4 event
20
+ * - type: Task Task | Story | Bug
21
+ * - priority: High Highest | High | Medium | Low
22
+ * - estimate: S XS | S | M | L | XL, or absent
23
+ * - depends: TD-00 other ticket ids, comma-separated, or absent
24
+ * - source: <hash of the PRD item it matches>
25
+ * - drafted: <hash of the content as drafted> (equal to the content: the PM has not edited it)
26
+ * - approved: <hash of the content the PM selected for a push>
27
+ * - jira: ATL-491 (written by `record`)
28
+ * - pushed: <hash of the content last pushed>
29
+ *
30
+ * ### User Story · ### Additional Context · ### User Flow · ### Functional Requirements · ### Terminal States
31
+ * ### Acceptance Criteria · ### Refs
32
+ *
33
+ * The CONTENT of a ticket is its summary, the four fields and the seven sections. Approval, push and edit detection
34
+ * all compare a hash of it, so a key or a hash written by `record` never counts as an edit.
35
+ *
36
+ * BOUNDARY (J15). No network module and no child process: an agent creates the tickets with the PM's own Atlassian
37
+ * connection (D53) and tells this module the keys. Who may approve is decided by the caller (pm-guard), never here.
38
+ */
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+ const crypto = require('crypto');
42
+ const planItems = require('./plan-items.js');
43
+ const planTree = require('./plan-tree.js');
44
+ const consoleState = require('./console-state');
45
+
46
+ const KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
47
+ const KEY_IN = /\b([A-Z][A-Z0-9_]+-\d+)\b/;
48
+ const ID_RE = /^[A-Za-z0-9][\w.-]{0,40}$/;
49
+ const TICKET_RE = /^## ([A-Za-z0-9][\w.-]{0,40}) · (.*)$/;
50
+ const FIELD_RE = /^- (type|priority|estimate|depends|source|drafted|approved|jira|pushed): (.*)$/;
51
+ const TYPES = Object.freeze(['Task', 'Story', 'Bug']);
52
+ const PRIORITIES = Object.freeze(['Highest', 'High', 'Medium', 'Low']);
53
+ const ESTIMATES = Object.freeze(['XS', 'S', 'M', 'L', 'XL']);
54
+ /** The template's sections, in order, as [key, heading]. */
55
+ const SECTIONS = Object.freeze([
56
+ ['story', 'User Story'], ['context', 'Additional Context'], ['flow', 'User Flow'],
57
+ ['functional', 'Functional Requirements'], ['terminal', 'Terminal States'], ['acceptance', 'Acceptance Criteria'], ['refs', 'Refs'],
58
+ ]);
59
+ const REQUIRED = Object.freeze(['story', 'functional', 'acceptance']);
60
+ const ALIASES = Object.freeze({
61
+ 'user story': 'story', story: 'story', 'additional context': 'context', context: 'context', background: 'context',
62
+ 'user flow': 'flow', flow: 'flow', 'functional requirements': 'functional', requirements: 'functional',
63
+ 'terminal states': 'terminal', 'end states': 'terminal', 'acceptance criteria': 'acceptance', acceptance: 'acceptance',
64
+ refs: 'refs', references: 'refs', links: 'refs',
65
+ });
66
+ const ITEM_SECTIONS = /^(user stories|stories|tickets|jira tickets|work items)\b/i;
67
+ const MAX_SUMMARY = 250;
68
+ const MAX_SECTION = 20000;
69
+
70
+ const isStr = (v) => typeof v === 'string';
71
+ const oneLine = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
72
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
73
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
74
+ const tokens = (s) => String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
75
+ const pick = (list, v) => list.find((x) => x.toLowerCase() === String(v || '').trim().toLowerCase()) || null;
76
+
77
+ function engine() {
78
+ const loaded = consoleState.loadEngine();
79
+ if (!loaded.ok) throw new Error(loaded.error);
80
+ return loaded.engine;
81
+ }
82
+ const mainRoot = (root) => planItems.ledgerRoot(root);
83
+ const stateDir = (root) => path.dirname(engine().layout(mainRoot(root)).initiatives);
84
+ function recordFm(root, initiative) {
85
+ const file = planTree.recordPath(root, initiative);
86
+ if (!file) return null;
87
+ try { return engine().parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {}; } catch { return {}; }
88
+ }
89
+ /** The PRD of an initiative: `prd_path` in a code repository, `doc_path` (the lane's draft) in a document repository. */
90
+ function prdOf(rfm) {
91
+ for (const k of ['prd_path', 'doc_path']) if (rfm && isStr(rfm[k]) && rfm[k].trim()) return rfm[k].trim();
92
+ return null;
93
+ }
94
+ function jiraDir(root) { return path.join(stateDir(root), 'tracker', 'jira'); }
95
+ function draftPath(root, initiative) { return path.join(jiraDir(root), `${initiative}-tickets.md`); }
96
+ const relOf = (root, file) => path.relative(mainRoot(root), file).split(path.sep).join('/');
97
+ function draftRel(root, initiative) { return relOf(root, draftPath(root, initiative)); }
98
+
99
+ // ── J1 · the ticket items of the PRD ───────────────────────────────────────────────────────────────
100
+ const stripFm = (text) => String(text).replace(/^---\n[\s\S]*?\n---\n?/, '');
101
+ const slug = (s) => tokens(s).slice(0, 6).join('-') || 'item';
102
+ /** "TD-01 · Carry the version" / "S3: Pay" / "Pay" → { id, title } */
103
+ function splitHeading(h) {
104
+ const t = oneLine(h.replace(/\*\*/g, ''));
105
+ const m = /^([A-Za-z]{1,5}-?\d+)\s*[·:.\-—–]\s*(.+)$/.exec(t);
106
+ if (m) return { id: m[1].toUpperCase(), title: m[2].replace(/\.$/, '').trim() };
107
+ return { id: null, title: t.replace(/\.$/, '') };
108
+ }
109
+ /** "As a PM, I want to pay with a saved card, so that …" → "Pay with a saved card" */
110
+ function titleFromStory(text) {
111
+ const m = /\bI want(?: to)? (.+?)(?:,? so that\b|[.;]|$)/i.exec(oneLine(text));
112
+ if (!m) return clip(oneLine(text), 80);
113
+ const t = m[1].trim();
114
+ return clip(t[0].toUpperCase() + t.slice(1), 80);
115
+ }
116
+ /** The items under one heading: its subheadings, else its top-level bullets. */
117
+ function itemsIn(lines, level) {
118
+ const out = [];
119
+ const sub = '#'.repeat(level + 1) + ' ';
120
+ if (lines.some((l) => l.startsWith(sub))) {
121
+ let cur = null;
122
+ for (const l of lines) {
123
+ if (l.startsWith(sub)) { cur = { heading: l.slice(sub.length), body: [] }; out.push(cur); continue; }
124
+ if (cur) cur.body.push(l);
125
+ }
126
+ return out.map((s) => { const h = splitHeading(s.heading); return { id: h.id, title: h.title, body: s.body.join('\n').trim() }; });
127
+ }
128
+ let cur = null;
129
+ for (const l of lines) {
130
+ const b = /^[-*] (.+)$/.exec(l);
131
+ if (b) { cur = { first: b[1], rest: [] }; out.push(cur); continue; }
132
+ if (cur && /^\s+\S/.test(l)) cur.rest.push(l.trim());
133
+ else if (cur && l.trim()) cur = null;
134
+ }
135
+ return out.map((s) => {
136
+ const lead = /^\*\*(.+?)\*\*\s*(.*)$/.exec(s.first);
137
+ const body = [lead ? lead[2] : s.first, ...s.rest].join('\n').trim();
138
+ if (lead) { const h = splitHeading(lead[1]); return { id: h.id, title: h.title || titleFromStory(body), body }; }
139
+ return { id: null, title: titleFromStory(body), body };
140
+ }).filter((s) => /\bas an? \b/i.test(s.body) || s.id);
141
+ }
142
+ /** The items in a PRD: the sections headed "User stories", "Stories", "Tickets", "Jira tickets" or "Work items". */
143
+ function itemsInPrd(text) {
144
+ const lines = stripFm(text).split('\n');
145
+ const out = [];
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const m = /^(#{2,4}) (.+)$/.exec(lines[i]);
148
+ if (!m || !ITEM_SECTIONS.test(m[2].trim())) continue;
149
+ const level = m[1].length;
150
+ const body = [];
151
+ for (let j = i + 1; j < lines.length; j++) {
152
+ const h = /^(#{1,6}) /.exec(lines[j]);
153
+ if (h && h[1].length <= level) break;
154
+ body.push(lines[j]);
155
+ }
156
+ out.push(...itemsIn(body, level));
157
+ }
158
+ return out;
159
+ }
160
+ /** A story file: each `## ` section is an item. */
161
+ function itemsInFile(text) {
162
+ return itemsIn(stripFm(text).split('\n').filter((l) => !/^# /.test(l)), 1);
163
+ }
164
+ function readPrd(root, prd) {
165
+ try { return fs.readFileSync(path.join(root, prd), 'utf8'); } catch { /* the main tree */ }
166
+ try { return fs.readFileSync(path.join(mainRoot(root), prd), 'utf8'); } catch { return null; }
167
+ }
168
+ function items(root, initiative) {
169
+ const fm = recordFm(root, initiative);
170
+ if (!fm) return { ok: false, error: `no initiative ${initiative} in this repository` };
171
+ const base = mainRoot(root);
172
+ const found = []; const sources = [];
173
+ const prd = prdOf(fm) || '';
174
+ if (prd) {
175
+ const text = readPrd(root, prd);
176
+ if (text != null) { const s = itemsInPrd(text); for (const x of s) x.source = prd; if (s.length) { found.push(...s); sources.push(prd); } }
177
+ }
178
+ const dir = path.join(stateDir(root), 'stories');
179
+ let names = [];
180
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort(); } catch { names = []; }
181
+ for (const n of names) {
182
+ let text = '';
183
+ try { text = fs.readFileSync(path.join(dir, n), 'utf8'); } catch { continue; }
184
+ const ffm = engine().parseLeadingFrontmatter(text) || {};
185
+ const mine = ffm.initiative_id === initiative || n === `${initiative}.md` || n.startsWith(`${initiative}-`);
186
+ if (!mine) continue;
187
+ const rel = path.relative(base, path.join(dir, n)).split(path.sep).join('/');
188
+ const s = itemsInFile(text);
189
+ for (const x of s) x.source = rel;
190
+ if (s.length) { found.push(...s); sources.push(rel); }
191
+ }
192
+ // Ids: the item's own (TD-01, S3) or a slug of its title; a repeat gets -2, -3.
193
+ const seen = new Map();
194
+ for (const s of found) {
195
+ let id = s.id || slug(s.title);
196
+ const n = (seen.get(id) || 0) + 1; seen.set(id, n);
197
+ if (n > 1) id = `${id}-${n}`;
198
+ s.id = id;
199
+ s.title = clip(oneLine(s.title), MAX_SUMMARY);
200
+ s.hash = sha(s.title + '\n' + s.body);
201
+ }
202
+ return { ok: true, initiative, items: found, sources, prd: prd || null };
203
+ }
204
+
205
+ // ── J2 · one item in the PM's template ─────────────────────────────────────────────────────────────
206
+ const sectionKey = (line) => {
207
+ const t = String(line).replace(/^#{1,6}\s+/, '').replace(/\*\*/g, '').replace(/:\s*$/, '').trim().toLowerCase();
208
+ return ALIASES[t] || null;
209
+ };
210
+ /** "Type: Task · Priority: High · Estimate: S · Depends on: TD-00" → the four fields it names. */
211
+ function metaLine(line) {
212
+ const t = String(line).replace(/\*\*/g, '').replace(/^\s*[-*]\s+/, '');
213
+ if (!/\b(type|priority|estimate|depends on)\s*:/i.test(t)) return null;
214
+ const out = {};
215
+ for (const part of t.split(/\s*[·|]\s*/)) {
216
+ const m = /^\s*(type|priority|estimate|depends on)\s*:\s*(.*)$/i.exec(part);
217
+ if (!m) continue;
218
+ const k = m[1].toLowerCase(); const v = m[2].trim();
219
+ if (k === 'type') out.type = pick(TYPES, v);
220
+ else if (k === 'priority') out.priority = pick(PRIORITIES, v);
221
+ else if (k === 'estimate') out.estimate = pick(ESTIMATES, v);
222
+ else out.depends = /^[-—–]?$|^none$/i.test(v) ? [] : v.split(/[\s,]+/).map((x) => x.trim()).filter((x) => ID_RE.test(x));
223
+ }
224
+ return Object.keys(out).length ? out : null;
225
+ }
226
+ const LINK_RE = /\[[^\]]+\]\([^)\s]+\)/g;
227
+ /**
228
+ * A ticket from a PRD item. The item's own subsections fill the template by name; a `Type: … · Priority: …` line sets
229
+ * the fields; text before the first subsection is the User Story when it reads "As a …", else Additional Context.
230
+ */
231
+ function fromItem(item, prd) {
232
+ const t = { id: item.id, summary: item.title, type: 'Task', priority: 'Medium', estimate: null, depends: [], sections: {} };
233
+ const buckets = { lead: [] };
234
+ let cur = 'lead';
235
+ for (const raw of String(item.body || '').split('\n')) {
236
+ const m = cur === 'lead' ? metaLine(raw) : null;
237
+ if (m) { for (const [k, v] of Object.entries(m)) if (v !== null) t[k] = v; continue; }
238
+ const sum = cur === 'lead' ? /^\s*(?:[-*]\s+)?(?:\*\*)?summary(?:\*\*)?\s*:(?:\*\*)?\s*(.+)$/i.exec(raw) : null;
239
+ if (sum) { t.summary = clip(oneLine(sum[1].replace(/\*\*/g, '')), MAX_SUMMARY); continue; }
240
+ const looksHeading = /^#{1,6}\s+\S/.test(raw) || /^\s*\*\*[^*]+\*\*:?\s*$/.test(raw) || /^[A-Z][A-Za-z ]{2,30}:\s*$/.test(raw);
241
+ const k = looksHeading ? sectionKey(raw.trim()) : null;
242
+ if (k) { cur = k; buckets[k] = buckets[k] || []; continue; }
243
+ (buckets[cur] = buckets[cur] || []).push(raw);
244
+ }
245
+ // A "## " line would start a new ticket in the draft file, and a "### " line a new section: keep them as text.
246
+ const text = (lines) => (lines || []).join('\n').replace(/^\n+|\n+$/g, '').replace(/^(#{1,3}) /gm, '#### ');
247
+ const lead = text(buckets.lead);
248
+ for (const [k] of SECTIONS) if (k !== 'refs') t.sections[k] = text(buckets[k]);
249
+ if (lead) {
250
+ const k = /^\s*(?:[-*]\s+)?as an? \b/i.test(lead) ? 'story' : 'context';
251
+ t.sections[k] = [lead, t.sections[k]].filter(Boolean).join('\n\n');
252
+ }
253
+ // Refs: the PRD and the item, the item's own refs, and every other link the item carries.
254
+ const own = text(buckets.refs);
255
+ const links = [];
256
+ for (const [k, lines] of Object.entries(buckets)) {
257
+ if (k === 'refs') continue;
258
+ for (const l of lines) for (const m of l.matchAll(LINK_RE)) if (!own.includes(m[0]) && !links.includes(m[0])) links.push(m[0]);
259
+ }
260
+ const refs = [prd ? `- PRD: \`${prd}\` · ${item.id}` : `- Item: ${item.id}`];
261
+ if (own) refs.push(own);
262
+ for (const l of links) refs.push(`- ${l}`);
263
+ t.sections.refs = refs.join('\n');
264
+ t.depends = t.depends.filter((d) => d !== t.id);
265
+ return t;
266
+ }
267
+
268
+ // ── the draft file ─────────────────────────────────────────────────────────────────────────────────
269
+ function parseDraft(text) {
270
+ const fm = engine().parseLeadingFrontmatter(text) || {};
271
+ const tickets = [];
272
+ let cur = null; let inFields = false; let sec = 'context';
273
+ for (const l of stripFm(text).split('\n')) {
274
+ const h = TICKET_RE.exec(l);
275
+ if (h) { cur = { id: h[1], summary: h[2].trim(), fields: {}, buckets: {} }; tickets.push(cur); inFields = true; sec = 'context'; continue; }
276
+ if (!cur) continue;
277
+ if (/^## /.test(l)) { cur = null; continue; }
278
+ const f = FIELD_RE.exec(l);
279
+ if (inFields && f) { cur.fields[f[1]] = f[2].trim(); continue; }
280
+ inFields = false;
281
+ const s = /^### (.+)$/.exec(l);
282
+ const k = s ? sectionKey(s[1]) : null;
283
+ if (k) { sec = k; cur.buckets[k] = cur.buckets[k] || []; continue; }
284
+ (cur.buckets[sec] = cur.buckets[sec] || []).push(l);
285
+ }
286
+ const hashOk = (v) => (/^[0-9a-f]{16}$/.test(v || '') ? v : null);
287
+ return {
288
+ fm, tickets: tickets.map((t) => {
289
+ const sections = {};
290
+ for (const [k] of SECTIONS) {
291
+ const body = (t.buckets[k] || []).join('\n').replace(/^\n+|\n+$/g, '');
292
+ sections[k] = filled(body) ? body : '';
293
+ }
294
+ return {
295
+ id: t.id, summary: t.summary,
296
+ type: pick(TYPES, t.fields.type) || 'Task', priority: pick(PRIORITIES, t.fields.priority) || 'Medium',
297
+ estimate: pick(ESTIMATES, t.fields.estimate), depends: String(t.fields.depends || '').split(/[\s,]+/).filter((x) => ID_RE.test(x)),
298
+ sections, source: hashOk(t.fields.source), drafted: hashOk(t.fields.drafted), approved: hashOk(t.fields.approved),
299
+ jira: KEY_RE.test(t.fields.jira || '') ? t.fields.jira.toUpperCase() : null, pushed: hashOk(t.fields.pushed),
300
+ };
301
+ }),
302
+ };
303
+ }
304
+ function readDraft(root, initiative) {
305
+ let text;
306
+ try { text = fs.readFileSync(draftPath(root, initiative), 'utf8'); } catch { return null; }
307
+ return parseDraft(text);
308
+ }
309
+ /** A section that only says "—" is empty. */
310
+ function filled(s) { return !!String(s || '').replace(/^[\s—–-]+$/, '').trim(); }
311
+ /** The content of a ticket: what a push carries, and so what approval and edit detection cover. */
312
+ const norm = (s) => (filled(s) ? String(s).trim() : '');
313
+ const contentOf = (t) => JSON.stringify([t.id, oneLine(t.summary), t.type, t.priority, t.estimate || '', (t.depends || []).join(','),
314
+ ...SECTIONS.map(([k]) => norm(t.sections[k]))]);
315
+ const contentHash = (t) => sha(contentOf(t));
316
+ const nowIso = (now) => new Date(now == null ? Date.now() : now).toISOString().replace(/\.\d{3}Z$/, 'Z');
317
+ function renderDraft({ initiative, fm, tickets, prd, timestamp }) {
318
+ const lines = ['---', 'type: Reference', `title: "Jira tickets: ${initiative}"`, `timestamp: ${timestamp}`];
319
+ if (fm.epic_mode === 'new' || fm.epic_mode === 'existing') lines.push(`epic_mode: ${fm.epic_mode}`);
320
+ if (KEY_RE.test(fm.epic || '')) lines.push(`epic: ${String(fm.epic).toUpperCase()}`);
321
+ lines.push('---', '');
322
+ lines.push(`# Jira tickets: ${initiative}`, '',
323
+ `Drafted by PMOS from ${prd ? `[${prd}](/${prd})` : 'the PRD'}, one ticket per item. Read and edit them on the initiative's Jira tickets tab, or here. Check the ones that are ready and press Push: only checked tickets are sent. A ticket edited after it was checked must be checked again.`, '',
324
+ 'PMOS owns the text of these tickets and Jira owns their status. When the PRD changes, the tab flags each ticket whose item changed.', '');
325
+ for (const t of tickets) {
326
+ lines.push(`## ${t.id} · ${oneLine(t.summary)}`);
327
+ lines.push(`- type: ${t.type}`, `- priority: ${t.priority}`);
328
+ if (t.estimate) lines.push(`- estimate: ${t.estimate}`);
329
+ if (t.depends && t.depends.length) lines.push(`- depends: ${t.depends.join(', ')}`);
330
+ for (const k of ['source', 'drafted', 'approved', 'jira', 'pushed']) if (t[k]) lines.push(`- ${k}: ${t[k]}`);
331
+ lines.push('');
332
+ for (const [k, head] of SECTIONS) {
333
+ const body = norm(t.sections[k]);
334
+ lines.push(`### ${head}`, '', body || '—', '');
335
+ }
336
+ }
337
+ return lines.join('\n');
338
+ }
339
+ function writeDraft(root, initiative, d, { now, prd } = {}) {
340
+ const file = draftPath(root, initiative);
341
+ fs.mkdirSync(path.dirname(file), { recursive: true });
342
+ const tmp = `${file}.${process.pid}.tmp`;
343
+ fs.writeFileSync(tmp, renderDraft({ initiative, fm: d.fm, tickets: d.tickets, prd, timestamp: nowIso(now) }));
344
+ fs.renameSync(tmp, file);
345
+ }
346
+
347
+ // ── J4 · complete ──────────────────────────────────────────────────────────────────────────────────
348
+ const NAME = Object.fromEntries(SECTIONS);
349
+ function missingOf(t) {
350
+ const out = [];
351
+ if (!oneLine(t.summary)) out.push('Summary');
352
+ for (const k of REQUIRED) if (!filled(t.sections[k])) out.push(NAME[k]);
353
+ return out;
354
+ }
355
+
356
+ // ── J3 · draft ─────────────────────────────────────────────────────────────────────────────────────
357
+ function anchorOf(rfm) {
358
+ return isStr(rfm.jira_issue_key) && KEY_RE.test(rfm.jira_issue_key.trim()) ? rfm.jira_issue_key.trim().toUpperCase() : null;
359
+ }
360
+ function draft(root, initiative, { dryRun = false, now } = {}) {
361
+ const st = items(root, initiative);
362
+ if (!st.ok) return st;
363
+ if (!st.items.length) return { ok: false, error: `no ticket items found for ${initiative} — add a "User stories" or "Tickets" section to its PRD${st.prd ? ` (${st.prd})` : ''}, one subheading per ticket, such as "### TD-01 · Title"` };
364
+ const old = readDraft(root, initiative);
365
+ const prev = old ? old.tickets : [];
366
+ const byId = new Map(prev.map((t) => [t.id, t]));
367
+ const changes = [];
368
+ const out = [];
369
+ for (const it of st.items) {
370
+ const was = byId.get(it.id);
371
+ if (!was) {
372
+ const t = { ...fromItem(it, it.source), source: it.hash, approved: null, jira: null, pushed: null };
373
+ t.drafted = contentHash(t);
374
+ out.push(t); changes.push({ ticket: t.id, what: 'added', summary: t.summary });
375
+ continue;
376
+ }
377
+ byId.delete(it.id);
378
+ const t = { ...was };
379
+ if (t.source !== it.hash && !t.jira && (!t.drafted || t.drafted === contentHash(t))) {
380
+ Object.assign(t, fromItem(it, it.source), { source: it.hash });
381
+ t.drafted = contentHash(t);
382
+ changes.push({ ticket: t.id, what: 'redrafted', summary: t.summary, why: 'its item changed in the PRD and the ticket was not edited or pushed' });
383
+ }
384
+ out.push(t);
385
+ }
386
+ // Tickets whose item is gone stay, at the end: the tab says so and Jira is not touched (J10).
387
+ for (const t of byId.values()) out.push(t);
388
+ const fm = old ? { ...old.fm } : {};
389
+ if (!fm.epic_mode) {
390
+ const anchor = anchorOf(recordFm(root, initiative) || {});
391
+ fm.epic_mode = anchor ? 'existing' : 'new';
392
+ if (anchor && !fm.epic) fm.epic = anchor;
393
+ }
394
+ const result = { ok: true, initiative, draft: draftRel(root, initiative), sources: st.sources, tickets: out.length, changes, changed: !old || changes.length > 0 };
395
+ if (dryRun || (old && !changes.length)) return result;
396
+ writeDraft(root, initiative, { fm, tickets: out }, { now, prd: st.prd });
397
+ return result;
398
+ }
399
+
400
+ // ── J4, J5, J10, J12 · what the tab draws ──────────────────────────────────────────────────────────
401
+ function epicOf(d, rfm) {
402
+ const fm = (d && d.fm) || {};
403
+ const anchor = anchorOf(rfm);
404
+ const mode = fm.epic_mode === 'existing' || fm.epic_mode === 'new' ? fm.epic_mode : (anchor ? 'existing' : 'new');
405
+ const key = KEY_RE.test(fm.epic || '') ? String(fm.epic).toUpperCase() : (mode === 'existing' ? anchor : null);
406
+ const pushedAny = !!(d && d.tickets.some((t) => t.jira));
407
+ return { mode, key, locked: pushedAny || (mode === 'new' && !!key), summary: isStr(rfm.title) ? rfm.title : null };
408
+ }
409
+ /** Is the handoff done, so the tab opens (J12)? */
410
+ function gateOf(root, initiative, d, rfm) {
411
+ if (d && d.tickets.some((t) => t.jira)) return { open: true, why: 'a ticket is in Jira' };
412
+ let stages = [];
413
+ try { stages = planTree.stagesFor(root, initiative); } catch { stages = []; }
414
+ if (!stages.some((s) => s.id === 'handoff')) return { open: true, why: 'this lane has no handoff stage' };
415
+ if (isStr(rfm.handed_off) && rfm.handed_off.trim()) return { open: true, why: 'handed off' };
416
+ let fold = null;
417
+ try { fold = planItems.readFold(root); } catch { fold = null; }
418
+ const mine = fold ? (fold.order || []).map((id) => fold.items[id])
419
+ .filter((it) => it && it.initiative === initiative && it.stage === 'handoff' && !it.proposed && it.state !== 'withdrawn') : [];
420
+ if (mine.length && mine.every((it) => it.state === 'done')) return { open: true, why: 'every Handoff item is done' };
421
+ return { open: false, why: mine.length ? 'the Handoff item is not done yet' : 'the handoff is not done yet' };
422
+ }
423
+ function view(root, initiative, { snapshot } = {}) {
424
+ const rfm = recordFm(root, initiative);
425
+ if (!rfm) return { ok: false, status: 404, error: `no initiative ${initiative} in this repository` };
426
+ const st = items(root, initiative);
427
+ const d = readDraft(root, initiative);
428
+ const byItem = new Map((st.ok ? st.items : []).map((x) => [x.id, x]));
429
+ const reading = (snapshot && snapshot.tickets) || new Map();
430
+ const tickets = (d ? d.tickets : []).map((t) => {
431
+ const h = contentHash(t);
432
+ const it = byItem.get(t.id);
433
+ const missing = missingOf(t);
434
+ const prdChanged = !!(it && t.source && it.hash !== t.source);
435
+ const removed = !!(st.ok && !it);
436
+ const edited = !!(t.jira && t.pushed && t.pushed !== h);
437
+ const waiting = !!(t.approved === h && (!t.jira || t.pushed !== h));
438
+ const r = t.jira ? reading.get(t.jira) || null : null;
439
+ // One flag per row, the most urgent first (the mockup: a row carries one).
440
+ const flag = prdChanged ? 'prd-changed' : removed ? 'removed' : (r && r.flagged) ? 'blocked' : missing.length ? 'incomplete'
441
+ : waiting ? 'pushing' : edited ? 'edited' : null;
442
+ return {
443
+ id: t.id, summary: t.summary, type: t.type, priority: t.priority, estimate: t.estimate, depends: t.depends,
444
+ sections: t.sections, key: t.jira, inJira: !!t.jira, edited, prdChanged, removed, missing, waiting,
445
+ selectable: !missing.length && !removed && (!t.jira || t.pushed !== h), flag,
446
+ jira: r ? { status: r.status, category: r.category, sprint: r.sprint, subtasks: r.subtasks, flagged: !!r.flagged, reason: r.reason || null, url: r.url || null, stale: !!r.stale } : null,
447
+ prd: prdChanged ? (() => { const p = fromItem(it, it.source); return { summary: p.summary, type: p.type, priority: p.priority, estimate: p.estimate, depends: p.depends, sections: p.sections }; })() : null,
448
+ };
449
+ });
450
+ const have = new Set(tickets.map((t) => t.id));
451
+ const fresh = (st.ok ? st.items : []).filter((x) => !have.has(x.id)).map((x) => ({ id: x.id, summary: x.title }));
452
+ return {
453
+ ok: true, initiative, title: isStr(rfm.title) ? rfm.title : initiative, prd: st.ok ? st.prd : null,
454
+ gate: gateOf(root, initiative, d, rfm), epic: epicOf(d, rfm), drafted: !!d, draft: draftRel(root, initiative),
455
+ items: st.ok ? st.items.length : 0, newInPrd: fresh, tickets,
456
+ inJira: tickets.filter((t) => t.inJira).length, checkedAt: snapshot ? snapshot.fetchedAt : null,
457
+ types: TYPES, priorities: PRIORITIES, estimates: ESTIMATES, sectionNames: SECTIONS.map(([k, n]) => ({ key: k, name: n })),
458
+ };
459
+ }
460
+
461
+ // ── J5 · edit ──────────────────────────────────────────────────────────────────────────────────────
462
+ function mustDraft(root, initiative) {
463
+ const d = readDraft(root, initiative);
464
+ if (!d) return { error: `no ticket draft for ${initiative} — press Draft tickets, or run pmos jira draft ${initiative}` };
465
+ return { d };
466
+ }
467
+ function edit(root, initiative, id, patch, { now } = {}) {
468
+ const { d, error } = mustDraft(root, initiative);
469
+ if (error) return { ok: false, status: 404, error };
470
+ const t = d.tickets.find((x) => x.id === id);
471
+ if (!t) return { ok: false, status: 404, error: `no ticket ${id} in the draft` };
472
+ const p = patch && typeof patch === 'object' ? patch : {};
473
+ if ('summary' in p) {
474
+ const s = clip(oneLine(p.summary), MAX_SUMMARY);
475
+ if (!s) return { ok: false, status: 400, error: 'a ticket needs a summary' };
476
+ t.summary = s;
477
+ }
478
+ if ('type' in p) { const v = pick(TYPES, p.type); if (!v) return { ok: false, status: 400, error: `type is one of ${TYPES.join(', ')}` }; t.type = v; }
479
+ if ('priority' in p) { const v = pick(PRIORITIES, p.priority); if (!v) return { ok: false, status: 400, error: `priority is one of ${PRIORITIES.join(', ')}` }; t.priority = v; }
480
+ if ('estimate' in p) {
481
+ if (p.estimate === null || p.estimate === '') t.estimate = null;
482
+ else { const v = pick(ESTIMATES, p.estimate); if (!v) return { ok: false, status: 400, error: `estimate is one of ${ESTIMATES.join(', ')}, or none` }; t.estimate = v; }
483
+ }
484
+ if ('depends' in p) {
485
+ const list = Array.isArray(p.depends) ? p.depends : String(p.depends || '').split(/[\s,]+/);
486
+ const ids = new Set(d.tickets.map((x) => x.id));
487
+ const dep = [...new Set(list.map((x) => String(x).trim()).filter(Boolean))];
488
+ const bad = dep.filter((x) => !ids.has(x) || x === id);
489
+ if (bad.length) return { ok: false, status: 400, error: `${bad.join(', ')} ${bad.length === 1 ? 'is not another ticket' : 'are not other tickets'} of this draft` };
490
+ t.depends = dep;
491
+ }
492
+ if ('sections' in p) {
493
+ if (!p.sections || typeof p.sections !== 'object' || Array.isArray(p.sections)) return { ok: false, status: 400, error: 'sections is an object of section texts' };
494
+ for (const [k, v] of Object.entries(p.sections)) {
495
+ if (!NAME[k]) return { ok: false, status: 400, error: `no section ${k}` };
496
+ if (!isStr(v) || v.length > MAX_SECTION) return { ok: false, status: 400, error: `${NAME[k]} must be text of at most ${MAX_SECTION} characters` };
497
+ // A "## " line would start a new ticket in the file, and a "### " line a new section: keep them as text.
498
+ t.sections[k] = v.replace(/\r\n?/g, '\n').replace(/^(#{1,3}) /gm, '#### ').trim();
499
+ }
500
+ }
501
+ writeDraft(root, initiative, d, { now, prd: prdOf(recordFm(root, initiative)) });
502
+ return { ok: true, initiative, ticket: id, missing: missingOf(t), edited: !!(t.jira && t.pushed !== contentHash(t)) };
503
+ }
504
+
505
+ // ── J10 · the PRD's version, or keep the ticket ────────────────────────────────────────────────────
506
+ function resolvePrd(root, initiative, id, use, { now } = {}) {
507
+ if (use !== 'prd' && use !== 'keep') return { ok: false, status: 400, error: 'say which to use: prd or keep' };
508
+ const { d, error } = mustDraft(root, initiative);
509
+ if (error) return { ok: false, status: 404, error };
510
+ const t = d.tickets.find((x) => x.id === id);
511
+ if (!t) return { ok: false, status: 404, error: `no ticket ${id} in the draft` };
512
+ const st = items(root, initiative);
513
+ const it = st.ok ? st.items.find((x) => x.id === id) : null;
514
+ if (!it) return { ok: false, status: 409, error: `${id} is no longer in the PRD — nothing to take from it` };
515
+ if (t.source === it.hash) return { ok: true, initiative, ticket: id, already: true };
516
+ if (use === 'prd') {
517
+ Object.assign(t, fromItem(it, it.source));
518
+ t.drafted = contentHash(t);
519
+ }
520
+ t.source = it.hash;
521
+ writeDraft(root, initiative, d, { now, prd: st.prd });
522
+ return { ok: true, initiative, ticket: id, use, inJira: !!t.jira, pushable: !!t.jira && t.pushed !== contentHash(t) };
523
+ }
524
+
525
+ // ── J8 · the epic ──────────────────────────────────────────────────────────────────────────────────
526
+ function setEpic(root, initiative, { mode, key } = {}, { now } = {}) {
527
+ const { d, error } = mustDraft(root, initiative);
528
+ if (error) return { ok: false, status: 404, error };
529
+ const rfm = recordFm(root, initiative) || {};
530
+ const cur = epicOf(d, rfm);
531
+ if (cur.locked) return { ok: false, status: 409, error: `the epic is set (${cur.key || 'created at the first push'}) — tickets are already under it` };
532
+ if (mode === 'new') { d.fm.epic_mode = 'new'; delete d.fm.epic; }
533
+ else if (mode === 'existing') {
534
+ const m = KEY_IN.exec(String(key || '').toUpperCase());
535
+ if (!m) return { ok: false, status: 400, error: 'paste the epic\'s Jira link or key, such as ATL-482' };
536
+ d.fm.epic_mode = 'existing'; d.fm.epic = m[1];
537
+ } else return { ok: false, status: 400, error: 'say new or existing' };
538
+ writeDraft(root, initiative, d, { now, prd: prdOf(rfm) });
539
+ return { ok: true, initiative, epic: epicOf(d, rfm) };
540
+ }
541
+
542
+ // ── J6 · approve the selected tickets (the caller checked it is the PM) ────────────────────────────
543
+ function approve(root, initiative, ids, { now } = {}) {
544
+ const { d, error } = mustDraft(root, initiative);
545
+ if (error) return { ok: false, status: 404, error };
546
+ const want = [...new Set((ids || []).map(String))];
547
+ if (!want.length) return { ok: false, status: 400, error: 'select at least one ticket' };
548
+ const st = items(root, initiative);
549
+ const inPrd = new Set(st.ok ? st.items.map((x) => x.id) : []);
550
+ const refused = [];
551
+ for (const id of want) {
552
+ const t = d.tickets.find((x) => x.id === id);
553
+ if (!t) { refused.push(`${id}: no such ticket`); continue; }
554
+ const miss = missingOf(t);
555
+ if (miss.length) { refused.push(`${id}: missing ${miss.join(', ')}`); continue; }
556
+ if (st.ok && !inPrd.has(id)) { refused.push(`${id}: no longer in the PRD`); continue; }
557
+ if (t.jira && t.pushed === contentHash(t)) refused.push(`${id}: already in Jira as ${t.jira}, unchanged`);
558
+ }
559
+ if (refused.length) return { ok: false, status: 409, error: `nothing was approved — ${refused.join('; ')}` };
560
+ const rfm = recordFm(root, initiative) || {};
561
+ const ep = epicOf(d, rfm);
562
+ if (ep.mode === 'existing' && !ep.key) return { ok: false, status: 409, error: 'choose the epic first: paste the existing epic\'s link, or choose Create new epic' };
563
+ if (!d.fm.epic_mode) d.fm.epic_mode = ep.mode;
564
+ if (ep.key && !d.fm.epic) d.fm.epic = ep.key;
565
+ for (const id of want) { const t = d.tickets.find((x) => x.id === id); t.approved = contentHash(t); }
566
+ writeDraft(root, initiative, d, { now, prd: prdOf(rfm) });
567
+ return { ok: true, initiative, approved: want, epic: epicOf(d, rfm) };
568
+ }
569
+
570
+ // ── J7 · the push plan ─────────────────────────────────────────────────────────────────────────────
571
+ function describe(t) {
572
+ const head = [`Type: ${t.type}`, `Priority: ${t.priority}`, `Estimate: ${t.estimate || '—'}`, `Depends on: ${t.depends.length ? t.depends.join(', ') : '—'}`].join(' · ');
573
+ const out = [head, ''];
574
+ for (const [k, name] of SECTIONS) out.push(`## ${name}`, '', norm(t.sections[k]) || '—', '');
575
+ out.push('Drafted in PMOS, which owns this text. Edit it there: the next push replaces it here.');
576
+ return out.join('\n').trim();
577
+ }
578
+ function pushPlan(root, initiative) {
579
+ const rfm = recordFm(root, initiative);
580
+ if (!rfm) return { ok: false, error: `no initiative ${initiative} in this repository` };
581
+ const { d, error } = mustDraft(root, initiative);
582
+ if (error) return { ok: false, status: 409, error };
583
+ const ep = epicOf(d, rfm);
584
+ const title = isStr(rfm.title) ? rfm.title : initiative;
585
+ const labels = ['pmos', `pmos-${initiative}`.slice(0, 60)];
586
+ const keyOf = new Map(d.tickets.filter((t) => t.jira).map((t) => [t.id, t.jira]));
587
+ const create = []; const update = []; let notApproved = 0; let unchanged = 0;
588
+ for (const t of d.tickets) {
589
+ const h = contentHash(t);
590
+ if (t.jira && t.pushed === h) { unchanged++; continue; }
591
+ if (t.approved !== h) { notApproved++; continue; }
592
+ const fields = {
593
+ summary: clip(oneLine(t.summary), MAX_SUMMARY), description: describe(t), issuetype: t.type, priority: t.priority, labels,
594
+ blocked_by: t.depends.map((dep) => ({ ticket: dep, key: keyOf.get(dep) || null })),
595
+ };
596
+ if (!t.jira) create.push({ ticket: t.id, hash: h, ...fields });
597
+ else update.push({ ticket: t.id, key: t.jira, hash: h, ...fields });
598
+ }
599
+ return {
600
+ ok: true, initiative, draft: draftRel(root, initiative),
601
+ epic: ep.key ? { key: ep.key, mode: ep.mode } : { key: null, mode: 'new', create: { summary: clip(oneLine(title), MAX_SUMMARY), issuetype: 'Epic', labels,
602
+ description: `PMOS initiative ${initiative}.${prdOf(rfm) ? ` Spec: ${prdOf(rfm)}.` : ''} The tickets under this epic are drafted in PMOS, which owns their text.` } },
603
+ create, update, notApproved, unchanged,
604
+ };
605
+ }
606
+
607
+ // ── J9 · record what was pushed ──────────────────────────────────────────────────────────────────────
608
+ /**
609
+ * @param {{epic?: string, tickets?: Object<string,string>, pushed?: string[]}} keys
610
+ * tickets: ticket id → the Jira key the agent created; pushed: ticket ids the agent updated in Jira.
611
+ */
612
+ function record(root, initiative, keys, { now } = {}) {
613
+ const { d, error } = mustDraft(root, initiative);
614
+ if (error) return { ok: false, status: 404, error };
615
+ const byId = new Map(d.tickets.map((t) => [t.id, t]));
616
+ const rfm = recordFm(root, initiative) || {};
617
+ if (keys.epic) {
618
+ if (!KEY_RE.test(keys.epic)) return { ok: false, error: `epic ${JSON.stringify(keys.epic)} is not a Jira key like ATL-482` };
619
+ const ep = epicOf(d, rfm);
620
+ if (ep.key && ep.key !== keys.epic.toUpperCase()) return { ok: false, error: `the epic is already ${ep.key}` };
621
+ d.fm.epic = keys.epic.toUpperCase();
622
+ if (!d.fm.epic_mode) d.fm.epic_mode = ep.mode;
623
+ }
624
+ const done = [];
625
+ const taken = new Map(d.tickets.filter((t) => t.jira).map((t) => [t.jira, t.id]));
626
+ const approvedNow = (t) => t.approved === contentHash(t);
627
+ for (const [id, key] of Object.entries(keys.tickets || {})) {
628
+ const t = byId.get(id);
629
+ if (!t) return { ok: false, error: `no ticket ${id} in the draft` };
630
+ if (!approvedNow(t)) return { ok: false, status: 409, error: `${id} is not approved as it stands — nothing is recorded for content the PM has not selected` };
631
+ if (!KEY_RE.test(key)) return { ok: false, error: `${id}: ${JSON.stringify(key)} is not a Jira key like ATL-491` };
632
+ const k = key.toUpperCase();
633
+ if (t.jira && t.jira !== k) return { ok: false, error: `${id} is already ${t.jira} — a ticket is created once` };
634
+ if (taken.has(k) && taken.get(k) !== id) return { ok: false, error: `${k} is already ${taken.get(k)}` };
635
+ t.jira = k; t.pushed = contentHash(t); taken.set(k, id); done.push({ ticket: id, key: k, what: 'created' });
636
+ }
637
+ for (const id of keys.pushed || []) {
638
+ const t = byId.get(id);
639
+ if (!t) return { ok: false, error: `no ticket ${id} in the draft` };
640
+ if (!t.jira) return { ok: false, error: `${id} has no Jira key yet — record the key it was created as` };
641
+ if (!approvedNow(t)) return { ok: false, status: 409, error: `${id} is not approved as it stands — nothing is recorded for content the PM has not selected` };
642
+ t.pushed = contentHash(t); done.push({ ticket: id, key: t.jira, what: 'updated' });
643
+ }
644
+ writeDraft(root, initiative, d, { now, prd: prdOf(rfm) });
645
+ return { ok: true, initiative, epic: d.fm.epic || null, recorded: done };
646
+ }
647
+
648
+ /** The tickets of one initiative that are in Jira: key → { id, summary }. And the epic. */
649
+ function pushedTickets(root, initiative) {
650
+ const d = readDraft(root, initiative);
651
+ if (!d) return { epic: null, tickets: new Map() };
652
+ const tickets = new Map();
653
+ for (const t of d.tickets) if (t.jira) tickets.set(t.jira, { id: t.id, summary: t.summary });
654
+ return { epic: KEY_RE.test(d.fm.epic || '') ? String(d.fm.epic).toUpperCase() : null, tickets };
655
+ }
656
+ /** Every initiative with a ticket draft in this repository. */
657
+ function draftedInitiatives(root) {
658
+ let names = [];
659
+ try { names = fs.readdirSync(jiraDir(root)).filter((n) => n.endsWith('-tickets.md')); } catch { return []; }
660
+ return names.map((n) => n.slice(0, -'-tickets.md'.length)).filter((id) => planItems.INIT_RE.test(id)).sort();
661
+ }
662
+
663
+ module.exports = {
664
+ TYPES, PRIORITIES, ESTIMATES, SECTIONS, REQUIRED,
665
+ items, itemsInPrd, itemsInFile, fromItem, draft, view, edit, resolvePrd, setEpic, approve, pushPlan, record,
666
+ readDraft, parseDraft, pushedTickets, draftedInitiatives, missingOf, contentHash, describe,
667
+ draftPath, draftRel, jiraDir, relOf, recordFm, prdOf,
668
+ };