create-pmos 0.144.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.144.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@9f8623c
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:54:19Z"}
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:54:19Z"}
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:54:19Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T02:35:41Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -37,6 +37,7 @@ const { harness } = require('./c3-ini-harness');
37
37
  await page.fill('[data-ibt="f-more"]', 'Which trials need SSO first?');
38
38
  await page.click('[data-ibt="more-go"]');
39
39
  await until(() => store(() => window.__c3boards.asks.find((a) => a.id === 'ask-1').needMore === true));
40
+ await until(async () => /prompted for more detail/.test(await toast()));
40
41
  ok('T6 Ask for more records the question; the ask stays open', await store(() => { const a = window.__c3boards.asks.find((x) => x.id === 'ask-1'); return a.state === 'open' && a.moreNote === 'Which trials need SSO first?'; }) && /prompted for more detail/.test(await toast()));
41
42
 
42
43
  await page.click('[data-ibt-ask="ask-1"] .tt').catch(() => {});
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-updates-test.js — console3 Settings › Updates runs the app update itself, and the kit Update sends the checked version
4
+ * (initiative c3-app-update).
5
+ *
6
+ * The page used to say "run it from the main app" and link to /, and its kit Update posted no version, which the server
7
+ * refuses with "Check for updates first". Against a real server; the kit routes and /api/sessions are stood in by the
8
+ * page's route table, so nothing is installed and npm is never asked. Hooks only (data-c3set-*).
9
+ */
10
+ const path = require('path');
11
+ const { spawn } = require('child_process');
12
+ const { findBrowser } = require('./lib/find-browser');
13
+ const T = require('./test-run').configure({ name: 'c3-updates' });
14
+
15
+ const BASE = T.BASE;
16
+ const results = [];
17
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
18
+ let srv = null; let browser = null;
19
+ const finish = async (code) => {
20
+ if (browser) await browser.close().catch(() => {});
21
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
22
+ T.killTmux();
23
+ const bad = results.filter((x) => !x.cond).length;
24
+ console.log(`\n${results.length - bad}/${results.length} c3-updates assertions passed`);
25
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
26
+ };
27
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
28
+
29
+ async function waitFor(page, fn, arg, ms = 8000) {
30
+ try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; }
31
+ }
32
+
33
+ (async () => {
34
+ const exe = findBrowser();
35
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-updates suite cannot run'); return finish(1); }
36
+ let chromium;
37
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-updates suite cannot run'); return finish(1); }
38
+
39
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
40
+ await T.waitForServer(BASE);
41
+ browser = await chromium.launch({ executablePath: exe });
42
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
43
+ const page = await ctx.newPage();
44
+ const errors = [];
45
+ page.on('pageerror', (e) => errors.push(String(e && e.message || e)));
46
+
47
+ // The stand-ins: an installed app 0.1.0 and a kit 0.1.0 in every workspace, both behind 0.2.0.
48
+ const S = { app: '0.1.0', kit: '0.1.0', checkout: false, upgrade: { ok: true }, down: 0 };
49
+ const calls = [];
50
+ const body = (ws) => ({ ok: true, ws, app: { version: S.app, fromCheckout: S.checkout }, kit: { present: true, version: S.kit }, source: { kind: 'npm' } });
51
+ const wsOf = (route) => { try { return JSON.parse(route.request().postData() || '{}'); } catch { return {}; } };
52
+ await page.route((u) => u.pathname === '/api/kit/status', (route) => route.fulfill({ json: { ...body(new URL(route.request().url()).searchParams.get('ws')), busy: null } }));
53
+ await page.route((u) => u.pathname === '/api/kit/check', (route) => { const b = wsOf(route); calls.push(['check', b]); route.fulfill({ json: { ...body(b.ws), latest: '0.2.0', appBehind: S.app !== '0.2.0', kitBehind: S.kit !== '0.2.0' } }); });
54
+ await page.route((u) => u.pathname === '/api/kit/update', (route) => {
55
+ const b = wsOf(route); calls.push(['kit', b]);
56
+ if (b.version !== '0.2.0') return route.fulfill({ status: 502, json: { ok: false, error: 'Check for updates first' } });
57
+ route.fulfill({ json: { ...body(b.ws), kit: { present: true, version: '0.2.0' }, output: 'kit updated' } });
58
+ });
59
+ await page.route((u) => u.pathname === '/api/kit/upgrade-app', (route) => {
60
+ const b = wsOf(route); calls.push(['app', b]);
61
+ if (!S.upgrade.ok) return route.fulfill({ status: 403, json: S.upgrade });
62
+ S.down = 2; // the server is away for two polls, then answers on the new version
63
+ route.fulfill({ json: { ok: true, ws: b.ws, from: '0.1.0', to: b.version, log: '/tmp/app-update.log' } });
64
+ });
65
+ let reloaded = false;
66
+ await page.route((u) => u.pathname === '/api/sessions', async (route) => {
67
+ if (!calls.some((c) => c[0] === 'app' && S.upgrade.ok)) return route.continue();
68
+ if (S.down > 0) { S.down -= 1; return route.abort('connectionrefused'); }
69
+ S.app = '0.2.0';
70
+ const r = await route.fetch(); const j = await r.json().catch(() => ({}));
71
+ return route.fulfill({ json: { ...j, version: S.app } });
72
+ });
73
+ page.on('framenavigated', (f) => { if (f === page.mainFrame() && calls.some((c) => c[0] === 'app')) reloaded = true; });
74
+ const named = (kind) => calls.filter((c) => c[0] === kind);
75
+
76
+ const open = async () => {
77
+ await page.goto(BASE + '/c3.html?tab=settings');
78
+ await waitFor(page, () => !!document.querySelector('[data-c3set-nav] [data-c3set-sec="updates"]'));
79
+ await page.click('[data-c3set-nav] [data-c3set-sec="updates"]');
80
+ return waitFor(page, () => !!document.querySelector('[data-c3set-section="updates"] [data-c3set-check]:not([disabled])'));
81
+ };
82
+
83
+ /* ── A: before a check, nothing is offered and npm is not asked ── */
84
+ ok('A1 Updates opens with Check now', await open());
85
+ ok('A2 no app Update and no Open the main app before a check', await page.evaluate(() => !document.querySelector('[data-c3set-app-update]') && !/Open the main app/.test(document.querySelector('[data-c3set-section="updates"]').textContent)));
86
+ ok('A3 nothing was asked of npm before the press', calls.length === 0, JSON.stringify(calls));
87
+
88
+ /* ── B: the kit Update sends the version the check found ── */
89
+ await page.click('[data-c3set-check]');
90
+ ok('B1 after the check, the app row offers Update to 0.2.0', await waitFor(page, () => { const b = document.querySelector('[data-c3set-app-update]'); return !!b && /Update to 0\.2\.0/.test(b.textContent); }));
91
+ ok('B2 the page no longer sends you to the main app', await page.evaluate(() => !/Open the main app/.test(document.querySelector('[data-c3set-section="updates"]').textContent)));
92
+ ok('B3 each workspace row offers a kit Update', await waitFor(page, () => !!document.querySelector('[data-c3set-kit-update]')));
93
+ const kitWs = await page.getAttribute('[data-c3set-kit-update]', 'data-c3set-kit-update');
94
+ await page.click(`[data-c3set-kit-update="${kitWs}"]`);
95
+ ok('B4 kit Update posts that workspace and the checked version', await waitFor(page, () => !!document.querySelector('[data-c3-toast]') && /Updated the kit/.test(document.querySelector('[data-c3-toast]').textContent)) && named('kit').length === 1 && named('kit')[0][1].ws === kitWs && named('kit')[0][1].version === '0.2.0', JSON.stringify(named('kit')));
96
+ ok('B5 that row no longer offers Update', await waitFor(page, (w) => !document.querySelector(`[data-c3set-kit-update="${w}"]`), kitWs));
97
+
98
+ /* ── C: the app update asks first, then runs, waits for the restart and reloads ── */
99
+ await page.click('[data-c3set-app-update]');
100
+ ok('C1 it asks first, and says PMOS stops while the new version installs', await waitFor(page, () => { const c = document.querySelector('[data-c3set-app-confirm]'); return !!c && /stops/.test(c.textContent) && !!document.querySelector('[data-c3set-app-go]'); }));
101
+ ok('C2 nothing runs before the answer', named('app').length === 0);
102
+ await page.click('[data-c3set-app-cancel]');
103
+ ok('C3 Cancel puts the Update button back', await waitFor(page, () => !document.querySelector('[data-c3set-app-confirm]') && !!document.querySelector('[data-c3set-app-update]')) && named('app').length === 0);
104
+ await page.click('[data-c3set-app-update]');
105
+ await page.click('[data-c3set-app-go]');
106
+ for (let i = 0; i < 40 && !named('app').length; i++) await new Promise((r) => setTimeout(r, 100));
107
+ ok('C4 the update posts the checked version', named('app').length === 1 && named('app')[0][1].version === '0.2.0', JSON.stringify(named('app')));
108
+ ok('C5 the row says it is updating and that the page reloads', await waitFor(page, () => { const b = document.querySelector('[data-c3set-app-busy]'); return !!b && /reloads/.test(b.textContent); }, null, 3000) || reloaded);
109
+ for (let i = 0; i < 200 && !reloaded; i++) await new Promise((r) => setTimeout(r, 100));
110
+ ok('C6 the page reloads by itself when the new version answers', reloaded);
111
+
112
+ /* ── D: a refusal is shown on the row ── */
113
+ S.app = '0.1.0'; S.upgrade = { ok: false, error: 'This page signed in with a token that changes when the workspace restarts' };
114
+ calls.length = 0; reloaded = false;
115
+ await open();
116
+ await page.click('[data-c3set-check]');
117
+ await waitFor(page, () => !!document.querySelector('[data-c3set-app-update]'));
118
+ await page.click('[data-c3set-app-update]');
119
+ await page.click('[data-c3set-app-go]');
120
+ ok('D1 a refused update says why, on the app row', await waitFor(page, () => { const r = document.querySelector('[data-c3set-row="upd.app"]'); return !!r && /token that changes/.test(r.textContent); }));
121
+ ok('D2 a refused update does not reload the page', !reloaded);
122
+
123
+ /* ── E: a checkout updates with git ── */
124
+ S.checkout = true; S.upgrade = { ok: true }; calls.length = 0;
125
+ await open();
126
+ await page.click('[data-c3set-check]');
127
+ ok('E1 a checkout says git pull and offers no app Update', await waitFor(page, () => /git pull/.test(document.querySelector('[data-c3set-row="upd.app"]').textContent)) && !(await page.$('[data-c3set-app-update]')));
128
+
129
+ ok('F1 the page throws no error', errors.length === 0, errors.slice(0, 3).join(' | '));
130
+ finish();
131
+ })();
@@ -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.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Settings in console3: one home for each setting"
4
4
  description: "How to open Settings in console3, what each section holds, what the scope tags and the environment notes mean, and where the settings are kept."
5
- timestamp: 2026-10-11
5
+ timestamp: 2026-10-12
6
6
  ---
7
7
 
8
8
  # Settings in console3: one home for each setting
@@ -55,6 +55,14 @@ When a section has changed settings, **Reset section** at the top of the section
55
55
 
56
56
  Some rows show a feature that PMOS does not have yet, such as choosing calendars, mapping Jira projects, pairing a phone, the device list, and export or reset of every setting. The control on these rows is off, and the row says why.
57
57
 
58
+ ## Update PMOS and the kit
59
+
60
+ Open **Updates** and press **Check now**. PMOS asks the npm registry for the latest version only when you press it.
61
+
62
+ - **Update to** a version on the **PMOS app** row updates the app. The row asks first: press **Restart and update**, or **Cancel**. PMOS stops, installs the new version, and starts again. Open terminals keep running. The page reloads when the new version starts. If the update is refused, or PMOS starts again on the old version, or PMOS does not come back, the row says so and names the update log.
63
+ - When PMOS runs from a PMOS checkout, the row says to update it with `git pull`. There is no Update button.
64
+ - **Update** on a workspace row updates the kit in that repository. It replaces the kit files in its `pmos/` folder (AGENTS.md, skills, scripts, templates, `okf/core`, the mod and KIT-VERSION). It does not change `state/`, `planning/` or `okf/product/`, and it does not commit. Review the change in the repository as a normal pull request.
65
+
58
66
  ## Where the settings are kept
59
67
 
60
68
  The settings that had a home before this page keep it. For example, the recording engine is kept by the notes engine, Validate consent by the validate route, and the theme and the new-session defaults in this browser.
@@ -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.144.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.144.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.144.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; } } };
@@ -15,7 +15,7 @@
15
15
  * TypeSafe key GET /api/typesafe (present or not; the key is never read)
16
16
  * about you GET and POST /api/proactive/settings {name, about:{role, work, duties}} (the Welcome page writes the same)
17
17
  * running sessions GET /api/sessions calendar import POST /api/sources/refresh {source:'calendar'}
18
- * versions GET /api/kit/status · POST /api/kit/check, /api/kit/update
18
+ * versions GET /api/kit/status · POST /api/kit/check, /api/kit/update, /api/kit/upgrade-app (then GET /api/sessions until the new version)
19
19
  * Every other setting is in the settings store: GET /api/settings and POST /api/settings (lib/settings-store.js).
20
20
  * A board's own workflow is edited on the board; here are only the templates new boards start from.
21
21
  * Nothing here asks for or shows a key or a password. */
@@ -161,7 +161,7 @@ export function mountSettings(host, ctx = {}) {
161
161
  ensureCss();
162
162
  const M = {
163
163
  sec: SECTIONS.some((s) => s[0] === ctx.section) ? ctx.section : 'about', q: '', ws: ctx.ws || '', data: null, err: {},
164
- engine: null, people: null, consent: null, online: null, profiles: null, typesafe: null, kits: {}, about: null, running: null,
164
+ engine: null, people: null, consent: null, online: null, profiles: null, typesafe: null, kits: {}, kitBusy: null, app: { phase: null, error: '' }, about: null, running: null,
165
165
  tplEdit: null, wsEdit: null, wsDrop: null, addPath: '',
166
166
  };
167
167
  const root = el('div', { class: 'brief c3set', 'data-c3set': '' });
@@ -607,6 +607,28 @@ export function mountSettings(host, ctx = {}) {
607
607
  lockedRow('look.icons', 'Card icons', 'Chosen from the built-in icon set by the card kind (D7).', 'Chosen by code rules', 'computer')], 'eye')];
608
608
  }
609
609
 
610
+ /* ── the app update (workspace-kit-update): the server stops, reinstalls and starts again, so the page waits for it ── */
611
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
612
+ async function updateApp(ws, from, to) {
613
+ M.app = { phase: 'busy', error: '', to }; draw();
614
+ const r = await pmPost('/api/kit/upgrade-app', { ws, version: to });
615
+ if (!r || r.ok === false) { M.app = { phase: null, error: (r && r.error) || 'The update did not start.' }; draw(); return; }
616
+ await waitForRestart(r.from || from, r.log || '');
617
+ }
618
+ /** Reload when the server is back on a new version; otherwise say what happened and name the log. */
619
+ async function waitForRestart(from, log) {
620
+ let sawDown = false;
621
+ const until = Date.now() + 5 * 60000;
622
+ while (Date.now() < until) {
623
+ await pause(2000);
624
+ const j = await pmGet('/api/sessions');
625
+ if (!j || j.ok === false || !j.version) { sawDown = true; continue; }
626
+ if (j.version !== from) { location.reload(); return; }
627
+ if (sawDown) { M.app = { phase: null, error: 'PMOS started again on ' + j.version + ', so the update did not finish. The update log is ' + log + '.' }; draw(); return; }
628
+ }
629
+ M.app = { phase: null, error: 'PMOS did not come back on this address. Type pmos in a terminal to open it. The update log is ' + log + '.' }; draw();
630
+ }
631
+
610
632
  function updatesSec() {
611
633
  const rows = (ctx.workspaces || []).map((w) => {
612
634
  const k = M.kits[w.id];
@@ -616,20 +638,31 @@ export function mountSettings(host, ctx = {}) {
616
638
  el('div', { class: 'chips' }, chip(!k ? 'Reading' : k.error ? k.error : kit && kit.present ? 'kit ' + (kit.version || 'vendored by hand') : 'no kit in this workspace', 'mono'),
617
639
  k && k.latest ? (behind ? tag(k.latest + ' available', 'warn') : tag('Up to date', 'ok')) : null),
618
640
  M.err['kit:' + w.id] ? fold('alert', M.err['kit:' + w.id], 'w') : null),
619
- el('div', { class: 'acts' }, behind ? btn('Update', { variant: 'primary', onclick: async () => { const r = await pmPost('/api/kit/update', { ws: w.id }); if (r && r.ok) { M.kits[w.id] = { ...M.kits[w.id], ...r, kitBehind: false }; toast('Updated the kit in ' + (w.label || w.name || w.id)); } else M.err['kit:' + w.id] = (r && r.error) || 'Not updated.'; draw(); } }) : null));
641
+ el('div', { class: 'acts' }, behind ? btn('Update', { variant: 'primary', disabled: !!M.kitBusy, attrs: { 'data-c3set-kit-update': w.id }, onclick: async () => { M.kitBusy = w.id; delete M.err['kit:' + w.id]; draw(); const r = await pmPost('/api/kit/update', { ws: w.id, version: k.latest }); M.kitBusy = null; if (r && r.ok) { M.kits[w.id] = { ...M.kits[w.id], ...r, kitBehind: false }; toast('Updated the kit in ' + (w.label || w.name || w.id)); } else M.err['kit:' + w.id] = (r && r.error) || 'Not updated.'; draw(); } }) : null));
620
642
  });
621
643
  const first = (ctx.workspaces || [])[0];
622
644
  const st = first && M.kits[first.id];
623
645
  const app = st && st.app; const appBehind = st && st.appBehind;
624
- const appRow = frame({ key: 'upd.app', label: 'PMOS app ' + ((app && app.version) || ''),
625
- help: appBehind ? 'The app update restarts PMOS. Run it from the main app\'s Settings, Updates, which waits for the restart.' : 'Press Check now to ask the npm registry for the latest version.',
626
- control: [st && st.latest ? tag(appBehind ? st.latest + ' available' : 'Up to date', appBehind ? 'acc' : 'ok') : null,
627
- btn('Check now', { ic: 'undo', disabled: !first, attrs: { 'data-c3set-check': '' }, onclick: async () => {
646
+ // A checkout updates with git, unless PMOS_UPDATE_SOURCE names a local package (server.js /api/kit/upgrade-app).
647
+ const gitOnly = !!(app && app.fromCheckout && !(st.source && st.source.kind === 'local'));
648
+ const U = M.app; const busy = U.phase === 'busy';
649
+ const appHelp = !appBehind ? 'Press Check now to ask the npm registry for the latest version.'
650
+ : gitOnly ? 'This workspace runs from a PMOS checkout. Update it with git pull.'
651
+ : 'The update stops PMOS, installs ' + st.latest + ' in place of ' + ((app && app.version) || '?') + ', and starts it again. Sessions keep running in tmux. This page reloads when the new version starts.';
652
+ const asking = appBehind && !gitOnly && U.phase === 'confirm';
653
+ const appRow = frame({ key: 'upd.app', label: 'PMOS app ' + ((app && app.version) || ''), help: appHelp, err: U.error || null,
654
+ note: asking ? el('span', { 'data-c3set-app-confirm': '' }, fold('alert', 'PMOS stops for about a minute while ' + st.latest + ' installs. Open terminals keep running.', 'w'))
655
+ : busy ? el('span', { 'data-c3set-app-busy': '', role: 'status', 'aria-live': 'polite' }, fold('cloud', 'Updating PMOS to ' + U.to + '. This page reloads when the new version starts.')) : null,
656
+ control: asking ? [btn('Cancel', { variant: 'ghost', attrs: { 'data-c3set-app-cancel': '' }, onclick: () => { M.app = { phase: null, error: '' }; draw(); } }),
657
+ btn('Restart and update', { variant: 'primary', attrs: { 'data-c3set-app-go': '' }, onclick: () => updateApp(first.id, (app && app.version) || '', st.latest) })]
658
+ : [st && st.latest ? tag(appBehind ? st.latest + ' available' : 'Up to date', appBehind ? 'acc' : 'ok') : null,
659
+ btn('Check now', { ic: 'undo', disabled: !first || busy, attrs: { 'data-c3set-check': '' }, onclick: async () => {
660
+ M.app = { phase: null, error: '' };
628
661
  for (const w of ctx.workspaces || []) { const r = await pmPost('/api/kit/check', { ws: w.id }); M.kits[w.id] = r && r.ok ? r : { ...(M.kits[w.id] || {}), error: (r && r.error) || 'The check failed.' }; }
629
662
  const s2 = first && M.kits[first.id]; toast(s2 && s2.appBehind ? 'PMOS ' + s2.latest + ' is available' : 'Checked. PMOS is up to date'); draw();
630
663
  } }),
631
- appBehind ? btn('Open the main app', { variant: 'primary', onclick: () => { location.href = '/'; } }) : null] });
632
- return [sect('PMOS app', [list([appRow])], { ic: 'cloud' }), sect('Kit in each workspace', [list(rows)], { ic: 'repo', count: rows.length }), groupOf('updates', 'When', 'cloud')];
664
+ appBehind && !gitOnly && !busy ? btn('Update to ' + st.latest, { variant: 'primary', attrs: { 'data-c3set-app-update': '' }, onclick: () => { M.app = { phase: 'confirm', error: '' }; draw(); } }) : null] });
665
+ return [sect('PMOS app', [list([appRow])], { ic: 'cloud', attrs: { 'data-c3set-app': '' } }), sect('Kit in each workspace', [list(rows)], { ic: 'repo', count: rows.length }), groupOf('updates', 'When', 'cloud')];
633
666
  }
634
667
 
635
668
  const BUILD = { about: aboutSec, workspaces: workspacesSec, boards: boardsSec, connectors: connectorsSec, recording: recordingSec, time: timeSec, sharing: sharingSec, access: accessSec, sessions: sessionsSec, privacy: privacySec, appearance: appearanceSec, updates: updatesSec };
@@ -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",
@@ -133,7 +135,8 @@
133
135
  "pm-card-layout-test.js",
134
136
  "pm-portfolio-inline-test.js",
135
137
  "pm-brief-page-test.js",
136
- "c3-settings-test.js"
138
+ "c3-settings-test.js",
139
+ "c3-updates-test.js"
137
140
  ],
138
141
  "ui": [
139
142
  "ui-test.js"
@@ -160,7 +163,9 @@
160
163
  "work-evidence.test.js": 1,
161
164
  "brief-page.test.js": 1,
162
165
  "work-assigned.test.js": 1,
166
+ "briefing-real-data.test.js": 1,
163
167
  "calendar-events.test.js": 1,
168
+ "tracker-read.test.js": 1,
164
169
  "pm-brief-page-test.js": 30,
165
170
  "item-steps.test.js": 1,
166
171
  "status-facts-panel.test.js": 1,
@@ -263,7 +268,8 @@
263
268
  "pm-card-layout-test.js": 20,
264
269
  "pm-portfolio-inline-test.js": 15,
265
270
  "settings-store.test.js": 1,
266
- "c3-settings-test.js": 25
271
+ "c3-settings-test.js": 25,
272
+ "c3-updates-test.js": 20
267
273
  },
268
274
  "_comment_seconds": "Measured wall time of each suite run alone (4-CPU sandbox, 2026-10-05). Only the ORDER uses it: the slowest suites start first, and --shard deals suites to the least-loaded shard so the shards finish together. A wrong or missing number never skips a suite; a missing one counts as 10."
269
275
  }