create-pmos 0.57.1 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.57.1",
3
+ "version": "0.58.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@fd17645
1
+ kernel-source: pmos@e8fcb2a
2
2
  assembled: 2026-10-06
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-06T11:33:15Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:54:59Z"}
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-06T11:33:15Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:54:59Z"}
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-06T11:33:15Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:54:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -36,6 +36,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
36
36
  ## Where to read next
37
37
 
38
38
  - [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
39
+ - [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
39
40
  - [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
40
41
  - [Layout](layout.md): the frame, the keys and the rule for where a list or a detail opens.
41
42
  - [Statuses](statuses.md): what each status means, where it comes from, and what you can change.
@@ -0,0 +1,141 @@
1
+ ---
2
+ type: Guide
3
+ title: "Jira: tickets drafted from the PRD, and their status"
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-06
6
+ ---
7
+
8
+ # Jira: tickets drafted from the PRD, and their status
9
+
10
+ Your team works in Jira. You plan in PMOS. This page shows how the two stay in step without a second Jira:
11
+
12
+ 1. PMOS drafts one Jira ticket for each ticket item of the PRD, in your ticket template.
13
+ 2. You read the tickets, edit them if needed, and select the ones that are ready.
14
+ 3. An agent creates the selected tickets in Jira, with your own Atlassian connection.
15
+ 4. Each day, an agent reads their status back. The initiative's **Jira tickets** tab shows it.
16
+
17
+ PMOS owns the text of a ticket. Jira owns its status. PMOS holds no Jira password or token: your agent session reads and writes Jira with the connection you set up in it.
18
+
19
+ ## Where it is
20
+
21
+ Open an initiative. Its sheet has four tabs: Breakdown, Files, History and **Jira tickets**.
22
+
23
+ In a document repository whose lane has a Handoff stage, the Jira tickets tab opens when the handoff is done. Mark the Handoff item done in the Breakdown, or record `handed_off:` in the initiative. In other repositories the tab is open as soon as the PRD has ticket items.
24
+
25
+ ## Write the ticket items in the PRD
26
+
27
+ PMOS reads a section headed **User stories**, **Stories**, **Tickets**, **Jira tickets** or **Work items**. Each ticket is a subheading with an ID:
28
+
29
+ ```markdown
30
+ ## Tickets
31
+
32
+ ### TD-01 · Carry the real runtime version on every GA4 event
33
+ Type: Task · Priority: High · Estimate: S · Depends on: —
34
+
35
+ As a PM, I want every GA4 event to carry the runtime version, so that I can compare releases.
36
+
37
+ #### Additional Context
38
+ #### User Flow
39
+ #### Functional Requirements
40
+ 1. …
41
+ #### Terminal States
42
+ #### Acceptance Criteria
43
+ - Given …, when …, then …
44
+ #### Refs
45
+ - [Tagging plan](https://example.com/plan)
46
+ ```
47
+
48
+ - Keep the ID (`TD-01`) stable. PMOS matches a ticket to its item by ID.
49
+ - The `Type · Priority · Estimate · Depends on` line is optional. The defaults are Task, Medium, no estimate and no dependency.
50
+ - Text before the first subsection is the User Story when it starts "As a …".
51
+ - A bullet such as `- **S1 · Pay with a saved card.** As a …` also works.
52
+
53
+ ## Draft, read and edit
54
+
55
+ Press **Draft tickets**, or run:
56
+
57
+ ```
58
+ pmos jira draft <initiative>
59
+ ```
60
+
61
+ The draft is a file, `<state>/tracker/jira/<initiative>-tickets.md`. Every row of the tab opens a ticket: its fields, its full text and its status in Jira. **Edit text** changes the summary and the sections. Type, Priority, Estimate and Depends on are lists.
62
+
63
+ A ticket needs a User Story, Functional Requirements and Acceptance Criteria before it can be pushed. A ticket without them shows **Missing text**.
64
+
65
+ To see every ticket in the terminal:
66
+
67
+ ```
68
+ pmos jira show <initiative>
69
+ ```
70
+
71
+ ## Choose the epic
72
+
73
+ Above the list, choose **Create new** (an epic named after the initiative, made at the first push) or **Use an existing epic** and paste its link or key. After the first push the epic is fixed. In the terminal:
74
+
75
+ ```
76
+ pmos jira epic <initiative> new
77
+ pmos jira epic <initiative> ATL-482
78
+ ```
79
+
80
+ ## Push the tickets that are ready
81
+
82
+ Check the tickets that are ready and press **Push N to Jira**. Only checked tickets are sent. An agent session starts and creates them under the epic. A ticket you edit after you checked it must be checked again.
83
+
84
+ In your own terminal, the same selection is:
85
+
86
+ ```
87
+ pmos jira approve <initiative> TD-01 TD-02
88
+ ```
89
+
90
+ It asks you to type the initiative's id. An agent session cannot select tickets.
91
+
92
+ The agent runs the **Push to Jira** skill. It reads what to send with `pmos jira pending <initiative>`, and records each key with `pmos jira record <initiative> TD-01=ATL-491`. It never moves, assigns, comments on, closes or deletes a Jira issue.
93
+
94
+ ## Status, sprint and progress
95
+
96
+ Each row shows the ticket's status in Jira, and whether it is in the **Active sprint**, the **Next sprint** or the **Backlog**, with its subtasks done. A blocked ticket shows **Blocked** and the reason.
97
+
98
+ The **Jira status check** reads this for every initiative. **Check now** on the tab reads it for one. To run it every day before the morning brief (06:45), schedule it at 06:30:
99
+
100
+ ```
101
+ pmos schedule add --skill jira-sync --cadence daily --time 06:30
102
+ ```
103
+
104
+ The check reads Jira only. It writes `<state>/tracker/jira/<initiative>.md` with:
105
+
106
+ ```
107
+ pmos jira query [<initiative>]
108
+ pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]
109
+ ```
110
+
111
+ ## When the PRD changes
112
+
113
+ Each time the tab opens, PMOS compares every ticket with its item in the PRD:
114
+
115
+ - **PRD changed**: the item changed since the ticket was drafted. Open the ticket to see both versions, then press **Use the PRD version** or **Keep the ticket as it is**. A ticket that is in Jira then shows **Edited, not in Jira yet** until you push it again.
116
+ - **New in PRD**: an item has no ticket yet. Press **Add to tickets**.
117
+ - **Removed from PRD**: the item is gone. The Jira ticket is not changed.
118
+
119
+ A ticket you did not edit and did not push is redrafted at the next **Draft tickets**.
120
+
121
+ ## Which initiative is this Jira key?
122
+
123
+ ```
124
+ pmos jira which ATL-491
125
+ ```
126
+
127
+ The morning brief, the day check and requirement gathering use it to tie a Jira issue to its initiative.
128
+
129
+ ## Every command
130
+
131
+ - `pmos jira draft <initiative> [--dry-run]`
132
+ - `pmos jira show <initiative>`
133
+ - `pmos jira epic <initiative> new|<KEY or link>`
134
+ - `pmos jira approve <initiative> <ID> …`
135
+ - `pmos jira pending <initiative>`
136
+ - `pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID]`
137
+ - `pmos jira query [<initiative>]`
138
+ - `pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]`
139
+ - `pmos jira which <KEY>`
140
+
141
+ Every command takes `--json` and `--root <dir>`.
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+ /* jira-cli.js — `pmos jira …`: Jira tickets drafted from the PRD, pushed by the PM's selection, and their status
3
+ * (initiative jira-wbs).
4
+ *
5
+ * pmos jira draft <initiative> [--dry-run] the PRD's items → the ticket draft (lib/jira-tickets.js)
6
+ * pmos jira show <initiative> every ticket, its flags and its status in Jira
7
+ * pmos jira epic <initiative> new|<KEY or link> create a new epic, or use an existing one
8
+ * pmos jira approve <initiative> <ID> … the PM selects tickets to push, in their own terminal
9
+ * pmos jira pending <initiative> what the agent creates or updates in Jira (approved only)
10
+ * pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys the agent created
11
+ * pmos jira query [<initiative>] the Jira search for the tickets an initiative pushed
12
+ * pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] a saved search → the status snapshot
13
+ * pmos jira which <KEY> the initiative a Jira key belongs to
14
+ * every command: [--json] [--root <dir>]
15
+ *
16
+ * This command never talks to Jira. An agent session does, with the PM's own Atlassian connection (D53, D88), and
17
+ * hands the result to `sync` or `record`. `approve` is PM-only (pm-guard act `push`): an agent session is refused.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { execFileSync } = require('child_process');
22
+ const { parseArgs } = require('./schedule-cli');
23
+ const jiraStatus = require('./jira-status.js');
24
+ const jiraTickets = require('./jira-tickets.js');
25
+ const planItems = require('./plan-items.js');
26
+ const guard = require('./pm-guard.js');
27
+
28
+ const COMMANDS = { jira: ['draft', 'show', 'epic', 'approve', 'pending', 'record', 'query', 'sync', 'which'] };
29
+ const USAGE = [
30
+ 'usage: pmos jira draft|show|epic|approve|pending|record|query|sync|which — Jira tickets from the PRD, and their status',
31
+ ' pmos jira draft <initiative> [--dry-run] draft one ticket per PRD item, in the ticket template',
32
+ ' pmos jira show <initiative> every ticket, what needs you, and its status in Jira',
33
+ ' pmos jira epic <initiative> new|<KEY or link> create a new epic at the first push, or use an existing one',
34
+ ' pmos jira approve <initiative> <ID> … select tickets to push (you, in your own terminal)',
35
+ ' pmos jira pending <initiative> what the push creates or updates in Jira',
36
+ ' pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys created in Jira',
37
+ ' pmos jira query [<initiative>] the Jira search for the pushed tickets',
38
+ ' pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] save the status read from Jira',
39
+ ' pmos jira which <KEY> the initiative a Jira key belongs to',
40
+ ' every command: [--json] [--root <dir>]',
41
+ 'This command never calls Jira. Your agent reads and writes Jira with your own connection.',
42
+ ];
43
+ const BOOL = new Set(['json', 'dry-run', 'help']);
44
+ const VALUE = new Set(['from', 'query', 'root', 'epic', 'updated']);
45
+ const FLAG_TEXT = { 'prd-changed': 'PRD changed', removed: 'Removed from PRD', blocked: 'Blocked', incomplete: 'Missing text', pushing: 'Selected, waiting for the push', edited: 'Edited, not in Jira yet' };
46
+
47
+ function parse(argv) {
48
+ const rest = []; const bools = {};
49
+ for (const a of argv) {
50
+ if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
51
+ else if (a === '-h') bools.help = true;
52
+ else rest.push(a);
53
+ }
54
+ const { pos, flags } = parseArgs(rest);
55
+ return { pos, flags: { ...flags, ...bools } };
56
+ }
57
+ function repoRoot(cwd) {
58
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
59
+ catch { return cwd; }
60
+ }
61
+ function readInput(from, cwd, stdin) {
62
+ if (from === '-') return stdin != null ? stdin : fs.readFileSync(0, 'utf8');
63
+ return fs.readFileSync(path.resolve(cwd, from), 'utf8');
64
+ }
65
+ function statusLine(t) {
66
+ if (!t.inJira) return 'not in Jira';
67
+ if (!t.jira) return `${t.key} · not checked yet`;
68
+ const j = t.jira;
69
+ const where = j.category === 'done' ? 'Done' : jiraStatus.sprintLabel(j.sprint);
70
+ return `${t.key} · ${j.status} · ${where}${j.subtasks && j.subtasks.total ? ` · subtasks ${j.subtasks.done} of ${j.subtasks.total}` : ''}${j.stale ? ' · not in the last read' : ''}`;
71
+ }
72
+
73
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, now, stdin, ask } = {}) {
74
+ const args = argv[0] === 'jira' ? argv.slice(1) : argv.slice();
75
+ const verb = args[0];
76
+ const { pos, flags } = parse(args.slice(1));
77
+ const json = !!flags.json;
78
+ if (!verb || verb === 'help' || verb === '--help' || verb === '-h' || flags.help) {
79
+ for (const l of USAGE) say(l);
80
+ return verb ? 0 : 1;
81
+ }
82
+ if (!COMMANDS.jira.includes(verb)) { say(`unknown jira command "${verb}" — one of ${COMMANDS.jira.join(', ')}`); return 2; }
83
+ for (const k of Object.keys(flags)) {
84
+ if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
85
+ if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
86
+ }
87
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
88
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
89
+ const out = (r, lines) => { if (json) say(JSON.stringify(r)); else for (const l of lines) say(l); return 0; };
90
+ const need = (what) => { say(`usage: pmos jira ${verb} ${what}`); return 2; };
91
+ const initiative = pos[0];
92
+ const known = () => planItems.INIT_RE.test(initiative || '') && !!jiraTickets.recordFm(root, initiative);
93
+
94
+ try {
95
+ if (verb === 'query') {
96
+ if (initiative) {
97
+ const q = jiraStatus.queryFor(root, initiative);
98
+ if (!q.ok) return fail(q.error);
99
+ if (!q.linked) return fail(`${initiative} has no ticket in Jira yet — push its tickets first`);
100
+ return out(q, [q.query]);
101
+ }
102
+ const all = jiraStatus.linkedInitiatives(root).filter((x) => x.linked);
103
+ return out({ ok: true, initiatives: all }, all.length ? all.map((x) => `${x.initiative}\t${x.query}`) : ['no initiative has a ticket in Jira yet']);
104
+ }
105
+ if (verb === 'which') {
106
+ if (!initiative) return need('<KEY>');
107
+ const r = jiraStatus.which(root, initiative);
108
+ if (!r.ok) return fail(r.error);
109
+ return out(r, r.initiatives.length ? r.initiatives.map((h) => `${r.key} → ${h.initiative} · ${h.role}${h.ticket ? ` ${h.ticket} · ${h.summary}` : ''}`) : [`${r.key} belongs to no initiative in this repository`]);
110
+ }
111
+ if (!initiative) return need('<initiative> …');
112
+ if (!known()) return fail(`no initiative ${initiative} in this repository`);
113
+
114
+ if (verb === 'draft') {
115
+ const r = jiraTickets.draft(root, initiative, { dryRun: !!flags['dry-run'], now });
116
+ if (!r.ok) return fail(r.error);
117
+ const lines = [`${initiative} · ${r.tickets} ${r.tickets === 1 ? 'ticket' : 'tickets'} in ${r.draft}${r.changed ? '' : ' · no change'}${flags['dry-run'] ? ' · dry run, nothing written' : ''}`];
118
+ for (const c of r.changes) lines.push(` ${c.what.padEnd(9)} ${c.ticket} · ${c.summary}${c.why ? ` (${c.why})` : ''}`);
119
+ lines.push('Read them on the Jira tickets tab, or with pmos jira show. Select the ready ones there, or with pmos jira approve.');
120
+ return out(r, lines);
121
+ }
122
+ if (verb === 'show') {
123
+ const r = jiraTickets.view(root, initiative, { snapshot: jiraStatus.readSnapshot(root, initiative) });
124
+ if (!r.ok) return fail(r.error);
125
+ const lines = [`${initiative} · ${r.inJira} of ${r.tickets.length} in Jira · ${r.checkedAt ? `checked ${r.checkedAt}` : 'not checked yet'} · epic ${r.epic.key || (r.epic.mode === 'new' ? 'new, created at the first push' : 'not chosen')}`];
126
+ if (!r.gate.open) lines.push(` The Jira tickets tab opens when the handoff is done (${r.gate.why}).`);
127
+ for (const t of r.tickets) {
128
+ lines.push(` ${t.id} · ${t.summary} · ${[t.type, t.priority, t.estimate].filter(Boolean).join(' · ')}`);
129
+ lines.push(` ${statusLine(t)}${t.flag ? ` · ${FLAG_TEXT[t.flag]}${t.flag === 'incomplete' ? `: ${t.missing.join(', ')}` : t.flag === 'blocked' && t.jira.reason ? `: ${t.jira.reason}` : ''}` : ''}`);
130
+ }
131
+ for (const n of r.newInPrd) lines.push(` ${n.id} · ${n.summary} · New in PRD: run pmos jira draft ${initiative}`);
132
+ if (!r.drafted) lines.push(` No draft yet: pmos jira draft ${initiative}`);
133
+ return out(r, lines);
134
+ }
135
+ if (verb === 'epic') {
136
+ const v = pos[1];
137
+ if (!v) return need('<initiative> new|<KEY or link>');
138
+ const r = jiraTickets.setEpic(root, initiative, v === 'new' ? { mode: 'new' } : { mode: 'existing', key: v }, { now });
139
+ if (!r.ok) return fail(r.error);
140
+ return out(r, [r.epic.mode === 'new' ? `${initiative} · a new epic is created at the first push` : `${initiative} · tickets go under ${r.epic.key}`]);
141
+ }
142
+ if (verb === 'approve') {
143
+ const ids = pos.slice(1);
144
+ if (!ids.length) return need('<initiative> <ID> …');
145
+ const g = await guard.requirePmCli({ act: 'push', expect: initiative,
146
+ ...(isTTY === undefined ? {} : { isTTY }), ...(env === undefined ? {} : { env }), ask: ask || guard.askAtTerminal });
147
+ if (!g.ok) return fail(g.error);
148
+ const r = jiraTickets.approve(root, initiative, ids, { now });
149
+ if (!r.ok) return fail(r.error);
150
+ return out(r, [`selected ${r.approved.join(', ')} for ${initiative}. Your agent can push them now: run the Push to Jira skill, or press Push on the tab.`]);
151
+ }
152
+ if (verb === 'pending') {
153
+ const r = jiraTickets.pushPlan(root, initiative);
154
+ if (!r.ok) return fail(r.error);
155
+ const lines = [`${initiative} · ${r.create.length} to create · ${r.update.length} to update · ${r.unchanged} unchanged · ${r.notApproved} not selected`];
156
+ lines.push(r.epic.key ? ` epic ${r.epic.key}` : ` epic create "${r.epic.create.summary}"`);
157
+ for (const t of r.create) lines.push(` create ${t.ticket} · ${t.summary} · ${t.issuetype} · ${t.priority}`);
158
+ for (const t of r.update) lines.push(` update ${t.ticket} ${t.key} · ${t.summary}`);
159
+ return out(r, lines);
160
+ }
161
+ if (verb === 'record') {
162
+ const tickets = {};
163
+ for (const p of pos.slice(1)) {
164
+ const m = /^([A-Za-z0-9][\w.-]{0,40})=(.+)$/.exec(p);
165
+ if (!m) return fail(`"${p}" is not <ID>=<KEY>`, 2);
166
+ tickets[m[1]] = m[2];
167
+ }
168
+ const updated = flags.updated ? String(flags.updated).split(',').map((s) => s.trim()).filter(Boolean) : [];
169
+ if (!Object.keys(tickets).length && !updated.length && !flags.epic) return need('<initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID]');
170
+ const r = jiraTickets.record(root, initiative, { epic: flags.epic || null, tickets, pushed: updated }, { now });
171
+ if (!r.ok) return fail(r.error);
172
+ return out(r, [`${initiative}${r.epic ? ` · epic ${r.epic}` : ''} · recorded ${r.recorded.length}`, ...r.recorded.map((x) => ` ${x.what} ${x.ticket} ${x.key}`)]);
173
+ }
174
+ if (verb === 'sync') {
175
+ if (!flags.from) return need('<initiative> --from <file|->');
176
+ let raw;
177
+ try { raw = JSON.parse(readInput(flags.from, cwd, stdin)); }
178
+ catch (e) { return fail(`could not read ${flags.from} as JSON — ${String((e && e.message) || e).slice(0, 160)}`); }
179
+ const r = jiraStatus.sync(root, initiative, raw, { query: flags.query || undefined, dryRun: !!flags['dry-run'], now });
180
+ if (!r.ok) return fail(r.error);
181
+ const lines = [`${initiative} · read ${r.read} ${r.read === 1 ? 'ticket' : 'tickets'}${r.ignored ? ` · ${r.ignored} other issues ignored` : ''}${r.skipped ? ` · ${r.skipped} skipped (no key or summary)` : ''}${r.dryRun ? ' · dry run, nothing written' : ''}`];
182
+ for (const c of r.changes) lines.push(` ${c.key} · ${c.what}${c.from !== undefined ? `: ${c.from} → ${c.to}` : c.status ? `: ${c.status}` : ''}${c.reason ? ` (${c.reason})` : ''}`);
183
+ if (!r.changes.length) lines.push(' no change');
184
+ for (const k of r.missing) lines.push(` ${k} · not in this read; its last status is kept`);
185
+ if (!r.dryRun) lines.push(`snapshot ${r.snapshot}`);
186
+ return out(r, lines);
187
+ }
188
+ } catch (e) {
189
+ return fail(String((e && e.message) || e).slice(0, 200));
190
+ }
191
+ return 2;
192
+ }
193
+
194
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+ /* jira-status.js — the status of the Jira tickets PMOS pushed, read back read-only (initiative jira-wbs, PRD J11, J13).
3
+ *
4
+ * THE READ PATH. An agent session reads Jira read-only with the PM's own Atlassian connection (D53: PMOS holds no
5
+ * tracker credential; D71: an agent skill reads a source, the server does not) and saves the search result to a file.
6
+ * This module reads that file and writes a snapshot the initiative's Jira tickets tab shows. It writes nothing else:
7
+ * no plan item, no record, no ledger line (D88: the Breakdown is the handoff checklist, not a copy of Jira).
8
+ *
9
+ * queryFor(root, initiative) → { linked, query } the JQL for the tickets this initiative pushed
10
+ * linkedInitiatives(root) → [{ initiative, query }]
11
+ * normalize(raw) → { issues, skipped } | { error }
12
+ * sync(root, initiative, raw, opts) → the snapshot, written to <state>/tracker/jira/<initiative>.md
13
+ * readSnapshot(root, initiative) → { fetchedAt, query, tickets: Map<key, reading> } | null
14
+ * which(root, key) → the initiative and ticket a key belongs to
15
+ *
16
+ * A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed } | null,
17
+ * subtasks: { done, total }, flagged, reason, url }. A ticket the search did not return keeps its last reading, marked
18
+ * stale. Titles and statuses only: never a description, a comment or a person.
19
+ *
20
+ * BOUNDARY (J15). No network module, no child process. The only write is the snapshot file.
21
+ */
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const jiraTickets = require('./jira-tickets.js');
25
+
26
+ const KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
27
+ const isStr = (v) => typeof v === 'string';
28
+ const oneLine = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
29
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
30
+ const nowIso = (now) => new Date(now == null ? Date.now() : now).toISOString().replace(/\.\d{3}Z$/, 'Z');
31
+
32
+ // ── the query ──────────────────────────────────────────────────────────────────────────────────────
33
+ function queryFor(root, initiative) {
34
+ if (!jiraTickets.recordFm(root, initiative)) return { ok: false, error: `no initiative ${initiative} in this repository` };
35
+ const keys = [...jiraTickets.pushedTickets(root, initiative).tickets.keys()];
36
+ if (!keys.length) return { ok: true, initiative, linked: false, keys: [], query: null };
37
+ return { ok: true, initiative, linked: true, keys, query: `key in (${keys.join(', ')}) ORDER BY key` };
38
+ }
39
+ function linkedInitiatives(root) {
40
+ return jiraTickets.draftedInitiatives(root).map((id) => queryFor(root, id)).filter((q) => q.ok);
41
+ }
42
+
43
+ // ── reading a saved search result ──────────────────────────────────────────────────────────────────
44
+ function issuesList(raw) {
45
+ if (Array.isArray(raw)) return raw;
46
+ if (!raw || typeof raw !== 'object') return null;
47
+ if (Array.isArray(raw.issues)) return raw.issues;
48
+ if (raw.issues && Array.isArray(raw.issues.nodes)) return raw.issues.nodes;
49
+ if (Array.isArray(raw.values)) return raw.values;
50
+ if (raw.data && typeof raw.data === 'object') return issuesList(raw.data);
51
+ return null;
52
+ }
53
+ const nameOf = (v) => (v && typeof v === 'object' ? oneLine(v.name || v.value || '') : oneLine(v));
54
+ function categoryOf(status, cat) {
55
+ const key = String((cat && typeof cat === 'object' ? (cat.key || cat.name) : cat) || '').toLowerCase();
56
+ if (key === 'done' || key === 'complete') return 'done';
57
+ if (key === 'indeterminate' || key === 'in progress' || key === 'doing') return 'doing';
58
+ if (key === 'new' || key === 'to do' || key === 'todo') return 'todo';
59
+ const s = String(status || '').toLowerCase();
60
+ if (/done|closed|resolved|released|complete|shipped|deployed/.test(s)) return 'done';
61
+ if (/progress|doing|started|review|\bqa\b|test|verif|\buat\b|block/.test(s)) return 'doing';
62
+ return 'todo';
63
+ }
64
+ /** Jira's Flagged field is a custom field whose id differs per site: any field holding the value "Impediment". */
65
+ function flaggedIn(fields) {
66
+ if (fields.flagged === true) return true;
67
+ for (const v of Object.values(fields)) {
68
+ if (Array.isArray(v) && v.some((x) => x && typeof x === 'object' && /impediment/i.test(String(x.value || '')))) return true;
69
+ }
70
+ return false;
71
+ }
72
+ const SPRINT_STATES = ['active', 'future', 'closed'];
73
+ /** One sprint, from an object { name, state } or a bare name. */
74
+ function sprintOne(v) {
75
+ if (!v) return null;
76
+ if (isStr(v)) { const name = clip(oneLine(v), 80); return name ? { name, state: 'active' } : null; }
77
+ if (typeof v !== 'object') return null;
78
+ const name = clip(oneLine(v.name), 80);
79
+ const state = String(v.state || '').toLowerCase();
80
+ if (!name) return null;
81
+ return { name, state: SPRINT_STATES.includes(state) ? state : 'active' };
82
+ }
83
+ /** The sprint that matters: the active one, else the next, else the last closed. null is the backlog. */
84
+ function sprintOf(v) {
85
+ const all = (Array.isArray(v) ? v : [v]).map(sprintOne).filter(Boolean);
86
+ return all.find((s) => s.state === 'active') || all.find((s) => s.state === 'future') || all[all.length - 1] || null;
87
+ }
88
+ /** Jira Cloud keeps the sprint in a custom field: any array of objects that carry a sprint's name and state. */
89
+ function sprintIn(fields) {
90
+ if ('sprint' in fields) return sprintOf(fields.sprint);
91
+ for (const v of Object.values(fields)) {
92
+ if (Array.isArray(v) && v.length && v.every((x) => x && typeof x === 'object' && 'name' in x && 'state' in x && ('boardId' in x || 'id' in x))) return sprintOf(v);
93
+ }
94
+ return null;
95
+ }
96
+ function subtasksOf(v) {
97
+ if (Array.isArray(v)) {
98
+ const done = v.filter((s) => {
99
+ const st = s && (s.fields ? s.fields.status : s.status);
100
+ return categoryOf(nameOf(st), st && typeof st === 'object' ? st.statusCategory : (s && s.category)) === 'done';
101
+ }).length;
102
+ return { done, total: v.length };
103
+ }
104
+ if (v && typeof v === 'object' && Number.isInteger(v.total) && v.total >= 0) {
105
+ const done = Number.isInteger(v.done) ? Math.max(0, Math.min(v.done, v.total)) : 0;
106
+ return { done, total: v.total };
107
+ }
108
+ return { done: 0, total: 0 };
109
+ }
110
+ /** https://site.atlassian.net/rest/api/3/issue/10001 → https://site.atlassian.net/browse/KEY */
111
+ function urlOf(x, key) {
112
+ const raw = isStr(x.url) ? x.url : isStr(x.self) ? x.self : '';
113
+ let u;
114
+ try { u = new URL(raw); } catch { return null; }
115
+ if (u.protocol !== 'https:') return null;
116
+ if (/\/browse\//.test(u.pathname)) return u.origin + u.pathname;
117
+ return `${u.origin}/browse/${key}`;
118
+ }
119
+ /** One issue of either shape → a reading, or null when it has no key or no summary. */
120
+ function normalizeIssue(x) {
121
+ if (!x || typeof x !== 'object') return null;
122
+ const key = oneLine(x.key).toUpperCase();
123
+ if (!KEY_RE.test(key)) return null;
124
+ if (x.fields && typeof x.fields === 'object') {
125
+ const f = x.fields;
126
+ const summary = clip(oneLine(f.summary), 250);
127
+ if (!summary) return null;
128
+ const st = f.status || {};
129
+ const status = nameOf(st) || 'Unknown';
130
+ const flagged = flaggedIn(f) || /block|impediment|on hold/i.test(status);
131
+ return { key, summary, status, category: categoryOf(status, st.statusCategory), sprint: sprintIn(f), subtasks: subtasksOf(f.subtasks),
132
+ flagged, reason: flagged ? (flaggedIn(f) ? 'Flagged in Jira' : `Status: ${status}`) : null, url: urlOf(x, key) };
133
+ }
134
+ // The flat shape: what the skill tells the agent to write when it is unsure of the connector's shape.
135
+ const summary = clip(oneLine(x.summary || x.title), 250);
136
+ if (!summary) return null;
137
+ const status = oneLine(x.status) || 'Unknown';
138
+ const flagged = x.flagged === true || /block|impediment|on hold/i.test(status);
139
+ return { key, summary, status, category: categoryOf(status, x.category), sprint: sprintOf(x.sprint), subtasks: subtasksOf(x.subtasks),
140
+ flagged, reason: flagged ? clip(oneLine(x.reason || x.flag_reason || (x.flagged === true ? 'Flagged in Jira' : `Status: ${status}`)), 160) : null,
141
+ url: urlOf(x, key) };
142
+ }
143
+ function normalize(raw) {
144
+ const list = issuesList(raw);
145
+ if (!list) return { error: 'the file holds no list of Jira issues (expected "issues": [...], "issues": {"nodes": [...]} or a list)' };
146
+ const issues = []; const seen = new Set(); let skipped = 0;
147
+ for (const x of list) {
148
+ const n = normalizeIssue(x);
149
+ if (!n || seen.has(n.key)) { skipped++; continue; }
150
+ seen.add(n.key);
151
+ issues.push(n);
152
+ }
153
+ return { issues, skipped };
154
+ }
155
+
156
+ // ── the snapshot ───────────────────────────────────────────────────────────────────────────────────
157
+ function snapshotPath(root, initiative) { return path.join(jiraTickets.jiraDir(root), `${initiative}.md`); }
158
+ function snapshotRel(root, initiative) { return jiraTickets.relOf(root, snapshotPath(root, initiative)); }
159
+ const cell = (s) => oneLine(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|') || '—';
160
+ const SPRINT_WORD = { active: 'Active sprint', future: 'Next sprint', closed: 'Closed sprint' };
161
+ function sprintLabel(s) { return s ? `${SPRINT_WORD[s.state] || 'Sprint'}: ${s.name}` : 'Backlog'; }
162
+ function renderSnapshot({ initiative, fetchedAt, query, readings, ids }) {
163
+ const rows = readings.map((r) => `| ${cell(ids.get(r.key) || '')} | ${cell(r.key)} | ${cell(r.summary)} | ${cell(r.status)} | ${cell(r.category === 'done' ? 'Done' : sprintLabel(r.sprint))} | ${r.subtasks.total ? `${r.subtasks.done} of ${r.subtasks.total}` : '—'} | ${cell(r.flagged ? r.reason : '')} |${r.stale ? ' not in the last read |' : ''}`);
164
+ return [
165
+ '---', 'type: Reference', `title: "Jira status: ${initiative}"`, `timestamp: ${fetchedAt}`, `initiative: ${initiative}`, '---', '',
166
+ `# Jira status: ${initiative}`, '',
167
+ `Read from Jira at ${fetchedAt} by the Jira status check, read-only. The Jira tickets tab of the initiative shows it. Written by \`pmos jira sync\`; do not edit by hand.`, '',
168
+ query ? `Search: \`${query.replace(/`/g, "'")}\`` : 'Search: not recorded', '',
169
+ '| Ticket | Key | Summary | Status | Sprint | Subtasks | Blocked |', '|---|---|---|---|---|---|---|', ...rows, '',
170
+ '```json', JSON.stringify({ fetched_at: fetchedAt, query: query || null, tickets: readings }, null, 0), '```', '',
171
+ ].join('\n');
172
+ }
173
+ function readSnapshot(root, initiative) {
174
+ let text;
175
+ try { text = fs.readFileSync(snapshotPath(root, initiative), 'utf8'); } catch { return null; }
176
+ const m = /```json\n([\s\S]*?)\n```/.exec(text);
177
+ if (!m) return null;
178
+ let data;
179
+ try { data = JSON.parse(m[1]); } catch { return null; }
180
+ const tickets = new Map();
181
+ for (const r of Array.isArray(data.tickets) ? data.tickets : []) if (r && KEY_RE.test(String(r.key || ''))) tickets.set(r.key, r);
182
+ return { fetchedAt: isStr(data.fetched_at) ? data.fetched_at : null, query: isStr(data.query) ? data.query : null, tickets };
183
+ }
184
+
185
+ /**
186
+ * Write the snapshot from a saved search result. Only the tickets this initiative pushed are kept; any other issue in
187
+ * the result is counted and ignored. A pushed ticket missing from the result keeps its last reading, marked stale.
188
+ */
189
+ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
190
+ if (!jiraTickets.recordFm(root, initiative)) return { ok: false, error: `no initiative ${initiative} in this repository` };
191
+ const n = normalize(raw);
192
+ if (n.error) return { ok: false, error: n.error };
193
+ const pushed = jiraTickets.pushedTickets(root, initiative).tickets;
194
+ if (!pushed.size) return { ok: false, error: `${initiative} has no ticket in Jira yet — push its tickets first` };
195
+ const prev = readSnapshot(root, initiative);
196
+ const got = new Map(n.issues.filter((i) => pushed.has(i.key)).map((i) => [i.key, i]));
197
+ const ignored = n.issues.length - got.size;
198
+ const ids = new Map([...pushed].map(([k, v]) => [k, v.id]));
199
+ const readings = []; const changes = []; const missing = [];
200
+ for (const key of pushed.keys()) {
201
+ const r = got.get(key);
202
+ const was = prev ? prev.tickets.get(key) : null;
203
+ if (!r) {
204
+ missing.push(key);
205
+ if (was) readings.push({ ...was, stale: true });
206
+ continue;
207
+ }
208
+ readings.push(r);
209
+ if (!was) { changes.push({ key, what: 'first read', status: r.status }); continue; }
210
+ if (was.status !== r.status) changes.push({ key, what: 'status', from: was.status, to: r.status });
211
+ const sw = sprintLabel(was.sprint); const sn = sprintLabel(r.sprint);
212
+ if (sw !== sn) changes.push({ key, what: 'sprint', from: sw, to: sn });
213
+ if (!!was.flagged !== !!r.flagged) changes.push({ key, what: r.flagged ? 'blocked' : 'unblocked', reason: r.reason || null });
214
+ }
215
+ const fetchedAt = nowIso(now);
216
+ const result = { ok: true, initiative, read: got.size, ignored, skipped: n.skipped, missing, changes, snapshot: snapshotRel(root, initiative), dryRun: !!dryRun };
217
+ if (dryRun) return result;
218
+ const file = snapshotPath(root, initiative);
219
+ fs.mkdirSync(path.dirname(file), { recursive: true });
220
+ const tmp = `${file}.${process.pid}.tmp`;
221
+ fs.writeFileSync(tmp, renderSnapshot({ initiative, fetchedAt, query: query || (prev && prev.query) || null, readings, ids }));
222
+ fs.renameSync(tmp, file);
223
+ return { ...result, fetchedAt };
224
+ }
225
+
226
+ // ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
227
+ function which(root, key) {
228
+ const k = String(key || '').trim().toUpperCase();
229
+ if (!KEY_RE.test(k)) return { ok: false, error: `${JSON.stringify(key)} is not a Jira key like ATL-482` };
230
+ const hits = [];
231
+ for (const id of jiraTickets.draftedInitiatives(root)) {
232
+ const p = jiraTickets.pushedTickets(root, id);
233
+ if (p.epic === k) hits.push({ initiative: id, role: 'epic' });
234
+ else if (p.tickets.has(k)) hits.push({ initiative: id, role: 'ticket', ticket: p.tickets.get(k).id, summary: p.tickets.get(k).summary });
235
+ }
236
+ return { ok: true, key: k, initiatives: hits };
237
+ }
238
+
239
+ module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel };