create-pmos 0.524.0 → 0.526.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 +57 -56
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/briefing-premium-board-test.js +1 -1
- package/workspace/changelog.json +56 -0
- package/workspace/ev-everywhere-test.js +2 -2
- package/workspace/guide/agent-capabilities.md +28 -2
- package/workspace/guide/board.md +14 -1
- package/workspace/guide/claude-proposes.md +3 -1
- package/workspace/guide/data-freshness.md +13 -0
- package/workspace/guide/home.md +11 -5
- package/workspace/guide/idle-lab.md +2 -2
- package/workspace/guide/lifecycle.md +16 -3
- package/workspace/guide/published-board.md +2 -0
- package/workspace/guide/today.md +11 -3
- package/workspace/lib/bg-queue.js +7 -3
- package/workspace/lib/board-premium.js +4 -1
- package/workspace/lib/board-publish-parts.js +14 -2
- package/workspace/lib/engine/boards.js +3 -2
- package/workspace/lib/headless.js +19 -4
- package/workspace/lib/idle-lab-engine.js +531 -0
- package/workspace/lib/initiative-chat.js +12 -2
- package/workspace/lib/initiative-cli.js +1 -1
- package/workspace/lib/initiative-premium.js +11 -3
- package/workspace/lib/lab-cli.js +220 -0
- package/workspace/lib/lifecycle-stage.js +17 -3
- package/workspace/lib/one-stage.js +131 -0
- package/workspace/lib/pmos-verbs.js +6 -0
- package/workspace/lib/proposals.js +5 -2
- package/workspace/lib/read-fingerprint.js +127 -0
- package/workspace/lib/refresh-cli.js +75 -0
- package/workspace/lib/refresh-probe.js +110 -0
- package/workspace/lib/refresh-run.js +133 -14
- package/workspace/lib/report-draft.js +1 -1
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-board-premium.js +22 -0
- package/workspace/lib/routes-boards.js +61 -12
- package/workspace/lib/routes-notes.js +1 -1
- package/workspace/lib/routes-your-move.js +64 -0
- package/workspace/lib/status-signal.js +123 -0
- package/workspace/lib/workflow-cli.js +3 -1
- package/workspace/lib/your-move.js +61 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/bd-acts.js +2 -1
- package/workspace/public/pm/bd-cols.js +8 -5
- package/workspace/public/pm/bd-prog-panel.js +3 -2
- package/workspace/public/pm/bd-span.js +2 -1
- package/workspace/public/pm/bd-views.js +8 -6
- package/workspace/public/pm/cozy-home.css +3 -0
- package/workspace/public/pm/cozy-today.css +151 -0
- package/workspace/public/pm/cozy.css +9 -0
- package/workspace/public/pm/good-morning.js +327 -0
- package/workspace/public/pm/grill-words.js +16 -1
- package/workspace/public/pm/home-app.js +187 -18
- package/workspace/public/pm/home-data.js +45 -13
- package/workspace/public/pm/home-day.js +66 -14
- package/workspace/public/pm/home-ini-model.js +1 -1
- package/workspace/public/pm/home-stage-card.js +7 -0
- package/workspace/public/pm/ini-tasks.js +2 -2
- package/workspace/public/pm/ini-view-model.js +10 -3
- package/workspace/public/pm/ini-view.css +9 -0
- package/workspace/public/pm/ini-view.js +19 -5
- package/workspace/public/pm/move-rank.js +155 -0
- package/workspace/public/pm/premium-board.js +10 -6
- package/workspace/public/pm/premium-initiative-head.js +39 -4
- package/workspace/public/pm/readonly-boot.js +8 -0
- package/workspace/public/pm/stage-chip.js +44 -0
- package/workspace/run-tests.js +1 -0
- package/workspace/server.js +63 -1
- package/workspace/skills/calendar-sync.skill +2 -1
- package/workspace/skills/idle-lab-prototype.skill +36 -0
- package/workspace/skills/idle-lab-scan.skill +32 -0
- package/workspace/skills/jira-sync.skill +2 -1
- package/workspace/skills/my-work-read.skill +2 -1
- package/workspace/skills/sources-read.skill +9 -6
- package/workspace/test-lists/good-morning.json +9 -0
- package/workspace/test-lists/idle-lab-engine.json +9 -0
- package/workspace/test-lists/one-stage.json +5 -0
- package/workspace/test-lists/status-signal.json +5 -0
- package/workspace/test-lists/your-move.json +9 -0
|
@@ -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;
|
|
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
|
-
|
|
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
|
|
107
|
-
if (!
|
|
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.
|
|
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 };
|
|
@@ -494,7 +494,7 @@ function draftOf(root, { cadence, slot, now, health } = {}) {
|
|
|
494
494
|
const slides = slidesData({
|
|
495
495
|
product: def.product || path.basename(main), cadence, slot: theSlot, nowMs, window, cycleDays: cycle,
|
|
496
496
|
blocks, rows, itemsOf,
|
|
497
|
-
stageLabel, lifecycleOf: (id) => lifecycle.labelOf(rows.find((r) => r.id === id)),
|
|
497
|
+
stageLabel, lifecycleOf: (id) => lifecycle.labelOf(rows.find((r) => r.id === id), main), // W1: the one stage
|
|
498
498
|
rankOf: (id) => (has(plc.byInit, id) && Number.isFinite(plc.byInit[id].rank) ? plc.byInit[id].rank : Infinity),
|
|
499
499
|
isDone: (it) => it.state === 'done' || integratedAll.has(it.id),
|
|
500
500
|
});
|
|
@@ -84,6 +84,7 @@ const MODULES = Object.freeze([
|
|
|
84
84
|
'routes-task-plans', // batch 11 item 8: drafts of an initiative's tasks, saved plans, Make live (task-plans.jsonl; page-press POST)
|
|
85
85
|
'routes-task-links', // batch 11 item 15: links between tasks, Claude's proposals, the quiet moves (task-links.jsonl; page-press POST)
|
|
86
86
|
'routes-proposals', // batch 12 items 2, 3 and 4: everything Claude proposes on an initiative, under its one ✦ (proposals.jsonl; page-press POST)
|
|
87
|
+
'routes-your-move', // workspace grill W3: Your move's overdue outcome verdicts (drafted from Jira) and the first key result with no reading (GET)
|
|
87
88
|
'routes-lab', // idle lab (build U): Intelligence › Lab, the compare view, the specimen files and the Lab's own ✦ (idle-lab.jsonl; page-press POST)
|
|
88
89
|
'routes-loose', // batch 13 item 3: Claude's clean-up of Home's "Not in an initiative" under the group's ✦ (loose-props/; page-press POST)
|
|
89
90
|
'routes-workflows', // workflows-per-initiative (D99): the workflow library, save, copy, archive and the chat's draft (workflows.jsonl; page-press POST)
|
|
@@ -119,6 +119,25 @@ module.exports = {
|
|
|
119
119
|
rows: rowsNow, members, columns: c ? c.columns : [], sprints: s || { source: 'none', sprints: [] }, docs: d, custom,
|
|
120
120
|
items: its && its.body && its.body.ok !== false && Array.isArray(its.body.items) ? its.body.items : [], held: hd ? hd.rows : [], cancelled: cn ? cn.rows : [], archived: ar ? ar.rows : [], asks: ak ? ak.asks || [] : null,
|
|
121
121
|
});
|
|
122
|
+
// workspace grill W2: a row nothing read for says No signal (grey), with where its status came from (r.signal: from, why, source,
|
|
123
|
+
// readAt); the header and the Needs a look menu count them apart from on track, from the board's own count (scripts/boards.js counts)
|
|
124
|
+
try {
|
|
125
|
+
const SS = require('./status-signal.js');
|
|
126
|
+
const sigBy = new Map();
|
|
127
|
+
for (const r of rowsNow.concat(members)) if (r && r.id && r.signal) sigBy.set(r.id, r.signal);
|
|
128
|
+
for (const L of [model.rows, model.initiatives && model.initiatives.list && model.initiatives.list.rows, ...((model.initiatives && model.initiatives.stages) || []).map((x) => x.rows)]) {
|
|
129
|
+
for (const r of L || []) {
|
|
130
|
+
if (!r || !r.id) continue;
|
|
131
|
+
if (r.status === 'no signal') r.statusLabel = 'No signal';
|
|
132
|
+
const g = sigBy.get(r.id);
|
|
133
|
+
if (g) r.signal = { from: g.from || null, why: String(g.why || '').slice(0, 200), source: String(g.source || '').slice(0, 120), readAt: g.readAt || null };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const I = model.initiatives || {}, st = (head.body.counts && head.body.counts.byStatus) || {};
|
|
137
|
+
I.none = Number(st['no signal']) || 0;
|
|
138
|
+
I.words = SS.wordsOf({ open: I.open, on: I.on, risk: I.risk, late: I.late, none: I.none });
|
|
139
|
+
if (model.chips) model.chips.none = I.none;
|
|
140
|
+
} catch { /* the statuses stay as the board sent them */ }
|
|
122
141
|
// every open initiative's anchor, not only the 100 rows listed (batch 17 item 1): Home's product card counts the goals as the ready-made
|
|
123
142
|
// dashboard counts them (lib/ready-reports.js anchorGoalsOf reads the same /api/boards/items rows)
|
|
124
143
|
const iniRows = its && its.body && Array.isArray(its.body.initiatives) ? its.body.initiatives : null;
|
|
@@ -188,6 +207,9 @@ module.exports = {
|
|
|
188
207
|
if (dir) FC.record(dir, pick.ws, items, { fold: F });
|
|
189
208
|
}
|
|
190
209
|
} catch { missing.push('forecasts'); }
|
|
210
|
+
// workspace grill W2: Claude's "Mark … at risk" ✦ on the programs of this product that the PM has not confirmed. The weekly update lists
|
|
211
|
+
// them first ("Before you send") with one press to confirm them all; a confirmed one makes its initiatives At risk on every surface
|
|
212
|
+
try { model.openRisks = require('./status-signal.js').openRisks(require('./routes-programs.js').all(ctx), pick.ws); } catch { model.openRisks = null; }
|
|
191
213
|
// a press re-derives the rows in the background; while the answer is stale the page asks again (initiative premium-board-actions)
|
|
192
214
|
let stale = false;
|
|
193
215
|
try { const w = ctx.getWs ? ctx.getWs(pick.ws) : null; const pk = w ? Rows.peek(w.root) : null; stale = !!(pk && (pk.pending || pk.stale)); } catch { stale = false; }
|