create-pmos 0.145.0 → 0.146.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.145.0",
3
+ "version": "0.146.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@e7517fc
1
+ kernel-source: pmos@ec68387
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:59:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T02:35:41Z"}
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:59:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T02:35:41Z"}
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:59:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T02:35:41Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Briefing: cards, time of day, Needs you and Portfolio pulse"
4
4
  description: "How the Briefing cards keep one height and flip in place, how to confirm an act on a large screen, how the day part follows the clock, how a Portfolio pulse row opens inline, and how overdue items reach Needs you."
5
- timestamp: 2026-10-11
5
+ timestamp: 2026-10-12
6
6
  ---
7
7
 
8
8
  # Briefing: cards, time of day, Needs you and Portfolio pulse
@@ -17,7 +17,7 @@ The tabs show counts: Briefing shows how many things need you, Work shows its in
17
17
 
18
18
  ### Kinds of card
19
19
 
20
- The label on a card says what kind of thing happened, for example **Deadline moved**, **Promise out of date**, **Blocked**, **Conflict**, **Risk**, **Waiting on someone** or **Report due**. A win keeps the label **Big win** or **Small win**. The colour of the card shows how serious it is: red is critical, amber is a warning, green is good news, and the accent colour is neutral.
20
+ The label on a card says what kind of thing happened, for example **Deadline moved**, **Promise out of date**, **Blocked**, **Conflict**, **Risk**, **Waiting on someone** or **Report due**. A win keeps the label **Big win** or **Small win**. A card from your own data has the label **Overdue**, **Due today**, **Blocked**, **Worth asking** or **For you today** (see The Briefing: today's brief). The colour of the card shows how serious it is: red is critical, amber is a warning, green is good news, and the accent colour is neutral.
21
21
 
22
22
  Each kind has a short list of what its card must say. For example, a **Deadline moved** card says what moved, by how much, by when you must decide and the one move. When the card does not know a part, the back of the card says so under **Not known yet**. PMOS does not guess a missing part.
23
23
 
@@ -17,6 +17,16 @@ The Briefing shows only what PMOS already holds. When PMOS holds nothing for a s
17
17
 
18
18
  The cards at the top are the signals that pass the relevance bar. Turn a card over to read its case.
19
19
 
20
+ The cards come from two places:
21
+
22
+ - **Your own data.** PMOS makes a card from each of these records:
23
+ - A Jira ticket or a plan (WBS) item assigned to you that is overdue, due today or blocked. "Blocked" means that the item is flagged, or that its status says blocked or on hold. The tickets come from the last My work read.
24
+ - An item in your Work list that is overdue or due today. An item that you parked, put on Waiting or moved to Later does not get a card.
25
+ - An item of today's morning brief that is worth asking, and an item of today's brief that is for you.
26
+ - **The signal engine.** It reads the initiative records of the repository, for example a spec written after the build.
27
+
28
+ Overdue cards come first, the most late first. The signal engine's cards come next. Then come the blocked, due today and brief cards. PMOS adds at most 5 cards from your own data. A card shows only the values that its record holds. When the record has a link, for example a Jira ticket, the case of the card has a link to it. The link opens in a new tab. When no record gives a card, the deck says that nothing needs you.
29
+
20
30
  Above the cards, the greeting names the part of the day, for example "Good morning, Wawan". The name is the first word of the git user name on the computer where PMOS runs. When git holds no name, the greeting has none. The part of the day follows the clock. There is no button to change it. When the clock moves to a new part, the line under the greeting and the order of the cards change after you stop using the page for a minute.
21
31
 
22
32
  ## Today
@@ -41,6 +51,14 @@ Needs you lists what only you can do. Its header says how many decisions, how ma
41
51
  3. Items outside PMOS that wait for you to confirm: talk to someone, meet, call, ask.
42
52
  4. Decisions that only you can make.
43
53
 
54
+ The decisions come from these records:
55
+
56
+ - A Jira ticket or a plan item assigned to you that is overdue, blocked, due today, or marked as your decision. Its row has the ticket key, and its case has a link to the ticket.
57
+ - A signal engine card that has a suggested move, or is Critical.
58
+ - An item of today's morning brief that is worth asking. The brief was not sure enough to act, so ask first.
59
+
60
+ An item that is in your own items above does not show again as a decision. **Done** and **Later** on a ticket row change the item in your Work list. They do not change Jira.
61
+
44
62
  Press a row to open it in place. A row of your own item has three actions. Each one writes to the item itself, the same record the Work page shows.
45
63
 
46
64
  - **Done** marks the item done. For an item outside PMOS you can first type one line about what happened. The line is optional.
@@ -51,7 +69,7 @@ PMOS can see evidence that an outside item is done. For example, a meeting with
51
69
 
52
70
  ## Portfolio pulse
53
71
 
54
- Portfolio pulse says how many initiatives need attention, then shows one bar for the status of the initiatives and one row for each initiative. **All initiatives** at the bottom opens the Initiatives page. Press a row to open it in place. It shows why the initiative has that status, and the promised date against the forecast. The page does not change.
72
+ Portfolio pulse says how many initiatives need attention, then shows one bar for the status of the initiatives and one row for each initiative. An initiative shows when it has a promised date, or when PMOS holds a read of its Jira epic. A row from a Jira epic shows "3 of 8 done" and a bar. "Done" counts the tickets under the epic that are done in Jira. A row with blocked tickets says **Blocked**. The Jira read is the last Jira status check; the opened row says its date. When no initiative has a promised date or a Jira read, the pulse says so. **All initiatives** at the bottom opens the Initiatives page. Press a row to open it in place. It shows why the initiative has that status, the promised date against the forecast, and the Jira progress when PMOS holds it. The page does not change.
55
73
 
56
74
  To go to the initiative, press **Open initiative** inside the opened row.
57
75
 
@@ -35,6 +35,13 @@ The calendar-sync skill reads today and the next 14 days, read-only, and saves t
35
35
 
36
36
  The events are kept in your own runtime folder on this machine, never in a repository.
37
37
 
38
+ The tracker-refresh skill reads tracker items from files already in your repository. It moves an item only when a file shows a new status, so a read can change nothing. Its last step records that the read finished, with this command:
39
+
40
+ - `pmos tracker done [--moved N] [--left N] [--not-found N]` records the time of the read for this repository and the counts of what it found. It moves no item.
41
+ - `pmos tracker status` says when tracker items were last read for this repository.
42
+
43
+ The record is kept in your own runtime folder on this machine, never in a repository. Tracker items show the time of this record, or the time the schedule last started the skill if that is later.
44
+
38
45
  ## Where you see it
39
46
 
40
47
  Each page shows a short line under its title that says when each source was last read, for example **Initiatives read 08:02 · Calendar read 07:15 · Jira status read 06:31**. The line never says that the data is up to date, because the agents read these sources on a schedule and not live.
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+ /* lib/briefing-real.js — the Briefing's cards, "Needs you" rows and pulse progress from the PM's own data (initiative briefing-real-data).
3
+ *
4
+ * build({ brief, myWork, itemLines, today }) → { lead, tail, needs }
5
+ * lead the overdue cards (most days late first); the route puts them before the engine's cards
6
+ * tail the blocked, due today, worth asking and brief priority cards, after the engine's cards
7
+ * lead + tail hold at most MAX_CARDS cards together (R4)
8
+ * needs "Needs you" rows: overdue, blocked, decision and due today assigned items, then the brief's worth asking items (R6)
9
+ * merge(engineCards, built) → cards lead, then the engine's cards unchanged, then tail
10
+ * engineNeeds(cards) → rows the engine's cards that carry a move or are Critical, as needs rows ({ card })
11
+ * jiraProgress(snapshot) → { done, total, blocked, epics, as_of } | null one initiative's Jira epic progress (R7)
12
+ * readSources(ctx, now, today) → { brief, myWork, itemLines } what the route reads; a source that fails gives nothing
13
+ *
14
+ * The sources: today's morning brief (<runtime>/briefs/<date>.json: `priorities` and `worth_asking`), GET /api/my-work items (Jira
15
+ * tickets and WBS items assigned to the PM), and the PM's Work ledger (<runtime>/pm-items/items.jsonl). A card or row is made only from
16
+ * a record, and a field is set only when the record holds its value. The ledger wins for an assigned item it holds: a done or dropped
17
+ * copy gives no card, and its due date, parking and waiting are the PM's. An assigned row carries its item id, so Done and Later work
18
+ * through the pm-items routes (they take a jira:, wbs: or brief: id on its first press).
19
+ *
20
+ * PURE except readSources. No network, no write.
21
+ */
22
+ const path = require('path');
23
+ const pmItems = require('./pm-items.js');
24
+
25
+ const MAX_CARDS = 5;
26
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
27
+ const BLOCKED_RE = /block|flag|imped|on hold/i;
28
+ const SOURCE_WORD = { calendar: 'Calendar', wispr: 'Wispr Flow', mail: 'Mail', slack: 'Slack', jira: 'Jira', confluence: 'Confluence', github: 'GitHub', drive: 'Drive', figma: 'Figma', website: 'Website', derived: 'PMOS' };
29
+ const EFFORT_WORD = { minutes: 'minutes', 'an-hour': 'an hour', 'half-day': 'half a day', day: 'a day' };
30
+ const COLOR = { bad: 'var(--destructive)', warn: 'var(--warning)', acc: 'var(--acc)', ink: 'var(--foreground)' };
31
+
32
+ const dayNo = (s) => Math.round(Date.parse(s + 'T00:00:00Z') / 864e5);
33
+ const isDate = (s) => DATE_RE.test(String(s || ''));
34
+ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };
35
+ const httpsUrl = (v) => { const s = String(v || ''); try { return new URL(s).protocol === 'https:' && s.length <= 500 ? s : null; } catch { return null; } };
36
+ const daysWord = (n) => (n === 1 ? '1 day' : n + ' days');
37
+ const lateText = (n) => (n > 0 ? daysWord(n) + ' overdue' : 'due today');
38
+
39
+ /** Blocked: the item carries `blocked`, or its status says blocked, flagged, impediment or on hold. */
40
+ function blockedOf(x) {
41
+ if (x.blocked) return x.blocked && typeof x.blocked === 'object' && x.blocked.reason ? clip(x.blocked.reason, 120) : 'Blocked';
42
+ if (x.status && BLOCKED_RE.test(String(x.status))) return 'Status: ' + clip(x.status, 60);
43
+ return null;
44
+ }
45
+
46
+ /** An open item's place in time: days late (0 is due today), or null when it has no due date or is not due yet. */
47
+ function lateBy(due, today) {
48
+ if (!isDate(due)) return null;
49
+ const n = dayNo(today) - dayNo(String(due).slice(0, 10));
50
+ return n >= 0 ? n : null;
51
+ }
52
+ /** The PM set it aside: parked, waiting on someone, moved to Later, or not before a later date. */
53
+ const setAside = (i, today) => !!(i.parked || i.waiting_on || i.list_override === 'later' || (isDate(i.defer) && i.defer > today));
54
+
55
+ /* ── the assigned items (GET /api/my-work), with the ledger's copy when it holds one ───────────────────────────── */
56
+ function assignedOf(myWork, ledger, held, today) {
57
+ const out = [];
58
+ for (const x of Array.isArray(myWork) ? myWork : []) {
59
+ if (!x || !x.id || !x.title || x.state === 'done' || x.category === 'done') continue;
60
+ let a = x;
61
+ if (held.has(x.id)) {
62
+ const L = ledger.get(x.id);
63
+ if (!L || L.done) continue; // dropped or done in Work: the PM's answer stands
64
+ if (setAside(L, today)) continue;
65
+ a = { ...x, due: L.due !== undefined ? L.due : x.due };
66
+ }
67
+ const late = lateBy(a.due, today);
68
+ const blocked = blockedOf(a);
69
+ const decide = a.kind === 'decide' && (a.state === 'brief' || a.state === 'yours');
70
+ if (late === null && !blocked && !decide) continue;
71
+ out.push({ a, late, blocked, decide });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function assignedLabel(a) {
77
+ return a.source === 'wbs' ? 'WBS ' + a.key + (a.repo ? ' in ' + a.repo : '') : 'Jira ' + a.key;
78
+ }
79
+
80
+ function assignedCard(e, today) {
81
+ const { a, late, blocked } = e;
82
+ const jira = a.source !== 'wbs';
83
+ const reason = late > 0 ? 'overdue' : blocked ? 'blocked' : 'due';
84
+ const kpis = [];
85
+ if (late > 0) kpis.push([late + ' d', 'overdue', COLOR.bad]);
86
+ else if (late === 0) kpis.push(['today', 'due', COLOR.warn]);
87
+ if (blocked) kpis.push(['blocked', 'in ' + (jira ? 'Jira' : 'the plan'), COLOR.warn]);
88
+ if (a.wsjf && /^P[1-4]$/.test(String(a.wsjf.tier))) kpis.push([a.wsjf.tier, 'WSJF tier', COLOR.acc]);
89
+ if (a.blocks > 0) kpis.push([String(a.blocks), a.blocks === 1 ? 'item waits on it' : 'items wait on it', COLOR.warn]);
90
+ const facts = [[jira ? 'inbox' : 'repo', assignedLabel(a) + (a.status ? ' · ' + clip(a.status, 40) : '')]];
91
+ if (isDate(a.due)) facts.push(['clock', 'Due ' + String(a.due).slice(0, 10)]);
92
+ if (blocked) facts.push(['alert', blocked]);
93
+ else if (a.from) facts.push(['user', 'From ' + clip(a.from, 60)]);
94
+ const sug = a.suggest ? clip(a.suggest, 120) : null;
95
+ return {
96
+ key: 'real:' + a.id, origin: jira ? 'jira' : 'wbs', itemId: a.id,
97
+ tag: reason === 'due' ? 'Just in' : 'Critical', tone: reason === 'overdue' ? 'critical' : 'warning',
98
+ kind: reason === 'overdue' ? 'Overdue' : reason === 'blocked' ? 'Blocked' : 'Due today',
99
+ headline: clip(a.title, 140), sub: assignedLabel(a) + ' · assigned to you',
100
+ hero: { kind: 'big-number' }, kpis: kpis.slice(0, 3), factsX: facts.slice(0, 3), facts: [],
101
+ deadline: late !== null ? { days: -late, source: jira ? 'jira' : 'wbs', text: lateText(late) } : null,
102
+ why: { text: reason === 'overdue' ? 'Assigned to you and past its due date.' : reason === 'blocked' ? 'Assigned to you and blocked: ' + blocked + '.' : 'Assigned to you and due today.' },
103
+ ...(sug ? { move: { suggested: { id: 'suggest', label: sug }, options: [{ id: 'suggest', label: sug }] } } : {}),
104
+ ...(httpsUrl(a.url) ? { url: httpsUrl(a.url), url_label: 'Open ' + a.key + ' in Jira' } : {}),
105
+ _late: late, _rank: reason === 'overdue' ? 0 : reason === 'blocked' ? 1 : 2,
106
+ };
107
+ }
108
+
109
+ function workCard(i, late) {
110
+ const kpis = [late > 0 ? [late + ' d', 'overdue', COLOR.bad] : ['today', 'due', COLOR.warn]];
111
+ if (/^P[1-3]$/.test(String(i.priority || ''))) kpis.push([i.priority, 'priority', COLOR.acc]);
112
+ const facts = [['repo', 'In your Work list'], ['clock', 'Due ' + i.due]];
113
+ if (i.person) facts.push(['user', clip(i.person, 60)]);
114
+ return {
115
+ key: 'real:work:' + i.id, origin: 'work', itemId: i.id,
116
+ tag: late > 0 ? 'Critical' : 'Just in', tone: late > 0 ? 'critical' : 'warning', kind: late > 0 ? 'Overdue' : 'Due today',
117
+ headline: clip(i.title || '(untitled)', 140), sub: 'Your Work item',
118
+ hero: { kind: 'big-number' }, kpis, factsX: facts, facts: [],
119
+ deadline: { days: -late, source: 'work', text: lateText(late) },
120
+ why: { text: late > 0 ? 'Your own Work item, past its due date.' : 'Your own Work item, due today.' },
121
+ _late: late, _rank: late > 0 ? 0 : 2,
122
+ };
123
+ }
124
+
125
+ function briefCard(x, ask) {
126
+ const src = SOURCE_WORD[x.source] || null;
127
+ const kpis = [];
128
+ if (typeof x.confidence === 'number') kpis.push([Math.round(x.confidence * 100) + '%', 'confidence', ask ? COLOR.warn : COLOR.acc]);
129
+ if (EFFORT_WORD[x.effort]) kpis.push([EFFORT_WORD[x.effort], 'effort', COLOR.ink]);
130
+ const facts = [];
131
+ if (src) facts.push(['inbox', 'From ' + src]);
132
+ if (x.who) facts.push(['user', clip(x.who, 60)]);
133
+ if (x.ask) facts.push(['reply', clip(x.ask, 120)]);
134
+ else if (x.evidence) facts.push(['info', clip(x.evidence, 120)]);
135
+ const todo = x.todo ? clip(x.todo, 120) : null;
136
+ return {
137
+ key: 'real:brief:' + x.id, origin: 'brief', ...(ask ? {} : { itemId: 'brief:' + x.id }),
138
+ tag: 'Just in', kind: ask ? 'Worth asking' : 'For you today', signal: ask ? 'question' : 'suggest',
139
+ headline: clip(x.title, 140), sub: x.why_now ? clip(x.why_now, 240) : null,
140
+ hero: { kind: 'big-number' }, kpis: kpis.slice(0, 3), factsX: facts.slice(0, 3), facts: [],
141
+ deadline: null,
142
+ why: { text: ask ? 'In today\'s morning brief, below the confidence bar: worth asking before acting.' : 'In today\'s morning brief, for you.' },
143
+ ...(todo ? { move: { suggested: { id: 'todo', label: todo }, options: [{ id: 'todo', label: todo }] } } : {}),
144
+ ...(httpsUrl(x.link) ? { url: httpsUrl(x.link), url_label: 'Open the source' } : {}),
145
+ _late: null, _rank: ask ? 3 : 4,
146
+ };
147
+ }
148
+
149
+ /* ── the needs rows (the shape public/pm/c3-brief.js needRow takes) ──────────────────────────────────────────── */
150
+ function assignedNeed(e) {
151
+ const { a, late, blocked, decide } = e;
152
+ const jira = a.source !== 'wbs';
153
+ const kind = late > 0 ? 'Overdue' : blocked ? 'Blocked' : decide ? 'Decide' : 'Due today';
154
+ const facts = [{ icon: jira ? 'inbox' : 'repo', text: assignedLabel(a) + (a.status ? ' · ' + clip(a.status, 40) : ''), source: jira ? 'Jira' : 'WBS' }];
155
+ if (isDate(a.due)) facts.push({ icon: 'clock', text: 'Due ' + String(a.due).slice(0, 10), source: jira ? 'Jira' : 'WBS' });
156
+ if (blocked) facts.push({ icon: 'alert', text: blocked, source: jira ? 'Jira' : 'WBS', tone: 'warn' });
157
+ if (a.ask) facts.push({ icon: 'reply', text: clip(a.ask, 160), source: a.from ? clip(a.from, 40) : (jira ? 'Jira' : 'WBS') });
158
+ return {
159
+ key: 'mw:' + a.id, itemId: a.id, origin: jira ? 'jira' : 'wbs', kind, headline: clip(a.title, 140),
160
+ icon: late > 0 || blocked ? 'alert' : decide ? 'scale' : 'clock', color: late > 0 ? COLOR.bad : blocked || late === 0 ? COLOR.warn : null,
161
+ time: late === null ? null : late > 0 ? 'overdue' : 'due today', dueText: late === null ? null : lateText(late),
162
+ src: jira ? a.key : 'WBS', p: a.wsjf && /^P[1-3]$/.test(String(a.wsjf.tier)) ? a.wsjf.tier : null,
163
+ why: late > 0 ? 'Assigned to you and past its due date.' : blocked ? 'Assigned to you and blocked.' : decide ? 'Assigned to you as a decision.' : 'Assigned to you and due today.',
164
+ facts, ...(httpsUrl(a.url) ? { url: httpsUrl(a.url), url_label: 'Open ' + a.key + ' in Jira' } : {}),
165
+ _rank: late > 0 ? 0 : blocked ? 1 : decide ? 2 : 3, _late: late,
166
+ };
167
+ }
168
+
169
+ function askNeed(x) {
170
+ const src = SOURCE_WORD[x.source] || null;
171
+ const facts = [];
172
+ if (x.evidence) facts.push({ icon: 'info', text: clip(x.evidence, 160), source: src || 'brief' });
173
+ if (x.ask) facts.push({ icon: 'reply', text: clip(x.ask, 160), source: x.who ? clip(x.who, 40) : (src || 'brief') });
174
+ if (typeof x.confidence === 'number') facts.push({ icon: 'search', text: 'Confidence ' + Math.round(x.confidence * 100) + '%: below the bar, so ask before acting', source: 'brief', tone: 'warn' });
175
+ return {
176
+ key: 'ask:' + x.id, origin: 'brief', kind: 'Worth asking', headline: clip(x.title, 140), icon: 'search', color: COLOR.acc,
177
+ time: null, src: src || 'Brief', why: x.why_now ? clip(x.why_now, 240) : null, facts,
178
+ ...(httpsUrl(x.link) ? { url: httpsUrl(x.link), url_label: 'Open the source' } : {}),
179
+ _rank: 4, _late: null,
180
+ };
181
+ }
182
+
183
+ const strip = (o) => { const { _late, _rank, ...rest } = o; return rest; };
184
+ const byRank = (a, b) => a._rank - b._rank || (b._late || 0) - (a._late || 0);
185
+
186
+ function build({ brief, myWork, itemLines, today } = {}) {
187
+ if (!isDate(today)) return { lead: [], tail: [], needs: [] };
188
+ const lines = Array.isArray(itemLines) ? itemLines : [];
189
+ const folded = pmItems.fold(lines);
190
+ const ledger = new Map(folded.map((i) => [i.id, i]));
191
+ const held = new Set(lines.filter((l) => l && l.id).map((l) => l.id));
192
+ const assigned = assignedOf(myWork, ledger, held, today);
193
+ const assignedIds = new Set((Array.isArray(myWork) ? myWork : []).map((x) => x && x.id).filter(Boolean));
194
+
195
+ const cards = [];
196
+ for (const e of assigned) if (e.late !== null || e.blocked) cards.push(assignedCard(e, today));
197
+ // the PM's own Work items (and ledger copies whose source no longer lists them); an assigned id was handled above
198
+ for (const i of folded) {
199
+ if (!i || i.done || assignedIds.has(i.id) || setAside(i, today)) continue;
200
+ const late = lateBy(i.due, today);
201
+ if (late !== null) cards.push(workCard(i, late));
202
+ }
203
+ const briefToday = brief && brief.date === today ? brief : null;
204
+ const fromBrief = (x) => x && x.id && x.title && !(held.has('brief:' + x.id) && (!ledger.get('brief:' + x.id) || ledger.get('brief:' + x.id).done));
205
+ const asks = briefToday && Array.isArray(briefToday.worth_asking) ? briefToday.worth_asking.filter(fromBrief) : [];
206
+ const mine = briefToday && Array.isArray(briefToday.priorities) ? briefToday.priorities.filter((x) => fromBrief(x) && x.owner === 'pm') : [];
207
+ for (const x of asks) cards.push(briefCard(x, true));
208
+ for (const x of mine) cards.push(briefCard(x, false));
209
+ cards.sort(byRank);
210
+ const kept = cards.slice(0, MAX_CARDS);
211
+
212
+ // Needs you: the assigned items the own half does not show (it shows the ledger's own items due by today), then the asks
213
+ const ownShown = (id) => { const L = ledger.get(id); return !!(L && !L.done && lateBy(L.due, today) !== null); };
214
+ const needs = [...assigned.filter((e) => !ownShown(e.a.id)).map(assignedNeed), ...asks.map(askNeed)].sort(byRank);
215
+
216
+ return { lead: kept.filter((c) => c._rank === 0).map(strip), tail: kept.filter((c) => c._rank !== 0).map(strip), needs: needs.map(strip) };
217
+ }
218
+
219
+ /** The engine's cards that carry a move or are Critical, as needs rows; the page shapes each from its card. Real cards (origin) are left out. */
220
+ function engineNeeds(cards) {
221
+ return (Array.isArray(cards) ? cards : []).filter((c) => c && !c.origin && c.headline && (c.move || c.tag === 'Critical'))
222
+ .map((c) => ({ key: 'card:' + (c.key || c.headline), kind: 'decision', headline: String(c.headline), tag: c.tag, card: c }));
223
+ }
224
+
225
+ /** The deck: the overdue cards, the engine's cards unchanged, then the rest. */
226
+ function merge(engineCards, built) {
227
+ const b = built || {};
228
+ return [...(b.lead || []), ...(Array.isArray(engineCards) ? engineCards : []), ...(b.tail || [])];
229
+ }
230
+
231
+ /** Needs you, in order: overdue and blocked assigned items, the engine's decisions, then decision, due today and worth asking rows. */
232
+ function needsOf(built, engineCards) {
233
+ const rows = (built && built.needs) || [];
234
+ const head = rows.filter((n) => n.kind === 'Overdue' || n.kind === 'Blocked');
235
+ const rest = rows.filter((n) => !(n.kind === 'Overdue' || n.kind === 'Blocked'));
236
+ return [...head, ...engineNeeds(engineCards), ...rest];
237
+ }
238
+
239
+ /* ── R7 · Jira epic progress, from one status snapshot (lib/jira-status.js readSnapshot) ──────────────────────── */
240
+ function jiraProgress(snap) {
241
+ if (!snap || !snap.tickets || typeof snap.tickets.values !== 'function') return null;
242
+ const all = [...snap.tickets.values()].filter((r) => r && r.key);
243
+ const parents = new Set(all.map((r) => r.parent).filter(Boolean));
244
+ const isEpic = (r) => /^epic$/i.test(String(r.type || '')) || parents.has(r.key);
245
+ const epics = all.filter(isEpic).map((r) => r.key);
246
+ const kids = all.filter((r) => !isEpic(r));
247
+ if (!kids.length) return null;
248
+ const done = kids.filter((r) => r.category === 'done').length;
249
+ const blocked = kids.filter((r) => r.category !== 'done' && r.flagged).length;
250
+ return { done, total: kids.length, blocked, epics, as_of: snap.fetchedAt || null };
251
+ }
252
+
253
+ /* ── the reads (the route's side) ─────────────────────────────────────────────────────────────────────────── */
254
+ async function readSources(ctx, now, today) {
255
+ const out = { brief: null, myWork: [], itemLines: [] };
256
+ const dir = ctx && ctx.runtimeDir;
257
+ if (!dir) return out;
258
+ try { out.brief = JSON.parse(require('fs').readFileSync(path.join(dir, 'briefs', today + '.json'), 'utf8')); } catch { out.brief = null; }
259
+ try { out.itemLines = pmItems.readLines(path.join(dir, 'pm-items', 'items.jsonl')); } catch { out.itemLines = []; }
260
+ try { const mw = await require('./routes-my-work.js').listForAsync(ctx, now.getTime()); out.myWork = mw && mw.ok ? mw.items : []; } catch { out.myWork = []; }
261
+ return out;
262
+ }
263
+
264
+ module.exports = { MAX_CARDS, build, merge, needsOf, engineNeeds, jiraProgress, readSources, blockedOf };
@@ -26,6 +26,7 @@
26
26
  const fs = require('fs');
27
27
  const path = require('path');
28
28
  const repoWatch = require('./repo-watch.js');
29
+ const trackerRead = require('./tracker-read.js');
29
30
 
30
31
  const H = 3600 * 1000;
31
32
  const SOURCES = Object.freeze([
@@ -153,8 +154,10 @@ function create(o) {
153
154
  out.push(row(S.jira, jiraAsOf(w.repoStateDir)));
154
155
  const mw = o.stateDir ? readJson(path.join(o.stateDir, 'my-work', 'read.json'), null) : null;
155
156
  out.push(row(S['my-work'], mw && mw.read_at ? Date.parse(mw.read_at) || 0 : 0));
157
+ // the read the skill stamped when it finished (a press of refresh or the schedule), else the schedule's last start
156
158
  const tr = scheduleOf('tracker-refresh');
157
- out.push(row(S.tracker, tr && tr.last_run ? Date.parse(tr.last_run) || 0 : 0));
159
+ const trRead = o.stateDir ? trackerRead.readAtMs(o.stateDir, w.root) : 0;
160
+ out.push(row(S.tracker, Math.max(trRead, tr && tr.last_run ? Date.parse(tr.last_run) || 0 : 0)));
158
161
  return out;
159
162
  }
160
163
 
@@ -50,6 +50,9 @@ const VERBS = Object.freeze({
50
50
  // Not a pm-os-absorb verb (run: null): workspace-data-freshness added it through the same table.
51
51
  calendar: Object.freeze({ module: 'calendar-cli.js', run: null, guide: 'data-freshness.md',
52
52
  help: 'pmos calendar import|status — the calendar events an agent session read, saved for Briefing and Calendar (pmos calendar help)' }),
53
+ // Not a pm-os-absorb verb (run: null): tracker-read-freshness added it through the same table.
54
+ tracker: Object.freeze({ module: 'tracker-cli.js', run: null, guide: 'data-freshness.md',
55
+ help: 'pmos tracker done|status — record when the tracker-refresh skill finished a read of this repository (pmos tracker help)' }),
53
56
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
54
57
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
55
58
  });
@@ -4,6 +4,12 @@
4
4
  * { ok, scope, lens, zoom, cards, ticker, banner, own_needs }
5
5
  * scope: whose deck asks. brief (or none) runs the engine's briefing detectors, rep its reports detectors (console3-deck-flow-fixes).
6
6
  * Any other scope answers no cards, so a page never shows another page's cards.
7
+ * brief scope also adds the PM's own data (lib/briefing-real.js, initiative briefing-real-data): cards from today's morning brief
8
+ * (worth asking, and the PM's priorities), from the assigned Jira and WBS items that are overdue, due today or blocked, and from
9
+ * the Work items that are overdue or due today. Overdue cards lead, the engine's cards follow unchanged, the rest come after;
10
+ * at most 5 are added. Each carries `origin` (brief, jira, wbs, work), and `url` when the record has a link.
11
+ * needs: "Needs you" rows (brief scope): overdue, blocked, decision and due today assigned items, the engine's cards that carry a
12
+ * move or are Critical (as { card }), and the brief's worth asking items. The PM's own Work items stay in own_needs.
7
13
  * own_needs: the PM's own items overdue or due today, as needs rows (lib/needs-overdue.js), overdue first. Briefing scope only; the page shows them above the cards.
8
14
  * notices: the drafts a dashboard schedule made in the last 7 days that are still in the outbox (lib/report-schedules.js noticesOf).
9
15
  * Additive; the bell lists them after the cards, and each opens Reports > Drafts.
@@ -28,7 +34,8 @@ function lensFor(date) {
28
34
 
29
35
  function tickerOf(cards) {
30
36
  return cards.slice(0, 3).map((c) => {
31
- const d = c.deadline && c.deadline.days != null ? ' (' + (c.deadline.days < 1 ? 'within a day' : Math.round(c.deadline.days) + 'd') + ')' : '';
37
+ // a negative deadline is past: its own text ("2 days overdue") says it
38
+ const d = c.deadline && c.deadline.days != null ? ' (' + (c.deadline.days < 0 && c.deadline.text ? c.deadline.text : c.deadline.days < 1 ? 'within a day' : Math.round(c.deadline.days) + 'd') + ')' : '';
32
39
  return c.tag + ': ' + c.headline + d;
33
40
  });
34
41
  }
@@ -43,6 +50,8 @@ function factsOnce(eng, root, iso) {
43
50
  return facts;
44
51
  }
45
52
 
53
+ const realLib = () => require('./briefing-real.js');
54
+
46
55
  function loadEngine() {
47
56
  // the engine is this repo's; `root` only says which repo's initiatives it reads
48
57
  const { present } = require(repoScript('signals/index.js'));
@@ -50,7 +59,8 @@ function loadEngine() {
50
59
  return { present, readRepoFacts };
51
60
  }
52
61
 
53
- /** Pure handler: (query, { root, now, load }) -> { code, body }. `load` is injectable for tests. */
62
+ /** Pure handler: (query, { root, now, load, real }) -> { code, body }. `load` is injectable for tests; `real` is lib/briefing-real.js
63
+ * build()'s answer, merged around the engine's cards in brief scope. */
54
64
  function briefing(query, opts = {}) {
55
65
  const q = query || {};
56
66
  const root = opts.root || path.resolve(__dirname, '..', '..');
@@ -71,15 +81,20 @@ function briefing(query, opts = {}) {
71
81
  // this is usually all four empty: the briefing detectors read promised dates, forecasts, holders and the like, and no
72
82
  // initiative record carries them yet. The answer then stays empty and says so by fallback_lens being absent.
73
83
  let fallback = false;
74
- if (!cards.length && !q.lens) {
84
+ const real = scope === 'brief' && opts.real ? opts.real : null;
85
+ const hasReal = !!(real && ((real.lead || []).length || (real.tail || []).length));
86
+ if (!cards.length && !q.lens && !hasReal) {
75
87
  for (const l of LENSES) {
76
88
  if (l === lens) continue;
77
89
  const c = eng.present(facts, { lens: l, zoom, today: iso, pages }).cards || [];
78
90
  if (c.length > cards.length) { cards = c; useLens = l; fallback = true; }
79
91
  }
80
92
  }
93
+ const engineCards = cards;
94
+ if (real) cards = realLib().merge(engineCards, real);
81
95
  const banner = cards.find((c) => c.pinned && c.tag === 'Critical') || null;
82
- return { code: 200, body: { ok: true, scope, lens: useLens, zoom, cards, ticker: tickerOf(cards), banner, ...(fallback ? { fallback_lens: true } : {}) } };
96
+ const needs = real ? { needs: realLib().needsOf(real, engineCards) } : {};
97
+ return { code: 200, body: { ok: true, scope, lens: useLens, zoom, cards, ticker: tickerOf(cards), banner, ...needs, ...(fallback ? { fallback_lens: true } : {}) } };
83
98
  } catch (e) {
84
99
  return { code: 500, body: { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) } };
85
100
  }
@@ -103,7 +118,12 @@ module.exports = {
103
118
  try { facts = await require('./facts-async.js').readFactsAsync(useRoot, { today: iso }); }
104
119
  catch (e) { ctx.json(res, { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
105
120
  const eng = loadEngine();
106
- const out = briefing(q, { root: useRoot, load: () => ({ present: eng.present, readRepoFacts: () => facts }) });
121
+ // the PM's own data (briefing-real-data): today's brief, the assigned work and the Work ledger. A source that fails gives nothing.
122
+ let real = null;
123
+ if ((q.scope ? String(q.scope) : 'brief') === 'brief') {
124
+ try { const src = await realLib().readSources(ctx, now, iso); real = realLib().build({ ...src, today: iso }); } catch { real = null; }
125
+ }
126
+ const out = briefing(q, { root: useRoot, real, load: () => ({ present: eng.present, readRepoFacts: () => facts }) });
107
127
  // additive: the PM's own overdue and due-today items, as needs rows beside the engine's cards (lib/needs-overdue.js); a read failure leaves the list empty
108
128
  if (out.code === 200 && out.body.scope === 'brief') {
109
129
  try {
@@ -12,7 +12,11 @@
12
12
  *
13
13
  * EVENTS: lib/calendar-events.js readAll, the imported events joined with the calendar the morning brief remembers and today's
14
14
  * brief (the same meetings the Agenda and the Record menu show). With no reader, events = [] and events_source is 'none'. KRS: no KR metric series is stored in the repo, so the strategy lens
15
- * gets none and says so. */
15
+ * gets none and says so.
16
+ *
17
+ * JIRA (initiative briefing-real-data, R7): a gantt row whose initiative has a Jira status snapshot (<state>/tracker/jira/<id>.md, written
18
+ * by lib/jira-status.js) carries `jira`: { done, total, blocked, epics, as_of } (lib/briefing-real.js jiraProgress). The Briefing's pulse
19
+ * shows such a row even without a promised date. No snapshot, no field. */
16
20
  const path = require('path');
17
21
  const { repoScript } = require('./repo-script.js');
18
22
  const model = require(repoScript('calendar-model.js'));
@@ -22,7 +26,7 @@ const tidyLib = require('./calendar-tidy.js');
22
26
  const today = () => { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; };
23
27
 
24
28
  /** The pure handler. deps: { readFacts(), readEvents?(range), readKrs?(), today, nowMs?, readDated?(range), readItems?(),
25
- * readContextSources?() } so tests need no repo. A dep that is missing gives its source as empty, said in layers_note. */
29
+ * readContextSources?(), readJira?() → Map<initiative, progress> } so tests need no repo. A dep that is missing gives its source as empty, said in layers_note. */
26
30
  function handle(query, deps = {}) {
27
31
  const view = (query && query.view) || 'month';
28
32
  const date = (query && query.date) || (deps.today || today());
@@ -38,6 +42,11 @@ function handle(query, deps = {}) {
38
42
  const b = model.bucketEvents(events, view, date);
39
43
  const nowDay = deps.today || today();
40
44
  const gantt = model.ganttRows(inis, view, date, { today: nowDay });
45
+ if (typeof deps.readJira === 'function') {
46
+ let jira = null;
47
+ try { jira = deps.readJira(); } catch { jira = null; } // a snapshot folder that cannot be read adds nothing
48
+ if (jira && typeof jira.get === 'function') for (const g of gantt) { const p = jira.get(g.id); if (p) g.jira = p; }
49
+ }
41
50
  const strategy = view === 'quarter' || view === 'year' ? model.strategyLens(inis, krs, nowDay) : null;
42
51
  const dated = typeof deps.readDated === 'function' ? (deps.readDated(range) || {}) : {
43
52
  unavailable: { plan_items: 'no reader is wired', releases: 'no reader is wired', reports: 'no reader is wired', own: 'no reader is wired' } };
@@ -94,6 +103,19 @@ function readers(root, runtimeDir) {
94
103
  try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
95
104
  return out;
96
105
  },
106
+ /* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts are not snapshots) */
107
+ readJira() {
108
+ const fs = require('fs');
109
+ const status = require('./jira-status.js');
110
+ const out = new Map();
111
+ let names = [];
112
+ try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')); } catch { return out; }
113
+ for (const n of names) {
114
+ const id = n.slice(0, -3);
115
+ 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 */ }
116
+ }
117
+ return out;
118
+ },
97
119
  readContextSources() {
98
120
  const src = { notes: [], planItems: [], ownItems: [] };
99
121
  try { src.planItems = planRows().map((p) => ({ id: p.id, initiative: p.initiative, title: p.title, createdAt: p.createdAt, doneAt: p.doneAt })); } catch { /* no plan ledger: no moved items */ }
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ /* tracker-cli.js — `pmos tracker …`: record that the tracker-refresh skill finished a read (initiative
3
+ * tracker-read-freshness).
4
+ * pmos tracker done [--moved N] [--left N] [--not-found N] [--json]
5
+ * pmos tracker status [--json]
6
+ * The repository is the one this terminal is in. The record is kept in the per-user runtime folder (lib/tracker-read.js),
7
+ * never in a repository. This command calls no tracker and moves no item.
8
+ */
9
+ const { execFileSync } = require('child_process');
10
+ const stateRoot = require('./state-root');
11
+ const TR = require('./tracker-read.js');
12
+
13
+ const COMMANDS = { tracker: ['done', 'status'] };
14
+ const USAGE = [
15
+ 'usage: pmos tracker done|status — when tracker items were last read for this repository',
16
+ ' pmos tracker done [--moved N] [--left N] [--not-found N] the tracker-refresh skill runs this as its last step',
17
+ ' pmos tracker status when the last read finished, and what it found',
18
+ ' every command: [--json]',
19
+ 'This command never calls a tracker and moves no item.',
20
+ ];
21
+
22
+ function parseArgs(argv) {
23
+ const out = { _: [] };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const t = argv[i];
26
+ if (!t.startsWith('--')) { out._.push(t); continue; }
27
+ const eq = t.indexOf('=');
28
+ const key = eq > 0 ? t.slice(2, eq) : t.slice(2);
29
+ if (key === 'json' || key === 'help') out[key] = true;
30
+ else if (eq > 0) out[key] = t.slice(eq + 1);
31
+ else if (argv[i + 1] !== undefined) out[key] = argv[++i];
32
+ else out[key] = '';
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function repoRoot(cwd) {
38
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
39
+ catch { return cwd; }
40
+ }
41
+
42
+ function run(argv, { say = console.log, env = process.env, stateDir, cwd = process.cwd(), now = () => new Date() } = {}) {
43
+ const a = parseArgs(argv.slice(1));
44
+ const sub = a._[0];
45
+ const out = (o, line) => { if (a.json) say(JSON.stringify(o)); else say(line); };
46
+ if (!sub || a.help || sub === 'help') { USAGE.forEach((l) => say(l)); return sub || a.help ? 0 : 2; }
47
+ if (!COMMANDS.tracker.includes(sub)) { say(`✗ pmos tracker ${sub} is not a command`); USAGE.forEach((l) => say(l)); return 2; }
48
+ const dir = stateDir || stateRoot.defaultRoot({ env });
49
+ const root = repoRoot(cwd);
50
+
51
+ if (sub === 'done') {
52
+ const counts = {};
53
+ for (const [flag, key] of [['moved', 'moved'], ['left', 'left'], ['not-found', 'not_found']]) {
54
+ if (a[flag] === undefined) continue;
55
+ const n = Number(a[flag]);
56
+ if (!Number.isInteger(n) || n < 0) { out({ ok: false, error: `--${flag} takes a whole number of 0 or more` }, `✗ --${flag} takes a whole number of 0 or more`); return 2; }
57
+ counts[key] = n;
58
+ }
59
+ const rec = TR.write(dir, root, counts, now());
60
+ out({ ok: true, ...rec }, `tracker items read at ${rec.read_at} for ${rec.root}. The freshness line shows it.`);
61
+ return 0;
62
+ }
63
+ const rec = TR.read(dir, root);
64
+ out({ ok: true, record: rec }, rec ? `tracker items last read at ${rec.read_at} (moved ${rec.moved ?? '?'}, left ${rec.left ?? '?'}, not found ${rec.not_found ?? '?'})` : 'tracker items not read yet for this repository');
65
+ return 0;
66
+ }
67
+
68
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ /* tracker-read.js — when the tracker-refresh skill last finished a read of one repository (initiative
3
+ * tracker-read-freshness).
4
+ *
5
+ * write(runtimeDir, root, { moved, left, not_found }, now) → the record `pmos tracker done` runs this
6
+ * read(runtimeDir, root) → the record or null
7
+ * readAtMs(runtimeDir, root) → ms, or 0
8
+ *
9
+ * The skill reads local files only and moves an item only when a file says so, so a read that changes nothing writes no
10
+ * ledger line. The freshness line needs to know the read happened all the same. Its last step stamps one small file per
11
+ * repository in the per-user runtime folder: <runtime>/tracker-read/<hash of the repository path>.json. It never goes into
12
+ * a repository, and the bi-weekly report does not read it.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+
18
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
19
+ const fileOf = (runtimeDir, root) =>
20
+ path.join(runtimeDir, 'tracker-read', crypto.createHash('sha1').update(real(root)).digest('hex').slice(0, 16) + '.json');
21
+ const count = (n) => (Number.isInteger(n) && n >= 0 ? n : null);
22
+
23
+ function write(runtimeDir, root, counts = {}, now = new Date()) {
24
+ const rec = { root: real(root), read_at: now.toISOString(), moved: count(counts.moved), left: count(counts.left), not_found: count(counts.not_found) };
25
+ const f = fileOf(runtimeDir, root);
26
+ fs.mkdirSync(path.dirname(f), { recursive: true });
27
+ const tmp = `${f}.${process.pid}.tmp`;
28
+ fs.writeFileSync(tmp, JSON.stringify(rec) + '\n');
29
+ fs.renameSync(tmp, f);
30
+ return rec;
31
+ }
32
+
33
+ function read(runtimeDir, root) {
34
+ if (!runtimeDir || !root) return null;
35
+ try { return JSON.parse(fs.readFileSync(fileOf(runtimeDir, root), 'utf8')); } catch { return null; }
36
+ }
37
+
38
+ const readAtMs = (runtimeDir, root) => { const r = read(runtimeDir, root); return (r && Date.parse(r.read_at)) || 0; };
39
+
40
+ module.exports = { write, read, readAtMs, fileOf };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.145.0",
3
+ "version": "0.146.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.145.0",
9
+ "version": "0.146.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.145.0",
3
+ "version": "0.146.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",
@@ -95,6 +95,8 @@ export function dayPartFor(date) {
95
95
  }
96
96
 
97
97
  const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
98
+ /* a link a card or a need may open: https only, anything else is dropped (initiative briefing-real-data) */
99
+ const httpsUrl = (v) => (typeof v === 'string' && /^https:\/\/\S+$/.test(v) && v.length <= 500 ? v : null);
98
100
 
99
101
  /** What the deck draws for one engine card. */
100
102
  export function cardView(card) {
@@ -134,6 +136,9 @@ export function cardView(card) {
134
136
  kpisX: Array.isArray(c.kpis) ? c.kpis.filter((k) => Array.isArray(k)).slice(0, 3).map((k) => ({ v: String(k[0]), l: String(k[1]), c: k[2] ? String(k[2]) : null })) : null,
135
137
  factsX: Array.isArray(c.factsX) ? c.factsX.filter((f) => Array.isArray(f)).slice(0, 3).map((f) => ({ icon: String(f[0]), text: String(f[1]) })) : null,
136
138
  rich: h.rich && typeof h.rich === 'object' ? h.rich : null,
139
+ /* where a card from the PM's own data came from (brief, jira, wbs, work) and the record's link, opened from the case file (briefing-real-data) */
140
+ origin: c.origin ? String(c.origin) : null,
141
+ url: httpsUrl(c.url), urlLabel: c.url_label ? String(c.url_label) : null,
137
142
  conf: rec ? num(rec.conf) : null,
138
143
  terms: w ? { impact: num(w.impact), urgency: num(w.urgency), irreversible: num(w.irreversible), score: num(w.score) } : null,
139
144
  };
@@ -190,6 +195,7 @@ export function caseModelOf(v, o = {}) {
190
195
  unsure: [c.unsure, c.template && c.template.gaps.length ? 'Not known yet: ' + c.template.gaps.map((g) => GAP_WORDS[g] || g).join(', ') + '.' : ''].filter(Boolean).join(' '),
191
196
  ifYouWait: dl ? (dl.days == null ? 'No deadline is known: ' + dl.text : dl.text) : '', why: c.why || '',
192
197
  outward: !sim, outboxReason: NO_ROUTE,
198
+ ...(c.url ? { go: { action: 'open', href: c.url, label: c.urlLabel || 'Open the record' } } : {}),
193
199
  };
194
200
  }
195
201
 
@@ -198,10 +204,15 @@ export function needCaseOf(n) {
198
204
  const reg = registered('need', n.key);
199
205
  if (reg) return reg;
200
206
  if (n.view) return caseModelOf(n.view, { sim: !!n.sim }); // a real row is one engine card: its case is the card's own
207
+ /* a row from the PM's own data (briefing-real-data) carries its facts, its reason and its link; every string is the row's own */
208
+ const facts = Array.isArray(n.facts) && n.facts.length ? n.facts.map((f) => ({ icon: f.icon || 'info', text: String(f.text || ''), source: f.source || '', tone: f.tone || '' })) : null;
209
+ const url = httpsUrl(n.url);
201
210
  return {
202
211
  id: 'need:' + n.key, situation: n.headline,
203
- evidence: [{ icon: 'clock', text: n.dueText ? n.dueText : 'Due ' + (n.time || 'soon'), source: n.src || '' }],
212
+ evidence: facts || [{ icon: 'clock', text: n.dueText ? n.dueText : 'Due ' + (n.time || 'soon'), source: n.src || '' }],
204
213
  options: [], outward: true, outboxReason: NO_ROUTE,
214
+ ...(n.why ? { why: String(n.why) } : {}), ...(n.dueText && facts ? { ifYouWait: String(n.dueText) } : {}),
215
+ ...(url ? { go: { action: 'open', href: url, label: n.url_label ? String(n.url_label) : 'Open the record' } } : {}),
205
216
  };
206
217
  }
207
218
 
@@ -1,6 +1,9 @@
1
1
  /* pm/c3-brief-needs.js — the Briefing's "Needs you" (PRD briefing-brief R3, R4, R6).
2
2
  * needsSection(S) the section: own items first (overdue, most late first; then due today; then outside PMOS), then the decisions only the PM
3
- * can make (the briefing's needs in the demo, the engine cards that carry a move or are Critical in real mode: c3-brief.js needRow).
3
+ * can make (c3-brief.js needRow): the briefing's `needs`. In real mode GET /api/briefing builds them (initiative briefing-real-data):
4
+ * the assigned Jira and WBS items that are overdue, blocked, a decision or due today, the engine cards that carry a move or are
5
+ * Critical (a row with `card`, drawn from the card), and the morning brief's worth asking items. Without `needs` (an older
6
+ * server), the engine cards that carry a move or are Critical; the cards from the PM's own data (`origin`) are not decisions.
4
7
  * itemRow(row, ctx) a row of an own item. It opens in place (c3-expand). Its actions write to the item's home record, through the pm-items routes:
5
8
  * Done POST /api/pm-items/done { id, outcome? }
6
9
  * Not yet POST /api/pm-items/notyet { id, when: 'later-today' | 'tomorrow' | 'next-week' } the item returns on that date
@@ -81,11 +84,20 @@ export function itemRow(r, ctx = {}) {
81
84
  /* The Needs you count for the Briefing tab's badge, read without the section: the decisions (the briefing's needs, or its engine cards that carry a move or are
82
85
  * Critical) plus the own items of the brief page. The section's own count (recount) takes over once it is on the page. */
83
86
  export function needsCount(b, page) {
84
- const decide = b && Array.isArray(b.needs) && b.needs.length ? b.needs.filter((n) => !BX.gone[n.key]).length : ((b && b.cards) || []).filter((c) => c && (c.move || c.tag === 'Critical')).length;
87
+ const decide = b && Array.isArray(b.needs) && b.needs.length ? b.needs.filter((n) => !BX.gone[n.key]).length : ((b && b.cards) || []).filter(isDecision).length;
85
88
  const own = page && page.ok !== false ? (page.needs || []).filter((r) => r.kind !== 'decision').length : 0;
86
89
  return decide + own;
87
90
  }
88
91
 
92
+ /* an engine card a PM decides on; a card from the PM's own data (origin) is a reminder, and its row comes in `needs` instead */
93
+ const isDecision = (c) => !!c && !c.origin && !!(c.move || c.tag === 'Critical');
94
+ /* a needs row that carries an engine card is drawn from the card, as the cards-only path draws it */
95
+ function fromCard(n, i) {
96
+ if (!n || !n.card) return n;
97
+ const v = cardView(n.card); const T = TAG_STYLE[v.tag] || TAG_STYLE['Just in'];
98
+ return { key: n.key || v.key || String(i), headline: v.headline, kind: v.tag, icon: T.icon, color: T.color, time: v.deadline && v.deadline.text ? v.deadline.text : null, src: v.tag, view: v };
99
+ }
100
+
89
101
  export function needsSection(S) {
90
102
  const count = el('span', { class: 'bdg g' }, '0');
91
103
  const quick = el('span', { class: 'chips2 dim mono c3s2', 'data-needs-summary': '' });
@@ -133,8 +145,8 @@ export function needsSection(S) {
133
145
  const fill = (b) => {
134
146
  last = b;
135
147
  const inc = !!S.incident;
136
- let needs = b && Array.isArray(b.needs) && b.needs.length ? b.needs.filter((n) => !BX.gone[n.key] && (!inc || n.hard)) : null;
137
- const decide = ((b && b.cards) || []).filter((c) => c && (c.move || c.tag === 'Critical'));
148
+ let needs = b && Array.isArray(b.needs) && b.needs.length ? b.needs.map(fromCard).filter((n) => !BX.gone[n.key] && (!inc || n.hard)) : null;
149
+ const decide = ((b && b.cards) || []).filter(isDecision);
138
150
  const key = needs ? needs.map((n) => n.key + n.headline) : decide.map((c) => (c.key || '') + c.headline);
139
151
  if (needs && S.pinNeed) { const at = needs.findIndex((n) => n.key === S.pinNeed); if (at > 0) needs.unshift(...needs.splice(at, 1)); }
140
152
  const g = JSON.stringify([key, inc, !!(b && b.kept_out), S.pinNeed || '']);
@@ -2,13 +2,15 @@
2
2
  * One status bar and a row per initiative (the Calendar's own gantt rows, GET /api/calendar-views quarter, c3-plan.js readQuarter; the demo's pulse rows
3
3
  * carry their own colour and reason). A row opens in place with the case component the Needs you rows use (c3-expand + c3-case): why it is where it
4
4
  * is, promised against forecast. A press on a row does NOT change the tab. "Open initiative" is an explicit link inside the opened row and is the only
5
- * thing here that does. Rows are references (data-ref-row): nothing in them changes a record. */
5
+ * thing here that does. Rows are references (data-ref-row): nothing in them changes a record.
6
+ * Jira (initiative briefing-real-data, R7): a gantt row with a `jira` field (its Jira epic's snapshot: done, total, blocked, as_of) shows even
7
+ * without a promised date, as "x of y done" with a bar; a row with a plan keeps its look and adds the Jira line to its case. */
6
8
  import { el } from './dom.js';
7
9
  import { c3ic } from './pm-work-view.js';
8
10
  import { makeXp, attachExpand } from './c3-expand.js';
9
11
  import { caseFile, hooks } from './c3-brief.js';
10
12
  import { readQuarter } from './c3-plan.js';
11
- import { row, signal, button, ensureComponentsCss } from './primitives.js';
13
+ import { row, signal, button, meter, ensureComponentsCss } from './primitives.js';
12
14
  import { ensureSprite } from './icons.js';
13
15
 
14
16
  /* tone: ok is neutral, warn is the warning token, bad the destructive one (R28-12) */
@@ -17,8 +19,32 @@ const RAG = { red: ['bad', 'Off track'], amber: ['warn', 'At risk'], green: ['ok
17
19
  const SIG = { bad: 'destructive', warn: 'warning' };
18
20
  const end = (o) => (o && (o.end || o.start)) || null;
19
21
 
20
- /** One gantt row as the pulse draws it: { id, title, cls, word, color, why, evidence }. Pure. */
22
+ /* a usable Jira progress: at least one ticket under the epic */
23
+ const jiraOf = (g) => (g && g.jira && Number.isInteger(g.jira.total) && g.jira.total > 0 && Number.isInteger(g.jira.done) ? g.jira : null);
24
+ const jiraLines = (j) => {
25
+ const ev = [['check', j.done + ' of ' + j.total + ' done in Jira' + (j.epics && j.epics.length ? ' (epic ' + j.epics.join(', ') + ')' : ''), 'Jira']];
26
+ if (j.blocked > 0) ev.push(['alert', j.blocked + ' blocked', 'Jira', 'warn']);
27
+ if (j.as_of) ev.push(['clock', 'Read from Jira ' + String(j.as_of).slice(0, 10), 'Jira']);
28
+ return ev;
29
+ };
30
+
31
+ /** One gantt row as the pulse draws it: { id, title, cls, word, color, why, evidence, progress? }. Pure. */
21
32
  export function pulseRow(g) {
33
+ const j = jiraOf(g);
34
+ if (j && !g.pulse && (!PST[g.status] || g.status === 'no plan')) {
35
+ /* no plan date: the row is the epic's progress (briefing-real-data R7) */
36
+ const all = j.done >= j.total;
37
+ const cls = j.blocked > 0 ? 'warn' : all ? 'ok' : '';
38
+ const word = j.blocked > 0 ? 'Blocked' : all ? 'Done in Jira' : 'In progress';
39
+ const why = j.done + ' of ' + j.total + ' done in Jira' + (j.blocked > 0 ? ', ' + j.blocked + ' blocked' : '') + '; no promised date';
40
+ return { id: g.id, title: g.title || g.id, cls, word, why, evidence: jiraLines(j), progress: { done: j.done, total: j.total } };
41
+ }
42
+ const r = planRow(g);
43
+ if (j) { r.evidence = r.evidence.concat(jiraLines(j)); r.progress = { done: j.done, total: j.total }; }
44
+ return r;
45
+ }
46
+
47
+ function planRow(g) {
22
48
  if (g.pulse && RAG[g.pulse.c]) {
23
49
  const [cls, word] = RAG[g.pulse.c];
24
50
  const ev = [];
@@ -53,9 +79,10 @@ export function pulse(S) {
53
79
  const draw = (rows, err) => {
54
80
  if (err) { body.replaceChildren(el('p', { class: 'dim mono c3s2', role: 'status' }, 'The initiatives could not be read: ' + err)); return; }
55
81
  if (!rows.length) { body.replaceChildren(note('No initiative has a promised date yet, so there is nothing to compare with the forecast.')); return; }
56
- const noPlan = rows.filter((g) => !g.pulse && g.status === 'no plan').length;
57
- rows = rows.filter((g) => g.pulse || g.status !== 'no plan');
58
- if (!rows.length) { body.replaceChildren(note('No initiative has a promised date yet, so there is nothing to compare with the forecast.'), ...(noPlan ? [el('p', { class: 'dim mono c3s2' }, noPlan + ' initiatives have no plan date.')] : [])); return; }
82
+ const shown = (g) => g.pulse || g.status !== 'no plan' || !!jiraOf(g); // a row without a plan shows when its Jira epic has progress
83
+ const noPlan = rows.filter((g) => !shown(g)).length;
84
+ rows = rows.filter(shown);
85
+ if (!rows.length) { body.replaceChildren(note('No initiative has a promised date or a Jira epic read yet, so there is nothing to show.'), ...(noPlan ? [el('p', { class: 'dim mono c3s2' }, noPlan + ' initiatives have no plan date.')] : [])); return; }
59
86
  const pr = rows.map(pulseRow);
60
87
  const need = pr.filter((r) => r.cls === 'bad' || r.cls === 'warn').length;
61
88
  attn.textContent = need + ' need attention'; foot.hidden = false;
@@ -65,7 +92,8 @@ export function pulse(S) {
65
92
  const rowEl = (r) => {
66
93
  const xp = makeXp(caseFile(pulseCase(r)));
67
94
  let ex = null;
68
- const head = row({ title: r.title, meta: [r.why], signals: [signal({ text: r.word, tone: SIG[r.cls] || 'muted' })], openLabel: r.title + ': ' + r.word, onOpen: () => ex.toggle(), openAttrs: { 'data-c3pg-prow': r.id } });
95
+ const bar = r.progress ? [el('span', { 'data-pulse-progress': r.progress.done + '/' + r.progress.total }, meter({ done: r.progress.done, total: r.progress.total, label: r.progress.done + ' of ' + r.progress.total + ' done' }))] : null;
96
+ const head = row({ title: r.title, meta: [r.why], signals: [signal({ text: r.word, tone: SIG[r.cls] || 'muted' })], trail: bar, openLabel: r.title + ': ' + r.word, onOpen: () => ex.toggle(), openAttrs: { 'data-c3pg-prow': r.id } });
69
97
  ex = attachExpand(head, xp, { key: 'pulse-' + r.id });
70
98
  return el('div', { class: 'pwrap', 'data-ref-row': '', 'data-pulse-row': r.id }, head, xp);
71
99
  };
@@ -171,13 +171,18 @@ export function renderCase(model, opts = {}) {
171
171
  root.append(el('ul', { class: 'chgl', role: 'list' }, ...c.evidence.map((r) => el('li', { class: r.tone }, c3ic(r.icon, 'sm'), el('span', null, r.text), r.tag ? el('span', { class: 'tag' }, r.tag) : null))));
172
172
  }
173
173
  if (optsHost) { drawOptions(); drawSlide(); root.append(optsHost, slideHost); }
174
+ /* a go with an https href is a link to the record outside PMOS (a Jira ticket, the brief's source): it opens in a new tab, changes nothing,
175
+ * and takes the place of the in-page go button */
176
+ const href = c.go && typeof c.go.href === 'string' && /^https:\/\//.test(c.go.href) ? c.go.href : null;
177
+ const goLink = href ? el('a', { class: 'chip2 lk', href, target: '_blank', rel: 'noopener noreferrer', 'data-case-link': '' }, c3ic('out', 'sm'), c.go.label) : null;
178
+ if (goLink) c.go = null;
174
179
  const goBtn = c.go ? el('button', { type: 'button', class: 'chip2 lk', onclick: () => { if (opts.onGo) opts.onGo(c.go); } }, c3ic('next', 'sm'), c.go.label) : null;
175
- if (c.unsure || c.ifYouWait || c.why || goBtn) {
180
+ if (c.unsure || c.ifYouWait || c.why || goBtn || goLink) {
176
181
  root.append(el('div', { class: 'cft' },
177
182
  c.unsure ? el('span', null, c3ic('info', 'sm'), 'Unsure: ' + c.unsure) : null,
178
183
  c.ifYouWait ? el('span', null, c3ic('alert', 'sm'), 'If you wait: ' + c.ifYouWait) : null,
179
184
  c.why ? el('span', null, c3ic('eye', 'sm'), 'Why shown: ' + c.why) : null,
180
- goBtn));
185
+ goLink || goBtn));
181
186
  }
182
187
  /* a handle for the caller: read or set the pick, and tear the slide down when the case leaves the page */
183
188
  root.c3case = { get picked() { return picked; }, pick, model: c, dispose() { if (slideHost) disposeSlide(slideHost); if (disposeHold) { disposeHold(); disposeHold = null; } } };
@@ -18,7 +18,7 @@ This skill runs before the bi-weekly report (`report-biweekly`). The report scri
18
18
  Rules that always hold (D71):
19
19
  1. LOCAL FILES ONLY. Read files in this repository. Never call a tracker, a connector, a URL or any network tool. Never run `curl`, `wget`, `git fetch` or `git pull`. A tracker that no local file describes is "not reached".
20
20
  2. READ-ONLY on every source. Never send, reply, forward, post, comment, create, edit, transition, label, archive or delete anything in Jira, Confluence, mail, chat, calendar or any other source.
21
- 3. THE ONLY WRITE is `pmos item move`. Never edit a repository file. Never run `pmos item add`, `set` or `withdraw`. Never write a ledger by hand. Never commit; the PM's Sync carries the ledger.
21
+ 3. THE ONLY WRITE is `pmos item move`, plus `pmos tracker done` as the last step, which writes no ledger line. Never edit a repository file. Never run `pmos item add`, `set` or `withdraw`. Never write a ledger by hand. Never commit; the PM's Sync carries the ledger.
22
22
  4. EVIDENCE FOR EVERY MOVE. Each move carries `--evidence file:<repository path>`: the local file that shows the tracker state. If you have no file to point at, do not move the item.
23
23
  5. NOTHING INVENTED. Move an item only to the state a file says. If the files disagree, use the newest one and say so. If no file says, leave the item and list it under "Not found".
24
24
  6. LIGHT ON CONTENT. Read titles, ids and statuses. Never copy a message body, an attachment, a token or a credential. Skip a file marked private, personal, confidential or HR, and count it.
@@ -43,6 +43,7 @@ Procedure:
43
43
  6. If the mapped state is the item's state, leave the item. Write nothing. List it under "Left as they are" with the file and its date.
44
44
  7. If the mapped state is different, run `pmos item move <item> <state> --evidence file:<path of the file>`. If the command is refused, read the reason and do not work around it. List the item under "Not found" with the reason.
45
45
  8. Finish with the four sections of the output contract, as short lines: one line for each item, with its id, the old and new state and the file. Under "Sources reached and not reached", give each tracker reference as reached (the file and its date) or not reached. If you moved nothing, say so in one line: a refresh that moves nothing writes no line, so the bi-weekly report can still list the tracker as not refreshed. Do not commit.
46
+ 9. Run `pmos tracker done --moved <n> --left <n> --not-found <n>` with the counts of the three sections. It writes no ledger line; it records in your runtime folder that this read finished, so the freshness line shows the time. Run it even when you moved nothing.
46
47
 
47
48
  Output contract — produce EXACTLY these sections, the same shape every run:
48
49
  - Moved
@@ -30,7 +30,9 @@
30
30
  "work-evidence.test.js",
31
31
  "brief-page.test.js",
32
32
  "work-assigned.test.js",
33
+ "briefing-real-data.test.js",
33
34
  "calendar-events.test.js",
35
+ "tracker-read.test.js",
34
36
  "item-steps.test.js",
35
37
  "status-facts-panel.test.js",
36
38
  "jev-claims.test.js",
@@ -161,7 +163,9 @@
161
163
  "work-evidence.test.js": 1,
162
164
  "brief-page.test.js": 1,
163
165
  "work-assigned.test.js": 1,
166
+ "briefing-real-data.test.js": 1,
164
167
  "calendar-events.test.js": 1,
168
+ "tracker-read.test.js": 1,
165
169
  "pm-brief-page-test.js": 30,
166
170
  "item-steps.test.js": 1,
167
171
  "status-facts-panel.test.js": 1,