create-pmos 0.182.0 → 0.184.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-phone-test.js +25 -13
- package/workspace/c3-quiet-test.js +1 -1
- package/workspace/guide/jira.md +12 -1
- package/workspace/guide/phone-companion.md +3 -1
- package/workspace/lib/c3-full-console.js +4 -4
- package/workspace/lib/engine/boards.js +27 -2
- package/workspace/lib/jira-cli.js +22 -1
- package/workspace/lib/jira-status.js +52 -5
- package/workspace/lib/routes-boards.js +6 -2
- package/workspace/lib/routes-calendar-views.js +2 -2
- package/workspace/lib/routes-stage-tools.js +15 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-focus.js +8 -6
- package/workspace/public/pm/c3-phone.css +9 -5
- package/workspace/public/pm/c3-phone.js +6 -6
- package/workspace/public/pm/c3-shell.js +4 -4
- package/workspace/skills/jira-sync.skill +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.184.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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T04:43:18Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T04:43:18Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T04:43:18Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* meeting a Later today row and the morning card open the meeting's sheet with its brief (GET /api/meeting-focus)
|
|
12
12
|
* record the red button shows the recording line and the notes being taken (just heard, decisions); Stop brings the bar back
|
|
13
13
|
* debrief 11:25 shows the decisions and actions of the recorded Checkout sync; a meeting with no recording asks you to tell PMOS
|
|
14
|
-
*
|
|
14
|
+
* nodesk there is no Full console; a phone that chose it before 2026-10-13 shows the companion again
|
|
15
|
+
* focus the meeting companion opens over the companion and Back to your day closes it
|
|
16
|
+
* narrow a long title and a long list of people never widen the companion past the screen
|
|
15
17
|
* desktop at 1280 wide no companion is drawn
|
|
16
18
|
* 620 the companion shows up to 640 wide, where the phone styling starts
|
|
17
19
|
* No script error may be raised.
|
|
@@ -131,6 +133,15 @@ const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p |
|
|
|
131
133
|
await page.click('[data-c3ph-rec-meeting]');
|
|
132
134
|
ok('moments: Record starts recording Checkout sync', await waitFor(page, () => /Checkout sync/.test(document.querySelector('[data-c3ph-recbar]')?.textContent || ''), null, 8000));
|
|
133
135
|
ok('moments: while recording, the card opens the meeting companion', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment] [data-c3ph-focus]'), null, 5000));
|
|
136
|
+
const fbtn = await waitFor(page, () => !!document.querySelector('[data-c3ph-focus]'), null, 8000);
|
|
137
|
+
ok('focus: the Open the meeting companion button is found', fbtn);
|
|
138
|
+
if (fbtn) {
|
|
139
|
+
await page.click('[data-c3ph-focus]');
|
|
140
|
+
ok('focus: the compact focus page shows with Back to your day', await waitFor(page, () => !!document.querySelector('[data-c3focus][data-fc-pop] [data-fc="leave"]') && !document.querySelector('[data-c3focus] [data-fc="pop-open"]'), null, 8000));
|
|
141
|
+
await page.click('[data-c3focus] [data-fc="pop-head"] [data-fc="leave"]');
|
|
142
|
+
ok('focus: Back to your day closes it and the companion shows', await waitFor(page, () => !document.querySelector('[data-c3focus]') && !!document.querySelector('[data-c3ph-moment]'), null, 5000));
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
if (await page.$('[data-c3ph-stop]')) await page.click('[data-c3ph-stop]');
|
|
135
146
|
|
|
136
147
|
await page.close();
|
|
@@ -145,19 +156,20 @@ const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p |
|
|
|
145
156
|
await page.close();
|
|
146
157
|
page = await open(pctx, '/c3.html?demo=1&at=10:45');
|
|
147
158
|
|
|
148
|
-
/* ──
|
|
149
|
-
await page.
|
|
150
|
-
|
|
159
|
+
/* ── the meeting companion and back ── */
|
|
160
|
+
ok('nodesk: the companion offers no Full console', await page.evaluate(() => !document.querySelector('[data-c3ph-desk]') && !/Full console/.test(document.querySelector('[data-c3ph]').textContent)));
|
|
161
|
+
/* ── a long title and a long list of people stay inside the screen ── */
|
|
162
|
+
ok('narrow: long text does not widen the companion', await page.evaluate(() => {
|
|
163
|
+
const long = 'SC-01 Technical blueprint and estimate: ideal build versus the minimum viable path for the partner programme, MTGA41, Victor, Ho, Jasmine, Cheng, Cara, Chai, Tan, Lee';
|
|
164
|
+
document.querySelectorAll('[data-c3ph] .c3ph-big, [data-c3ph] .c3ph-tt, [data-c3ph] .pmc-title__text, [data-c3ph] .pmc-row__meta > span').forEach((n) => { n.textContent = long; });
|
|
165
|
+
const host = document.querySelector('[data-c3ph]');
|
|
166
|
+
return host.scrollWidth <= innerWidth + 1 && document.documentElement.scrollWidth <= innerWidth + 1;
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
/* ── a phone that chose Full console before it was dropped shows the companion again ── */
|
|
170
|
+
await page.evaluate(() => localStorage.setItem('pmos.c3.phone', 'desk'));
|
|
151
171
|
await page.reload({ waitUntil: 'load' });
|
|
152
|
-
await waitFor(page, () => !!document.querySelector('
|
|
153
|
-
ok('desk: the choice survives a reload', await page.evaluate(() => !document.querySelector('[data-c3ph]')));
|
|
154
|
-
const more = await page.$('[data-c3pg="more"]');
|
|
155
|
-
if (more) {
|
|
156
|
-
await more.click();
|
|
157
|
-
const item = await page.$('[data-a="phcomp"]');
|
|
158
|
-
ok('desk: More offers Phone companion', !!item);
|
|
159
|
-
if (item) { await item.click(); ok('desk: Phone companion brings the companion back', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment]'), null, 5000)); }
|
|
160
|
-
} else ok('desk: the More button is found', false);
|
|
172
|
+
ok('nodesk: an old Full console choice is cleared and the companion shows', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment]') && !localStorage.getItem('pmos.c3.phone'), null, 8000));
|
|
161
173
|
await page.close();
|
|
162
174
|
await pctx.close();
|
|
163
175
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* more Backlog, Releases, Docs and Sprints show only after More; each still opens
|
|
10
10
|
* calendar one Layers button shows and hides the layer chips
|
|
11
11
|
* reports no deck is drawn
|
|
12
|
-
* phone at 400 wide
|
|
12
|
+
* phone at 400 wide on the desktop layout (lib/c3-full-console.js), nothing changes: the title shows and no dot is drawn (c3-phone-test.js tests the companion)
|
|
13
13
|
* No script error may be raised.
|
|
14
14
|
*/
|
|
15
15
|
const path = require('path');
|
package/workspace/guide/jira.md
CHANGED
|
@@ -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-
|
|
5
|
+
timestamp: 2026-10-13
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Jira: tickets drafted from the PRD, and their status
|
|
@@ -110,6 +110,15 @@ pmos jira query [<initiative>]
|
|
|
110
110
|
pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
## The board's Sprints tab shows the whole sprint
|
|
114
|
+
|
|
115
|
+
After it reads the initiatives, the check reads every issue in the sprints their tickets are in. It reads every issue, not only the issues of an initiative and not only the issues assigned to you. The board's **Sprints** tab and the sprint burndown show all of them. An issue that is in no initiative of the board shows **No initiative**. The check writes `<state>/tracker/jira/_sprints.md` with:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
pmos jira query --sprints
|
|
119
|
+
pmos jira sync --sprints --from <file|-> [--query "<jql>"]
|
|
120
|
+
```
|
|
121
|
+
|
|
113
122
|
## Link an initiative to its Jira epics
|
|
114
123
|
|
|
115
124
|
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.
|
|
@@ -152,6 +161,8 @@ The morning brief, the day check and requirement gathering use it to tie a Jira
|
|
|
152
161
|
- `pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID]`
|
|
153
162
|
- `pmos jira query [<initiative>]`
|
|
154
163
|
- `pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
164
|
+
- `pmos jira query --sprints`
|
|
165
|
+
- `pmos jira sync --sprints --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
155
166
|
- `pmos jira which <KEY>`
|
|
156
167
|
|
|
157
168
|
Every command takes `--json` and `--root <dir>`.
|
|
@@ -46,7 +46,9 @@ The red button records a conversation or a meeting with the same recorder as **R
|
|
|
46
46
|
|
|
47
47
|
## The full console
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
A phone always shows the companion. There is no way to the desktop pages on a phone: use your computer for them. In a window wider than 640 pixels the console always shows the desktop pages.
|
|
50
|
+
|
|
51
|
+
**Open the meeting companion** opens the meeting helper over the companion. To go back, press **Back to your day** at the top or the bottom. The meeting continues to record.
|
|
50
52
|
|
|
51
53
|
## Limits
|
|
52
54
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/* lib/c3-full-console.js — for browser suites that test the console's desktop layout at phone width.
|
|
3
|
-
* Since c3-phone-companion, console3 at 640px or less shows the phone companion. The desktop layout
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Since c3-phone-companion, console3 at 640px or less shows the phone companion. The PM has no way to the desktop layout there
|
|
4
|
+
* (Full console was dropped on 2026-10-13); pmos.c3.testDesk = 'desk' keeps it, for suites only. fullConsole(browser) makes every
|
|
5
|
+
* new context start with it, so a suite that resizes to 400 wide still tests the desktop layout. c3-phone-test.js tests the companion itself. */
|
|
6
6
|
function fullConsole(browser) {
|
|
7
7
|
const make = browser.newContext.bind(browser);
|
|
8
8
|
browser.newContext = async (...args) => {
|
|
9
9
|
const ctx = await make(...args);
|
|
10
|
-
await ctx.addInitScript(() => { try { localStorage.setItem('pmos.c3.
|
|
10
|
+
await ctx.addInitScript(() => { try { localStorage.setItem('pmos.c3.testDesk', 'desk'); } catch { /* no storage */ } });
|
|
11
11
|
return ctx;
|
|
12
12
|
};
|
|
13
13
|
return browser;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* filterRows(rows, query) → { total, offset, limit, rows, facets }
|
|
16
16
|
* health(rows) → 'ok' | 'warn' | 'bad'
|
|
17
17
|
* counts(rows, workflow) → { total, open, byCat, byStage, byStatus, risk, late }
|
|
18
|
-
* sprintsOf(snapshots, onBoard)
|
|
18
|
+
* sprintsOf(snapshots, onBoard, whole?) → { source, readAt, sprints }
|
|
19
19
|
* INI_TEMPLATES · isBuildStage · templateBuilds · boardSettings(board) the per-initiative workflows (console3-initiatives-parity)
|
|
20
20
|
* workflowOf(board, id, folded) → the stages an initiative runs: its own (ini-workflow), else the board's
|
|
21
21
|
* applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) → { ok, template, workflow, moved } | { ok:false, status, error }
|
|
@@ -523,8 +523,11 @@ function counts(rows, workflow) {
|
|
|
523
523
|
* The Jira sprints of a board from the cached status snapshots (lib/jira-status.js readSnapshot), read-only.
|
|
524
524
|
* snapshots: [{ initiative, fetchedAt, tickets: Map|[] of readings }]; onBoard(initiativeId) → boolean.
|
|
525
525
|
* A ticket with no sprint (the backlog) is in no sprint. source is 'none' when no snapshot of the board was read.
|
|
526
|
+
* whole: the whole-sprint read (lib/jira-status.js readSprints), { fetchedAt, tickets }. A sprint the board's tickets are in then
|
|
527
|
+
* lists EVERY issue Jira holds in it, whoever it is assigned to; an issue no initiative of the board holds has initiative null
|
|
528
|
+
* (jira-sprint-scope). Without it, a sprint lists only the board's own tickets.
|
|
526
529
|
*/
|
|
527
|
-
function sprintsOf(snapshots, onBoard = () => true) {
|
|
530
|
+
function sprintsOf(snapshots, onBoard = () => true, whole = null) {
|
|
528
531
|
const byName = new Map();
|
|
529
532
|
let readAt = null;
|
|
530
533
|
let any = false;
|
|
@@ -545,6 +548,28 @@ function sprintsOf(snapshots, onBoard = () => true) {
|
|
|
545
548
|
sp.counts[category]++;
|
|
546
549
|
}
|
|
547
550
|
}
|
|
551
|
+
const wt = whole && whole.tickets ? (whole.tickets instanceof Map ? [...whole.tickets.values()] : Array.isArray(whole.tickets) ? whole.tickets : []) : [];
|
|
552
|
+
let widened = false;
|
|
553
|
+
for (const sp of byName.values()) {
|
|
554
|
+
const all = wt.filter((t) => t && t.key && t.sprint && String(t.sprint.name) === sp.name);
|
|
555
|
+
if (!all.length) continue;
|
|
556
|
+
widened = true;
|
|
557
|
+
const own = new Map(sp.issues.map((i) => [i.key, i]));
|
|
558
|
+
const keys = new Set(all.map((t) => t.key));
|
|
559
|
+
if (!sp.start && typeof all[0].sprint.start === 'string' && all[0].sprint.start) sp.start = all[0].sprint.start;
|
|
560
|
+
if (!sp.end && typeof all[0].sprint.end === 'string' && all[0].sprint.end) sp.end = all[0].sprint.end;
|
|
561
|
+
sp.issues = [
|
|
562
|
+
...all.map((t) => {
|
|
563
|
+
const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
|
|
564
|
+
const o = own.get(t.key);
|
|
565
|
+
return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: o ? o.initiative : null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null };
|
|
566
|
+
}),
|
|
567
|
+
...sp.issues.filter((i) => !keys.has(i.key)), // a board ticket the sprint read has not seen yet keeps its own reading
|
|
568
|
+
];
|
|
569
|
+
sp.counts = { todo: 0, doing: 0, done: 0 };
|
|
570
|
+
for (const i of sp.issues) sp.counts[i.category]++;
|
|
571
|
+
}
|
|
572
|
+
if (widened && whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt; // the oldest read is the honest age
|
|
548
573
|
const order = { active: 0, future: 1, closed: 2 };
|
|
549
574
|
const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
|
|
550
575
|
for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys the agent created
|
|
11
11
|
* pmos jira query [<initiative>] the Jira search for the tickets an initiative pushed
|
|
12
12
|
* pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] a saved search → the status snapshot
|
|
13
|
+
* pmos jira query --sprints the Jira search for every issue in the sprints the tickets are in
|
|
14
|
+
* pmos jira sync --sprints --from <file|-> [--query "<jql>"] a saved whole-sprint search → the Sprints tab (jira-sprint-scope)
|
|
13
15
|
* pmos jira which <KEY> the initiative a Jira key belongs to
|
|
14
16
|
* every command: [--json] [--root <dir>]
|
|
15
17
|
*
|
|
@@ -36,11 +38,13 @@ const USAGE = [
|
|
|
36
38
|
' pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys created in Jira',
|
|
37
39
|
' pmos jira query [<initiative>] the Jira search for the pushed tickets',
|
|
38
40
|
' pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] save the status read from Jira',
|
|
41
|
+
' pmos jira query --sprints the Jira search for every issue in those tickets\' sprints',
|
|
42
|
+
' pmos jira sync --sprints --from <file|-> [--query "<jql>"] save every issue of the sprints, for the Sprints tab',
|
|
39
43
|
' pmos jira which <KEY> the initiative a Jira key belongs to',
|
|
40
44
|
' every command: [--json] [--root <dir>]',
|
|
41
45
|
'This command never calls Jira. Your agent reads and writes Jira with your own connection.',
|
|
42
46
|
];
|
|
43
|
-
const BOOL = new Set(['json', 'dry-run', 'help']);
|
|
47
|
+
const BOOL = new Set(['json', 'dry-run', 'help', 'sprints']);
|
|
44
48
|
const VALUE = new Set(['from', 'query', 'root', 'epic', 'updated']);
|
|
45
49
|
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
50
|
|
|
@@ -92,6 +96,23 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
92
96
|
const known = () => planItems.INIT_RE.test(initiative || '') && !!jiraTickets.recordFm(root, initiative);
|
|
93
97
|
|
|
94
98
|
try {
|
|
99
|
+
if (flags.sprints && verb !== 'query' && verb !== 'sync') return fail('--sprints goes with query or sync', 2);
|
|
100
|
+
if (verb === 'query' && flags.sprints) {
|
|
101
|
+
const q = jiraStatus.sprintQuery(root);
|
|
102
|
+
return out(q, [q.linked ? q.query : 'no Jira reading has a sprint yet — run the initiatives\' searches first']);
|
|
103
|
+
}
|
|
104
|
+
if (verb === 'sync' && flags.sprints) {
|
|
105
|
+
if (!flags.from) return need('--sprints --from <file|->');
|
|
106
|
+
let raw;
|
|
107
|
+
try { raw = JSON.parse(readInput(flags.from, cwd, stdin)); }
|
|
108
|
+
catch (e) { return fail(`could not read ${flags.from} as JSON — ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
109
|
+
const r = jiraStatus.syncSprints(root, raw, { query: flags.query || undefined, dryRun: !!flags['dry-run'], now });
|
|
110
|
+
if (!r.ok) return fail(r.error);
|
|
111
|
+
const lines = [`sprints · read ${r.read} ${r.read === 1 ? 'issue' : 'issues'}${r.ignored ? ` · ${r.ignored} in no sprint, ignored` : ''}${r.skipped ? ` · ${r.skipped} skipped (no key or summary)` : ''}${r.dryRun ? ' · dry run, nothing written' : ''}`];
|
|
112
|
+
for (const [name, n] of Object.entries(r.sprints)) lines.push(` ${name} · ${n} ${n === 1 ? 'issue' : 'issues'}`);
|
|
113
|
+
if (!r.dryRun) lines.push(`snapshot ${r.snapshot}`);
|
|
114
|
+
return out(r, lines);
|
|
115
|
+
}
|
|
95
116
|
if (verb === 'query') {
|
|
96
117
|
if (initiative) {
|
|
97
118
|
const q = jiraStatus.queryFor(root, initiative);
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* sync(root, initiative, raw, opts) → the snapshot, written to <state>/tracker/jira/<initiative>.md
|
|
14
14
|
* readSnapshot(root, initiative) → { fetchedAt, query, tickets: Map<key, reading> } | null
|
|
15
15
|
* which(root, key) → the initiative and ticket a key belongs to
|
|
16
|
+
* sprintQuery(root) → { linked, sprints, query } the JQL for EVERY issue in the sprints the initiatives' tickets
|
|
17
|
+
* are in, whoever holds them (jira-sprint-scope); linked false when no reading has a sprint
|
|
18
|
+
* syncSprints(root, raw, opts) → the whole-sprint snapshot, written to <state>/tracker/jira/_sprints.md
|
|
19
|
+
* readSprints(root) → { fetchedAt, query, tickets: Map<key, reading> } | null
|
|
16
20
|
*
|
|
17
21
|
* A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed, id?, start?, end?,
|
|
18
22
|
* completed? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
|
|
@@ -206,12 +210,13 @@ function snapshotRel(root, initiative) { return jiraTickets.relOf(root, snapshot
|
|
|
206
210
|
const cell = (s) => oneLine(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|') || '—';
|
|
207
211
|
const SPRINT_WORD = { active: 'Active sprint', future: 'Next sprint', closed: 'Closed sprint' };
|
|
208
212
|
function sprintLabel(s) { return s ? `${SPRINT_WORD[s.state] || 'Sprint'}: ${s.name}` : 'Backlog'; }
|
|
209
|
-
function renderSnapshot({ initiative, fetchedAt, query, readings, ids }) {
|
|
213
|
+
function renderSnapshot({ initiative, fetchedAt, query, readings, ids, title }) {
|
|
214
|
+
const name = title || initiative;
|
|
210
215
|
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 |' : ''}`);
|
|
211
216
|
return [
|
|
212
|
-
'---', 'type: Reference', `title: "Jira status: ${
|
|
213
|
-
`# Jira status: ${
|
|
214
|
-
`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.`, '',
|
|
217
|
+
'---', 'type: Reference', `title: "Jira status: ${name}"`, `timestamp: ${fetchedAt}`, ...(title ? [] : [`initiative: ${initiative}`]), '---', '',
|
|
218
|
+
`# Jira status: ${name}`, '',
|
|
219
|
+
`Read from Jira at ${fetchedAt} by the Jira status check, read-only. ${title ? 'The Sprints tab and the sprint burndown of a board show it.' : 'The Jira tickets tab of the initiative shows it.'} Written by \`pmos jira sync\`; do not edit by hand.`, '',
|
|
215
220
|
query ? `Search: \`${query.replace(/`/g, "'")}\`` : 'Search: not recorded', '',
|
|
216
221
|
'| Ticket | Key | Summary | Status | Sprint | Subtasks | Blocked |', '|---|---|---|---|---|---|---|', ...rows, '',
|
|
217
222
|
'```json', JSON.stringify({ fetched_at: fetchedAt, query: query || null, tickets: readings }, null, 0), '```', '',
|
|
@@ -275,6 +280,48 @@ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
|
|
|
275
280
|
return { ...result, fetchedAt };
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
// ── the whole sprint (jira-sprint-scope) ─────────────────────────────────────────────────────────────
|
|
284
|
+
// An initiative's read holds only its own tickets, so a sprint drawn from those reads lists only the tickets PMOS knows,
|
|
285
|
+
// not the sprint. This second read asks Jira for every issue in the sprints those tickets are in, whoever holds them.
|
|
286
|
+
const SPRINTS_FILE = '_sprints';
|
|
287
|
+
const jqlName = (n) => `"${String(n).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
288
|
+
function sprintQuery(root) {
|
|
289
|
+
const seen = new Map();
|
|
290
|
+
for (const q of linkedInitiatives(root).filter((x) => x.linked)) {
|
|
291
|
+
const snap = readSnapshot(root, q.initiative);
|
|
292
|
+
if (!snap) continue;
|
|
293
|
+
for (const t of snap.tickets.values()) {
|
|
294
|
+
const sp = t && t.sprint;
|
|
295
|
+
if (!sp || !sp.name) continue;
|
|
296
|
+
const k = sp.id || sp.name;
|
|
297
|
+
if (!seen.has(k)) seen.set(k, { name: sp.name, state: sp.state || null, ...(sp.id ? { id: sp.id } : {}) });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const sprints = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
301
|
+
if (!sprints.length) return { ok: true, linked: false, sprints, query: null };
|
|
302
|
+
return { ok: true, linked: true, sprints, query: `sprint in (${sprints.map((s) => s.id || jqlName(s.name)).join(', ')}) ORDER BY key` };
|
|
303
|
+
}
|
|
304
|
+
/** Save a whole-sprint read. The read replaces the last one whole: an issue moved out of a sprint is gone from it. */
|
|
305
|
+
function syncSprints(root, raw, { query, dryRun = false, now } = {}) {
|
|
306
|
+
const n = normalize(raw);
|
|
307
|
+
if (n.error) return { ok: false, error: n.error };
|
|
308
|
+
const kept = n.issues.filter((i) => i.sprint && i.sprint.name);
|
|
309
|
+
const fetchedAt = nowIso(now);
|
|
310
|
+
const prev = readSprints(root);
|
|
311
|
+
const bySprint = {};
|
|
312
|
+
for (const i of kept) bySprint[i.sprint.name] = (bySprint[i.sprint.name] || 0) + 1;
|
|
313
|
+
const result = { ok: true, read: kept.length, ignored: n.issues.length - kept.length, skipped: n.skipped, sprints: bySprint,
|
|
314
|
+
was: prev ? prev.tickets.size : null, snapshot: snapshotRel(root, SPRINTS_FILE), dryRun: !!dryRun };
|
|
315
|
+
if (dryRun) return result;
|
|
316
|
+
const file = snapshotPath(root, SPRINTS_FILE);
|
|
317
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
318
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
319
|
+
fs.writeFileSync(tmp, renderSnapshot({ initiative: SPRINTS_FILE, title: 'every issue in the sprints', fetchedAt, query: query || (prev && prev.query) || null, readings: kept, ids: new Map() }));
|
|
320
|
+
fs.renameSync(tmp, file);
|
|
321
|
+
return { ...result, fetchedAt };
|
|
322
|
+
}
|
|
323
|
+
function readSprints(root) { return readSnapshot(root, SPRINTS_FILE); }
|
|
324
|
+
|
|
278
325
|
// ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
|
|
279
326
|
function which(root, key) {
|
|
280
327
|
const k = String(key || '').trim().toUpperCase();
|
|
@@ -288,4 +335,4 @@ function which(root, key) {
|
|
|
288
335
|
return { ok: true, key: k, initiatives: hits };
|
|
289
336
|
}
|
|
290
337
|
|
|
291
|
-
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel };
|
|
338
|
+
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE };
|
|
@@ -489,7 +489,9 @@ const READS = {
|
|
|
489
489
|
if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
|
|
490
490
|
}
|
|
491
491
|
} catch { /* no snapshot yet */ }
|
|
492
|
-
|
|
492
|
+
let whole = null;
|
|
493
|
+
try { whole = jiraStatus.readSprints(r.w.root); } catch { whole = null; }
|
|
494
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id), whole) });
|
|
493
495
|
},
|
|
494
496
|
};
|
|
495
497
|
|
|
@@ -866,7 +868,9 @@ function meetingBoards(ctx) {
|
|
|
866
868
|
if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
|
|
867
869
|
}
|
|
868
870
|
} catch { /* no snapshot yet */ }
|
|
869
|
-
|
|
871
|
+
let whole = null;
|
|
872
|
+
try { whole = jiraStatus.readSprints(w.root); } catch { whole = null; }
|
|
873
|
+
sprints = B.sprintsOf(snaps, (id) => ids.has(id), whole);
|
|
870
874
|
}
|
|
871
875
|
return { ...head, health: B.health(rows), counts: { total: c.total, open: c.open, risk: c.risk, late: c.late },
|
|
872
876
|
rows: rows.filter((r) => r.status === 'late' || r.status === 'at risk').map((r) => ({ id: r.id, title: r.title, status: r.status, end: r.end || null, owner: r.owner || null })),
|
|
@@ -122,13 +122,13 @@ function readers(root, runtimeDir) {
|
|
|
122
122
|
try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
|
|
123
123
|
return out;
|
|
124
124
|
},
|
|
125
|
-
/* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts are not
|
|
125
|
+
/* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts and the _sprints.md whole-sprint read are not) */
|
|
126
126
|
readJira() {
|
|
127
127
|
const fs = require('fs');
|
|
128
128
|
const status = require('./jira-status.js');
|
|
129
129
|
const out = new Map();
|
|
130
130
|
let names = [];
|
|
131
|
-
try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')); } catch { return out; }
|
|
131
|
+
try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md') && !n.startsWith('_')); } catch { return out; }
|
|
132
132
|
for (const n of names) {
|
|
133
133
|
const id = n.slice(0, -3);
|
|
134
134
|
try { const p = require('./briefing-real.js').jiraProgress(status.readSnapshot(root, id)); if (p) out.set(id, p); } catch { /* one unreadable snapshot is left out */ }
|
|
@@ -164,6 +164,21 @@ function burndownFor(ctx, w, ids, board, sprint) {
|
|
|
164
164
|
if (snap.readAt && (!readAt || snap.readAt < readAt)) readAt = snap.readAt; // the oldest read is the honest age
|
|
165
165
|
}
|
|
166
166
|
if (!tickets.length) return { ok: true, available: false, reason: 'no Jira read for these initiatives yet' };
|
|
167
|
+
// jira-sprint-scope: a sprint burns down every issue in it, not only the initiatives' own tickets.
|
|
168
|
+
let whole = null;
|
|
169
|
+
try { whole = require('./jira-status.js').readSprints(w.root); } catch { whole = null; }
|
|
170
|
+
if (whole && whole.tickets.size) {
|
|
171
|
+
const names = new Set(tickets.filter((t) => t.sprint && t.sprint.name).map((t) => t.sprint.name));
|
|
172
|
+
const own = new Map(tickets.map((t) => [t.key, t]));
|
|
173
|
+
const add = [...whole.tickets.values()].filter((t) => t.sprint && names.has(t.sprint.name));
|
|
174
|
+
if (add.length) {
|
|
175
|
+
const keys = new Set(add.map((t) => t.key));
|
|
176
|
+
const merged = [...add.map((t) => ({ ...t, initiative: own.has(t.key) ? own.get(t.key).initiative : null })), ...tickets.filter((t) => !keys.has(t.key))];
|
|
177
|
+
tickets.length = 0;
|
|
178
|
+
tickets.push(...merged);
|
|
179
|
+
if (whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
167
182
|
return { ok: true, ...S.burndown(tickets, { sprint: sprint || null }), readAt };
|
|
168
183
|
}
|
|
169
184
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.184.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.184.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* pm/c3-focus.js — the Meeting focus page of console3 (initiative c3-meeting-focus; the mockup the PM approved on 2026-10-12, version 7).
|
|
2
|
-
* openFocus({ event, date, pop, meet, mtitle }) closeFocus()
|
|
2
|
+
* openFocus({ event, date, pop, back, meet, mtitle }) closeFocus()
|
|
3
3
|
* One calm full-screen page over console3, inside #c3-root: it covers the header, the tabs and the dock (they stay in the DOM, set inert) and Leave
|
|
4
4
|
* focus gives them back. Recording is not touched by leaving. It reads GET /api/meeting-focus every 3 seconds while it is open:
|
|
5
5
|
* header title, time, people, initiative; the clock (starts in, or elapsed and left); REC with Start recording or End meeting (the note dock);
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* In demo mode (?demo=1) c3-demo-focus.js answers the reads with the mockup's Partner API review.
|
|
13
13
|
* Compact mode (?pop=1, initiative c3-meet-extension): the page the Chrome extension frames over a Google Meet call. Only a head (PMOS, the topic or "Starts in",
|
|
14
14
|
* the time left, a fold button), What's being said, the conversation and a foot (Open focus in PMOS, only you see this). The stage, the bar of the talk, the now line and
|
|
15
|
-
* the captured items are not drawn, and there is no Leave focus.
|
|
15
|
+
* the captured items are not drawn, and there is no Leave focus. With back (the phone companion opens it so) the head and the foot have
|
|
16
|
+
* "Back to your day" in place of Open focus in PMOS, which closes the page as Leave focus does. ?focus=now first asks GET /api/meeting-focus/now (meet = the Meet code, mtitle = the tab's title) which
|
|
16
17
|
* meeting the call is; when none fits, the page says so and lists today's meetings to pick from. Fold shows the head only and tells the frame's parent with one
|
|
17
18
|
* message, { pmosFocus: 'fold' | 'open' }, that carries no data. */
|
|
18
19
|
import { el } from './dom.js';
|
|
@@ -80,7 +81,7 @@ export function closeFocus() {
|
|
|
80
81
|
if (f.opener && document.contains(f.opener) && typeof f.opener.focus === 'function') { try { f.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
export function openFocus({ event, date, pop, meet, mtitle } = {}) {
|
|
84
|
+
export function openFocus({ event, date, pop, back, meet, mtitle } = {}) {
|
|
84
85
|
closeFocus();
|
|
85
86
|
const ev = event || {};
|
|
86
87
|
const want = String(ev.id || '');
|
|
@@ -102,7 +103,8 @@ export function openFocus({ event, date, pop, meet, mtitle } = {}) {
|
|
|
102
103
|
el('div', { class: 'fc-popbody', 'data-fc': 'pop-body' },
|
|
103
104
|
r.band = el('div', { class: 'fc-bandwrap' }), convo,
|
|
104
105
|
r.foot = el('div', { class: 'fc-foot', 'data-fc': 'pop-foot' },
|
|
105
|
-
|
|
106
|
+
back ? pbtn('ghost', 'Back to your day', 'prev', { 'data-fc': 'leave', title: 'Back to the companion. The meeting keeps recording.' })
|
|
107
|
+
: lk({ 'data-fc': 'pop-open', title: 'Opens the full focus page in a new tab' }, icon('out', 'sm'), 'Open focus in PMOS'), el('span', { class: 'dim mono' }, 'only you see this'))))
|
|
106
108
|
: el('div', { class: 'fc-page' },
|
|
107
109
|
r.top = el('header', { class: 'card fc-top', 'aria-label': 'Meeting' }),
|
|
108
110
|
r.now = el('div', { class: 'fc-nowl', 'data-fc': 'now-line' }),
|
|
@@ -112,7 +114,7 @@ export function openFocus({ event, date, pop, meet, mtitle } = {}) {
|
|
|
112
114
|
el('aside', { class: 'fc-w', 'aria-label': 'Conversation' }, convo)));
|
|
113
115
|
root.append(page);
|
|
114
116
|
F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map(), pill: false },
|
|
115
|
-
pop: !!pop, meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
|
|
117
|
+
pop: !!pop, back: !!back, meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
|
|
116
118
|
timers: {}, askTimers: new Map(), sheet: null, sheetOpen: false, status: 'reading', error: '',
|
|
117
119
|
opener: document.activeElement && document.activeElement !== document.body ? document.activeElement : null, seenNotes: 0 };
|
|
118
120
|
// a fixed box inside an element with container-type is placed against that element, not the screen: lift it while the page is open
|
|
@@ -232,7 +234,7 @@ function drawPopHead(d, ph) {
|
|
|
232
234
|
: ph === 'after' ? (e.title || 'Meeting') + ' has ended' : cur ? cur.text : e.title;
|
|
233
235
|
const left = d && ph === 'during' && Number.isFinite(f) ? el('span', { class: 'mono dim', 'data-fc': 'left', title: 'Time left' }, clockText((f - nowMs()) / 1000)) : null;
|
|
234
236
|
const lbl = F.ui.pill ? 'Open' : 'Fold to a pill';
|
|
235
|
-
return [el('b', { class: 'mono' }, 'PMOS'), el('span', { class: 'fc-ph-topic', 'data-fc': 'pop-topic', title: typeof label === 'string' ? label : '' }, label), left,
|
|
237
|
+
return [F.back ? ib('Back to your day', 'prev', { 'data-fc': 'leave' }) : null, el('b', { class: 'mono' }, 'PMOS'), el('span', { class: 'fc-ph-topic', 'data-fc': 'pop-topic', title: typeof label === 'string' ? label : '' }, label), left,
|
|
236
238
|
ib(lbl, F.ui.pill ? 'next' : 'fold', { 'data-fc': 'pill', 'aria-pressed': String(F.ui.pill) })];
|
|
237
239
|
});
|
|
238
240
|
}
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
* Everything is scoped under .c3 and uses tokens only. */
|
|
5
5
|
.c3.c3-phone-on .c3grid{display:none !important}
|
|
6
6
|
.c3.c3-phone-on{min-height:100dvh}
|
|
7
|
-
.c3 .c3ph{display:flex;flex-direction:column;min-height:100dvh;background:var(--background)}
|
|
7
|
+
.c3 .c3ph{display:flex;flex-direction:column;min-height:100dvh;width:100%;max-width:100vw;min-width:0;overflow-x:hidden;background:var(--background)}
|
|
8
|
+
/* a long title or a long list of people wraps or is cut with an ellipsis; it never widens the companion past the screen */
|
|
9
|
+
.c3 .c3ph *{min-width:0}
|
|
10
|
+
.c3 .c3ph-body,.c3 .c3ph-card,.c3 .c3ph-later,.c3 .c3ph-fit,.c3 .c3ph-sheetbody{grid-template-columns:minmax(0,1fr)}
|
|
11
|
+
.c3 .c3ph-big,.c3 .c3ph-tt,.c3 .c3ph-line,.c3 .c3ph-hd b{overflow-wrap:anywhere}
|
|
8
12
|
.c3 .c3ph-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:calc(10px + env(safe-area-inset-top)) 16px 10px;position:sticky;top:0;background:var(--background);z-index:2}
|
|
9
|
-
.c3 .c3ph-hd b{font-size:var(--fs-4)}
|
|
10
|
-
.c3 .c3ph-hdr{display:flex;align-items:center;gap:8px}
|
|
13
|
+
.c3 .c3ph-hd b{font-size:var(--fs-4);flex:1;min-width:0}
|
|
14
|
+
.c3 .c3ph-hdr{display:flex;align-items:center;gap:8px;flex:none}
|
|
11
15
|
.c3 .c3ph-dotn{display:inline-flex;align-items:center;gap:4px;font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
|
|
12
16
|
.c3 .c3ph-body{flex:1;display:grid;gap:12px;align-content:start;padding:0 12px 16px}
|
|
13
17
|
.c3 .c3ph-card{display:grid;gap:8px;padding:14px}
|
|
@@ -20,13 +24,13 @@
|
|
|
20
24
|
.c3 .c3ph-rec-why{font-size:var(--fs-2);line-height:1.45;background:var(--muted);border-radius:8px;padding:8px 10px}
|
|
21
25
|
.c3 .c3ph-prep{display:flex;gap:6px;align-items:flex-start;font-size:var(--fs-2);line-height:1.45}
|
|
22
26
|
.c3 .c3ph-before,.c3 .c3ph-fit{display:grid;gap:6px;padding-top:8px;border-top:1px solid var(--border);font-size:var(--fs-2)}
|
|
23
|
-
.c3 .c3ph-before{grid-template-columns:1fr auto;align-items:center}
|
|
27
|
+
.c3 .c3ph-before{grid-template-columns:minmax(0,1fr) auto;align-items:center}
|
|
24
28
|
.c3 .c3ph-acts{display:grid;gap:8px}
|
|
25
29
|
.c3 .c3ph-acts.two{grid-template-columns:1fr 1fr}
|
|
26
30
|
.c3 .c3ph-b{min-height:44px;justify-content:center}
|
|
27
31
|
.c3 .c3ph-cnt{display:flex;justify-content:space-between;font-weight:600;font-size:var(--fs-2)}
|
|
28
32
|
.c3 .c3ph-cnt .dim{font-weight:400;font-family:var(--mono)}
|
|
29
|
-
.c3 .c3ph-later{display:grid;gap:4px;font-size:var(--fs-2);color:var(--muted-foreground);padding:0 4px}
|
|
33
|
+
.c3 .c3ph-later{display:grid;overflow:hidden;gap:4px;font-size:var(--fs-2);color:var(--muted-foreground);padding:0 4px}
|
|
30
34
|
.c3 .c3ph-ft{position:sticky;bottom:0;display:flex;align-items:center;gap:10px;padding:8px 12px calc(12px + env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--card);z-index:2}
|
|
31
35
|
.c3 .c3ph-ask{flex:1;display:flex;align-items:center;gap:8px;border:1px solid var(--border);border-radius:8px;padding:0 4px 0 12px;min-height:44px;color:var(--muted-foreground);background:var(--background)}
|
|
32
36
|
.c3 .c3ph-ask input.c3ph-in{flex:1;min-width:0;border:0;background:transparent;font-family:inherit;font-size:var(--fs-3);color:var(--foreground);padding:10px 0;outline:none;border-radius:0}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* points to the keyboard's own dictation; this page does not send audio anywhere.
|
|
19
19
|
* Record the red button records a conversation or a meeting, the same recorder as the desktop Record control (note-dock.js).
|
|
20
20
|
* While it records, the bar shows the clock and Stop. In demo mode it drives the simulated recorder and saves nothing.
|
|
21
|
-
*
|
|
21
|
+
* There is no way to the desktop pages on a phone. The meeting companion opens as the compact focus page with "Back to your day".
|
|
22
22
|
* The moment is worked out again every minute; when it changes, a short banner says so. ?at=HH:MM sets the clock in demo mode only. */
|
|
23
23
|
import { el } from './dom.js';
|
|
24
24
|
import { button, iconButton } from './primitives.js';
|
|
@@ -46,10 +46,11 @@ function ensureCss() {
|
|
|
46
46
|
document.head.appendChild(el('link', { id: CSS_ID, rel: 'stylesheet', href: '/pm/c3-phone.css' }));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** Is this device in phone mode? Phone width
|
|
49
|
+
/** Is this device in phone mode? Phone width: there is no choice to show the desktop pages on a phone (Wawan, 2026-10-13).
|
|
50
|
+
* pmos.c3.testDesk is for the browser suites that test the desktop layout at phone width (lib/c3-full-console.js); nothing offers it. */
|
|
50
51
|
export function phoneWanted(store) {
|
|
51
52
|
if (typeof matchMedia !== 'function' || !matchMedia('(max-width:640px)').matches) return false;
|
|
52
|
-
return !(store && store.get('pmos.c3.
|
|
53
|
+
return !(store && store.get('pmos.c3.testDesk') === 'desk');
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export function mountPhone(root, ctx = {}) {
|
|
@@ -194,7 +195,7 @@ export function mountPhone(root, ctx = {}) {
|
|
|
194
195
|
}
|
|
195
196
|
const decisions = () => waitRows().filter((r) => !r.pm).length;
|
|
196
197
|
const needOpts = { onDone: (r) => itemDone(r), onLater: (r) => itemLater(r, 'later-today') };
|
|
197
|
-
const focus = (m) => import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.id, start: m.start }, pop: true })).catch(() => toast('The meeting companion did not open.'));
|
|
198
|
+
const focus = (m) => import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.id, start: m.start }, pop: true, back: true })).catch(() => toast('The meeting companion did not open.'));
|
|
198
199
|
/* a meeting's sheet (c3-phone-detail.js): Record it while the meeting has not ended, and the meeting companion */
|
|
199
200
|
function meetingSheet(m) {
|
|
200
201
|
const t = now().getTime(); const ends = Date.parse(m.end || m.start) || 0;
|
|
@@ -311,8 +312,7 @@ export function mountPhone(root, ctx = {}) {
|
|
|
311
312
|
const d = now();
|
|
312
313
|
const n = waitRows().length;
|
|
313
314
|
head.replaceChildren(el('b', null, WD[d.getDay()] + ' ' + d.getDate() + ' ' + MO[d.getMonth()]),
|
|
314
|
-
el('span', { class: 'c3ph-hdr' }, n ? el('span', { class: 'c3ph-dotn', 'data-c3ph-count': String(n) }, c3ic('inbox', 'sm'), n + ' waiting') : null
|
|
315
|
-
button({ label: 'Full console', variant: 'ghost', size: 'sm', attrs: { 'data-c3ph-desk': '' }, onClick: () => ctx.onDesk && ctx.onDesk() })));
|
|
315
|
+
el('span', { class: 'c3ph-hdr' }, n ? el('span', { class: 'c3ph-dotn', 'data-c3ph-count': String(n) }, c3ic('inbox', 'sm'), n + ' waiting') : null));
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
const BANNER = { meeting: (m) => m.title + ' has started', before: (m, mo) => m.title + ' starts ' + inText(mo.minutes), after: (m) => m.title + ' just ended', gap: (m, mo) => (mo.minutes || 0) + ' free minutes', wrap: () => 'Time to wrap up', morning: () => 'Good morning' };
|
|
@@ -138,7 +138,6 @@ function toggleMore() {
|
|
|
138
138
|
item('alert', 'Incident day', () => toggleIncident(), S.incident, S.demo ? { 'data-a': 'inc' } : { 'data-a': 'inc', disabled: true, title: 'No incident feed is connected, so this page shows no incident.' }),
|
|
139
139
|
item('spark', 'Agent playbooks', () => openPlaybooks(), false, { 'data-a': 'pbopen' }),
|
|
140
140
|
item('phone', 'Phone preview', () => togglePreview(), !!S.preview, { 'data-a': 'phone' }),
|
|
141
|
-
S.root.classList.contains('ph') && store.get('pmos.c3.phone') === 'desk' ? item('phone', 'Phone companion', () => { store.set('pmos.c3.phone', ''); syncPhone(); }, false, { 'data-a': 'phcomp' }) : null,
|
|
142
141
|
item(S.theme === 'dark' ? 'sun' : 'moon', S.theme === 'dark' ? 'Light theme' : 'Dark theme', () => setTheme(S.theme === 'dark' ? 'light' : 'dark')),
|
|
143
142
|
((b) => { b.firstChild.replaceWith(gearIcon('sm')); return b; })(item('info', 'Settings', () => showTab('settings', true), false, { 'data-a': 'settings' })),
|
|
144
143
|
item('term', S.dockOpen ? 'Fold the dock' : 'Open the dock', () => { S.dockOpen = !S.dockOpen; keepDock(); syncDock(); }, false, { 'data-a': 'dockt' }, 'Ctrl J'),
|
|
@@ -151,13 +150,14 @@ function toggleMore() {
|
|
|
151
150
|
document.addEventListener('keydown', S.menuOff); document.addEventListener('pointerdown', S.menuOff);
|
|
152
151
|
const first = S.menu.querySelector('.c3pg-mi:not([disabled])'); if (first) first.focus();
|
|
153
152
|
}
|
|
154
|
-
/* The phone companion (c3-phone.js): at 640px or less
|
|
155
|
-
*
|
|
153
|
+
/* The phone companion (c3-phone.js): at 640px or less it fills the page and the desktop grid is hidden. There is no Full console:
|
|
154
|
+
* the old choice (pmos.c3.phone = 'desk', stored before 2026-10-13) is cleared so no phone stays on the desktop pages. */
|
|
156
155
|
function syncPhone() {
|
|
156
|
+
if (store.get('pmos.c3.phone')) store.set('pmos.c3.phone', '');
|
|
157
157
|
const want = phoneWanted(store);
|
|
158
158
|
if (want && !S.phone) {
|
|
159
159
|
S.root.classList.add('c3-phone-on');
|
|
160
|
-
S.phone = mountPhone(S.root, { ws: S.ws, demo: S.demo
|
|
160
|
+
S.phone = mountPhone(S.root, { ws: S.ws, demo: S.demo });
|
|
161
161
|
} else if (!want && S.phone) {
|
|
162
162
|
S.phone.dispose(); S.phone = null;
|
|
163
163
|
S.root.classList.remove('c3-phone-on');
|
|
@@ -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, 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."
|
|
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 every issue in the sprints those tickets are in, and save them in PMOS for the initiative's Jira tickets tab and the board's Sprints tab. It writes nothing to Jira."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
output:
|
|
@@ -35,10 +35,11 @@ Procedure:
|
|
|
35
35
|
- `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
|
|
36
36
|
The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
|
|
37
37
|
4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
|
|
38
|
+
4a. Read the whole sprints, after every initiative is synced. Run `pmos jira query --sprints`. It prints one Jira search for every issue in the sprints the tickets above are in, whoever the issue is assigned to. Do this even when the task names one initiative. If it prints "no Jira reading has a sprint yet", skip this step. Otherwise run that search with the same fields as step 2, fetch every page, save the result in the scratch folder in the same shape as step 3 (for example `<scratch>/jira-sprints.json`), run `pmos jira sync --sprints --from <the file> --query "<the JQL>"`, and delete the file. Do not record people from this search (step 5 is for the initiatives' own issues).
|
|
38
39
|
5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue (People adds each new person at once): {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
|
|
39
40
|
6. Finish with the three sections of the output contract, one short line each:
|
|
40
|
-
- Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded.
|
|
41
|
-
- Not reached: each initiative whose search failed, with the reason.
|
|
41
|
+
- Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded, and how many issues the sprint read saved per sprint.
|
|
42
|
+
- Not reached: each initiative whose search failed, with the reason, and the sprint read if it failed.
|
|
42
43
|
- Needs you: tickets newly blocked, tickets the search did not return, and any refusal.
|
|
43
44
|
|
|
44
45
|
Output contract — produce EXACTLY these sections, the same shape every run:
|