create-pmos 0.142.0 → 0.144.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.144.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@9f8623c
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:54:19Z"}
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:54:19Z"}
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:54:19Z"}
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']) {
@@ -1,14 +1,30 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: the Calendar tab of the console page"
4
- description: "The Calendar tab of the console page (/c3.html): the five zooms, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
5
- timestamp: 2026-10-11
4
+ description: "The Calendar tab of the console page (/c3.html): Calendar and Notes, the five zooms, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
5
+ timestamp: 2026-10-12
6
6
  ---
7
7
 
8
8
  # Guide: the Calendar tab of the console page
9
9
 
10
10
  The Calendar tab of the console page (`/c3.html`) shows time. It shows what happens and what falls due, by date. At the top it shows a short deck of headline cards about your calendar, for example a conflict or a meeting with no time to prepare. The deck shows nothing when PMOS has no card about your calendar. The tab shows no task list and no strategy panel. Your due items live in Work. The strategy and progress panels live in Reports and Initiatives.
11
11
 
12
+ ## Calendar and Notes
13
+
14
+ The switch at the start of the bar has two parts. **Calendar** shows the grid. **Notes** shows your notes.
15
+
16
+ In **Notes**, the list is at the left. The notes are in groups, one for each initiative. **No initiative** is the last group. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes whose title, initiative or summary has the words. A row shows when the note was made and its length. A row also shows **recording** or **paused** while the note is held, **no meeting** when the note has no meeting, and a warning mark when a statement differs from a file.
17
+
18
+ Press a row to open the note at the right. The note has three stages. Press **Before**, **During** or **After** to change the stage.
19
+
20
+ - **Before** shows what the note kept about its meeting: the agenda, what the meeting is for, what to do to be ready, what to read first and the people. A tick shows an agenda item that was heard in the transcript. A note with no meeting says so, and has **Link to a meeting** and **Link to an initiative**.
21
+ - **During** shows the live notes while the note is recorded or paused. At other times it shows the transcript.
22
+ - **After** shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file and the highlights. The transcript is folded at the bottom. When the final notes are written, click a text to correct it. Leave the text to save it. Press `Esc` to cancel. **Make task** adds a next step to the note's initiative.
23
+
24
+ A note that is recorded now opens on **During**. Other notes open on **After**. **Show in the calendar** goes back to the grid, on the day of the meeting, with the meeting selected. The copy button puts the note on the clipboard as Markdown.
25
+
26
+ In **Notes**, the zoom, the arrows and the calendar keys do nothing.
27
+
12
28
  ## Zoom and move
13
29
 
14
30
  Use **Day**, **Week**, **Month**, **Quarter** or **Year** to change the zoom. Use the arrows to go to the previous or the next period. Press **Today** to come back to today.
@@ -53,6 +69,8 @@ Press a meeting to show its context in the side column. Under the title, a row s
53
69
  - For a past meeting, **What came out of it** lists the decisions and actions of its note. **What moved after it** lists the items that changed within 48 hours and refer to the meeting or its initiative.
54
70
  - For a coming meeting, **Prepared** lists what is ready, and **To prepare** lists what is still open.
55
71
 
72
+ When the meeting has a note, the detail has **Open the note**. It opens the note in **Notes**.
73
+
56
74
  When PMOS holds nothing for a meeting, the detail says so. It does not guess.
57
75
 
58
76
  ## Free time
@@ -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:
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
4
  description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, decisions with their why and who, next steps with an open question, a discussion paragraph, and Wispr Flow's notes merged in and fetched by hand when the fetch fails; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository for conflicts, capture highlights during the meeting, create tasks from next steps, correct and tag lines, see what changed in your notes, replay the meeting, see screenshots of the shared screen beside the transcript, link a note to its meeting and its initiative, and get a summary."
5
- timestamp: 2026-10-11
5
+ timestamp: 2026-10-12
6
6
  ---
7
7
 
8
8
  # Guide: notes
@@ -22,7 +22,7 @@ Starting a note, from the Record control or from **Take notes** on a calendar ev
22
22
 
23
23
  ### The Record control in the console
24
24
 
25
- The console (the page at `/c3.html`) has the same Record control in its top bar, beside the Sessions button. It is always there, also when nothing is recording. **Record** (the microphone) starts an untitled note and listens at once. The arrow lists the meetings of today that have not started. Press one to start a note linked to that calendar event. While a meeting records, the red group shows its name and clock, **Highlight**, **Pause** and **Stop**. While it is paused, the group shows **Resume** and **Stop**. **Stop** asks first and offers **Pause instead**. A red dot shows on the Calendar tab while a meeting is held. The console has no Notes page, so the name in the red group opens the note in the main app.
25
+ The console (the page at `/c3.html`) has the same Record control in its top bar, beside the Sessions button. It is always there, also when nothing is recording. **Record** (the microphone) starts an untitled note and listens at once. The arrow lists the meetings of today that have not started. Press one to start a note linked to that calendar event. While a meeting records, the red group shows its name and clock, **Highlight**, **Pause** and **Stop**. While it is paused, the group shows **Resume** and **Stop**. **Stop** asks first and offers **Pause instead**. A red dot shows on the Calendar tab while a meeting is held. Press the name in the red group to open the note on the console's Calendar tab, in **Notes**, on the **During** stage. See [the Calendar tab of the console page](console-calendar.md).
26
26
 
27
27
  When Whisper or Qwen3-ASR transcribes the meeting, the red group also names the engine and the language, for example **Whisper · English**. Check it when lines come out in the wrong language: the language is set in the Notes settings.
28
28
 
@@ -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.144.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.144.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.144.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",