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.
- 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/first-paint-audit.js +5 -2
- package/workspace/guide/docs.md +1 -1
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/jira.md +141 -0
- package/workspace/guide/layout.md +2 -2
- 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/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 +206 -0
- package/workspace/public/pm/dest-docs.js +1 -1
- package/workspace/public/pm/frame-copy.js +1 -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/public/pm/topbar.js +5 -4
- package/workspace/server.js +69 -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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* jira-status.js — the status of the Jira tickets PMOS pushed, read back read-only (initiative jira-wbs, PRD J11, J13).
|
|
3
|
+
*
|
|
4
|
+
* THE READ PATH. An agent session reads Jira read-only with the PM's own Atlassian connection (D53: PMOS holds no
|
|
5
|
+
* tracker credential; D71: an agent skill reads a source, the server does not) and saves the search result to a file.
|
|
6
|
+
* This module reads that file and writes a snapshot the initiative's Jira tickets tab shows. It writes nothing else:
|
|
7
|
+
* no plan item, no record, no ledger line (D88: the Breakdown is the handoff checklist, not a copy of Jira).
|
|
8
|
+
*
|
|
9
|
+
* queryFor(root, initiative) → { linked, query } the JQL for the tickets this initiative pushed
|
|
10
|
+
* linkedInitiatives(root) → [{ initiative, query }]
|
|
11
|
+
* normalize(raw) → { issues, skipped } | { error }
|
|
12
|
+
* sync(root, initiative, raw, opts) → the snapshot, written to <state>/tracker/jira/<initiative>.md
|
|
13
|
+
* readSnapshot(root, initiative) → { fetchedAt, query, tickets: Map<key, reading> } | null
|
|
14
|
+
* which(root, key) → the initiative and ticket a key belongs to
|
|
15
|
+
*
|
|
16
|
+
* A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed } | null,
|
|
17
|
+
* subtasks: { done, total }, flagged, reason, url }. A ticket the search did not return keeps its last reading, marked
|
|
18
|
+
* stale. Titles and statuses only: never a description, a comment or a person.
|
|
19
|
+
*
|
|
20
|
+
* BOUNDARY (J15). No network module, no child process. The only write is the snapshot file.
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const jiraTickets = require('./jira-tickets.js');
|
|
25
|
+
|
|
26
|
+
const KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
|
|
27
|
+
const isStr = (v) => typeof v === 'string';
|
|
28
|
+
const oneLine = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
|
29
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
|
|
30
|
+
const nowIso = (now) => new Date(now == null ? Date.now() : now).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
31
|
+
|
|
32
|
+
// ── the query ──────────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
function queryFor(root, initiative) {
|
|
34
|
+
if (!jiraTickets.recordFm(root, initiative)) return { ok: false, error: `no initiative ${initiative} in this repository` };
|
|
35
|
+
const keys = [...jiraTickets.pushedTickets(root, initiative).tickets.keys()];
|
|
36
|
+
if (!keys.length) return { ok: true, initiative, linked: false, keys: [], query: null };
|
|
37
|
+
return { ok: true, initiative, linked: true, keys, query: `key in (${keys.join(', ')}) ORDER BY key` };
|
|
38
|
+
}
|
|
39
|
+
function linkedInitiatives(root) {
|
|
40
|
+
return jiraTickets.draftedInitiatives(root).map((id) => queryFor(root, id)).filter((q) => q.ok);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── reading a saved search result ──────────────────────────────────────────────────────────────────
|
|
44
|
+
function issuesList(raw) {
|
|
45
|
+
if (Array.isArray(raw)) return raw;
|
|
46
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
47
|
+
if (Array.isArray(raw.issues)) return raw.issues;
|
|
48
|
+
if (raw.issues && Array.isArray(raw.issues.nodes)) return raw.issues.nodes;
|
|
49
|
+
if (Array.isArray(raw.values)) return raw.values;
|
|
50
|
+
if (raw.data && typeof raw.data === 'object') return issuesList(raw.data);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const nameOf = (v) => (v && typeof v === 'object' ? oneLine(v.name || v.value || '') : oneLine(v));
|
|
54
|
+
function categoryOf(status, cat) {
|
|
55
|
+
const key = String((cat && typeof cat === 'object' ? (cat.key || cat.name) : cat) || '').toLowerCase();
|
|
56
|
+
if (key === 'done' || key === 'complete') return 'done';
|
|
57
|
+
if (key === 'indeterminate' || key === 'in progress' || key === 'doing') return 'doing';
|
|
58
|
+
if (key === 'new' || key === 'to do' || key === 'todo') return 'todo';
|
|
59
|
+
const s = String(status || '').toLowerCase();
|
|
60
|
+
if (/done|closed|resolved|released|complete|shipped|deployed/.test(s)) return 'done';
|
|
61
|
+
if (/progress|doing|started|review|\bqa\b|test|verif|\buat\b|block/.test(s)) return 'doing';
|
|
62
|
+
return 'todo';
|
|
63
|
+
}
|
|
64
|
+
/** Jira's Flagged field is a custom field whose id differs per site: any field holding the value "Impediment". */
|
|
65
|
+
function flaggedIn(fields) {
|
|
66
|
+
if (fields.flagged === true) return true;
|
|
67
|
+
for (const v of Object.values(fields)) {
|
|
68
|
+
if (Array.isArray(v) && v.some((x) => x && typeof x === 'object' && /impediment/i.test(String(x.value || '')))) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const SPRINT_STATES = ['active', 'future', 'closed'];
|
|
73
|
+
/** One sprint, from an object { name, state } or a bare name. */
|
|
74
|
+
function sprintOne(v) {
|
|
75
|
+
if (!v) return null;
|
|
76
|
+
if (isStr(v)) { const name = clip(oneLine(v), 80); return name ? { name, state: 'active' } : null; }
|
|
77
|
+
if (typeof v !== 'object') return null;
|
|
78
|
+
const name = clip(oneLine(v.name), 80);
|
|
79
|
+
const state = String(v.state || '').toLowerCase();
|
|
80
|
+
if (!name) return null;
|
|
81
|
+
return { name, state: SPRINT_STATES.includes(state) ? state : 'active' };
|
|
82
|
+
}
|
|
83
|
+
/** The sprint that matters: the active one, else the next, else the last closed. null is the backlog. */
|
|
84
|
+
function sprintOf(v) {
|
|
85
|
+
const all = (Array.isArray(v) ? v : [v]).map(sprintOne).filter(Boolean);
|
|
86
|
+
return all.find((s) => s.state === 'active') || all.find((s) => s.state === 'future') || all[all.length - 1] || null;
|
|
87
|
+
}
|
|
88
|
+
/** Jira Cloud keeps the sprint in a custom field: any array of objects that carry a sprint's name and state. */
|
|
89
|
+
function sprintIn(fields) {
|
|
90
|
+
if ('sprint' in fields) return sprintOf(fields.sprint);
|
|
91
|
+
for (const v of Object.values(fields)) {
|
|
92
|
+
if (Array.isArray(v) && v.length && v.every((x) => x && typeof x === 'object' && 'name' in x && 'state' in x && ('boardId' in x || 'id' in x))) return sprintOf(v);
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function subtasksOf(v) {
|
|
97
|
+
if (Array.isArray(v)) {
|
|
98
|
+
const done = v.filter((s) => {
|
|
99
|
+
const st = s && (s.fields ? s.fields.status : s.status);
|
|
100
|
+
return categoryOf(nameOf(st), st && typeof st === 'object' ? st.statusCategory : (s && s.category)) === 'done';
|
|
101
|
+
}).length;
|
|
102
|
+
return { done, total: v.length };
|
|
103
|
+
}
|
|
104
|
+
if (v && typeof v === 'object' && Number.isInteger(v.total) && v.total >= 0) {
|
|
105
|
+
const done = Number.isInteger(v.done) ? Math.max(0, Math.min(v.done, v.total)) : 0;
|
|
106
|
+
return { done, total: v.total };
|
|
107
|
+
}
|
|
108
|
+
return { done: 0, total: 0 };
|
|
109
|
+
}
|
|
110
|
+
/** https://site.atlassian.net/rest/api/3/issue/10001 → https://site.atlassian.net/browse/KEY */
|
|
111
|
+
function urlOf(x, key) {
|
|
112
|
+
const raw = isStr(x.url) ? x.url : isStr(x.self) ? x.self : '';
|
|
113
|
+
let u;
|
|
114
|
+
try { u = new URL(raw); } catch { return null; }
|
|
115
|
+
if (u.protocol !== 'https:') return null;
|
|
116
|
+
if (/\/browse\//.test(u.pathname)) return u.origin + u.pathname;
|
|
117
|
+
return `${u.origin}/browse/${key}`;
|
|
118
|
+
}
|
|
119
|
+
/** One issue of either shape → a reading, or null when it has no key or no summary. */
|
|
120
|
+
function normalizeIssue(x) {
|
|
121
|
+
if (!x || typeof x !== 'object') return null;
|
|
122
|
+
const key = oneLine(x.key).toUpperCase();
|
|
123
|
+
if (!KEY_RE.test(key)) return null;
|
|
124
|
+
if (x.fields && typeof x.fields === 'object') {
|
|
125
|
+
const f = x.fields;
|
|
126
|
+
const summary = clip(oneLine(f.summary), 250);
|
|
127
|
+
if (!summary) return null;
|
|
128
|
+
const st = f.status || {};
|
|
129
|
+
const status = nameOf(st) || 'Unknown';
|
|
130
|
+
const flagged = flaggedIn(f) || /block|impediment|on hold/i.test(status);
|
|
131
|
+
return { key, summary, status, category: categoryOf(status, st.statusCategory), sprint: sprintIn(f), subtasks: subtasksOf(f.subtasks),
|
|
132
|
+
flagged, reason: flagged ? (flaggedIn(f) ? 'Flagged in Jira' : `Status: ${status}`) : null, url: urlOf(x, key) };
|
|
133
|
+
}
|
|
134
|
+
// The flat shape: what the skill tells the agent to write when it is unsure of the connector's shape.
|
|
135
|
+
const summary = clip(oneLine(x.summary || x.title), 250);
|
|
136
|
+
if (!summary) return null;
|
|
137
|
+
const status = oneLine(x.status) || 'Unknown';
|
|
138
|
+
const flagged = x.flagged === true || /block|impediment|on hold/i.test(status);
|
|
139
|
+
return { key, summary, status, category: categoryOf(status, x.category), sprint: sprintOf(x.sprint), subtasks: subtasksOf(x.subtasks),
|
|
140
|
+
flagged, reason: flagged ? clip(oneLine(x.reason || x.flag_reason || (x.flagged === true ? 'Flagged in Jira' : `Status: ${status}`)), 160) : null,
|
|
141
|
+
url: urlOf(x, key) };
|
|
142
|
+
}
|
|
143
|
+
function normalize(raw) {
|
|
144
|
+
const list = issuesList(raw);
|
|
145
|
+
if (!list) return { error: 'the file holds no list of Jira issues (expected "issues": [...], "issues": {"nodes": [...]} or a list)' };
|
|
146
|
+
const issues = []; const seen = new Set(); let skipped = 0;
|
|
147
|
+
for (const x of list) {
|
|
148
|
+
const n = normalizeIssue(x);
|
|
149
|
+
if (!n || seen.has(n.key)) { skipped++; continue; }
|
|
150
|
+
seen.add(n.key);
|
|
151
|
+
issues.push(n);
|
|
152
|
+
}
|
|
153
|
+
return { issues, skipped };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── the snapshot ───────────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
function snapshotPath(root, initiative) { return path.join(jiraTickets.jiraDir(root), `${initiative}.md`); }
|
|
158
|
+
function snapshotRel(root, initiative) { return jiraTickets.relOf(root, snapshotPath(root, initiative)); }
|
|
159
|
+
const cell = (s) => oneLine(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|') || '—';
|
|
160
|
+
const SPRINT_WORD = { active: 'Active sprint', future: 'Next sprint', closed: 'Closed sprint' };
|
|
161
|
+
function sprintLabel(s) { return s ? `${SPRINT_WORD[s.state] || 'Sprint'}: ${s.name}` : 'Backlog'; }
|
|
162
|
+
function renderSnapshot({ initiative, fetchedAt, query, readings, ids }) {
|
|
163
|
+
const rows = readings.map((r) => `| ${cell(ids.get(r.key) || '')} | ${cell(r.key)} | ${cell(r.summary)} | ${cell(r.status)} | ${cell(r.category === 'done' ? 'Done' : sprintLabel(r.sprint))} | ${r.subtasks.total ? `${r.subtasks.done} of ${r.subtasks.total}` : '—'} | ${cell(r.flagged ? r.reason : '')} |${r.stale ? ' not in the last read |' : ''}`);
|
|
164
|
+
return [
|
|
165
|
+
'---', 'type: Reference', `title: "Jira status: ${initiative}"`, `timestamp: ${fetchedAt}`, `initiative: ${initiative}`, '---', '',
|
|
166
|
+
`# Jira status: ${initiative}`, '',
|
|
167
|
+
`Read from Jira at ${fetchedAt} by the Jira status check, read-only. The Jira tickets tab of the initiative shows it. Written by \`pmos jira sync\`; do not edit by hand.`, '',
|
|
168
|
+
query ? `Search: \`${query.replace(/`/g, "'")}\`` : 'Search: not recorded', '',
|
|
169
|
+
'| Ticket | Key | Summary | Status | Sprint | Subtasks | Blocked |', '|---|---|---|---|---|---|---|', ...rows, '',
|
|
170
|
+
'```json', JSON.stringify({ fetched_at: fetchedAt, query: query || null, tickets: readings }, null, 0), '```', '',
|
|
171
|
+
].join('\n');
|
|
172
|
+
}
|
|
173
|
+
function readSnapshot(root, initiative) {
|
|
174
|
+
let text;
|
|
175
|
+
try { text = fs.readFileSync(snapshotPath(root, initiative), 'utf8'); } catch { return null; }
|
|
176
|
+
const m = /```json\n([\s\S]*?)\n```/.exec(text);
|
|
177
|
+
if (!m) return null;
|
|
178
|
+
let data;
|
|
179
|
+
try { data = JSON.parse(m[1]); } catch { return null; }
|
|
180
|
+
const tickets = new Map();
|
|
181
|
+
for (const r of Array.isArray(data.tickets) ? data.tickets : []) if (r && KEY_RE.test(String(r.key || ''))) tickets.set(r.key, r);
|
|
182
|
+
return { fetchedAt: isStr(data.fetched_at) ? data.fetched_at : null, query: isStr(data.query) ? data.query : null, tickets };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Write the snapshot from a saved search result. Only the tickets this initiative pushed are kept; any other issue in
|
|
187
|
+
* the result is counted and ignored. A pushed ticket missing from the result keeps its last reading, marked stale.
|
|
188
|
+
*/
|
|
189
|
+
function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
|
|
190
|
+
if (!jiraTickets.recordFm(root, initiative)) return { ok: false, error: `no initiative ${initiative} in this repository` };
|
|
191
|
+
const n = normalize(raw);
|
|
192
|
+
if (n.error) return { ok: false, error: n.error };
|
|
193
|
+
const pushed = jiraTickets.pushedTickets(root, initiative).tickets;
|
|
194
|
+
if (!pushed.size) return { ok: false, error: `${initiative} has no ticket in Jira yet — push its tickets first` };
|
|
195
|
+
const prev = readSnapshot(root, initiative);
|
|
196
|
+
const got = new Map(n.issues.filter((i) => pushed.has(i.key)).map((i) => [i.key, i]));
|
|
197
|
+
const ignored = n.issues.length - got.size;
|
|
198
|
+
const ids = new Map([...pushed].map(([k, v]) => [k, v.id]));
|
|
199
|
+
const readings = []; const changes = []; const missing = [];
|
|
200
|
+
for (const key of pushed.keys()) {
|
|
201
|
+
const r = got.get(key);
|
|
202
|
+
const was = prev ? prev.tickets.get(key) : null;
|
|
203
|
+
if (!r) {
|
|
204
|
+
missing.push(key);
|
|
205
|
+
if (was) readings.push({ ...was, stale: true });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
readings.push(r);
|
|
209
|
+
if (!was) { changes.push({ key, what: 'first read', status: r.status }); continue; }
|
|
210
|
+
if (was.status !== r.status) changes.push({ key, what: 'status', from: was.status, to: r.status });
|
|
211
|
+
const sw = sprintLabel(was.sprint); const sn = sprintLabel(r.sprint);
|
|
212
|
+
if (sw !== sn) changes.push({ key, what: 'sprint', from: sw, to: sn });
|
|
213
|
+
if (!!was.flagged !== !!r.flagged) changes.push({ key, what: r.flagged ? 'blocked' : 'unblocked', reason: r.reason || null });
|
|
214
|
+
}
|
|
215
|
+
const fetchedAt = nowIso(now);
|
|
216
|
+
const result = { ok: true, initiative, read: got.size, ignored, skipped: n.skipped, missing, changes, snapshot: snapshotRel(root, initiative), dryRun: !!dryRun };
|
|
217
|
+
if (dryRun) return result;
|
|
218
|
+
const file = snapshotPath(root, initiative);
|
|
219
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
220
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
221
|
+
fs.writeFileSync(tmp, renderSnapshot({ initiative, fetchedAt, query: query || (prev && prev.query) || null, readings, ids }));
|
|
222
|
+
fs.renameSync(tmp, file);
|
|
223
|
+
return { ...result, fetchedAt };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
|
|
227
|
+
function which(root, key) {
|
|
228
|
+
const k = String(key || '').trim().toUpperCase();
|
|
229
|
+
if (!KEY_RE.test(k)) return { ok: false, error: `${JSON.stringify(key)} is not a Jira key like ATL-482` };
|
|
230
|
+
const hits = [];
|
|
231
|
+
for (const id of jiraTickets.draftedInitiatives(root)) {
|
|
232
|
+
const p = jiraTickets.pushedTickets(root, id);
|
|
233
|
+
if (p.epic === k) hits.push({ initiative: id, role: 'epic' });
|
|
234
|
+
else if (p.tickets.has(k)) hits.push({ initiative: id, role: 'ticket', ticket: p.tickets.get(k).id, summary: p.tickets.get(k).summary });
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, key: k, initiatives: hits };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel };
|