create-pmos 0.523.0 → 0.525.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.
Files changed (74) hide show
  1. package/package.json +63 -31
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/changelog.json +62 -0
  7. package/workspace/guide/agent-capabilities.md +6 -3
  8. package/workspace/guide/data-freshness.md +13 -0
  9. package/workspace/guide/home.md +1 -1
  10. package/workspace/guide/horizon-scan.md +2 -2
  11. package/workspace/guide/idle-lab.md +46 -0
  12. package/workspace/guide/index.md +2 -0
  13. package/workspace/guide/moments.md +43 -0
  14. package/workspace/guide/published-board.md +2 -0
  15. package/workspace/guide/welcome.md +37 -28
  16. package/workspace/lib/bg-queue.js +226 -0
  17. package/workspace/lib/board-publish-parts.js +14 -2
  18. package/workspace/lib/driver-run.js +8 -1
  19. package/workspace/lib/first-reveal.js +311 -0
  20. package/workspace/lib/idle-lab.js +275 -0
  21. package/workspace/lib/moments.js +241 -0
  22. package/workspace/lib/morning-goals.js +191 -0
  23. package/workspace/lib/onboard-fundamentals.js +205 -0
  24. package/workspace/lib/onboarding-progress.js +13 -1
  25. package/workspace/lib/orchestrator-spawn.js +9 -2
  26. package/workspace/lib/pmos-verbs.js +3 -0
  27. package/workspace/lib/project-onboard.js +86 -6
  28. package/workspace/lib/proposals.js +9 -5
  29. package/workspace/lib/read-fingerprint.js +127 -0
  30. package/workspace/lib/refresh-cli.js +75 -0
  31. package/workspace/lib/refresh-probe.js +110 -0
  32. package/workspace/lib/refresh-run.js +133 -14
  33. package/workspace/lib/route-modules.js +4 -0
  34. package/workspace/lib/routes-lab.js +137 -0
  35. package/workspace/lib/routes-loose.js +1 -0
  36. package/workspace/lib/routes-moments.js +42 -0
  37. package/workspace/lib/routes-morning-goals.js +109 -0
  38. package/workspace/lib/routes-onboard.js +28 -8
  39. package/workspace/lib/routes-proposals.js +12 -2
  40. package/workspace/lib/routes-reveal.js +73 -0
  41. package/workspace/lib/scan-run.js +4 -3
  42. package/workspace/lib/scan.js +5 -3
  43. package/workspace/package-lock.json +2 -2
  44. package/workspace/package.json +1 -1
  45. package/workspace/project-onboard-flow-test.js +70 -57
  46. package/workspace/public/pm/cozy-ev.css +3 -0
  47. package/workspace/public/pm/cozy-lab-ins.js +87 -0
  48. package/workspace/public/pm/cozy-lab.css +86 -0
  49. package/workspace/public/pm/cozy-lab.js +138 -0
  50. package/workspace/public/pm/cozy-nav.js +22 -8
  51. package/workspace/public/pm/cozy-onboard.css +31 -0
  52. package/workspace/public/pm/cozy-onboard.js +217 -111
  53. package/workspace/public/pm/cozy-reveal.css +52 -0
  54. package/workspace/public/pm/cozy-reveal.js +130 -0
  55. package/workspace/public/pm/home-app.js +38 -3
  56. package/workspace/public/pm/home-impact.css +12 -0
  57. package/workspace/public/pm/home-moment.js +41 -0
  58. package/workspace/public/pm/ini-props.js +15 -6
  59. package/workspace/public/pm/premium-board.js +5 -2
  60. package/workspace/public/pm/premium-first.js +1 -1
  61. package/workspace/public/pm/premium-insights.js +6 -1
  62. package/workspace/public/pm/quiet-place.js +31 -0
  63. package/workspace/public/pm/readonly-boot.js +8 -0
  64. package/workspace/public/premium-intelligence.html +3 -0
  65. package/workspace/replaced-tests.json +14 -0
  66. package/workspace/server.js +66 -5
  67. package/workspace/skills/calendar-sync.skill +2 -1
  68. package/workspace/skills/jira-sync.skill +2 -1
  69. package/workspace/skills/my-work-read.skill +2 -1
  70. package/workspace/skills/sources-read.skill +9 -6
  71. package/workspace/test-files.json +2 -0
  72. package/workspace/test-lists/first-reveal.json +9 -0
  73. package/workspace/test-lists/idle-lab.json +9 -0
  74. package/workspace/test-lists/morning-goals.json +9 -0
@@ -21,6 +21,8 @@
21
21
  * description the record's own description (initiative-description-field; filed by lib/description-props.js): type new (none yet) or
22
22
  * enrich (the PRD says more). data { text, from? }; one item { id: 'description', title: the text cut }. Always section 'check'.
23
23
  * ✓ writes the record's description line (lib/initiative-writer.js setDescription)
24
+ * prototyped · improved · scanned what the idle lab made while the PM was away (lib/idle-lab.js result): a first round of 2–3 specimens,
25
+ * a new round of one, a scan finding to attach as evidence. No items; always section 'check'. ✓ keeps it as a document
24
26
  * grill what Claude found when it grilled the initiative by itself (lib/grill.js): type = the angle, no items, a next step in data.next;
25
27
  * always section 'check' (its own ✓, never Keep all). 3 show, best rank first; the group's `more` counts the rest
26
28
  *
@@ -30,7 +32,7 @@
30
32
  * end { ini, run, ok, stopped? } the run ended (stopped: the PM pressed ■)
31
33
  * propose { ini, board, run, id, group, type, section, title, why, sources: [{ label, ref }], items: [item], data }
32
34
  * an agent filed it (`pmos propose …`); the last line for an id wins, so a newer run replaces what it files again
33
- * drop { ini, sig, facts? } ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date
35
+ * drop { ini, sig, facts?, why? } why: the PM's reason, when given (the idle lab learns from it). ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date
34
36
  * passes again. `facts` (batch 24 item 6) are what the ✕ rests on, not where the work sits: a date proposal's facts are its tasks
35
37
  * and the due dates they missed, a grill finding's are its angle and its sources. A ✕ holds while they hold, so a workflow switch
36
38
  * (new section keys, so new ids and signatures), a reworded reason or a new day (new computed dates) does not bring it back
@@ -64,6 +66,7 @@ const TYPES = { found: ['stage', 'docs', 'tasks', 'link', 'maybe', 'unplaced'],
64
66
  const SOURCES = ['drive', 'docs', 'sheets', 'slides', 'jira', 'calendar', 'notes', 'mail', 'slack', 'confluence', 'chat', 'repo'];
65
67
  const SOURCE_NAMES = { drive: 'Drive and Slides', docs: 'Docs', sheets: 'Sheets', slides: 'Slides', jira: 'Jira', calendar: 'Calendar and meeting notes', notes: 'Meeting notes',
66
68
  mail: 'Gmail', slack: 'Slack', confluence: 'Confluence', chat: 'Chat', repo: 'This repository' };
69
+ const LAB = ['prototyped', 'improved', 'scanned']; // the idle lab's results (lib/idle-lab.js result): no items, always section 'check'
67
70
  const CLOSED = new Set(['Done', "Won't do", 'Archived']);
68
71
  const RISK_DAYS = 3;
69
72
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -201,6 +204,7 @@ function tidyName(from, to) {
201
204
  function fold(lines) {
202
205
  const props = new Map(); // id → the newest propose line
203
206
  const drops = new Set(); // signatures
207
+ const dropWhy = new Map(); // signature → the PM's reason for the ✕, when one was given (the idle lab learns from it)
204
208
  const kept = []; // keep lines, oldest first
205
209
  const undone = new Set();
206
210
  const looks = new Map(); // ini → { run, why, at, ended, ok, stopped, sources: Map source → { state, n, what } }
@@ -214,11 +218,11 @@ function fold(lines) {
214
218
  else if (l.kind === 'src' && ini) { const L = looks.get(ini); if (L && L.run === l.run && str(l.source)) L.sources.set(l.source, { state: l.state === 'done' ? 'done' : 'reading', n: Number(l.n) || 0, what: str(l.what) }); }
215
219
  else if (l.kind === 'end' && ini) { const L = looks.get(ini); if (L && L.run === l.run) { L.ended = true; L.ok = !!l.ok; L.stopped = !!l.stopped; L.endAt = l.at || ''; } }
216
220
  else if (l.kind === 'propose' && ini && str(l.id)) props.set(l.id, { id: l.id, ini, board: str(l.board), run: str(l.run), group: l.group, type: l.type, section: str(l.section) || 'sure', title: str(l.title), why: str(l.why), sources: Array.isArray(l.sources) ? l.sources : [], items: Array.isArray(l.items) ? l.items.filter(isObj) : [], data: isObj(l.data) ? l.data : {}, at: l.at || '', by: l.by || null });
217
- else if (l.kind === 'drop' && str(l.sig)) { drops.add(l.sig); for (const f of Array.isArray(l.facts) ? l.facts : []) if (/^f:[0-9a-f]{10}$/.test(String(f))) drops.add(f); }
221
+ else if (l.kind === 'drop' && str(l.sig)) { drops.add(l.sig); if (str(l.why)) dropWhy.set(l.sig, one(l.why, 300)); for (const f of Array.isArray(l.facts) ? l.facts : []) if (/^f:[0-9a-f]{10}$/.test(String(f))) drops.add(f); }
218
222
  else if (l.kind === 'keep' && str(l.batch)) kept.push({ batch: l.batch, ini, board: str(l.board), ids: Array.isArray(l.ids) ? l.ids : [], wrote: Array.isArray(l.wrote) ? l.wrote.filter(isObj) : [], at: l.at || '', by: l.by || null });
219
223
  else if (l.kind === 'undo' && str(l.batch)) undone.add(l.batch);
220
224
  }
221
- return { props, drops, kept, undone, looks, epoch, t };
225
+ return { props, drops, dropWhy, kept, undone, looks, epoch, t };
222
226
  }
223
227
  /** The kept proposal ids (a batch undone gives its proposals back). */
224
228
  const keptIds = (F) => { const s = new Set(); for (const k of F.kept) if (!F.undone.has(k.batch)) for (const id of k.ids) s.add(id); return s; };
@@ -332,7 +336,7 @@ function datesOf(ini, T, o = {}) {
332
336
  }
333
337
 
334
338
  /* ── what r.props carries ── */
335
- const ORDER = { description: -2, outcome: -1, found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
339
+ const ORDER = { description: -2, outcome: -1, prototyped: -0.6, improved: -0.5, scanned: -0.4, found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
336
340
  const GRILL_SHOWN = 3;
337
341
  /**
338
342
  * One initiative's proposals, as the ✦ and the Viewer draw them, or null when there is nothing to show.
@@ -351,7 +355,7 @@ function viewOf(F, ini, o = {}) {
351
355
  p.items = p.items.filter((x) => !F.drops.has(itemSig(p, x)) && (!x.task || (T.has(x.task) && !(p.group !== 'found' && T.get(x.task).closed && p.group !== 'status'))));
352
356
  if (p.group === 'status') p.items = p.items.filter((x) => T.has(x.task) && !T.get(x.task).closed);
353
357
  if (p.group === 'names') p.items = p.items.filter((x) => T.has(x.task) && T.get(x.task).title !== x.to).map((x) => ({ ...x, from: T.get(x.task).title, ...(T.get(x.task).jira ? { jira: true } : {}) }));
354
- if (!p.items.length && p.type !== 'stage' && p.group !== 'grill') continue;
358
+ if (!p.items.length && p.type !== 'stage' && p.group !== 'grill' && !LAB.includes(p.group)) continue;
355
359
  if (p.group === 'grill' && factsOf(p).some((f) => F.drops.has(f))) continue;
356
360
  props.push(p);
357
361
  }
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ /* read-fingerprint.js — did a source read bring anything new? (smart refresh, step 1: measure)
3
+ *
4
+ * of(source, dirs) → { hash, n } | null the fingerprint of what PMOS stores for `source` now; null when nothing is stored
5
+ * compare(prev, fp) → 'first' | 'same' | 'changed' | null prev is the { hash, keys? } kept from the last read
6
+ * SOURCES · SHORT
7
+ * dirs { runtime: <per-user state folder>, jira: <repository state>/tracker/jira }
8
+ *
9
+ * THE RULE. A fingerprint hashes the FACTS a read stored and nothing else:
10
+ * - no read time (read_at, fetched_at, timestamp, "Read from Jira at …"): a read of the same data an hour later is the same read;
11
+ * - no text Claude wrote (a card's ask, a mention's gist, a digest's decisions, a sprint summary): Claude words the same facts a
12
+ * little differently each time, and a new wording is not new data.
13
+ * So two reads have the same fingerprint exactly when the tool said the same thing. `n` is how many records the hash covers.
14
+ *
15
+ * Calendar, Jira and work assigned to you store a whole snapshot each read, so a read is 'changed' when its hash differs (a meeting
16
+ * that was cancelled is a change). Your tools store only what is new since the last read, so an empty read after a full one is not
17
+ * a change: their fingerprint also carries `keys`, one short hash per item, and a read is 'changed' only when it holds a key the
18
+ * last read did not.
19
+ *
20
+ * Per source, the facts are:
21
+ * calendar each current event (the folded ledger): id, start, end, title, attendees
22
+ * jira each initiative's and the sprints' snapshot: every ticket's key, status, category, sprint, subtasks, points, flag
23
+ * mywork each card: key, title, status, category, priority, due, estimate, blocks, from
24
+ * tools each tool's stored read: its state, and per item its id, link, who, when, where and the mention flags
25
+ * The tracker is read by code from files in the repository, so it has no fingerprint here.
26
+ *
27
+ * Pure apart from reading the stores. It never writes.
28
+ */
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const crypto = require('crypto');
32
+
33
+ const SOURCES = Object.freeze(['calendar', 'jira', 'mywork', 'tools']);
34
+ const SHORT = 16; // hex digits kept: enough to tell two reads apart, short enough for a log line
35
+
36
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
37
+ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
38
+ /** The same JSON text for the same value, whatever order its keys were written in. Undefined and null fields are left out. */
39
+ function canon(v) {
40
+ if (Array.isArray(v)) return '[' + v.map(canon).join(',') + ']';
41
+ if (isObj(v)) return '{' + Object.keys(v).sort().filter((k) => v[k] !== undefined && v[k] !== null).map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}';
42
+ return JSON.stringify(v === undefined ? null : v);
43
+ }
44
+ const pick = (o, keys) => { const out = {}; for (const k of keys) if (o && o[k] !== undefined && o[k] !== null && o[k] !== '') out[k] = o[k]; return out; };
45
+ const byKey = (k) => (a, b) => String(a[k] || '').localeCompare(String(b[k] || ''));
46
+ const hashOf = (records) => ({ hash: crypto.createHash('sha256').update(canon(records)).digest('hex').slice(0, SHORT), n: records.length });
47
+
48
+ /* ── per source ── */
49
+ function calendar(runtime) {
50
+ const f = path.join(runtime, 'calendar-import', 'events.jsonl');
51
+ if (!fs.existsSync(f)) return null;
52
+ let CI;
53
+ try { CI = require(require('./repo-script.js').repoScript('calendar-import.js')); } catch { return null; }
54
+ let events = [];
55
+ try { events = CI.foldEvents(CI.readLines(f)); } catch { return null; }
56
+ return hashOf(events.map((e) => pick(e, ['id', 'at', 'end', 'title', 'attendees'])).sort(byKey('id')));
57
+ }
58
+ const SNAPSHOT_JSON = /```json\n([\s\S]*?)\n```/;
59
+ function jira(dir) {
60
+ let names = [];
61
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md') && n !== '_sprint-notes.md').sort(); } catch { return null; }
62
+ if (!names.length) return null;
63
+ const out = [];
64
+ for (const n of names) {
65
+ let text = ''; try { text = fs.readFileSync(path.join(dir, n), 'utf8'); } catch { continue; }
66
+ const m = SNAPSHOT_JSON.exec(text); if (!m) continue;
67
+ let data = null; try { data = JSON.parse(m[1]); } catch { continue; }
68
+ const tickets = (Array.isArray(data && data.tickets) ? data.tickets : [])
69
+ .map((t) => ({ ...pick(t, ['key', 'status', 'category', 'type', 'parent', 'points', 'resolved', 'flagged', 'stale']),
70
+ ...(t.sprint ? { sprint: pick(t.sprint, ['id', 'name', 'state', 'start', 'end', 'completed']) } : {}),
71
+ ...(t.subtasks ? { subtasks: pick(t.subtasks, ['done', 'total']) } : {}) }))
72
+ .sort(byKey('key'));
73
+ out.push({ file: n.slice(0, -3), tickets });
74
+ }
75
+ return out.length ? hashOf(out) : null;
76
+ }
77
+ function mywork(runtime) {
78
+ const r = readJson(path.join(runtime, 'my-work', 'read.json'));
79
+ if (!r || !Array.isArray(r.items)) return null;
80
+ return hashOf(r.items.map((x) => pick(x, ['key', 'title', 'status', 'category', 'priority', 'due', 'estimate', 'blocks', 'from'])).sort(byKey('key')));
81
+ }
82
+ const TOOL_ITEM = ['id', 'link', 'who', 'at', 'where', 'due', 'mention', 'via', 'reply_needed', 'replied', 'broadcast', 'bot', 'conversation'];
83
+ const MAX_KEYS = 600; // 10 tools of at most 60 items each
84
+ const keyOf = (v) => crypto.createHash('sha256').update(canon(v)).digest('hex').slice(0, 12);
85
+ function tools(runtime) {
86
+ const dir = path.join(runtime, 'source-reads');
87
+ let names = [];
88
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort(); } catch { return null; }
89
+ const out = []; const keys = new Set();
90
+ for (const n of names) {
91
+ const r = readJson(path.join(dir, n));
92
+ if (!isObj(r) || !r.source) continue;
93
+ out.push({ source: r.source, state: r.state,
94
+ items: (Array.isArray(r.items) ? r.items : []).map((x) => pick(x, TOOL_ITEM)).sort(byKey('id')),
95
+ digests: (Array.isArray(r.digests) ? r.digests : []).map((d) => pick(d, ['name', 'link'])).sort(byKey('name')),
96
+ places: (Array.isArray(r.places) ? r.places : []).map((p) => pick(p, ['name', 'link'])).sort(byKey('name')) });
97
+ const o = out[out.length - 1];
98
+ for (const x of o.items) keys.add(keyOf({ s: o.source, x }));
99
+ for (const d of o.digests) keys.add(keyOf({ s: o.source, d }));
100
+ keys.add(keyOf({ s: o.source, state: o.state }));
101
+ }
102
+ return out.length ? { ...hashOf(out), keys: [...keys].sort().slice(0, MAX_KEYS) } : null;
103
+ }
104
+
105
+ /** The fingerprint of what PMOS stores for `source` now, or null (nothing stored, or a source with no fingerprint). */
106
+ function of(source, dirs = {}) {
107
+ try {
108
+ if (source === 'calendar') return dirs.runtime ? calendar(dirs.runtime) : null;
109
+ if (source === 'jira') return dirs.jira ? jira(dirs.jira) : null;
110
+ if (source === 'mywork') return dirs.runtime ? mywork(dirs.runtime) : null;
111
+ if (source === 'tools') return dirs.runtime ? tools(dirs.runtime) : null;
112
+ } catch { /* a store that cannot be read has no fingerprint */ }
113
+ return null;
114
+ }
115
+ /** 'first' when there was no fingerprint before, 'same' or 'changed' after; null when there is none now. */
116
+ function compare(prev, fp) {
117
+ if (!fp || !fp.hash) return null;
118
+ const p = typeof prev === 'string' ? { hash: prev } : prev;
119
+ if (!p || !p.hash) return 'first';
120
+ if (p.hash === fp.hash) return 'same';
121
+ if (Array.isArray(fp.keys) && Array.isArray(p.keys)) { const had = new Set(p.keys); return fp.keys.some((k) => !had.has(k)) ? 'changed' : 'same'; }
122
+ return 'changed';
123
+ }
124
+ /** What the engine keeps between reads: the hash, and the item keys of a source that has them. */
125
+ const keep = (fp) => (fp ? { hash: fp.hash, ...(Array.isArray(fp.keys) ? { keys: fp.keys } : {}) } : null);
126
+
127
+ module.exports = { SOURCES, SHORT, of, compare, keep, canon };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+ /* refresh-cli.js — `pmos refresh …`: ask the tool first, read in full only when something changed (smart refresh, step 2).
3
+ * pmos refresh since <source> [--json] ask first or read in full, and the time to ask "what changed since"
4
+ * pmos refresh same <source> [--only <tool,…>] [--json] the tool said nothing changed: keep the stored read, move its read time to now
5
+ * <source> is calendar, jira, mywork or tools. The read skills (calendar-sync, jira-sync, my-work-read, sources-read) run these.
6
+ * This command calls no tool. The rules are in lib/refresh-probe.js.
7
+ */
8
+ const path = require('path');
9
+ const { execFileSync } = require('child_process');
10
+ const stateRoot = require('./state-root');
11
+ const P = require('./refresh-probe.js');
12
+
13
+ const COMMANDS = { refresh: ['since', 'same'] };
14
+ const USAGE = [
15
+ 'usage: pmos refresh since|same <source> — ask the tool first, read in full only when something changed',
16
+ ' pmos refresh since <source> says "ask" (ask the tool what changed since the last read) or "full" (read in full), and why',
17
+ ' pmos refresh same <source> [--only <tools>] the tool said nothing changed: keep the stored read and move its read time to now',
18
+ ' <source>: ' + P.SOURCES.join(', ') + '. --only (tools only): the tools to confirm, comma-separated, for example slack,mail',
19
+ ' every command: [--json] [--root <dir>]',
20
+ 'This command never calls a tool. A full read is due once a day, and `same` refuses then.',
21
+ ];
22
+
23
+ function parseArgs(argv) {
24
+ const out = { _: [] };
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const t = argv[i];
27
+ if (!t.startsWith('--')) { out._.push(t); continue; }
28
+ const eq = t.indexOf('=');
29
+ const key = eq > 0 ? t.slice(2, eq) : t.slice(2);
30
+ if (key === 'json' || key === 'help') out[key] = true;
31
+ else if (eq > 0) out[key] = t.slice(eq + 1);
32
+ else if (argv[i + 1] !== undefined) out[key] = argv[++i];
33
+ else out[key] = '';
34
+ }
35
+ return out;
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 = () => Date.now(), jiraDir } = {}) {
43
+ const a = parseArgs(argv.slice(1));
44
+ const sub = a._[0]; const source = a._[1];
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.refresh.includes(sub)) { say(`✗ pmos refresh ${sub} is not a command`); USAGE.forEach((l) => say(l)); return 2; }
48
+ if (!P.SOURCES.includes(source)) { out({ ok: false, error: `source must be one of ${P.SOURCES.join(', ')}` }, `✗ name the source: ${P.SOURCES.join(', ')}`); return 2; }
49
+ if (a.only !== undefined && source !== 'tools') { out({ ok: false, error: '--only is for tools' }, '✗ --only is for tools'); return 2; }
50
+ const rt = stateDir || stateRoot.defaultRoot({ env });
51
+ const dirs = {};
52
+ if (source === 'jira') {
53
+ if (jiraDir) dirs.jira = jiraDir;
54
+ else { const root = a.root ? path.resolve(cwd, String(a.root)) : repoRoot(cwd); dirs.jira = require('./jira-tickets.js').jiraDir(root); }
55
+ }
56
+ const t = now();
57
+
58
+ if (sub === 'since') {
59
+ const s = P.since(rt, source, dirs, t);
60
+ if (a.json) { say(JSON.stringify(s)); return 0; }
61
+ say(`${source} · ${s.verdict === 'ask' ? 'ask first' : 'read in full'}: ${s.why}`);
62
+ say(` last read ${s.last_read_at || 'never'} · last full read ${s.full_at || 'none recorded'}`);
63
+ for (const [k, v] of Object.entries(s.tools || {})) say(` ${k.padEnd(11)} ${v.state} ${v.read_at}`);
64
+ if (s.verdict === 'ask') say(` Ask the tool what changed since ${s.last_read_at} (the last ${s.minutes} minutes). Nothing changed: run \`pmos refresh same ${source}\` and stop. Anything changed: read in full.`);
65
+ return 0;
66
+ }
67
+ // same
68
+ const only = a.only ? String(a.only).split(',').map((x) => x.trim()).filter(Boolean) : null;
69
+ const r = P.same(rt, source, dirs, t, { only });
70
+ if (!r.ok) { out(r, `✗ ${r.error}`); return 1; }
71
+ out(r, `kept the stored read of ${source} (${r.stamped.join(', ')}) as unchanged, checked at ${r.same_at}`);
72
+ return 0;
73
+ }
74
+
75
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+ /* refresh-probe.js — ask the tool first, read in full only when something changed (smart refresh, step 2).
3
+ *
4
+ * since(runtime, source, dirs, now) → { source, last_read_at, full_at, verdict: 'ask'|'full', why, minutes?, tools? }
5
+ * same(runtime, source, dirs, now, {only}) → { ok, stamped: [what] } | { ok: false, error }
6
+ * askedSince(runtime, source, ms) → true when `same` confirmed this source at or after `ms`
7
+ * SOURCES · FULL_MS · FILE
8
+ * dirs { jira: <repository state>/tracker/jira } (Jira only)
9
+ *
10
+ * THE FLOW. A read skill runs `pmos refresh since <source>` first. With verdict 'ask' it asks the tool one cheap question: what changed
11
+ * since `last_read_at` (an updated-since search, a newer-than filter). When the tool says nothing changed, the skill runs
12
+ * `pmos refresh same <source>` and stops: the stored read is kept as it is, and only its read time moves to now, so Sources, the
13
+ * Briefing and the freshness rows know it was checked. When anything changed, or the verdict is 'full', the skill does its full read.
14
+ *
15
+ * THE SAFETY RULE. A question about changes can miss something (a meeting that moved into the 14 days, a ticket that left a search).
16
+ * So a full read is due when the last full read is older than FULL_MS, or there is none, and `same` refuses then: the skill reads
17
+ * in full. The last full read is `full_at` in <runtime>/refresh-state.json, which lib/refresh-run.js writes when a read ends that was
18
+ * not confirmed with `same`.
19
+ *
20
+ * FILES. `same` writes the read time of the stored read (calendar-import/read.json, my-work/read.json, source-reads/<tool>.json, the
21
+ * modified time of the Jira snapshots) and one line per source in <runtime>/refresh-probe.json: { <source>: { same_at, only? } }.
22
+ * It never changes what a read holds.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const SOURCES = Object.freeze(['calendar', 'jira', 'mywork', 'tools']);
28
+ const FULL_MS = 24 * 3600 * 1000;
29
+ const FILE = 'refresh-probe.json';
30
+ const TOOLS = Object.freeze(['mail', 'slack', 'wispr', 'jira', 'confluence', 'github', 'drive', 'figma', 'website', 'web']);
31
+
32
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
33
+ function writeJson(f, v) {
34
+ fs.mkdirSync(path.dirname(f), { recursive: true });
35
+ const tmp = `${f}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(v) + '\n'); fs.renameSync(tmp, f);
36
+ }
37
+ const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
38
+ const ms = (s) => { const t = Date.parse(s || ''); return Number.isFinite(t) ? t : 0; };
39
+
40
+ const calFile = (rt) => path.join(rt, 'calendar-import', 'read.json');
41
+ const workFile = (rt) => path.join(rt, 'my-work', 'read.json');
42
+ const toolFile = (rt, t) => path.join(rt, 'source-reads', t + '.json');
43
+ function jiraFiles(dir) {
44
+ try { return fs.readdirSync(dir).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')).map((n) => path.join(dir, n)); } catch { return []; }
45
+ }
46
+ const mtime = (f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } };
47
+
48
+ /** When each stored read was last read (or confirmed), in ms. Tools answer per tool. */
49
+ function lastRead(rt, source, dirs = {}) {
50
+ if (source === 'calendar') { const r = readJson(calFile(rt)); return { at: r ? ms(r.fetched_at) : 0 }; }
51
+ if (source === 'mywork') { const r = readJson(workFile(rt)); return { at: r ? ms(r.read_at) : 0 }; }
52
+ if (source === 'jira') { const fl = dirs.jira ? jiraFiles(dirs.jira) : []; return { at: fl.reduce((m, f) => Math.max(m, mtime(f)), 0), n: fl.length }; }
53
+ const per = {};
54
+ for (const t of TOOLS) { const r = readJson(toolFile(rt, t)); if (r && r.source === t && r.read_at) per[t] = { at: ms(r.read_at), state: r.state }; }
55
+ const ats = Object.values(per).map((x) => x.at).filter(Boolean);
56
+ return { at: ats.length ? Math.min(...ats) : 0, per };
57
+ }
58
+ const fullAt = (rt) => (source) => { const st = readJson(path.join(rt, 'refresh-state.json')) || {}; return Number(st[source] && st[source].full_at) || 0; };
59
+
60
+ /** Should the skill ask the tool first, or read in full? */
61
+ function since(rt, source, dirs = {}, now = Date.now()) {
62
+ if (!SOURCES.includes(source)) return { ok: false, error: `source must be one of ${SOURCES.join(', ')}` };
63
+ const last = lastRead(rt, source, dirs);
64
+ const full = fullAt(rt)(source);
65
+ let verdict = 'ask'; let why = 'the last full read is younger than 24 hours: ask the tool what changed since the last read';
66
+ if (!last.at) { verdict = 'full'; why = 'nothing is stored yet'; }
67
+ else if (!full) { verdict = 'full'; why = 'no full read is recorded yet'; }
68
+ else if (now - full > FULL_MS) { verdict = 'full'; why = 'the last full read is older than 24 hours'; }
69
+ // the question's window in whole minutes, with 5 minutes to spare, for a search that takes "updated in the last N minutes"
70
+ const out = { ok: true, source, last_read_at: iso(last.at), full_at: iso(full), verdict, why, ...(last.at ? { minutes: Math.max(1, Math.ceil((now - last.at) / 60000)) + 5 } : {}) };
71
+ if (last.per) out.tools = Object.fromEntries(Object.entries(last.per).map(([k, v]) => [k, { read_at: iso(v.at), state: v.state }]));
72
+ return out;
73
+ }
74
+
75
+ /** Confirm the stored read of `source` as unchanged: its read time moves to now, what it holds stays. */
76
+ function same(rt, source, dirs = {}, now = Date.now(), { only } = {}) {
77
+ const s = since(rt, source, dirs, now);
78
+ if (!s.ok) return s;
79
+ if (s.verdict === 'full') return { ok: false, error: `a full read of ${source} is due: ${s.why}. Read it in full; nothing was changed` };
80
+ const at = new Date(now).toISOString();
81
+ const stamped = [];
82
+ if (source === 'calendar') {
83
+ const r = readJson(calFile(rt)); writeJson(calFile(rt), { ...r, fetched_at: at, confirmed: true }); stamped.push('calendar');
84
+ } else if (source === 'mywork') {
85
+ const r = readJson(workFile(rt)); writeJson(workFile(rt), { ...r, read_at: at }); stamped.push('work assigned to you');
86
+ } else if (source === 'jira') {
87
+ const t = new Date(now);
88
+ for (const f of jiraFiles(dirs.jira)) { try { fs.utimesSync(f, t, t); stamped.push(path.basename(f, '.md')); } catch { /* a file that went away is not stamped */ } }
89
+ } else {
90
+ const want = only && only.length ? only : Object.keys(s.tools || {});
91
+ const bad = want.filter((x) => !TOOLS.includes(x));
92
+ if (bad.length) return { ok: false, error: `--only takes tools from ${TOOLS.join(', ')}; not ${bad.join(', ')}` };
93
+ const missing = want.filter((x) => !(s.tools || {})[x]);
94
+ if (missing.length) return { ok: false, error: `${missing.join(', ')} ${missing.length === 1 ? 'has' : 'have'} no stored read yet: read ${missing.length === 1 ? 'it' : 'them'} in full` };
95
+ for (const x of want) { const r = readJson(toolFile(rt, x)); writeJson(toolFile(rt, x), { ...r, read_at: at }); stamped.push(x); }
96
+ }
97
+ if (!stamped.length) return { ok: false, error: `no stored read of ${source} to confirm: read it in full` };
98
+ const pf = path.join(rt, FILE); const cur = readJson(pf) || {};
99
+ cur[source] = { same_at: at, ...(source === 'tools' && only && only.length ? { only: stamped } : {}) };
100
+ writeJson(pf, cur);
101
+ return { ok: true, source, same_at: at, stamped };
102
+ }
103
+
104
+ /** `same` confirmed this source at or after `t` (ms): the run that ends now asked first and did no full read. */
105
+ function askedSince(rt, source, t) {
106
+ const p = readJson(path.join(rt, FILE)) || {};
107
+ return !!(p[source] && ms(p[source].same_at) >= t);
108
+ }
109
+
110
+ module.exports = { SOURCES, FULL_MS, FILE, TOOLS, since, same, askedSince, lastRead };
@@ -20,20 +20,40 @@
20
20
  * { stateDir, now?, homeWs(), proactive() → { quiet, dayCheck: { start, end, every } }, schedules: { read, write },
21
21
  * startSkill(wsId, skill, name, origin) → { ok, session?, error? }, jobs: { [id]: (ws) → { ok, error? } },
22
22
  * freshRows(ws) → [freshness row], copyPaths(id, ws) → [path], tell({ source, label, error, ws }), sessionAlive(id) → bool,
23
- * setDayCheckEvery(every), setRepoWatch(on), warn(what, e), leadsDue?(ws) → number of popup leads due }
23
+ * setDayCheckEvery(every), setRepoWatch(on), warn(what, e), leadsDue?(ws) → number of popup leads due,
24
+ * readDirs?(ws) → { runtime, jira } where the stored reads are, for their fingerprint (smart refresh step 1); by default the
25
+ * state folder and the Jira folder copyPaths('jira') names }
26
+ * startSkill takes a list of skills for a shared session (smart refresh step 2): one session that reads several due sources.
27
+ *
28
+ * SMART REFRESH (steps 1 and 2). Each done read of a stored source gets a fingerprint of its facts (lib/read-fingerprint.js); the end
29
+ * line says whether it brought anything new (`change`) and whether it was a full read or only asked the tool first (`how`). The
30
+ * fingerprint and the time of the last full read are kept in the source's state (`fp`, `full_at`). When two or more tool sources are
31
+ * due at the same minute, one shared session reads them one after another (startSkill with a list), instead of one session each. A source of a
32
+ * shared session is done when its stored read is newer than the session's start, and failed when it is not.
24
33
  * Nothing here calls out of this machine: a skill source's run is the PM's own agent session, with the PM's connections.
25
34
  */
26
35
  const fs = require('fs');
27
36
  const path = require('path');
28
37
  const R = require('./refresh.js');
29
38
  const H = require('./source-heal.js');
39
+ const FP = require('./read-fingerprint.js');
40
+ const P = require('./refresh-probe.js');
30
41
 
31
42
  const PRUNE_EVERY_MS = 24 * 3600 * 1000;
32
43
  const LOST_MS = 6 * 3600 * 1000; // a run with no live session and no end after this long is written as interrupted
33
44
  const ALL_MS = 3 * 3600 * 1000; // a Refresh all that has not ended after this long is over: a new press starts a new one
34
45
  const DERIVED = Object.freeze(['leads', 'brief']); // what Refresh all writes again after the sources, in this order
35
46
  const DERIVED_LIST = Object.freeze(['scan', 'coredocs', 'leads', 'brief']); // what Sources lists as derived content: built from stored reads, never a tool
36
- const AUTH_RETRY_MS = 2 * 60 * 1000; // a run that failed on Claude's sign-in is tried once more after this (the token is usually refreshed by then)
47
+ const AUTH_RETRY_MS = 2 * 60 * 1000;
48
+ /* The sources a session reads, from its origin: one for most, several for a shared session (`refresh:<id>+<id>…`, step 2). */
49
+ const SHARED_RE = /^refresh:([a-z]+(?:\+[a-z]+)+)$/;
50
+ function sourcesOfOrigin(origin, opts) {
51
+ const m = SHARED_RE.exec(String(origin || ''));
52
+ if (m) return [...new Set(m[1].split('+'))].filter((x) => R.BY_ID.has(x) && R.BY_ID.get(x).run === 'skill');
53
+ const one = R.sourceOfOrigin(origin, opts);
54
+ return one ? [one] : [];
55
+ }
56
+ const sharedOrigin = (ids) => 'refresh:' + ids.join('+'); // a run that failed on Claude's sign-in is tried once more after this (the token is usually refreshed by then)
37
57
 
38
58
  function create(host) {
39
59
  const now = () => (host.now ? host.now() : Date.now());
@@ -92,7 +112,8 @@ function create(host) {
92
112
  return 'ok';
93
113
  };
94
114
  }
95
- const runningCount = (state) => Object.values(state).reduce((n, st) => n + Object.keys(openOf(st)).length, 0);
115
+ // sessions, not sources: a shared session that reads three sources is one run against the run limit
116
+ const runningCount = (state) => { const ids = new Set(); for (const st of Object.values(state)) for (const k of Object.keys(openOf(st))) ids.add(k); return ids.size; };
96
117
  function snapshot(id, set, state, t) {
97
118
  const src = R.BY_ID.get(id); if (!src || !src.uses.length) return [];
98
119
  const f = log(); const is = inputStateFn(set, state, t);
@@ -103,8 +124,12 @@ function create(host) {
103
124
  const skillOfSchedule = (id) => { try { const x = host.schedules.read().find((y) => y.id === id); return x ? x.skill : null; } catch { return null; } };
104
125
  function started(s) {
105
126
  if (!s) return;
106
- const id = R.sourceOfOrigin(s.origin, { skillOfSchedule });
107
- if (!id) return;
127
+ const ids = sourcesOfOrigin(s.origin, { skillOfSchedule });
128
+ if (!ids.length) return;
129
+ for (const id of ids) startedOne(s, id, ids.length > 1 ? ids : null);
130
+ if (s.state === 'exited') ended(s);
131
+ }
132
+ function startedOne(s, id, shared) {
108
133
  const t = now();
109
134
  const set = settings(); const state = R.readState(dir);
110
135
  const ex = expect.get(id) || {}; expect.delete(id);
@@ -115,15 +140,37 @@ function create(host) {
115
140
  state[id] = { ...st, open: { ...openOf(st), [s.id]: { run, at: t, trigger } }, started_at: t, trigger, waiting_since: null, waiting_for: [], pending: null };
116
141
  if (ex.slot != null) state[id].last_slot = ex.slot;
117
142
  R.writeState(dir, state);
118
- R.append(dir, { source: id, ws: s.ws || null, ev: 'start', run, trigger, session: s.id, inputs, ...(ex.note ? { note: ex.note } : {}) }, t);
119
- if (s.state === 'exited') ended(s);
143
+ R.append(dir, { source: id, ws: s.ws || null, ev: 'start', run, trigger, session: s.id, inputs, ...(ex.note ? { note: ex.note } : {}), ...(shared ? { shared } : {}) }, t);
120
144
  }
121
145
  /** End the open run of session `s`. `over` sets the result for a run whose session has no outcome of its own. */
122
146
  function ended(s, over) {
123
147
  if (!s) return;
148
+ const now0 = R.readState(dir);
149
+ const ids = Object.keys(now0).filter((k) => openOf(now0[k])[s.id]);
150
+ if (!ids.length) return;
151
+ const shared = ids.length > 1;
152
+ let readAfter = false;
153
+ for (const id of ids) { if (endedOne(s, id, over, shared) && R.BY_ID.get(id).group === 'tools') readAfter = true; }
154
+ // popup context slice 3b: a read of the tools that ends well is followed by one leads session, once no other read is going
155
+ if (readAfter) afterRead(s.ws || null);
156
+ }
157
+ /** Where the stored reads are: the state folder, and the Jira folder of the workspace (the one copyPaths keeps a copy of). */
158
+ function readDirs(ws) {
159
+ if (host.readDirs) return host.readDirs(ws) || {};
160
+ let j = []; try { j = host.copyPaths('jira', ws) || []; } catch { j = []; }
161
+ return { runtime: dir, jira: j[0] || null };
162
+ }
163
+ /** The stored read of `id` is newer than `since` (ms): the shared session read it. From the source's lib/freshness.js row. */
164
+ function readSince(id, ws, since) {
165
+ const src = R.BY_ID.get(id); if (!src || !src.fresh) return false;
166
+ let rowsNow = []; try { rowsNow = host.freshRows(ws) || []; } catch { rowsNow = []; }
167
+ const r = rowsNow.find((x) => x.id === src.fresh);
168
+ const at = r && r.as_of ? Date.parse(r.as_of) || 0 : 0;
169
+ return at >= since - 1000;
170
+ }
171
+ /** End the open run of session `s` for source `id`. → true when the run is done. */
172
+ function endedOne(s, id, over, shared) {
124
173
  const state = R.readState(dir);
125
- const id = Object.keys(state).find((k) => openOf(state[k])[s.id]);
126
- if (!id) return;
127
174
  const t = now();
128
175
  const st = state[id]; const o = openOf(st)[s.id];
129
176
  let result; let got = null; let error = null;
@@ -131,6 +178,11 @@ function create(host) {
131
178
  else if (s.outcome) { result = s.outcome.ok ? 'done' : /Stopped by you/.test(s.outcome.why || '') ? 'interrupted' : 'failed'; got = s.outcome.headline || null; error = s.outcome.ok ? null : s.outcome.why; }
132
179
  else if (id === 'brief') { result = 'failed'; error = 'The session ended without submitting a brief' + (s.note ? ` (${s.note})` : '') + '.'; }
133
180
  else { result = /code 0\)$/.test(s.note || '') ? 'done' : 'failed'; error = result === 'failed' ? s.note || 'the session ended' : null; }
181
+ // a shared session has one outcome for several sources: each source is done only when its stored read is newer than the start
182
+ if (shared && !over && result !== 'interrupted') {
183
+ if (readSince(id, s.ws || null, o.at)) { result = 'done'; error = null; }
184
+ else { result = 'failed'; got = null; error = 'The shared refresh session did not save a new read of it' + (error ? ` (${error})` : '') + '.'; }
185
+ }
134
186
  const rest = { ...openOf(st) }; delete rest[s.id];
135
187
  const rules = settings().rules;
136
188
  let copy = null;
@@ -138,6 +190,19 @@ function create(host) {
138
190
  if (result === 'done' && src && src.copy) { try { copy = R.keepCopy(dir, id, host.copyPaths(id, s.ws), t); } catch (e) { warn('refresh copy', e); } }
139
191
  const next = { ...st, open: rest, last_end: t, last_result: result };
140
192
  if (result === 'done') { next.retry_at = null; next.retried = false; next.auth_retried = false; }
193
+ // smart refresh step 1: what this read brought, measured on the stored facts; no change shows on any page
194
+ let measured = null;
195
+ if (result === 'done' && src && FP.SOURCES.includes(id)) {
196
+ try {
197
+ const dirs = readDirs(s.ws || null);
198
+ const fp = FP.of(id, dirs);
199
+ const change = FP.compare(st.fp, fp);
200
+ const asked = P.askedSince(dirs.runtime || dir, id, o.at);
201
+ if (fp) { next.fp = FP.keep(fp); measured = { change, how: asked ? 'asked' : 'full', fp: fp.hash }; }
202
+ else measured = { how: asked ? 'asked' : 'full' };
203
+ if (!asked) next.full_at = t;
204
+ } catch (e) { warn('read fingerprint', e); }
205
+ }
141
206
  // harness-health: a failed sign-in is often a token being refreshed by another run at the same moment (2026-10-21 08:34,
142
207
  // four runs of Refresh all failed in 2 s, and Claude was signed in a minute later). Try once more, whatever started it.
143
208
  const authFail = result === 'failed' && !!(s.outcome && s.outcome.auth);
@@ -148,13 +213,12 @@ function create(host) {
148
213
  else if (result === 'failed' && o.trigger === 'retry') next.retry_at = null;
149
214
  state[id] = next;
150
215
  R.writeState(dir, state);
151
- R.append(dir, { source: id, ws: s.ws || null, ev: 'end', run: o.run, result, took_ms: t - o.at, got, error, ...(copy ? { copy } : {}) }, t);
152
- // popup context slice 3b: a read of the tools that ends well is followed by one leads session, once no other read is going
153
- if (result === 'done' && src && src.group === 'tools') afterRead(s.ws || null);
216
+ R.append(dir, { source: id, ws: s.ws || null, ev: 'end', run: o.run, result, took_ms: t - o.at, got, error, ...(copy ? { copy } : {}), ...(measured || {}) }, t);
154
217
  // a source under repair is told once by the repair, when the PM must act (lib/source-heal.js), not on each failed read
155
218
  if (result === 'failed' && scheduled && rules.tell === 'brief' && !next.retry_at && !healing(id)) {
156
219
  try { host.tell({ source: id, label: src ? src.label : id, error: error || 'it failed', ws: s.ws || null }); } catch (e) { warn('refresh failure note', e); }
157
220
  }
221
+ return result === 'done';
158
222
  }
159
223
  /** One leads session after the reads (PM pick, 2026-10-21), when the leads are on and a popup's lead is due. It waits for the
160
224
  * reads still going (runNow), and a second read that ends joins the one that is waiting or running. */
@@ -201,6 +265,7 @@ function create(host) {
201
265
  }
202
266
  if (changed) R.writeState(dir, state);
203
267
  const first = !tick.seen; tick.seen = true;
268
+ shareDue(t, set, rules, first);
204
269
  for (const src of R.SOURCES.filter((x) => x.run === 'skill')) {
205
270
  const cur = R.readState(dir);
206
271
  const st = { ...(cur[src.id] || {}), running: running(cur[src.id]) };
@@ -226,7 +291,7 @@ function create(host) {
226
291
  const f = log();
227
292
  const lastDone = (id) => { const runs = f.runs(id, 20) || []; const l = runs.find((r) => r.result === 'done'); return l ? Date.parse(l.end_at || l.start_at) || 0 : 0; };
228
293
  const first = R.refreshFirst(src, { now: t, settings: set, inputState: inputStateFn(set, cur, t), lastDone });
229
- const begun = first.filter((id) => { const x = R.BY_ID.get(id); return start(x, (set.sources[id] && set.sources[id].ws) || ws, { trigger: d.trigger, note: 'Refreshed first, before ' + src.label }).ok; });
294
+ const begun = startMany(first.map((id) => R.BY_ID.get(id)), (id) => (set.sources[id] && set.sources[id].ws) || ws, { trigger: d.trigger, note: 'Refreshed first, before ' + src.label });
230
295
  if (begun.length) {
231
296
  const nx = R.readState(dir);
232
297
  nx[src.id] = { ...(nx[src.id] || {}), waiting_since: t, waiting_for: begun, waiting_why: null };
@@ -268,6 +333,60 @@ function create(host) {
268
333
  }
269
334
  if (JSON.stringify(heal) !== before) writeHeal(heal);
270
335
  }
336
+ /* ── smart refresh step 2: due sources share one session ──
337
+ * The tool sources (calendar, Jira, work assigned to you, your tools) that are due at the same minute, on their schedule, a catch-up
338
+ * or a retry, start as ONE session per workspace: one Claude start instead of up to four. A press, a repair and Refresh all keep their
339
+ * own sessions. */
340
+ const AUTO = new Set(['schedule', 'catch-up', 'retry']);
341
+ function shareDue(t, set, rules, first) {
342
+ const cur = R.readState(dir);
343
+ if (runningCount(cur) >= Number(rules.max || 3)) return;
344
+ const due = [];
345
+ for (const src of R.SOURCES.filter((x) => x.run === 'skill' && x.group === 'tools')) {
346
+ const st = { ...(cur[src.id] || {}), running: running(cur[src.id]) };
347
+ const d = R.decide(src, st, { now: t, settings: set, rules, win: win(), quiet: quiet(), runningCount: runningCount(cur), inputState: inputStateFn(set, cur, t), first });
348
+ if (d.act === 'start' && AUTO.has(d.trigger) && !d.pressed) due.push({ src, d, ws: (set.sources[src.id] && set.sources[src.id].ws) || host.homeWs() });
349
+ }
350
+ const byWs = new Map();
351
+ for (const x of due) { const a = byWs.get(x.ws) || []; a.push(x); byWs.set(x.ws, a); }
352
+ for (const [ws, list] of byWs) {
353
+ if (list.length < 2) continue; // one due source starts alone in the tick below
354
+ startTogether(list.map((x) => ({ src: x.src, trigger: x.d.trigger, slot: x.d.slot, note: x.d.note })), ws);
355
+ }
356
+ }
357
+ /** Start `srcs` (sources) for a scheduled run: in one shared session when two or more share a workspace and the host can, else each
358
+ * alone. `wsOf(id)` names each one's workspace. → [id] that started */
359
+ function startMany(srcs, wsOf, opts) {
360
+ const list = srcs.filter(Boolean);
361
+ if (list.length < 2) return list.filter((x) => start(x, wsOf(x.id), opts).ok).map((x) => x.id);
362
+ const byWs = new Map();
363
+ for (const x of list) { const w = wsOf(x.id); const a = byWs.get(w) || []; a.push(x); byWs.set(w, a); }
364
+ const begun = [];
365
+ for (const [w, a] of byWs) {
366
+ if (a.length === 1) { if (start(a[0], w, opts).ok) begun.push(a[0].id); continue; }
367
+ if (startTogether(a.map((src) => ({ src, ...opts })), w).ok) begun.push(...a.map((x) => x.id));
368
+ }
369
+ return begun;
370
+ }
371
+ /** One shared session for `items` ([{ src, trigger, slot, note }]) in workspace `ws`. A session that does not start is written as a
372
+ * failed run of each source, the same as start() does for one. */
373
+ function startTogether(items, ws) {
374
+ const ids = items.map((x) => x.src.id);
375
+ for (const x of items) expect.set(x.src.id, { trigger: x.trigger, slot: x.slot, note: x.note });
376
+ let r;
377
+ try { r = host.startSkill(ws, items.map((x) => x.src.skill), items.map((x) => x.src.label).join(', ') + ' · refresh', sharedOrigin(ids)); }
378
+ catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
379
+ for (const x of items) {
380
+ if (!expect.has(x.src.id)) continue;
381
+ expect.delete(x.src.id);
382
+ const t = now(); const run = R.newRun(x.src.id, t); const cur = R.readState(dir);
383
+ cur[x.src.id] = { ...(cur[x.src.id] || {}), last_slot: x.slot != null ? x.slot : (cur[x.src.id] || {}).last_slot, waiting_since: null, waiting_for: [], pending: null, last_result: 'failed' };
384
+ R.writeState(dir, cur);
385
+ R.append(dir, { source: x.src.id, ws, ev: 'start', run, trigger: x.trigger, shared: ids }, t);
386
+ R.append(dir, { source: x.src.id, ws, ev: 'end', run, result: 'failed', took_ms: 0, error: (r && r.error) || 'could not start' }, t);
387
+ }
388
+ return r || { ok: false, error: 'could not start' };
389
+ }
271
390
  /** What /api/sources shows per source: the repair view, keyed by the lib/freshness.js id. */
272
391
  const healView = () => H.viewAll(dir);
273
392
  function start(src, ws, { trigger, slot, note }) {
@@ -456,4 +575,4 @@ function create(host) {
456
575
  return { heal: healView, tick, started, ended, briefDone, codeRun, jobGate, runNow, refreshAll, allState, derived, setSource, setRules, ensureOn, rows, settings, log, scheduleOfSkill, managedSkill: (skill) => R.SKILL_OF.get(skill) || null };
457
576
  }
458
577
 
459
- module.exports = { create };
578
+ module.exports = { create, sourcesOfOrigin, sharedOrigin };