create-pmos 0.142.0 → 0.143.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.142.0",
3
+ "version": "0.143.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@69e497b
1
+ kernel-source: pmos@7aa3b30
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T01:34:35Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T01:40:01Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-12T01:34:35Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T01:40:01Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-12T01:34:35Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T01:40:01Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -61,12 +61,12 @@ const { harness } = require('./c3-ini-harness');
61
61
  ok('S1 the settings sheet offers no Build template while Build and deliver is off', await st(() => document.querySelector('[data-ini-offer="fix"]').disabled && document.querySelector('[data-ini-offer="product"]').disabled));
62
62
  await page.click('[data-ini="build"]');
63
63
  await until(() => st(() => !!document.querySelector('[data-ini="jira-key"]')));
64
- await page.fill('[data-ini="jira-key"]', 'off');
64
+ await page.fill('[data-ini="jira-key"]', 'off, mov');
65
65
  ok('S2 turning it on offers the Build templates and asks for the Jira key', await st(() => !document.querySelector('[data-ini-offer="fix"]').disabled && document.querySelector('[data-ini-offer="fix"]').checked));
66
66
  await page.click('[data-ini="settings-save"]');
67
67
  await until(() => store(() => window.__c3boards.boards.find((b) => b.id === 'office').build === true));
68
- const off = await store(() => { const b = window.__c3boards.boards.find((x) => x.id === 'office'); return { build: b.build, jira: b.jira, offer: b.offer }; });
69
- ok('S3 Save posts buildDeliver: Office move mirrors Jira OFF and offers the Build templates', off.build === true && off.jira === 'OFF' && off.offer.includes('fix'), JSON.stringify(off));
68
+ const off = await store(() => { const b = window.__c3boards.boards.find((x) => x.id === 'office'); return { build: b.build, jira: b.jira, projects: (b.jiraProjects || []).join(), offer: b.offer }; });
69
+ ok('S3 Save posts buildDeliver: Office move mirrors Jira OFF and MOV (two Jira projects) and offers the Build templates', off.build === true && off.jira === 'OFF' && off.projects === 'OFF,MOV' && off.offer.includes('fix'), JSON.stringify(off));
70
70
  const s4 = await until(async () => { const t = await toast(); return /Build and deliver on, mirroring Jira OFF/.test(t) && t; });
71
71
  ok('S4 a toast says what changed', !!s4, await toast());
72
72
  await until(() => st(() => !!document.querySelector('[data-ini-tab="sprints"]')));
@@ -144,6 +144,19 @@ const { harness } = require('./c3-ini-harness');
144
144
  ok('W4 the initiative now runs Research only and moved to Plan; other initiatives keep theirs', r07.stage === 'Plan' && r07.wf === 'Discover,Plan,Evaluate'
145
145
  && await store(() => !window.__c3boards.rows.find((r) => r.id === 'IDPF-12').template), JSON.stringify(r07));
146
146
 
147
+ /* ── Jira epics of an initiative (jira-epic-items) ── */
148
+ await page.click('[data-ini="jira-epics"]');
149
+ await until(() => st(() => !!document.querySelector('[data-ini="epics-input"]')));
150
+ await page.fill('[data-ini="epics-input"]', 'IDPF-900, XX-1');
151
+ await page.click('[data-ini="epics-save"]');
152
+ const epErr = await until(() => st(() => { const e = document.querySelector('[data-ini="epics-form"] [data-ini="error"]'); return e && e.textContent; }));
153
+ ok('E1 an epic outside the board\'s Jira projects is refused, and the sheet says why', /XX-1 is not in a Jira project of IDPF/.test(epErr || ''), epErr);
154
+ await page.fill('[data-ini="epics-input"]', 'idpf-900, idpf-901');
155
+ await page.click('[data-ini="epics-save"]');
156
+ await until(() => store(() => (window.__c3boards.rows.find((r) => r.id === 'IDPF-07').epics || []).length === 2));
157
+ const epChip = await until(() => st(() => { const c = document.querySelector('[data-ini="jira-epics"]'); return c && /Jira epics IDPF-900, IDPF-901/.test(c.textContent) && c.textContent; }));
158
+ ok('E2 the epics are saved upper-cased, and the initiative header names them', !!epChip, epChip);
159
+
147
160
  /* ── phone width ── */
148
161
  await page.setViewportSize({ width: 400, height: 860 });
149
162
  for (const loc of ['', 'board:idpf:summary', 'ini:idpf:IDPF-12:overview']) {
@@ -14,7 +14,7 @@ The Initiatives tab of the console page (`/c3.html`) has these levels: **Portfol
14
14
  The Portfolio shows one row per board. A board is a product folder, for example IDPF. Each row shows:
15
15
 
16
16
  - the board name and its health: On track, Needs attention or Off track
17
- - the owner, and **Build and deliver** with the Jira project key, or "PMOS tasks only"
17
+ - the owner, and **Build and deliver** with the Jira project keys, or "PMOS tasks only"
18
18
  - the initiatives per category: Discover, Plan, Do and Learn
19
19
  - the next release
20
20
 
@@ -39,7 +39,7 @@ The tabs are **Summary**, **Initiatives**, **Triage**, **Backlog**, **Board**, *
39
39
 
40
40
  Press the gear in the board header to open the board settings.
41
41
 
42
- - **Build and deliver**: turn it on to mirror a Jira project. Type the Jira project key. With it off, every item is a PMOS task and the Build workflows are not available.
42
+ - **Build and deliver**: turn it on to mirror Jira. Type one or more Jira project keys, separated by commas, for example `IDPF, LB`. A board can map several Jira projects. With it off, every item is a PMOS task and the Build workflows are not available.
43
43
  - **Workflows this board offers**: the templates that a new initiative on this board can use.
44
44
  - **Default for new initiatives and accepted asks**: the template that a new initiative or an accepted ask starts on.
45
45
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Jira: tickets drafted from the PRD, and their status"
4
4
  description: "How to draft Jira tickets from a PRD in your ticket template, push the ones that are ready, and see their status, sprint and progress on the initiative's Jira tickets tab."
5
- timestamp: 2026-10-11
5
+ timestamp: 2026-10-12
6
6
  ---
7
7
 
8
8
  # Jira: tickets drafted from the PRD, and their status
@@ -110,6 +110,20 @@ pmos jira query [<initiative>]
110
110
  pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]
111
111
  ```
112
112
 
113
+ ## Link an initiative to its Jira epics
114
+
115
+ Your team may already plan in Jira, with an epic for each piece of work. Then link the initiative to its epics. You do not have to draft or push tickets.
116
+
117
+ 1. Open the initiative. On a board with **Build and deliver** on, press **Link Jira epics** in the initiative header.
118
+ 2. Type the epic keys, separated by commas, for example `IDPF-2685, LB-120`. Each epic must be in one of the board's Jira projects.
119
+ 3. Press **Save epics**.
120
+
121
+ The **Jira status check** then reads each epic and the issues under it. The initiative's **Jira items**, **Breakdown** and **Timeline** show them, and the board's **Backlog**, **Board** and **Timeline** too. They are read-only: change them in Jira.
122
+
123
+ An initiative record that names an epic in its frontmatter (`jira: IDPF-2685`) is linked to it until you save epics on the page. Save an empty list to unlink.
124
+
125
+ An initiative whose tickets PMOS pushed keeps the tickets it pushed. The epic link is for initiatives with no pushed ticket.
126
+
113
127
  ## When the PRD changes
114
128
 
115
129
  Each time the tab opens, PMOS compares every ticket with its item in the PRD:
@@ -83,6 +83,13 @@ const templateBuilds = (k) => !!INI_TEMPLATES[k] && INI_TEMPLATES[k].stages.some
83
83
  /** A board's Build and deliver settings, with the defaults of a board that never set them: buildDeliver is true when the
84
84
  * ledger line does not carry it (the stage-records thread reads the same field the same way), every template the board may run is offered, and new initiatives start with
85
85
  * the board's own template. */
86
+ const JIRA_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
87
+ const JIRA_PROJECT_RE = /^[A-Za-z][A-Za-z0-9_]{0,19}$/;
88
+ /** The Jira projects a board mirrors: the ledger's `jira` (one key, or a list of keys), else the keys in its tracker label. */
89
+ function jiraProjectsOf(b) {
90
+ const raw = Array.isArray(b.jira) ? b.jira : typeof b.jira === 'string' && b.jira ? [b.jira] : String((/^Jira · (.+)$/.exec(b.tracker || '') || [])[1] || '').split(/[,\s]+/);
91
+ return [...new Set(raw.filter((k) => typeof k === 'string' && JIRA_PROJECT_RE.test(k.trim())).map((k) => k.trim().toUpperCase()))];
92
+ }
86
93
  function boardSettings(board) {
87
94
  const b = board || {};
88
95
  const build = typeof b.buildDeliver === 'boolean' ? b.buildDeliver : true;
@@ -92,8 +99,8 @@ function boardSettings(board) {
92
99
  const tpl = INI_TEMPLATES[b.template] ? b.template : 'product';
93
100
  let defTpl = typeof b.defTpl === 'string' && offer.includes(b.defTpl) ? b.defTpl : (offer.includes(tpl) ? tpl : offer[0]);
94
101
  if (!defTpl) defTpl = 'simple';
95
- const jira = build ? (typeof b.jira === 'string' && b.jira ? b.jira : (/^Jira · (.+)$/.exec(b.tracker || '') || [])[1] || null) : null;
96
- return { build, jira, offer, defTpl };
102
+ const projects = build ? jiraProjectsOf(b) : [];
103
+ return { build, jira: projects[0] || null, jiraProjects: projects, offer, defTpl };
97
104
  }
98
105
 
99
106
  /* The PMOS-self board: the nine stage names of scripts/nine-stages.js, read-only, each in its category. */
@@ -139,7 +146,7 @@ const TOOLS = Object.freeze({
139
146
 
140
147
  const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'done']);
141
148
  const VIEWS = Object.freeze(['now', 'mine', 'risk', 'noowner', 'all']);
142
- const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update']);
149
+ const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update', 'ini-epics']);
143
150
  const TASK_STATES = Object.freeze(['To do', 'In progress', 'In review', 'Done']);
144
151
 
145
152
  const copyStages = (stages) => stages.map((s) => ({ name: s.name, cat: s.cat, hint: s.hint || '' }));
@@ -166,6 +173,7 @@ function fold(events) {
166
173
  const stages = new Map(); // key(board, id) → stage name (the last move)
167
174
  const iniWorkflows = new Map(); // key(board, id) → { template, stages } (the initiative's own workflow)
168
175
  const tasks = new Map(); // task id → { id, board, initiative, title, epic, owner, state, start, due, at, by, history }
176
+ const epics = new Map(); // initiative id → the Jira epic keys the PM linked to it (the last ini-epics line wins)
169
177
  const list = [];
170
178
  for (let e of Array.isArray(events) ? events : []) {
171
179
  if (typeof e === 'string') { try { e = JSON.parse(e); } catch { continue; } }
@@ -232,13 +240,15 @@ function fold(events) {
232
240
  if (typeof e.start === 'string') t.start = e.start;
233
241
  if (typeof e.due === 'string') t.due = e.due;
234
242
  t.history.push({ at: e.at || null, by: e.by || null, state: e.state || null, start: e.start || null, due: e.due || null, why: e.why || '' });
243
+ } else if (e.kind === 'ini-epics' && typeof e.id === 'string' && Array.isArray(e.epics)) {
244
+ epics.set(e.id, e.epics.filter((k) => typeof k === 'string' && JIRA_KEY_RE.test(k)).map((k) => k.toUpperCase()));
235
245
  } else if (e.kind === 'ask-decline' && typeof e.ask === 'string' && asks.has(e.ask)) {
236
246
  const a = asks.get(e.ask);
237
247
  if (a.state !== 'open') continue;
238
248
  Object.assign(a, { state: 'declined', why: e.why || '', closedAt: e.at || null });
239
249
  }
240
250
  }
241
- return { boards, moves, assigns, asks, stages, iniWorkflows, tasks, events: list };
251
+ return { boards, moves, assigns, asks, stages, iniWorkflows, tasks, epics, events: list };
242
252
  }
243
253
 
244
254
  /** The stages an initiative runs on a board: its own workflow when one is recorded, else the board's. */
@@ -537,6 +547,6 @@ module.exports = {
537
547
  CATEGORIES, WORKFLOW_TEMPLATES, TOOLS, SELF_LADDER, READING_CAT, STATUSES, VIEWS, SORTS,
538
548
  templateWorkflow, isTemplateEdited, fold, readingOf, deriveStage, stageOf, catOf, moveWarnings, cardWarnings,
539
549
  applyWorkflowEdit, filterRows, health, counts, sprintsOf, key,
540
- INI_TEMPLATES, TASK_STATES, isBuildStage, templateBuilds, boardSettings, workflowOf, templateOf, applyIniWorkflow, taskOf, shiftDay,
550
+ INI_TEMPLATES, TASK_STATES, isBuildStage, templateBuilds, boardSettings, jiraProjectsOf, JIRA_KEY_RE, JIRA_PROJECT_RE, workflowOf, templateOf, applyIniWorkflow, taskOf, shiftDay,
541
551
  ITEM_PROGRESS, itemStatus, taskItem, rollupItems,
542
552
  };
@@ -6,7 +6,8 @@
6
6
  * This module reads that file and writes a snapshot the initiative's Jira tickets tab shows. It writes nothing else:
7
7
  * no plan item, no record, no ledger line (D88: the Breakdown is the handoff checklist, not a copy of Jira).
8
8
  *
9
- * queryFor(root, initiative) → { linked, query } the JQL for the tickets this initiative pushed
9
+ * queryFor(root, initiative) → { linked, mode, query } the JQL for the tickets this initiative pushed, or, when it
10
+ * pushed none, for its Jira epics and their children (mode 'epic', jira-epic-items)
10
11
  * linkedInitiatives(root) → [{ initiative, query }]
11
12
  * normalize(raw) → { issues, skipped } | { error }
12
13
  * sync(root, initiative, raw, opts) → the snapshot, written to <state>/tracker/jira/<initiative>.md
@@ -35,11 +36,15 @@ const nowIso = (now) => new Date(now == null ? Date.now() : now).toISOString().r
35
36
  function queryFor(root, initiative) {
36
37
  if (!jiraTickets.recordFm(root, initiative)) return { ok: false, error: `no initiative ${initiative} in this repository` };
37
38
  const keys = [...jiraTickets.pushedTickets(root, initiative).tickets.keys()];
38
- if (!keys.length) return { ok: true, initiative, linked: false, keys: [], query: null };
39
- return { ok: true, initiative, linked: true, keys, query: `key in (${keys.join(', ')}) ORDER BY key` };
39
+ if (keys.length) return { ok: true, initiative, linked: true, mode: 'pushed', keys, query: `key in (${keys.join(', ')}) ORDER BY key` };
40
+ // EPIC MODE (jira-epic-items): no ticket pushed, but the initiative has Jira epics. Read each epic and its children.
41
+ const epics = jiraTickets.epicKeysOf(root, initiative);
42
+ if (epics.length) return { ok: true, initiative, linked: true, mode: 'epic', keys: epics, epics, query: `key in (${epics.join(', ')}) OR parent in (${epics.join(', ')}) ORDER BY key` };
43
+ return { ok: true, initiative, linked: false, keys: [], query: null };
40
44
  }
41
45
  function linkedInitiatives(root) {
42
- return jiraTickets.draftedInitiatives(root).map((id) => queryFor(root, id)).filter((q) => q.ok);
46
+ const ids = [...new Set([...jiraTickets.draftedInitiatives(root), ...jiraTickets.epicInitiatives(root)])].sort();
47
+ return ids.map((id) => queryFor(root, id)).filter((q) => q.ok);
43
48
  }
44
49
 
45
50
  // ── reading a saved search result ──────────────────────────────────────────────────────────────────
@@ -147,6 +152,15 @@ function urlOf(x, key) {
147
152
  return `${u.origin}/browse/${key}`;
148
153
  }
149
154
  /** One issue of either shape → a reading, or null when it has no key or no summary. */
155
+ /** The issue type's name and the parent's key, when the search returned them: { type?, parent? }. */
156
+ function typeParent(t, p) {
157
+ const out = {};
158
+ const type = clip(nameOf(t), 40);
159
+ if (type) out.type = type;
160
+ const pk = oneLine(p && typeof p === 'object' ? p.key : p).toUpperCase();
161
+ if (KEY_RE.test(pk)) out.parent = pk;
162
+ return out;
163
+ }
150
164
  function normalizeIssue(x) {
151
165
  if (!x || typeof x !== 'object') return null;
152
166
  const key = oneLine(x.key).toUpperCase();
@@ -160,7 +174,8 @@ function normalizeIssue(x) {
160
174
  const flagged = flaggedIn(f) || /block|impediment|on hold/i.test(status);
161
175
  return { key, summary, status, category: categoryOf(status, st.statusCategory), sprint: sprintIn(f), subtasks: subtasksOf(f.subtasks), fixVersion: fixVersionsOf(f.fixVersions),
162
176
  points: pointsIn(f), resolved: dayOf(f.resolutiondate || f.resolved),
163
- flagged, reason: flagged ? (flaggedIn(f) ? 'Flagged in Jira' : `Status: ${status}`) : null, url: urlOf(x, key) };
177
+ flagged, reason: flagged ? (flaggedIn(f) ? 'Flagged in Jira' : `Status: ${status}`) : null, url: urlOf(x, key),
178
+ ...typeParent(f.issuetype, f.parent) };
164
179
  }
165
180
  // The flat shape: what the skill tells the agent to write when it is unsure of the connector's shape.
166
181
  const summary = clip(oneLine(x.summary || x.title), 250);
@@ -170,7 +185,7 @@ function normalizeIssue(x) {
170
185
  return { key, summary, status, category: categoryOf(status, x.category), sprint: sprintOf(x.sprint), subtasks: subtasksOf(x.subtasks), fixVersion: fixVersionsOf(x.fixVersions != null ? x.fixVersions : x.fixVersion),
171
186
  points: pointsIn(x), resolved: dayOf(x.resolved || x.resolutiondate),
172
187
  flagged, reason: flagged ? clip(oneLine(x.reason || x.flag_reason || (x.flagged === true ? 'Flagged in Jira' : `Status: ${status}`)), 160) : null,
173
- url: urlOf(x, key) };
188
+ url: urlOf(x, key), ...typeParent(x.type != null ? x.type : x.issuetype, x.parent) };
174
189
  }
175
190
  function normalize(raw) {
176
191
  const list = issuesList(raw);
@@ -223,13 +238,18 @@ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
223
238
  const n = normalize(raw);
224
239
  if (n.error) return { ok: false, error: n.error };
225
240
  const pushed = jiraTickets.pushedTickets(root, initiative).tickets;
226
- if (!pushed.size) return { ok: false, error: `${initiative} has no ticket in Jira yet — push its tickets first` };
241
+ const epics = pushed.size ? [] : jiraTickets.epicKeysOf(root, initiative);
242
+ if (!pushed.size && !epics.length) return { ok: false, error: `${initiative} has no ticket in Jira yet — push its tickets first, or link its Jira epic` };
227
243
  const prev = readSnapshot(root, initiative);
228
- const got = new Map(n.issues.filter((i) => pushed.has(i.key)).map((i) => [i.key, i]));
244
+ // Pushed mode keeps the pushed keys. Epic mode keeps the epics and the issues under them; an issue the search returned
245
+ // with no parent field is kept, since the search was scoped to the epics; one under another parent is ignored.
246
+ const keep = pushed.size ? (i) => pushed.has(i.key) : (i) => epics.includes(i.key) || !i.parent || epics.includes(i.parent);
247
+ const got = new Map(n.issues.filter(keep).map((i) => [i.key, i]));
229
248
  const ignored = n.issues.length - got.size;
230
249
  const ids = new Map([...pushed].map(([k, v]) => [k, v.id]));
231
250
  const readings = []; const changes = []; const missing = [];
232
- for (const key of pushed.keys()) {
251
+ const expected = pushed.size ? [...pushed.keys()] : [...new Set([...epics, ...got.keys(), ...(prev ? [...prev.tickets.keys()].filter((k) => { const r = prev.tickets.get(k); return epics.includes(k) || !r.parent || epics.includes(r.parent); }) : [])])];
252
+ for (const key of expected) {
233
253
  const r = got.get(key);
234
254
  const was = prev ? prev.tickets.get(key) : null;
235
255
  if (!r) {
@@ -42,6 +42,7 @@ const crypto = require('crypto');
42
42
  const planItems = require('./plan-items.js');
43
43
  const planTree = require('./plan-tree.js');
44
44
  const consoleState = require('./console-state');
45
+ const { repoScript } = require('./repo-script.js');
45
46
 
46
47
  const KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
47
48
  const KEY_IN = /\b([A-Z][A-Z0-9_]+-\d+)\b/;
@@ -354,8 +355,10 @@ function missingOf(t) {
354
355
  }
355
356
 
356
357
  // ── J3 · draft ─────────────────────────────────────────────────────────────────────────────────────
358
+ /** The Jira epic a record names: `jira_issue_key`, else `jira_epic`, else `jira`, when the value is a Jira key. */
357
359
  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;
360
+ for (const k of ['jira_issue_key', 'jira_epic', 'jira']) if (rfm && isStr(rfm[k]) && KEY_RE.test(rfm[k].trim())) return rfm[k].trim().toUpperCase();
361
+ return null;
359
362
  }
360
363
  function draft(root, initiative, { dryRun = false, now } = {}) {
361
364
  const st = items(root, initiative);
@@ -653,6 +656,31 @@ function pushedTickets(root, initiative) {
653
656
  for (const t of d.tickets) if (t.jira) tickets.set(t.jira, { id: t.id, summary: t.summary });
654
657
  return { epic: KEY_RE.test(d.fm.epic || '') ? String(d.fm.epic).toUpperCase() : null, tickets };
655
658
  }
659
+ /** The Jira epics the PM linked on the initiative page: the boards ledger's `ini-epics` lines (lib/routes-boards.js),
660
+ * folded by scripts/boards.js. Map initiative id → keys; an initiative the PM never linked is absent. */
661
+ function linkedEpics(root) {
662
+ const base = mainRoot(root);
663
+ for (const rel of [['pmos', 'state'], ['state'], ['.pmos']]) {
664
+ let text;
665
+ try { text = fs.readFileSync(path.join(base, ...rel, 'boards.jsonl'), 'utf8'); } catch { continue; }
666
+ try { return require(repoScript('boards.js')).fold(text.split('\n').filter(Boolean)).epics || new Map(); } catch { return new Map(); }
667
+ }
668
+ return new Map();
669
+ }
670
+ /** The Jira epics of an initiative: the ones the PM linked on the page, else the one its record names (anchorOf). */
671
+ function epicKeysOf(root, initiative, linked = linkedEpics(root)) {
672
+ if (linked.has(initiative)) return linked.get(initiative);
673
+ const a = anchorOf(recordFm(root, initiative) || {});
674
+ return a ? [a] : [];
675
+ }
676
+ /** Every initiative with a Jira epic, linked on the page or named by its record, whether or not it has a ticket draft. */
677
+ function epicInitiatives(root) {
678
+ const linked = linkedEpics(root);
679
+ let names = [];
680
+ try { names = fs.readdirSync(engine().layout(mainRoot(root)).initiatives).filter((n) => n.endsWith('.md')).map((n) => n.slice(0, -3)); } catch { names = []; }
681
+ const ids = new Set([...names, ...linked.keys()].filter((id) => planItems.INIT_RE.test(id)));
682
+ return [...ids].filter((id) => epicKeysOf(root, id, linked).length && recordFm(root, id)).sort();
683
+ }
656
684
  /** Every initiative with a ticket draft in this repository. */
657
685
  function draftedInitiatives(root) {
658
686
  let names = [];
@@ -663,6 +691,6 @@ function draftedInitiatives(root) {
663
691
  module.exports = {
664
692
  TYPES, PRIORITIES, ESTIMATES, SECTIONS, REQUIRED,
665
693
  items, itemsInPrd, itemsInFile, fromItem, draft, view, edit, resolvePrd, setEpic, approve, pushPlan, record,
666
- readDraft, parseDraft, pushedTickets, draftedInitiatives, missingOf, contentHash, describe,
694
+ readDraft, parseDraft, pushedTickets, draftedInitiatives, epicInitiatives, epicKeysOf, linkedEpics, anchorOf, missingOf, contentHash, describe,
667
695
  draftPath, draftRel, jiraDir, relOf, recordFm, prdOf,
668
696
  };
@@ -19,8 +19,9 @@
19
19
  * POST /api/boards/asks/accept { ws, board, ask, title, lane, anchor, intent?, id? } → { ok, initiative, stage }
20
20
  * POST /api/boards/asks/decline { ws, board, ask, why } → { ok }
21
21
  * POST /api/boards/asks/more { ws, board, ask, note? } → { ok }
22
- * POST /api/boards/settings { ws, board, buildDeliver?, jira?, offer?, defTpl? } → { ok, board }
22
+ * POST /api/boards/settings { ws, board, buildDeliver?, jira?, offer?, defTpl? } → { ok, board } jira: one key or a list
23
23
  * POST /api/boards/ini-workflow { ws, board, id, template?, stages, moveTo? } → { ok, row, moved }
24
+ * POST /api/boards/ini-epics { ws, board, id, epics: [Jira key] } → { ok, row } the Jira epics of an initiative ([] unlinks)
24
25
  * POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner? } → { ok, item }
25
26
  * POST /api/boards/tasks/state { ws, board, task, state } → { ok, item }
26
27
  * POST /api/boards/tasks/reschedule { ws, board, task, days } → { ok, item }
@@ -116,8 +117,8 @@ function boardsOf(ctx, w, folded) {
116
117
  return {
117
118
  ws: w.id, id: b0.id, name: typeof led.name === 'string' && led.name ? led.name : b0.name, domain: 'domain' in led ? led.domain : b0.domain,
118
119
  kind: b0.kind, template, templateEdited: selfBoard ? false : B.isTemplateEdited(template, workflow), workflow, self: selfBoard,
119
- tracker: led.tracker || (set.jira ? `Jira · ${set.jira}` : null), sprints: !!led.sprints || (set.build && !!set.jira),
120
- build: set.build, buildDeliver: set.build, jira: set.jira, offer: set.offer, defTpl: set.defTpl,
120
+ tracker: set.jiraProjects.length ? `Jira · ${set.jiraProjects.join(', ')}` : led.tracker || null, sprints: !!led.sprints || (set.build && !!set.jira),
121
+ build: set.build, buildDeliver: set.build, jira: set.jira, jiraProjects: set.jiraProjects, offer: set.offer, defTpl: set.defTpl,
121
122
  };
122
123
  });
123
124
  }
@@ -201,12 +202,19 @@ function baseRows(w) {
201
202
  reading: B.readingOf(r.nine_stages, { accepted: !!rec.accepted }),
202
203
  lifecycle: rec.stopped ? rec.stopped.state : null,
203
204
  hasSpec, record: relRec || null, domainFolder: dm ? dm[1] : null,
205
+ recordEpic: jiraTickets.anchorOf(fm),
204
206
  });
205
207
  }
206
208
  rowCache.set(w.root, { key: cacheKey, rows });
207
209
  return { ok: true, rows };
208
210
  }
209
211
 
212
+ /** The Jira epics of an initiative: the ones the PM linked on the page (the ledger), else the epic its record names. */
213
+ function epicsOf(base, folded) {
214
+ if (folded && folded.epics && folded.epics.has(base.id)) return { epics: folded.epics.get(base.id), epicsFrom: 'pmos' };
215
+ return base.recordEpic ? { epics: [base.recordEpic], epicsFrom: 'record' } : { epics: [], epicsFrom: null };
216
+ }
217
+
210
218
  /** The PRD row of one base row on one board. */
211
219
  function boardRow(board, base, folded) {
212
220
  const s = B.stageOf(board, base.id, base.reading, folded);
@@ -217,6 +225,7 @@ function boardRow(board, base, folded) {
217
225
  horizon: base.horizon, status: base.status, start: base.start, end: base.end, progress: base.progress, warnings: [],
218
226
  rank: base.rank, health: base.health, updated: base.updated, reading: base.reading, stageSource: s.source, lifecycle: base.lifecycle,
219
227
  template: B.templateOf(board, base.id, folded), workflow: wf.map((x) => ({ name: x.name, cat: x.cat })),
228
+ ...epicsOf(base, folded),
220
229
  };
221
230
  const w = [];
222
231
  if (s.source === 'move') {
@@ -272,9 +281,9 @@ function itemsOf(w, fr, board, rows) {
272
281
  let d = null;
273
282
  let snap = null;
274
283
  try { d = jiraTickets.readDraft(w.root, row.id); } catch { d = null; }
275
- if (!d) continue;
276
284
  try { snap = jiraStatus.readSnapshot(w.root, row.id); } catch { snap = null; }
277
285
  const reading = (k) => (snap && snap.tickets && snap.tickets.get ? snap.tickets.get(k) : null) || null;
286
+ if (!d) { epicItems(row, board, snap, jiraKeys, out); continue; }
278
287
  const epicKey = /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(String((d.fm && d.fm.epic) || '')) ? String(d.fm.epic).toUpperCase() : null;
279
288
  if (epicKey) {
280
289
  const rd = reading(epicKey);
@@ -294,6 +303,22 @@ function itemsOf(w, fr, board, rows) {
294
303
  return B.rollupItems(out);
295
304
  }
296
305
 
306
+ /** The Jira items of an initiative PMOS pushed nothing for: its linked epics and the issues under them, from the last
307
+ * read of the Jira status check (lib/jira-status.js, epic mode). An issue under no linked epic hangs under the first. */
308
+ function epicItems(row, board, snap, seen, out) {
309
+ if (!snap || !snap.tickets || !snap.tickets.size || !(row.epics || []).length) return;
310
+ const epics = row.epics.filter((k) => snap.tickets.has(k));
311
+ const first = epics[0] || null;
312
+ const item = (r, kind, epic) => ({ uid: r.key, key: r.key, kind, src: 'jira', title: r.summary || r.key, status: B.itemStatus(r.status, r.category), blocked: !!r.flagged, owner: null, ini: row.id, epic, start: null, end: null, ref: null, board: board.id });
313
+ for (const k of epics) { if (!seen.has(k)) { out.push(item(snap.tickets.get(k), 'Epic', null)); seen.add(k); } }
314
+ for (const r of snap.tickets.values()) {
315
+ if (seen.has(r.key) || (r.parent && !row.epics.includes(r.parent)) || (!r.parent && r.type === 'Epic')) continue; // a reading of an epic no longer linked
316
+ const kind = ['Story', 'Task', 'Bug'].find((t) => t.toLowerCase() === String(r.type || '').toLowerCase()) || 'Task';
317
+ out.push(item(r, kind, r.parent && epics.includes(r.parent) ? r.parent : first));
318
+ seen.add(r.key);
319
+ }
320
+ }
321
+
297
322
  // ── HTTP ──────────────────────────────────────────────────────────────────────────────────────────
298
323
  function wsOf(ctx, wsParam) {
299
324
  const w = ctx.getWs(wsParam || null);
@@ -415,8 +440,8 @@ const READS = {
415
440
  '/api/boards/items': (ctx, res, url) => {
416
441
  const r = boardRead(ctx, res, url);
417
442
  if (!r) return;
418
- ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, build: r.board.build, jira: r.board.jira, items: itemsOf(r.w, r.fr, r.board, r.rows),
419
- initiatives: r.rows.map((x) => ({ id: x.id, title: x.title, stage: x.stage, cat: x.cat, status: x.status, owner: x.owner, horizon: x.horizon, start: x.start, end: x.end, progress: x.progress, template: x.template })) });
443
+ ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, build: r.board.build, jira: r.board.jira, jiraProjects: r.board.jiraProjects, items: itemsOf(r.w, r.fr, r.board, r.rows),
444
+ initiatives: r.rows.map((x) => ({ id: x.id, title: x.title, stage: x.stage, cat: x.cat, status: x.status, owner: x.owner, horizon: x.horizon, start: x.start, end: x.end, progress: x.progress, template: x.template, epics: x.epics })) });
420
445
  },
421
446
  '/api/boards/sprints': (ctx, res, url) => {
422
447
  const r = boardRead(ctx, res, url);
@@ -461,10 +486,12 @@ function settingsOf(body, board) {
461
486
  const v = {};
462
487
  const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
463
488
  if (bd != null) { if (typeof bd !== 'boolean') return { error: 'buildDeliver is true or false' }; v.buildDeliver = bd; }
464
- if (body.jira != null && body.jira !== '') {
465
- if (typeof body.jira !== 'string' || !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(body.jira.trim())) return { error: 'jira is a Jira project key: a letter, then letters, digits or _ (at most 20)' };
466
- v.jira = body.jira.trim().toUpperCase();
467
- } else if (body.jira === '') v.jira = null;
489
+ if (body.jira != null && body.jira !== '' && !(Array.isArray(body.jira) && !body.jira.length)) {
490
+ const list = Array.isArray(body.jira) ? body.jira : typeof body.jira === 'string' ? body.jira.split(/[,\s]+/).filter(Boolean) : null;
491
+ if (!list || list.length > 10 || list.some((k) => typeof k !== 'string' || !B.JIRA_PROJECT_RE.test(k.trim()))) return { error: 'jira is one or more Jira project keys (at most 10), each a letter, then letters, digits or _ (at most 20)' };
492
+ const keys = [...new Set(list.map((k) => k.trim().toUpperCase()))];
493
+ v.jira = keys.length === 1 ? keys[0] : keys;
494
+ } else if (body.jira === '' || Array.isArray(body.jira)) v.jira = null;
468
495
  const build = 'buildDeliver' in v ? v.buildDeliver : board ? board.build : true;
469
496
  if (body.offer != null) {
470
497
  if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !B.INI_TEMPLATES[k])) return { error: `offer is a list of templates: ${Object.keys(B.INI_TEMPLATES).join(', ')}` };
@@ -501,7 +528,7 @@ const WRITES = {
501
528
  if (set.error) return fail(ctx, res, 400, set.error);
502
529
  const ev = { kind: 'board', op: 'create', board: id, name, domain: body.domain ? String(body.domain).trim() : null, template: body.template, workflow, sprints: false, tracker: null };
503
530
  for (const k of ['buildDeliver', 'jira', 'offer', 'defTpl']) if (k in set.value) ev[k] = set.value[k];
504
- if (ev.buildDeliver !== false && ev.jira) { ev.tracker = `Jira · ${ev.jira}`; ev.sprints = true; }
531
+ if (ev.buildDeliver !== false && ev.jira) { ev.tracker = `Jira · ${[].concat(ev.jira).join(', ')}`; ev.sprints = true; }
505
532
  append(p.file, g.actor, ev);
506
533
  const board = boardsOf(ctx, p.w, B.fold(readLedger(ctx, p.w))).find((b) => b.id === id);
507
534
  ctx.json(res, { ok: true, board });
@@ -613,9 +640,9 @@ const WRITES = {
613
640
  if (!p.file) return fail(ctx, res, 409, NO_STATE);
614
641
  const ev = { kind: 'board', op: 'settings', board: p.board.id, ...set.value };
615
642
  const build = 'buildDeliver' in set.value ? set.value.buildDeliver : p.board.build;
616
- const jira = 'jira' in set.value ? set.value.jira : p.board.jira;
617
- ev.tracker = build && jira ? `Jira · ${jira}` : null;
618
- ev.sprints = !!(build && jira);
643
+ const jira = 'jira' in set.value ? (set.value.jira ? [].concat(set.value.jira) : []) : p.board.jiraProjects;
644
+ ev.tracker = build && jira.length ? `Jira · ${jira.join(', ')}` : null;
645
+ ev.sprints = !!(build && jira.length);
619
646
  append(p.file, g.actor, ev);
620
647
  const board = boardsOf(ctx, p.w, B.fold(readLedger(ctx, p.w))).find((b) => b.id === p.board.id);
621
648
  ctx.json(res, { ok: true, board });
@@ -639,6 +666,26 @@ const WRITES = {
639
666
  const row = boardRow(p.board, b0, B.fold(readLedger(ctx, p.w)));
640
667
  ctx.json(res, { ok: true, row, moved: ed.moved });
641
668
  },
669
+ '/api/boards/ini-epics': (ctx, res, body, g) => {
670
+ const p = postFrame(ctx, res, body);
671
+ if (!p) return;
672
+ if (typeof body.id !== 'string' || !body.id) return fail(ctx, res, 400, 'id is required');
673
+ if (!p.board.build) return fail(ctx, res, 409, `${p.board.name} has Build and deliver off, so it mirrors no Jira`);
674
+ const list = Array.isArray(body.epics) ? body.epics : typeof body.epics === 'string' ? body.epics.split(/[,\s]+/).filter(Boolean) : null;
675
+ if (!list || list.length > 20 || list.some((k) => typeof k !== 'string' || !B.JIRA_KEY_RE.test(k.trim()))) return fail(ctx, res, 400, 'epics is a list of Jira keys (at most 20), each like IDPF-2685');
676
+ const epics = [...new Set(list.map((k) => k.trim().toUpperCase()))];
677
+ const projects = p.board.jiraProjects || [];
678
+ const off = projects.length ? epics.filter((k) => !projects.includes(k.split('-')[0])) : [];
679
+ if (off.length) return fail(ctx, res, 400, `${off.join(', ')} ${off.length === 1 ? 'is' : 'are'} not in a Jira project of ${p.board.name} (${projects.join(', ')})`);
680
+ if (!p.file) return fail(ctx, res, 409, NO_STATE);
681
+ const base = needRows(ctx, res, p.w);
682
+ if (!base) return;
683
+ const b0 = base.find((r) => r.id === body.id);
684
+ if (!b0 || boardIdOf(b0, p.fr.boards, p.fr.folded, p.w) !== p.board.id) return fail(ctx, res, 404, `no initiative ${body.id} on this board`);
685
+ append(p.file, g.actor, { kind: 'ini-epics', board: p.board.id, id: body.id, epics });
686
+ const row = boardRow(p.board, b0, B.fold(readLedger(ctx, p.w)));
687
+ ctx.json(res, { ok: true, row });
688
+ },
642
689
  '/api/boards/tasks/add': (ctx, res, body, g) => {
643
690
  const p = postFrame(ctx, res, body);
644
691
  if (!p) return;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.142.0",
3
+ "version": "0.143.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.142.0",
9
+ "version": "0.143.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.142.0",
3
+ "version": "0.143.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -211,7 +211,7 @@ const openAsks = (S, bid) => S.asks.filter((a) => a.board === bid && a.state ===
211
211
  function rowOut(S, r) {
212
212
  const wf = wfOf(S, r);
213
213
  return { id: r.id, title: r.title, lane: r.lane, stage: r.stage, cat: catIn(wf, r.stage), owner: r.owner || null, anchor: r.anchor, horizon: r.horizon, status: r.status, start: r.start, end: r.end, progress: r.progress,
214
- warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })) };
214
+ warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })), epics: (r.epics || []).slice(), epicsFrom: r.epics ? 'pmos' : null };
215
215
  }
216
216
  function boardOut(S, b) {
217
217
  const list = S.rows.filter((r) => r.board === b.id);
@@ -219,7 +219,7 @@ function boardOut(S, b) {
219
219
  for (const r of list) byCat[catOfRow(S, r)]++;
220
220
  const rel = nextRelease(S, b.id);
221
221
  return { ws: b.ws, id: b.id, name: b.name, full: b.full, domain: b.domain, template: b.template, templateEdited: tplEdited(b), workflow: b.workflow.map((s) => ({ ...s })), self: b.self, tracker: b.tracker, sprints: b.sprints,
222
- build: b.build, buildDeliver: b.build, jira: b.jira, offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
222
+ build: b.build, buildDeliver: b.build, jira: b.jira, jiraProjects: b.build && b.jira ? (b.jiraProjects || [b.jira]).slice() : [], offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
223
223
  counts: { total: list.length, open: list.filter(isOpen).length, byCat, risk: list.filter((r) => r.status === 'at risk').length, late: list.filter((r) => r.status === 'late').length },
224
224
  asks: openAsks(S, b.id), nextRelease: rel ? { id: rel.id, name: rel.name, at: rel.date, date: rel.date, status: rel.status } : null };
225
225
  }
@@ -396,7 +396,11 @@ export function handle(S, method, path, p, body) {
396
396
  const v = {};
397
397
  const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
398
398
  if (bd != null) { if (typeof bd !== 'boolean') return fail(400, 'buildDeliver is true or false'); v.build = bd; }
399
- if (body.jira != null && body.jira !== '') { const j = String(body.jira).trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(j)) return fail(400, 'jira is a Jira project key: a letter, then letters, digits or _ (at most 20)'); v.jira = j.toUpperCase(); }
399
+ if (body.jira != null && body.jira !== '') {
400
+ const keys = [].concat(body.jira).join(',').split(/[,\s]+/).filter(Boolean);
401
+ if (!keys.length || keys.some((j) => !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(j))) return fail(400, 'jira is one or more Jira project keys (at most 10), each a letter, then letters, digits or _ (at most 20)');
402
+ v.jiraProjects = [...new Set(keys.map((j) => j.toUpperCase()))]; v.jira = v.jiraProjects[0];
403
+ }
400
404
  const build = 'build' in v ? v.build : b.build;
401
405
  if (body.offer != null) {
402
406
  if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !INI_TEMPLATES[k])) return fail(400, 'offer is a list of templates: ' + Object.keys(INI_TEMPLATES).join(', '));
@@ -413,7 +417,8 @@ export function handle(S, method, path, p, body) {
413
417
  Object.assign(b, v);
414
418
  if (!b.build) { b.offer = b.offer.filter((k) => !templateBuilds(k)); if (!b.offer.length) b.offer = ['simple']; if (!b.offer.includes(b.defTpl)) b.defTpl = b.offer[0]; }
415
419
  if (b.build && !b.jira) b.jira = b.prefix;
416
- b.tracker = b.build && b.jira ? 'Jira · ' + b.jira : null; b.sprints = !!(b.build && b.jira);
420
+ if (!b.jiraProjects || !b.jiraProjects.includes(b.jira)) b.jiraProjects = b.jira ? [b.jira] : [];
421
+ b.tracker = b.build && b.jira ? 'Jira · ' + b.jiraProjects.join(', ') : null; b.sprints = !!(b.build && b.jira);
417
422
  S.events.push({ at: at(0, 8), by: 'You', kind: 'board', op: 'settings', board: b.id, ...v });
418
423
  return okb({ board: boardOut(S, b) });
419
424
  }
@@ -441,6 +446,20 @@ export function handle(S, method, path, p, body) {
441
446
  S.events.push({ at: at(0, 8), by: 'You', kind: 'board-workflow', board: b.id, stages: b.workflow, moved: mr });
442
447
  return okb({ board: boardOut(S, b), moved });
443
448
  }
449
+ if (path === '/api/boards/ini-epics') {
450
+ if (!b.build) return fail(409, b.name + ' has Build and deliver off, so it mirrors no Jira');
451
+ const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
452
+ if (!r) return fail(404, 'no initiative ' + (body.id || '') + ' on this board');
453
+ const keys = Array.isArray(body.epics) ? body.epics : [];
454
+ if (keys.length > 20 || keys.some((k) => !/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(String(k)))) return fail(400, 'epics is a list of Jira keys (at most 20), each like IDPF-2685');
455
+ const epics = [...new Set(keys.map((k) => String(k).toUpperCase()))];
456
+ const projects = b.jiraProjects && b.jiraProjects.length ? b.jiraProjects : b.jira ? [b.jira] : [];
457
+ const off = projects.length ? epics.filter((k) => !projects.includes(k.split('-')[0])) : [];
458
+ if (off.length) return fail(400, off.join(', ') + (off.length === 1 ? ' is' : ' are') + ' not in a Jira project of ' + b.name + ' (' + projects.join(', ') + ')');
459
+ r.epics = epics;
460
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'ini-epics', board: b.id, id: r.id, epics });
461
+ return okb({ row: rowOut(S, r) });
462
+ }
444
463
  if (path === '/api/boards/ini-workflow') {
445
464
  if (b.self) return fail(409, 'The PMOS board follows the ladder its records derive.');
446
465
  const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
@@ -14,7 +14,7 @@
14
14
  * Every control is a button; the drag handlers are drag events on the cards and columns (pointer handlers stay on the document). */
15
15
  import { el } from './dom.js';
16
16
  import { registerBoardTab } from './c3-initiatives.js';
17
- import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
17
+ import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, jiraLabel, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
18
18
  import { STATUSES, wfOf, tplName, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
19
19
  import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
20
20
 
@@ -67,7 +67,7 @@ function mountItems(tab) {
67
67
  others > 0 ? el('span', { class: 'dim ini-note' }, plural(others, 'more initiative has', 'more initiatives have') + ' no epics or tasks yet') : null),
68
68
  b.build
69
69
  ? el('div', { class: 'chips2', role: 'group', 'aria-label': 'Source filter', 'data-wk': 'srcs' }, lbl('SOURCE'),
70
- ...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + (b.jira || '')]], ['pmos', 'PMOS tasks']].map(([v, l]) => pill({ 'data-wk-src': v, 'aria-pressed': String(st.src === v), onclick: () => { st.src = v; draw('[data-wk-src="' + v + '"]'); } }, ...[].concat(l))))
70
+ ...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + jiraLabel(b)]], ['pmos', 'PMOS tasks']].map(([v, l]) => pill({ 'data-wk-src': v, 'aria-pressed': String(st.src === v), onclick: () => { st.src = v; draw('[data-wk-src="' + v + '"]'); } }, ...[].concat(l))))
71
71
  : el('div', { class: 'dim ini-note', 'data-wk': 'no-jira' }, icon('info', 'sm'), ' Build and deliver is off for ' + b.name + ', so there is no Jira here: every item is a PMOS task. ', lk({ onclick: () => ctx.openBoardSettings() }, 'Board settings')),
72
72
  el('div', { class: 'dim ini-note', 'data-wk': 'count' }, plural(n, 'item') + (ne ? ' in ' + plural(ne, 'epic') : '') + (st.ini ? ' · ' + st.ini + ' only' : '') + (st.src ? ' · ' + (st.src === 'jira' ? 'Jira only' : 'PMOS only') : '') + ' · ',
73
73
  any ? lk({ 'data-wk': 'clear', onclick: () => { Object.assign(st, { q: '', ini: '', src: '', kind: '', hideDone: false }); draw(); } }, 'Clear filters') : null, any ? ' · ' : null, 'same filters on Backlog, Board and Timeline'),
@@ -178,7 +178,7 @@ function mountItems(tab) {
178
178
  requestAnimationFrame(() => { if (!dead) tlInit(host); });
179
179
  return [sect({ ic: 'cal', title: 'Timeline', count: rows.length, right: mono('press a row to open its epics') }, node,
180
180
  hidden > 0 ? el('div', { class: 'chips2' }, el('span', { class: 'dim ini-note' }, plural(hidden, 'more Now initiative', 'more Now initiatives') + ' with no epics yet'), pill({ 'data-wk': 'tl-all', onclick: () => { st.tlAll = true; draw(); } }, 'Show all')) : null,
181
- b.build ? note('Release dates come from Jira ' + (b.jira || '') + ' fix versions. PMOS bars drag to reschedule; Jira bars are locked.') : note('Every bar here is a PMOS task: drag it to reschedule.'))];
181
+ b.build ? note('Release dates come from Jira ' + jiraLabel(b) + ' fix versions. PMOS bars drag to reschedule; Jira bars are locked.') : note('Every bar here is a PMOS task: drag it to reschedule.'))];
182
182
  }
183
183
 
184
184
  function draw(focusSel) {
@@ -8,7 +8,7 @@
8
8
  * its id, one with no date says "No date yet". */
9
9
  import { el } from './dom.js';
10
10
  import { registerBoardTab } from './c3-initiatives.js';
11
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, plural, day, daysBetween } from './c3-ini-ui.js';
11
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween } from './c3-ini-ui.js';
12
12
  import { loadItems } from './c3-ini-work.js';
13
13
 
14
14
  const whenOf = (r) => String(r.release_at || r.deploy_at || r.date || '').slice(0, 10);
@@ -45,7 +45,7 @@ registerBoardTab('releases', async (host, ctx) => {
45
45
  host.replaceChildren(cols(
46
46
  [sect({ ic: 'out', title: 'Unreleased', count: up.length, attrs: { 'data-inb': 'release-next' } }, up.length ? list(up.map((r) => row(r, false))) : empty('No release planned for ' + b.name + '.', { 'data-inb': 'release-none-next' })),
47
47
  past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null],
48
- [card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + (b.jira || '') + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
48
+ [card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + jiraLabel(b) + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
49
49
  el('div', { class: 'chips2' }, pbtn('outline', 'Open the Calendar', 'cal', { 'data-inb': 'open-calendar', onclick: () => ctx.showTab('cal') })))]));
50
50
  return () => { dead = true; };
51
51
  });
@@ -7,7 +7,7 @@
7
7
  * the stage-records change; when that route is not on this server yet, the card says so quietly). source 'none' says there is no tracker. */
8
8
  import { el } from './dom.js';
9
9
  import { registerBoardTab } from './c3-initiatives.js';
10
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, plural, day } from './c3-ini-ui.js';
10
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day } from './c3-ini-ui.js';
11
11
  import { loadItems } from './c3-ini-work.js';
12
12
  import { recordCards } from './c3-ini-records.js';
13
13
 
@@ -22,7 +22,7 @@ registerBoardTab('sprints', async (host, ctx) => {
22
22
  const [r, items] = await Promise.all([ctx.api.get('/api/boards/sprints', { board: b.id }), loadItems(ctx)]);
23
23
  if (dead) return () => {};
24
24
  if (!r || !r.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
25
- if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + (b.jira || '') + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
25
+ if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
26
26
  const sprints = r.sprints || [];
27
27
  const active = sprints.find((s) => s.state === 'active');
28
28
  const others = sprints.filter((s) => s !== active);
@@ -32,7 +32,7 @@ registerBoardTab('sprints', async (host, ctx) => {
32
32
  right: [el('span', { class: 'chip2', title: 'Status lives in Jira' }, icon('lock', 'sm'))], attrs: { 'data-inb-issue': i.key } });
33
33
  const t = readTime(r.readAt);
34
34
  const left = [
35
- el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + (b.jira || '') + '.' : 'Not read yet.') + ' Change sprints in Jira.'),
35
+ el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + jiraLabel(b) + '.' : 'Not read yet.') + ' Change sprints in Jira.'),
36
36
  active ? sect({ ic: 'check', title: active.name + ' items', count: (active.issues || []).length, right: tag('read-only'), attrs: { 'data-inb-sprint': active.name, 'data-inb-state': 'active' } },
37
37
  (active.issues || []).length ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
38
38
  : empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
@@ -14,7 +14,7 @@
14
14
  * Every tab has an honest empty state and an error line when its read fails. */
15
15
  import { el } from './dom.js';
16
16
  import { registerInitiativeTab } from './c3-initiatives.js';
17
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
17
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
18
18
  import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
19
19
  import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
20
20
  import { loadDocs, renderDocs } from './c3-ib-docs.js';
@@ -36,6 +36,10 @@ function tab(id, build) {
36
36
  });
37
37
  }
38
38
  const mine = (ctx, items) => items.filter((w) => w.ini === ctx.row.id);
39
+ /** Why an initiative shows no Jira item yet: no epic linked, or its epics not read yet. */
40
+ const epicsLine = (ctx) => ((ctx.row.epics || []).length
41
+ ? 'No Jira item read yet for ' + ctx.row.epics.join(', ') + '. The daily Jira check reads each epic and the issues under it.'
42
+ : 'No Jira epic is linked to ' + ctx.row.id + ' yet. Press Link Jira epics above to choose its epics' + (jiraLabel(ctx.board) ? ' in Jira ' + jiraLabel(ctx.board) : '') + '.');
39
43
 
40
44
  /* ── Overview ── */
41
45
  async function toolCard(ctx, t, R, rels) {
@@ -48,7 +52,7 @@ async function toolCard(ctx, t, R, rels) {
48
52
  case 'tickets': {
49
53
  const tk = its.filter((w) => w.src === 'jira');
50
54
  if (!b.build) body = [note(b.name + ' has Build and deliver off, so there is no Jira. Progress comes from PMOS tasks: ' + by('Done') + ' of ' + its.length + ' done.')];
51
- else if (!tk.length) body = [note('No Jira ' + (b.jira || '') + ' items linked yet.')];
55
+ else if (!tk.length) body = [note(epicsLine(ctx))];
52
56
  else {
53
57
  const d = tk.filter((w) => w.status === 'Done').length; const p = tk.filter((w) => /progress|review/i.test(w.status)).length; const td = tk.length - d - p;
54
58
  body = [hbs([hbr('Done', d, d / tk.length, 'ok'), hbr('In progress', p, p / tk.length, 'acc'), hbr('To do', td, td / tk.length, 'pm')]),
@@ -129,7 +133,7 @@ tab('overview', async (ctx, isDead, host) => {
129
133
  ['Owner', ownerText(r.owner)], ['Anchor', r.anchor || 'No anchor'], ['Horizon', r.horizon ? r.horizon[0].toUpperCase() + r.horizon.slice(1) : 'Not set'], ['Lane', r.lane || 'Not set']]),
130
134
  hbs([hbr('Progress', Math.round(p * 100) + '%', p, toneOf(r.status))]),
131
135
  el('div', { class: 'chips2', 'data-ini': 'board-links' }, ...['backlog', 'board', 'timeline'].map((k) => lk({ 'data-ini-boardlink': k, onclick: () => ctx.openBoard(b.id, k, { ini: r.id }) }, icon('next', 'sm'), b.name + ' ' + k[0].toUpperCase() + k.slice(1) + ', this initiative only')))),
132
- card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + (b.jira || '') + ' and PMOS' : 'PMOS tasks' },
136
+ card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
133
137
  note(plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') + '.'), el('div', { class: 'chips2' }, lk({ onclick: () => ctx.go({ view: 'ini', board: b.id, id: r.id, tab: 'breakdown' }) }, icon('next', 'sm'), 'Open breakdown'))),
134
138
  rels.length ? card({ ic: 'out', title: 'Releases', sub: 'live in the Calendar' }, chgl(rels.map((x) => el('li', null, icon('out', 'sm'), el('span', null, (x.name || x.id) + ' · ' + day(String(x.release_at || x.deploy_at || '').slice(0, 10))), tag('Calendar'))))) : null,
135
139
  ];
@@ -196,7 +200,7 @@ tab('tickets', async (ctx, isDead) => {
196
200
  if (isDead()) return null;
197
201
  if (!R.ok) return errLine('The Jira items could not be read: ' + R.error + '.');
198
202
  const tk = mine(ctx, R.items).filter((w) => w.src === 'jira');
199
- if (!tk.length) return empty('No Jira ' + (ctx.board.jira || '') + ' items are linked to ' + ctx.row.id + ' yet.', { 'data-inb': 'tk-empty' });
203
+ if (!tk.length) return empty(epicsLine(ctx), { 'data-inb': 'tk-empty' });
200
204
  const its = tk.filter((w) => w.kind !== 'Epic'); const n = Math.max(1, its.length);
201
205
  const c = (s) => its.filter((w) => w.status === s).length;
202
206
  return cols(
@@ -75,6 +75,8 @@ export function geo(node, vars) {
75
75
  }
76
76
 
77
77
  /* ── text bits ── */
78
+ /** The Jira projects a board mirrors, for a sentence: "IDPF, LB" (a board may map several Jira projects). */
79
+ export const jiraLabel = (b) => ((b && Array.isArray(b.jiraProjects) && b.jiraProjects.length) ? b.jiraProjects.join(', ') : (b && b.jira) || '');
78
80
  export const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's');
79
81
  export const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
80
82
  export const chip = (...kids) => el('span', { class: 'chip2' }, ...kids);
@@ -42,7 +42,7 @@ import { ensureC3Css } from './pm-work-view.js';
42
42
  import { openSheet, closeSheet } from './c3-sheet.js';
43
43
  import { toast } from './c3-toast.js';
44
44
  import {
45
- btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
45
+ btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, jiraLabel, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
46
46
  stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO,
47
47
  } from './c3-ini-ui.js';
48
48
  import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf, catOf, kindOf, tplName, TOOL_META, toolLabel, toolIcon, loadItems, dropItems, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
@@ -107,7 +107,7 @@ function ensureCss() {
107
107
  document.head.appendChild(l);
108
108
  }
109
109
  const warnChip = (w) => st('warn', 'alert', w, { title: 'An order rule, shown as a warning. It never blocks.', 'data-ini-warning': w });
110
- const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), b.jira ? 'Jira · ' + b.jira : 'Jira not set') : el('span', { class: 'tag' }, 'PMOS tasks only'));
110
+ const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), jiraLabel(b) ? 'Jira · ' + jiraLabel(b) : 'Jira not set') : el('span', { class: 'tag' }, 'PMOS tasks only'));
111
111
  const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
112
112
  const sentenceOf = (bad, warn) => (bad.length || warn.length
113
113
  ? [bad.length ? plural(bad.length, 'board') + (bad.length === 1 ? ' is' : ' are') + ' off track' : '', warn.length ? plural(warn.length, 'board') + ' need' + (warn.length === 1 ? 's' : '') + ' attention' : ''].filter(Boolean).join(', ')
@@ -271,7 +271,7 @@ export function mountInitiatives(pane, opts) {
271
271
  const s = pick.map((i) => i.start).sort()[0]; const e = pick.map((i) => i.end).sort().pop();
272
272
  const avg = pick.reduce((n, i) => n + (Number(i.progress) || 0), 0) / pick.length;
273
273
  rows.push({ uid: 'b:' + b.id, label: b.name + ' · ' + pick.length + ' of ' + now.length, start: s, end: e, progress: avg, cls: 'grp', tone: b.health === 'bad' ? 'bad' : b.health === 'warn' ? 'warn' : 'ok',
274
- open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + (b.jira || '') : 'Board · PMOS tasks only', hint: 'Click the row to fold the board' },
274
+ open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + jiraLabel(b) : 'Board · PMOS tasks only', hint: 'Click the row to fold the board' },
275
275
  kids: pick.map((i) => iniTlRow(ctx, i, workTlRows(ctx, r.items.filter((w) => w.ini === i.id), () => { dropItems(ctx); draw(); }), b)) });
276
276
  }
277
277
  const { from, to } = tlRange(P.today);
@@ -318,7 +318,7 @@ export function mountInitiatives(pane, opts) {
318
318
  lh('cols', b.name + (b.full ? ' · ' + b.full : ''), (HEALTH[b.health] || 'No health yet') + (risk ? ', ' + risk + ' at risk or late' : '') + (asks ? ', ' + plural(asks, 'new ask') + ' to triage' : '') + '. '
319
319
  + plural(c.total || 0, 'initiative') + (items.ok ? ', ' + plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') : '') + '.'),
320
320
  el('div', { class: 'chips2', 'aria-label': 'Board setup', 'data-ini': 'setup' },
321
- ...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), b.jira ? ['Jira project ', el('b', { class: 'mono' }, b.jira), ' · ' + plural(jiraN, 'item')] : 'No Jira project set'), chip('PMOS tasks ' + pmN)]
321
+ ...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), jiraLabel(b) ? [(b.jiraProjects || []).length > 1 ? 'Jira projects ' : 'Jira project ', el('b', { class: 'mono' }, jiraLabel(b)), ' · ' + plural(jiraN, 'item')] : 'No Jira project set'), chip('PMOS tasks ' + pmN)]
322
322
  : [chip(icon('x', 'sm'), 'Build and deliver off'), chip('No Jira · ' + plural(pmN, 'PMOS task'))]),
323
323
  chip(icon('flag', 'sm'), 'New initiatives start with ' + def.name), lk({ onclick: () => openBoardSettings(b), 'data-ini': 'setup-change' }, 'Change')),
324
324
  b.self ? note(SELF_WHY) : null,
@@ -356,7 +356,7 @@ export function mountInitiatives(pane, opts) {
356
356
  [card({ ic: 'flag', title: 'Where the initiatives are', sub: 'each on its own workflow' },
357
357
  hbs(CATS.map((c) => btn({ class: 'ini-hbb', 'data-ini-catgo': c, title: 'See ' + c + ' initiatives', onclick: () => goIni({ view: 'all', cat: c }) }, hbr([sw(c), ' ', c], byCat(c), byCat(c) / maxN, 'acc', { class: 'hbr ini-c', 'data-cat': c })))),
358
358
  note('Stages roll up into four categories. Press one to list its initiatives.')),
359
- card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + (b.jira || '') + ' and PMOS' : 'PMOS tasks' },
359
+ card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
360
360
  hbs(['To do', 'In progress', 'In review', 'Done'].map((s) => hbr(s, nSt(s), nSt(s) / tot, s === 'Done' ? 'ok' : s === 'To do' ? 'pm' : 'acc'))),
361
361
  el('div', { class: 'chips2' }, lk({ onclick: () => ctx.openBoard(b.id, 'board') }, 'Open the Board'), lk({ onclick: () => ctx.openBoard(b.id, 'timeline') }, 'Open the Timeline'))),
362
362
  card({ ic: 'report', title: 'At a glance' }, chgl([
@@ -381,7 +381,7 @@ export function mountInitiatives(pane, opts) {
381
381
 
382
382
  function openBoardSettings(b) {
383
383
  if (b.self) { openSheet({ title: 'Board settings · ' + b.name, icon: 'lock', body: el('div', { class: 'ini-form' }, note(SELF_WHY)) }); return; }
384
- const B = { build: b.build !== false, jira: b.jira || '', offer: new Set(offered(b)), def: b.defTpl || offered(b)[0], err: '', busy: false };
384
+ const B = { build: b.build !== false, jira: jiraLabel(b), offer: new Set(offered(b)), def: b.defTpl || offered(b)[0], err: '', busy: false };
385
385
  const host = el('div', { class: 'ini-form', 'data-ini': 'settings-form' });
386
386
  const sh = openSheet({ title: 'Board settings · ' + b.name, icon: 'cols', body: host, wide: true });
387
387
  let usage = null;
@@ -394,11 +394,11 @@ export function mountInitiatives(pane, opts) {
394
394
  const inis = usage && usage.ok ? usage.initiatives : [];
395
395
  const jiraN = usage && usage.ok ? usage.items.filter((w) => w.src === 'jira').length : 0;
396
396
  const usingBuild = inis.filter((i) => wfOf(i, b).some(isBuildStage)).length;
397
- const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project key', placeholder: 'Project key, e.g. IDPF', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
397
+ const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project keys', placeholder: 'Project keys, e.g. IDPF, LB', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
398
398
  host.replaceChildren(
399
399
  note('Settings for the board. Workflows belong to each initiative; here you choose which templates the board offers and which one new initiatives start with.'),
400
400
  checkbox(B.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'build' }, (e) => { B.build = e.target.checked; if (B.build) Object.keys(TEMPLATES).filter(templateBuilds).forEach((k) => B.offer.add(k)); render('[data-ini="build"]'); }),
401
- B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECT'), el('div', { class: 'form' }, jira), note('Shows Jira epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only).'))
401
+ B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, jira), note('One or more Jira projects, separated by commas. Shows their epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only). Link each initiative to its Jira epics on the initiative page.'))
402
402
  : note('Off: Backlog, Board and Timeline show PMOS tasks only. No Sprints or Releases tab, and initiatives choose workflows without Build and Deliver.'),
403
403
  !B.build && b.build ? el('div', { class: 'fold w', role: 'alert', 'data-ini': 'build-off-warning' }, icon('alert', 'sm'), el('span', null, plural(jiraN, 'Jira item') + ' will be hidden from this board.' + (usingBuild ? ' ' + plural(usingBuild, 'initiative') + ' use Build or Deliver stages; they keep them until you edit their workflow.' : ''))) : null,
404
404
  lbl('WORKFLOWS THIS BOARD OFFERS'),
@@ -416,16 +416,16 @@ export function mountInitiatives(pane, opts) {
416
416
  }
417
417
  async function save() {
418
418
  const key = B.jira.trim();
419
- if (B.build && key && !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(key)) { B.err = 'A Jira project key is a letter, then letters, digits or _.'; render(); return; }
419
+ if (B.build && key && !key.split(/[,\s]+/).filter(Boolean).every((k) => /^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(k))) { B.err = 'Each Jira project key is a letter, then letters, digits or _. Separate keys with a comma.'; render(); return; }
420
420
  B.busy = true; B.err = ''; render();
421
421
  const body = { board: b.id, buildDeliver: B.build, offer: [...B.offer], defTpl: B.def };
422
- if (B.build && key) body.jira = key;
422
+ if (B.build) body.jira = key;
423
423
  const r = await apiFor(b.ws).post('/api/boards/settings', body);
424
424
  B.busy = false;
425
425
  if (!r || !r.ok) { B.err = 'The settings were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
426
426
  sh.close();
427
427
  const nb = r.board || {};
428
- toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (nb.jira ? ', mirroring Jira ' + nb.jira : '') : 'off, PMOS tasks only') + '. New initiatives start with ' + (TEMPLATES[nb.defTpl] || TEMPLATES[B.def]).name + '.');
428
+ toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (jiraLabel(nb) ? ', mirroring Jira ' + jiraLabel(nb) : '') : 'off, PMOS tasks only') + '. New initiatives start with ' + (TEMPLATES[nb.defTpl] || TEMPLATES[B.def]).name + '.');
429
429
  refresh();
430
430
  }
431
431
  render();
@@ -445,7 +445,7 @@ export function mountInitiatives(pane, opts) {
445
445
  lbl('BOARD NAME'), el('div', { class: 'form' }, name),
446
446
  wsList.length > 1 ? el('label', { class: 'ini-field' }, lbl('WORKSPACE'), el('select', { 'data-ini': 'nb-ws', onchange: (e) => { N.ws = e.target.value; } }, ...wsList.map((w) => el('option', { value: w.id, selected: w.id === N.ws }, w.name || w.id)))) : null,
447
447
  checkbox(N.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'nb-build' }, (e) => { N.build = e.target.checked; render('[data-ini="nb-build"]'); }),
448
- N.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECT'), el('div', { class: 'form' }, el('input', { type: 'text', value: N.jira, placeholder: 'Project key, e.g. PP', 'aria-label': 'Jira project key', 'data-ini': 'nb-jira', oninput: (e) => { N.jira = e.target.value; } })),
448
+ N.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, el('input', { type: 'text', value: N.jira, placeholder: 'Project keys, e.g. PP, PPM', 'aria-label': 'Jira project keys', 'data-ini': 'nb-jira', oninput: (e) => { N.jira = e.target.value; } })),
449
449
  note('Epics, stories, tasks and bugs from this project show next to PMOS tasks. Sprints and releases are mirrored, read-only.'))
450
450
  : note('Off: no Jira. Initiatives here use workflows without Build and Deliver, and the work is PMOS tasks. Good for a microsite run by an agency, an office move or hiring.'),
451
451
  lbl('DEFAULT WORKFLOW FOR NEW INITIATIVES'), tplSeg(allowed, N.tpl, (k) => { N.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'), tplCase(N.tpl),
@@ -690,6 +690,7 @@ export function mountInitiatives(pane, opts) {
690
690
  el('div', { class: 'calbody', 'data-ini-page': row.id },
691
691
  lh('target', row.title || row.id, [row.id, bb.name, row.anchor || 'no anchor', row.owner || 'no owner', row.start || row.end ? day(row.start) + ' to ' + day(row.end) : 'no dates yet', row.horizon ? row.horizon[0].toUpperCase() + row.horizon.slice(1) : '', Math.round((Number(row.progress) || 0) * 100) + '% done'].filter(Boolean).join(' · ')),
692
692
  el('div', { class: 'chips2', 'data-ini': 'ini-chips' }, statusChip(row.status), chip(icon('bolt', 'sm'), plural(ep, 'epic') + ', ' + plural(work.length - ep, 'item')), buildTag(bb),
693
+ bb.build ? pill({ 'data-ini': 'jira-epics', 'aria-haspopup': 'dialog', title: 'The Jira epics of this initiative', onclick: () => openEpics(bb, row) }, icon('jira', 'sm'), (row.epics || []).length ? 'Jira epics ' + row.epics.join(', ') : 'Link Jira epics') : null,
693
694
  row.lane ? tag(row.lane) : null, el('span', { class: 'chips2', 'data-ini': 'warnings', 'aria-live': 'polite' }, ...warnings.map(warnChip))),
694
695
  stepper, bb.self ? note(SELF_WHY) : null, strip,
695
696
  segTabs(tabs, tab, (k) => go({ view: 'ini', board: bb.id, id: row.id, tab: k }), 'Initiative views', { 'data-ini': 'ini-tabs' }),
@@ -697,6 +698,34 @@ export function mountInitiatives(pane, opts) {
697
698
  if (P.focusStep) { const f = stepper.querySelector('[data-ini-step="' + CSS.escape(P.focusStep) + '"]'); P.focusStep = null; if (f) f.focus(); }
698
699
  mountTab(host, null, (INI_TAB_LIST.find((t) => t[0] === tab) || [])[1], ctxI, token);
699
700
  }
701
+ /* The Jira epics of an initiative: the daily Jira check reads each one and the issues under it, read-only. */
702
+ function openEpics(b, row) {
703
+ const E = { text: (row.epics || []).join(', '), err: '', busy: false };
704
+ const host = el('div', { class: 'ini-form', 'data-ini': 'epics-form' });
705
+ const sh = openSheet({ title: 'Jira epics · ' + row.id, icon: 'bolt', body: host });
706
+ function render() {
707
+ const input = el('input', { type: 'text', value: E.text, 'aria-label': 'Jira epic keys', placeholder: 'Epic keys, e.g. IDPF-2685, LB-120', 'data-ini': 'epics-input', oninput: (e) => { E.text = e.target.value; } });
708
+ host.replaceChildren(
709
+ note('The Jira epics this initiative runs on. The daily Jira check reads each epic and the issues under it, read-only, and shows them as its Jira items.' + (jiraLabel(b) ? ' ' + b.name + ' mirrors Jira ' + jiraLabel(b) + '.' : '')),
710
+ row.epicsFrom === 'record' ? note('Now from the initiative record (jira: ' + row.epics.join(', ') + '). Saving here takes over from it.') : null,
711
+ el('div', { class: 'ini-field' }, lbl('JIRA EPICS'), el('div', { class: 'form' }, input)),
712
+ E.err ? errLine(E.err) : null,
713
+ el('div', { class: 'chips2' }, pbtn('primary', E.busy ? 'Saving' : 'Save epics', null, { 'data-ini': 'epics-save', disabled: E.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
714
+ input.focus();
715
+ }
716
+ async function save() {
717
+ const keys = E.text.split(/[,\s]+/).filter(Boolean);
718
+ if (!keys.every((k) => /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(k))) { E.err = 'An epic key is a project key, a dash and a number, like IDPF-2685. Separate keys with a comma.'; render(); return; }
719
+ E.busy = true; E.err = ''; render();
720
+ const r = await apiFor(b.ws).post('/api/boards/ini-epics', { board: b.id, id: row.id, epics: keys });
721
+ E.busy = false;
722
+ if (!r || !r.ok) { E.err = 'The epics were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
723
+ sh.close();
724
+ toast(keys.length ? row.id + ' runs on Jira ' + keys.map((k) => k.toUpperCase()).join(', ') + '. The next Jira check reads them.' : row.id + ' has no Jira epic now.');
725
+ P.ini = null; dropItems(); draw();
726
+ }
727
+ render();
728
+ }
700
729
  function pickTool(id) {
701
730
  if (P.loc.tab !== 'overview' || P.loc.tool !== id) { go({ ...P.loc, tab: 'overview', tool: id }); return; }
702
731
  const n = root.querySelector('[data-ini-tool-card="' + CSS.escape(id) + '"]');
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: jira-sync
3
3
  label: "Jira status check"
4
- description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, and save them in PMOS for the initiative's Jira tickets tab. It writes nothing to Jira."
4
+ description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and save them in PMOS for the initiative's Jira tickets tab. It writes nothing to Jira."
5
5
  run: agent
6
6
  agent: claude
7
7
  output:
@@ -15,15 +15,17 @@ output:
15
15
  Rules that always hold:
16
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
17
  2. THE ONLY WRITE is `pmos jira sync`, and `pmos people record` once at the end (step 5). 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, the Flagged field, the story points field, the resolution date, and the display names of the assignee and the reporter. Never ask for or copy a description, a comment, an attachment, an email address, any other detail of a person, or a token.
18
+ 3. LIGHT ON CONTENT. Ask Jira only for these fields: summary, status, the issue type, the parent, the sprint field, subtasks, the Flagged field, the story points field, the resolution date, and the display names of the assignee and the reporter. Never ask for or copy a description, a comment, an attachment, an email address, any other detail of a person, or a token.
19
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
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
21
 
22
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.
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. An initiative PMOS pushed no ticket for, but which is linked to Jira epics, gets a search for those epics and the issues under them. If no initiative has a ticket or an epic in Jira, say so in one line and stop.
24
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. Story points are also a custom field, named "Story Points" or "Story point estimate" (often `customfield_10016`); ask for it by name. Fetch every page.
25
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", "points", "resolved", "flagged", "reason", "url"}`
26
+ `{"key", "summary", "status", "category", "type", "parent", "sprint", "subtasks", "points", "resolved", "flagged", "reason", "url"}`
27
+ - `type`: the issue type's name, for example `Epic`, `Story`, `Task` or `Bug`.
28
+ - `parent`: the parent issue's key (the epic of a story), or `null`.
27
29
  - `category`: the status category, `new`, `indeterminate` or `done`.
28
30
  - `sprint`: `{"id": "42", "name": "Sprint 42", "state": "active", "start": "2026-10-01", "end": "2026-10-14"}` for the active sprint, `"future"` for a planned one, or `null` when the issue is in the backlog. Copy `id`, `start`, `end` and `completed` (a closed sprint's completion date) from Jira's sprint field; leave out any that Jira does not give.
29
31
  - `points`: the story points as a number, or `null` when none are set.