create-pmos 0.138.0 → 0.140.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.138.0",
3
+ "version": "0.140.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@fe0b38d
1
+ kernel-source: pmos@86ccd34
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:10:16Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T01:22:45Z"}
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:10:16Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T01:22:45Z"}
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:10:16Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T01:22:45Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -92,12 +92,15 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
92
92
  const h0 = await p.evaluate((s) => document.querySelector(s).offsetHeight, card);
93
93
  await p.evaluate((s) => document.querySelector(s).click(), card);
94
94
  ok('R4 the card flips', await until(() => p.evaluate((s) => document.querySelector(s).classList.contains('back'), card), 4000));
95
- await sleep(500);
96
- const fit = await p.evaluate((s) => {
95
+ /* The back grows over a transition; a slow runner is still mid-grow after a fixed wait, so poll until it settles. */
96
+ const measure = () => p.evaluate((s) => {
97
97
  const c = document.querySelector(s); const cb = c.getBoundingClientRect(); const fb = c.querySelector('.fb');
98
98
  const inside = (e) => !e || e.getBoundingClientRect().bottom <= cb.bottom + 1;
99
99
  return { grew: c.offsetHeight, noClip: fb.scrollHeight <= fb.clientHeight + 1, opts: inside(c.querySelector('.opts')), conf: inside(c.querySelector('.c3slide, .c3hold')), foot: inside(c.querySelector('.cft')), hasOpts: !!c.querySelector('.opt') };
100
100
  }, card);
101
+ await sleep(500);
102
+ let fit = await measure();
103
+ await until(async () => { fit = await measure(); return fit.grew >= h0 && fit.noClip && fit.opts && fit.conf && fit.foot && fit.hasOpts; }, 4000);
101
104
  ok('R4 the flipped back grows to fit its content', fit.grew >= h0 && fit.noClip && fit.opts && fit.conf && fit.foot && fit.hasOpts, JSON.stringify({ h0, fit }));
102
105
  /* decide: a hold button (fine pointer) or a click; the slide where the pointer is coarse */
103
106
  const act = await p.evaluate((s) => { const c = document.querySelector(s); const hb = c.querySelector('.c3hold .hb'); if (hb) { const r = hb.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, mode: hb.getAttribute('data-hold') }; } return null; }, card);
@@ -43,6 +43,6 @@ Each page shows a short line under its title that says when each source was last
43
43
  - A source that was never read says **not read yet**, in amber.
44
44
  - A source whose last read failed is red and says when it was last read.
45
45
  - A watched repository is not named, because the workspace checks it every few seconds.
46
- - When a source is old, failed or never read, a **Refresh** button for the first of them follows the line.
46
+ - The refresh icon at the end of the line reads every source again: the initiatives on this machine, and an agent read for Calendar, Jira status, My work and tracker items. A source that is switched off or is being read already is left alone. A short note says how many reads started, or which could not start.
47
47
 
48
48
  Settings › Data freshness shows every source in one table: when it was read, its state, its schedule, and **Refresh**.
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /* lib/calendar-events.js — every calendar event the workspace holds, in one list (incident calendar-one-event-source).
3
+ *
4
+ * readAll(runtimeDir, { now }) → [event] the shape of routes-calendar-import.js readEvents: { id, at, end, title, attendees, ... }
5
+ *
6
+ * The workspace keeps meetings in three places:
7
+ * 1. <runtime>/calendar-import/events.jsonl the import ledger (Settings › Import now, `pmos calendar import`);
8
+ * 2. <runtime>/calendar-memory.json the calendar the morning brief submits (lib/calendar-memory.js), 90 days back and 31 ahead;
9
+ * 3. <runtime>/briefs/<today>.json today's brief, whose calendar is today's meetings with their plan.
10
+ * The Agenda and the Record menu read 2 and 3 (GET /api/proactive/agenda). Calendar and the Briefing's Today read 1 only, so a PM whose
11
+ * meetings came in with the morning brief saw them under Record and nowhere else. This reader joins the three. An imported event wins
12
+ * over a remembered one with the same start minute and title, because it carries the PM's notes; today's brief replaces the remembered
13
+ * events of today, as agendaMonth does.
14
+ *
15
+ * `at` and `end` of a remembered event are local date-times with the offset (2026-10-12T10:45:00+08:00), so a reader that takes the
16
+ * first ten characters as the day (calendar-model.js bucketEvents) gets the local day. READ ONLY; a file that cannot be read gives nothing. */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const calMem = require('./calendar-memory.js');
20
+
21
+ const pad = (n) => String(n).padStart(2, '0');
22
+ const localDate = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
23
+ /** A local date-time with its offset: the same instant, with the local day in its first ten characters. */
24
+ function localIso(ms) {
25
+ const d = new Date(ms);
26
+ const off = -d.getTimezoneOffset();
27
+ const sign = off >= 0 ? '+' : '-';
28
+ const a = Math.abs(off);
29
+ return `${localDate(ms)}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
30
+ }
31
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
32
+ const minuteKey = (ms, title) => Math.floor(ms / 60000) + '|' + calMem.normTitle(title);
33
+
34
+ /** A remembered or briefed event as an imported one. `src` says where it came from. */
35
+ function asEvent(e, src) {
36
+ const s = Date.parse(e.start);
37
+ if (!e || !e.title || Number.isNaN(s)) return null;
38
+ const f = e.end && !Number.isNaN(Date.parse(e.end)) ? Date.parse(e.end) : s + 30 * 60000;
39
+ const people = e.with ? String(e.with).split(/\s*,\s*/).filter(Boolean) : [];
40
+ return { id: String(e.id || calMem.seriesKey(e) + '@' + s), at: localIso(s), end: localIso(f), title: String(e.title), attendees: people.slice(0, 12),
41
+ initiative: e.initiative || null, agent_prep_notes: typeof e.prep === 'string' ? e.prep : '', pm_notes: [], src, label: 'reported', trust: 'untrusted' };
42
+ }
43
+
44
+ function readAll(runtimeDir, { now = Date.now() } = {}) {
45
+ if (!runtimeDir) return [];
46
+ let imported = [];
47
+ try { imported = require('./routes-calendar-import.js').readEvents(runtimeDir).map((e) => ({ ...e, src: e.src || 'import' })); } catch { imported = []; }
48
+ const today = localDate(now);
49
+ const mem = readJson(path.join(runtimeDir, 'calendar-memory.json'));
50
+ const brief = readJson(path.join(runtimeDir, 'briefs', today + '.json'));
51
+ const briefed = brief && brief.date === today && Array.isArray(brief.calendar) ? brief.calendar.map((e) => asEvent(e, 'brief')).filter(Boolean) : null;
52
+ let remembered = (mem && Array.isArray(mem.events) ? mem.events : []).map((e) => asEvent(e, 'memory')).filter(Boolean);
53
+ if (briefed) remembered = remembered.filter((e) => e.at.slice(0, 10) !== today).concat(briefed);
54
+ const seen = new Set(imported.filter((e) => e && e.at && !Number.isNaN(Date.parse(e.at))).map((e) => minuteKey(Date.parse(e.at), e.title)));
55
+ const out = imported.slice();
56
+ for (const e of remembered) {
57
+ const k = minuteKey(Date.parse(e.at), e.title);
58
+ if (seen.has(k)) continue;
59
+ seen.add(k); out.push(e);
60
+ }
61
+ return out.sort((a, b) => (Date.parse(a.at) || 0) - (Date.parse(b.at) || 0));
62
+ }
63
+
64
+ module.exports = { readAll, asEvent, localIso };
@@ -168,7 +168,7 @@ function connectors(homeDir) {
168
168
  }
169
169
 
170
170
  let read;
171
- try { read = require('./profiles').readProfiles(home); }
171
+ try { read = require('./profiles').readProfiles(home, { detect: true }); }
172
172
  catch { return empty('the profile reader is unavailable — PMOS cannot say what is resolvable, so it says nothing.'); }
173
173
 
174
174
  // ABSENT and BROKEN are different facts here for the same reason they are in the profile reader:
@@ -186,7 +186,9 @@ function connectors(homeDir) {
186
186
  const names = MCP_FOR_KIND[kind] || [];
187
187
  for (const prof of read.profiles) {
188
188
  const mcp = Array.isArray(prof.mcp) ? prof.mcp : [];
189
- const hit = mcp.find((s) => names.includes(String(s).toLowerCase()));
189
+ // a claude.ai connector is listed as "claude.ai Google Drive": compare it as "google-drive"
190
+ const key = (s) => String(s).toLowerCase().replace(/^claude\.ai\s+/, '').trim().replace(/\s+/g, '-');
191
+ const hit = mcp.find((s) => names.includes(key(s)));
190
192
  if (!hit) continue;
191
193
  configured.push({ kind, profile: prof.id, server: hit, declared: true });
192
194
  break; // one profile per kind is enough to offer the lookup
@@ -24,6 +24,12 @@
24
24
  * variable an alias sets (a key, a proxy) is left alone, and the surface says only one variable is applied.
25
25
  *
26
26
  * A descriptor under `~/.claude/profiles` always wins over a detected profile of the same id (see profiles.js).
27
+ *
28
+ * A detected profile's connectors come from the config directory's own `.claude.json`: the names of the MCP servers
29
+ * added with `claude mcp add --scope user` (`mcpServers`) and of the claude.ai connectors that account has used
30
+ * (`claudeAiMcpEverConnected`). Only the NAMES are kept. A server's command, URL and environment are never read into
31
+ * the result, so nothing secret leaves this module. Before this, a detected profile always carried `mcp: []`, and
32
+ * console3 Settings said "This profile lists no connectors" for a profile with many.
27
33
  */
28
34
  const fs = require('fs');
29
35
  const path = require('path');
@@ -37,6 +43,7 @@ const FISH_FUNCTIONS = '.config/fish/functions';
37
43
  const BIN_DIRS = ['.local/bin', 'bin', '.bin', '.config/claude/bin'];
38
44
  const MAX_TEXT = 256 * 1024; // a start-up file larger than this is not read
39
45
  const MAX_SCRIPT = 16 * 1024; // a launcher script larger than this is a binary or something else
46
+ const MAX_CONFIG = 32 * 1024 * 1024; // Claude's .claude.json keeps project history and can be large
40
47
 
41
48
  const safeRead = (file, max) => {
42
49
  try {
@@ -142,7 +149,24 @@ function fromConfigDirs(home) {
142
149
  }
143
150
 
144
151
  /**
145
- * detectProfiles(homeDir) → [{ id, label, account:null, mcp:[], env:{CLAUDE_CONFIG_DIR}, path, detected:{source, from, line} }]
152
+ * connectorsIn(dir) → the connector names Claude's config directory `dir` lists, or [] when it lists none or the file
153
+ * cannot be read. User-scope MCP servers first, then claude.ai connectors, each sorted, no repeats. Names only.
154
+ */
155
+ function connectorsIn(dir) {
156
+ if (typeof dir !== 'string' || !dir) return [];
157
+ const text = safeRead(path.join(dir, '.claude.json'), MAX_CONFIG);
158
+ if (!text) return [];
159
+ let cfg;
160
+ try { cfg = JSON.parse(text); } catch { return []; }
161
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return [];
162
+ const servers = cfg.mcpServers && typeof cfg.mcpServers === 'object' && !Array.isArray(cfg.mcpServers) ? Object.keys(cfg.mcpServers) : [];
163
+ const cloud = Array.isArray(cfg.claudeAiMcpEverConnected) ? cfg.claudeAiMcpEverConnected.filter((x) => typeof x === 'string') : [];
164
+ const clean = (xs) => xs.map((x) => x.trim()).filter((x) => x && x.length <= 120).sort((a, b) => a.localeCompare(b));
165
+ return [...new Set([...clean(servers), ...clean(cloud)])];
166
+ }
167
+
168
+ /**
169
+ * detectProfiles(homeDir) → [{ id, label, account:null, mcp:[names from connectorsIn], env:{CLAUDE_CONFIG_DIR}, path, detected:{source, from, line} }]
146
170
  * Sorted by id. When one id turns up in several places the shell definition wins over a launcher, a launcher over a bare
147
171
  * directory: the command is what the PM types, so its directory is the one that is meant. A profile whose directory does
148
172
  * not exist is dropped (the command would start Claude on an empty home, which is a new login, not a profile).
@@ -187,7 +211,7 @@ function detectProfiles(homeDir) {
187
211
  if (!cur || rank[f.source] < rank[cur.source]) byId.set(f.id, f);
188
212
  }
189
213
  return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((f) => ({
190
- id: f.id, label: f.id, account: null, mcp: [], env: { CLAUDE_CONFIG_DIR: f.dir },
214
+ id: f.id, label: f.id, account: null, mcp: connectorsIn(f.dir), env: { CLAUDE_CONFIG_DIR: f.dir },
191
215
  path: f.from, detected: { source: f.source, from: f.from, line: f.line },
192
216
  }));
193
217
  }
@@ -217,4 +241,4 @@ function searched(homeDir) {
217
241
  return out;
218
242
  }
219
243
 
220
- module.exports = { detectProfiles, searched, resolveDir, configDirOf, fromShellText, ID_RE };
244
+ module.exports = { detectProfiles, connectorsIn, searched, resolveDir, configDirOf, fromShellText, ID_RE };
@@ -4,7 +4,7 @@
4
4
  * → { ok, date, me?, today: [meeting], needs: [row], decisions_error? } me: the PM's first name from git config user.name, or null
5
5
  *
6
6
  * READ ONLY, and built only from what PMOS already holds. Nothing is invented.
7
- * today one row per meeting of the local day, in time order, from the cached calendar events (routes-calendar-import.js readEvents,
7
+ * today one row per meeting of the local day, in time order, from every calendar event the workspace holds (calendar-events.js readAll,
8
8
  * the same source as /api/calendar-views): { id, title, start, end, minutes, people, initiative, prepared, before, state }.
9
9
  * prepared: [{ kind: 'prep'|'pm-note'|'note'|'last-note', text, id? }] what PMOS holds for the meeting (the agent's prep note,
10
10
  * the PM's notes on the event, the note linked to it, the last note of an earlier occurrence). before: [{ id, title, due,
@@ -173,7 +173,7 @@ module.exports = {
173
173
  if (Number.isNaN(now.getTime())) now = new Date();
174
174
  try {
175
175
  const dir = ctx.runtimeDir;
176
- const events = dir ? require('./routes-calendar-import.js').readEvents(dir) : [];
176
+ const events = dir ? require('./calendar-events.js').readAll(dir, { now: now.getTime() }) : [];
177
177
  const items = dir ? pmItems.fold(pmItems.readLines(path.join(dir, 'pm-items', 'items.jsonl'))) : [];
178
178
  // The decisions come from the signal engine, whose repository read is slow. The page already holds them (GET /api/briefing feeds its deck), so the
179
179
  // read is made only when the caller asks (?decisions=1); the items and the agenda never wait for it.
@@ -10,8 +10,8 @@
10
10
  * the planned end is the promised date, the planned start is that date minus planned_days; no forecast is invented, so an
11
11
  * initiative without one shows as 'no plan' (or 'overdue' past its promise).
12
12
  *
13
- * EVENTS: lib/calendar-memory.js has validate, merge and month functions only; no reader of the stored calendar is exported.
14
- * Until one is, events = [] and events_source is 'none'. KRS: no KR metric series is stored in the repo, so the strategy lens
13
+ * EVENTS: lib/calendar-events.js readAll, the imported events joined with the calendar the morning brief remembers and today's
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
15
  * gets none and says so. */
16
16
  const path = require('path');
17
17
  const { repoScript } = require('./repo-script.js');
@@ -166,7 +166,7 @@ module.exports = {
166
166
  catch (e) { ctx.json(res, { ok: false, error: 'the calendar could not read the repository: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
167
167
  const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
168
168
  readFacts: () => facts,
169
- readEvents: () => require('./routes-calendar-import.js').readEvents(ctx.runtimeDir),
169
+ readEvents: () => require('./calendar-events.js').readAll(ctx.runtimeDir),
170
170
  ...readers(root, ctx.runtimeDir),
171
171
  });
172
172
  ctx.json(res, out.body, out.status);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.138.0",
3
+ "version": "0.140.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.138.0",
9
+ "version": "0.140.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.138.0",
3
+ "version": "0.140.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",
@@ -3507,6 +3507,8 @@
3507
3507
  gap: 8px;
3508
3508
  font-size: 12px;
3509
3509
  color: var(--muted-foreground);
3510
+ /* the header row can be wider than a phone (its tools row); the line wraps inside the screen, not past it */
3511
+ max-width: calc(100vw - 24px);
3510
3512
  }
3511
3513
  .fr-fresh__state--fresh { color: var(--foreground); }
3512
3514
  .fr-fresh__state--stale { color: var(--warning); }
@@ -3515,6 +3517,7 @@
3515
3517
  .fr-fresh__state--building,
3516
3518
  .fr-fresh__state--off { color: var(--muted-foreground); }
3517
3519
  .fr-fresh__sep { color: var(--muted-foreground); }
3520
+ .fr-fresh__note { color: var(--muted-foreground); }
3518
3521
  .fr-fresh-table__grid {
3519
3522
  width: 100%;
3520
3523
  border-collapse: collapse;
@@ -2,7 +2,7 @@
2
2
  *
3
3
  * freshnessLine(ws) → element the console header's short line: when each source was last read ("Calendar read
4
4
  * 07:15 · Jira status read 08:02"), the old, failed and never-read ones flagged, and
5
- * a Refresh button for the first of those. It never says "up to date": the sources
5
+ * a refresh icon, always there, that reads every source again. It never says "up to date": the sources
6
6
  * are read on a schedule, not live (pm/freshness-words.js). Updates itself every 15 s.
7
7
  * freshnessTable(ws) → element Settings › Data freshness: every source, when it was read, its state, its schedule,
8
8
  * Refresh, and the repository's Watch for changes switch.
@@ -14,8 +14,8 @@
14
14
  */
15
15
  import { el, setChildren } from './dom.js';
16
16
  import { pmGet, pmPost } from './http.js';
17
- import { button, SEL } from './primitives.js';
18
- import { lineItems, behindOf } from './freshness-words.js';
17
+ import { button, iconButton, SEL } from './primitives.js';
18
+ import { lineItems, refreshTargets } from './freshness-words.js';
19
19
 
20
20
  const POLL_MS = 15000;
21
21
  const live = new Map(); // kind|ws -> { node, timer, version }
@@ -67,25 +67,46 @@ function poll(key, ws, draw) {
67
67
  return cur.node;
68
68
  }
69
69
 
70
+ /* The line's refresh icon: one press reads every source again (the PM's choice, 2026-10-12). The initiatives rebuild on
71
+ * this machine; Calendar, Jira, My work and tracker items each start their agent read. A source already being read, or
72
+ * switched off, is left alone. The icon and its result note are kept on the line's element, so a redraw does not lose them. */
73
+ function refreshAll(n, d) {
74
+ n._frData = d;
75
+ if (n._frBtn) return n._frBtn;
76
+ const btn = iconButton({ icon: 'refresh-cw', ariaLabel: 'Read every source again', size: 'sm', attrs: { 'data-c3pg': 'freshness-refresh' },
77
+ onClick: async () => {
78
+ const cur = n._frData;
79
+ const ids = refreshTargets(cur.sources);
80
+ btn.disabled = true;
81
+ note(n, 'Starting ' + ids.length + ' reads…');
82
+ const res = await Promise.all(ids.map((id) => pmPost('/api/sources/refresh', { ws: cur.ws, source: id }).catch(() => null)));
83
+ const bad = ids.filter((id, i) => !(res[i] && res[i].ok)).map((id) => (cur.sources.find((s) => s.id === id) || {}).label || id);
84
+ note(n, bad.length ? 'Could not start: ' + bad.join(', ') : 'Reading ' + ids.length + ' sources again');
85
+ const c = live.get('line|' + cur.ws); if (c) c.tick();
86
+ setTimeout(() => { btn.disabled = false; note(n, ''); }, 30000);
87
+ } });
88
+ n._frBtn = btn;
89
+ return btn;
90
+ }
91
+ function note(n, text) {
92
+ if (!text) { if (n._frMsg) n._frMsg.remove(); n._frMsg = null; return; }
93
+ if (!n._frMsg) { n._frMsg = el('span', { class: 'fr-fresh__note', role: 'status' }); n.append(n._frMsg); }
94
+ n._frMsg.textContent = text;
95
+ }
96
+
70
97
  /** The header line: when each source was last read, old, failed and never-read ones flagged. It never claims "up to date". */
71
98
  export function freshnessLine(ws) {
72
99
  return poll('line|' + ws, ws, (node, d) => {
73
100
  const n = node || el('div', { class: 'fr-fresh', 'data-c3pg': 'freshness' });
74
101
  if (!d) { if (node) setChildren(n, [el('span', { class: tone('never') }, 'Could not read when the sources were read')]); else n.textContent = ''; return n; }
75
102
  const items = lineItems(d.sources);
76
- const behind = behindOf(items);
77
103
  const kids = [];
78
104
  items.forEach((it, i) => {
79
105
  if (i) kids.push(el('span', { class: 'fr-fresh__sep', 'aria-hidden': 'true' }, '·'));
80
106
  kids.push(el('span', { class: tone(it.state), title: it.title, 'data-fr-line': it.id }, it.text));
81
107
  });
82
- if (behind.length) {
83
- const first = behind[0];
84
- const src = d.sources.find((s) => s.id === first.id);
85
- const btn = button({ label: 'Refresh ' + (src ? src.label : first.id), size: 'sm', attrs: { 'data-c3pg': 'freshness-refresh' },
86
- onClick: () => refresh(d.ws, first.id, btn) });
87
- kids.push(btn);
88
- }
108
+ kids.push(refreshAll(n, d));
109
+ if (n._frMsg) kids.push(n._frMsg);
89
110
  setChildren(n, kids);
90
111
  return n;
91
112
  });
@@ -3,6 +3,9 @@
3
3
  * lineItems(sources, now?) → [{ id, state, text, title }] one honest phrase per source: when it was last read, or
4
4
  * that it was never read, failed or is reading now
5
5
  * behindOf(items) → [item] the sources to flag: old, failed or never read
6
+ * refreshTargets(sources) → [id] what the refresh icon reads again: every source but the
7
+ * repository (its rebuild is the initiatives'), one switched
8
+ * off, and one being read now
6
9
  * whenRead(iso, now?) → '07:15' | 'yesterday 18:02' | '3 d ago'
7
10
  *
8
11
  * The line never says "up to date". Calendar, Jira, My work and tracker items are read by an agent on a schedule, not
@@ -44,3 +47,5 @@ export function lineItems(sources, now = Date.now()) {
44
47
  }
45
48
 
46
49
  export const behindOf = (items) => items.filter((i) => i.state === 'stale' || i.state === 'failed' || i.state === 'never');
50
+
51
+ export const refreshTargets = (sources) => (sources || []).filter((s) => s && s.id !== 'repository' && s.state !== 'off' && s.state !== 'building').map((s) => s.id);
@@ -29,6 +29,7 @@
29
29
  "work-focus.test.js",
30
30
  "work-evidence.test.js",
31
31
  "brief-page.test.js",
32
+ "calendar-events.test.js",
32
33
  "item-steps.test.js",
33
34
  "status-facts-panel.test.js",
34
35
  "jev-claims.test.js",
@@ -157,6 +158,7 @@
157
158
  "work-focus.test.js": 1,
158
159
  "work-evidence.test.js": 1,
159
160
  "brief-page.test.js": 1,
161
+ "calendar-events.test.js": 1,
160
162
  "pm-brief-page-test.js": 30,
161
163
  "item-steps.test.js": 1,
162
164
  "status-facts-panel.test.js": 1,