create-pmos 0.517.0 → 0.519.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 +39 -73
- 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/board-columns-test.js +1 -1
- package/workspace/board-programs-test.js +26 -7
- package/workspace/briefing-premium-board-test.js +3 -3
- package/workspace/calm-timeline-test.js +2 -2
- package/workspace/changelog.json +38 -0
- package/workspace/guide/data-freshness.md +26 -4
- package/workspace/guide/index.md +2 -2
- package/workspace/guide/next-prompt.md +4 -2
- package/workspace/guide/programs.md +2 -1
- package/workspace/guide/settings.md +1 -1
- package/workspace/lib/headless.js +12 -2
- package/workspace/lib/refresh-run.js +39 -2
- package/workspace/lib/routes-next-prompt.js +67 -27
- package/workspace/lib/routes-sources.js +6 -2
- package/workspace/lib/source-heal.js +140 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/bd-span.js +13 -17
- package/workspace/public/pm/cozy-evmodel.js +18 -1
- package/workspace/public/pm/cozy-nav.js +5 -5
- package/workspace/public/pm/cozy-settings.js +23 -16
- package/workspace/public/pm/premium-vw-guide.css +7 -2
- package/workspace/server.js +1 -1
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
/* routes-next-prompt.js — the chat's suggested next prompt and its Sharper card over HTTP (batch 26 item 2, design B; lib/next-prompt.js).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/next-prompt/prefs → { ok, next, sharpen, sharpenSet, style, rules, api }
|
|
5
|
-
* next: suggestions after a reply are on. sharpen: Sharpen as I type is on now (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* next: suggestions after a reply are on. sharpen: Sharpen as I type is on now (on unless the PM turned it off; 'auto' is off only
|
|
6
|
+
* while an opted-in API path is failing). sharpenSet: 'auto'|'on'|'off'. style: 'polish'|'engineer'|'own'. rules: whether Your own
|
|
7
|
+
* rules are written. api: the opted-in API path is in use (PMOS_NEXT_PROMPT_API=on and a key). apiDown: { why, auth } while it fails.
|
|
8
8
|
* POST /api/next-prompt/prefs { key: 'next'|'sharpen'|'style'|'rules', value } → { ok } (saved in <runtime>/next-prompt/prefs.json)
|
|
9
9
|
* POST /api/next-prompt/next { key, asked, reply, initiative?, stage?, product?, title? } → { ok, on, cards: [{ text, move, rec }] }
|
|
10
10
|
* POST /api/next-prompt/sharpen { key, text, reply?, initiative?, title? } → { ok, on, text | null }
|
|
11
11
|
*
|
|
12
12
|
* Both POSTs are page presses (lib/pm-guard.js requirePagePress; an agent session header is refused) and run ONE short Claude call:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* a background read-only `claude -p` run on the PM's own Claude login (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…), the
|
|
14
|
+
* default (Wawan 2026-11-04). Only with PMOS_NEXT_PROMPT_API=on does it go to the API with the PM's key (lib/anthropic-key.js, model
|
|
15
|
+
* PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), and a failed API call then falls back to the background run. The answer goes through the shared door, lib/ai-guard.js file():
|
|
15
16
|
* the kill switch (`pmos ai off next-prompt`, `pmos ai off sharpen-prompt`, PMOS_AI=off), the replay gate (fixtures/next-prompt-replay.json,
|
|
16
17
|
* recorded answers, never a model), the citation check and the audit (<runtime>/ai-audit.jsonl). A card with no verified source is never
|
|
17
18
|
* shown. The reply and the typed text a card cites are kept in <runtime>/next-prompt/ (the newest 30 files), outside every repository.
|
|
@@ -61,14 +62,29 @@ function setPref(runtimeDir, key, value) {
|
|
|
61
62
|
const get = (ctx, k) => { const v = readPrefs(ctx.runtimeDir)[k]; return PREFS[k](v) ? v : undefined; };
|
|
62
63
|
const envOf = (ctx) => ctx.env || process.env;
|
|
63
64
|
|
|
65
|
+
/* Wawan 2026-11-04: "just default to normal claude session with -p". Both calls run as a background `claude -p` on the PM's own Claude login.
|
|
66
|
+
* The API key is used only when PMOS_NEXT_PROMPT_API=on opts in. */
|
|
64
67
|
function apiKey(ctx) {
|
|
65
68
|
const env = envOf(ctx);
|
|
66
|
-
if (
|
|
69
|
+
if (!/^(on|1|true|yes)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
|
|
67
70
|
try { return require('./anthropic-key.js').getKey(ctx.runtimeDir, env); } catch { return null; }
|
|
68
71
|
}
|
|
72
|
+
/* The API's health, per state folder, in memory: after a failed call PMOS stops calling the API for DOWN_MS and uses the background run,
|
|
73
|
+
* so a spent balance or a refused key does not fail every reply again. A server restart, or a new key saved, tries the API again. */
|
|
74
|
+
const DOWN_MS = 15 * 60 * 1000;
|
|
75
|
+
const apiDown = new Map(); // runtimeDir → { until, why, auth, key }
|
|
76
|
+
function downOf(ctx, key) {
|
|
77
|
+
const d = apiDown.get(ctx.runtimeDir);
|
|
78
|
+
if (!d) return null;
|
|
79
|
+
if (d.until <= Date.now() || d.key !== keyId(key)) { apiDown.delete(ctx.runtimeDir); return null; }
|
|
80
|
+
return d;
|
|
81
|
+
}
|
|
82
|
+
const keyId = (k) => (k ? crypto.createHash('sha1').update(String(k)).digest('hex').slice(0, 12) : '');
|
|
69
83
|
function prefs(ctx) {
|
|
70
|
-
const set = get(ctx, 'sharpen') || 'auto',
|
|
71
|
-
|
|
84
|
+
const set = get(ctx, 'sharpen') || 'auto', k = apiKey(ctx), down = k ? downOf(ctx, k) : null, api = !!k && !down;
|
|
85
|
+
const style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
|
|
86
|
+
return { next: get(ctx, 'next') !== false, sharpen: set !== 'off' && !(set === 'auto' && k && down), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api,
|
|
87
|
+
...(down ? { apiDown: { why: down.why, auth: down.auth } } : {}) };
|
|
72
88
|
}
|
|
73
89
|
function offWhy(ctx, job) {
|
|
74
90
|
const why = AG.off(job, { env: envOf(ctx), settingsDir: ctx.runtimeDir });
|
|
@@ -81,31 +97,55 @@ function offWhy(ctx, job) {
|
|
|
81
97
|
|
|
82
98
|
/* ── the call ────────────────────────────────────────────────────────────────────────────── */
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
100
|
+
// A model the account cannot use (404) tries the next one before the call counts as failed.
|
|
101
|
+
const MODELS = ['claude-sonnet-5-5', 'claude-sonnet-5', 'claude-haiku-5-5'];
|
|
102
|
+
async function viaApi(key, prompt, env, create = null) {
|
|
103
|
+
const make = create || ((o) => { const Anthropic = require('@anthropic-ai/sdk'); return new Anthropic({ apiKey: key }).messages.create(o); });
|
|
104
|
+
const want = String(env.PMOS_NEXT_PROMPT_MODEL || '').trim();
|
|
105
|
+
const models = want ? [want] : MODELS;
|
|
106
|
+
for (let i = 0; i < models.length; i++) {
|
|
107
|
+
try {
|
|
108
|
+
const res = await make({ model: models[i], max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
|
|
109
|
+
return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
|
|
110
|
+
} catch (e) { if (!(e && e.status === 404) || i === models.length - 1) throw e; }
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
89
113
|
}
|
|
90
|
-
|
|
114
|
+
/** A background read-only Claude run → its answer text, or null after `fail(why)` says why it gave none. */
|
|
115
|
+
function viaHeadless(ctx, prompt, origin, fail = () => {}) {
|
|
91
116
|
const headless = require('./headless.js');
|
|
92
|
-
if (typeof ctx.spawnSession !== 'function'
|
|
117
|
+
if (typeof ctx.spawnSession !== 'function') { fail('no background run here'); return Promise.resolve(null); }
|
|
118
|
+
if (headless.kindFor({ agent: 'claude', origin }) !== 'ask') { fail('the run would not be read-only'); return Promise.resolve(null); }
|
|
93
119
|
return new Promise((resolve) => {
|
|
94
|
-
let settled = false; const done = (v) => { if (
|
|
95
|
-
const t = setTimeout(() => done(null), TIMEOUT_MS);
|
|
120
|
+
let settled = false; const done = (v, why) => { if (settled) return; settled = true; clearTimeout(t); if (v == null) fail(why); resolve(v); };
|
|
121
|
+
const t = setTimeout(() => done(null, 'the run took longer than ' + Math.round(TIMEOUT_MS / 1000) + ' s'), TIMEOUT_MS);
|
|
96
122
|
let s = null;
|
|
97
123
|
try {
|
|
98
124
|
s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
|
|
99
|
-
onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null); } });
|
|
100
|
-
} catch { s = null; }
|
|
101
|
-
if (!s || s.state === 'exited') done(null);
|
|
125
|
+
onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null, 'the run ended without an answer' + (d && d.note ? ': ' + String(d.note).slice(0, 80) : '')); } });
|
|
126
|
+
} catch (e) { s = null; }
|
|
127
|
+
if (!s || s.state === 'exited') done(null, 'the run did not start' + (s && s.note ? ': ' + String(s.note).slice(0, 80) : ''));
|
|
102
128
|
});
|
|
103
129
|
}
|
|
104
|
-
|
|
130
|
+
/** Why an API call failed, in words fit for the audit: the HTTP status and the error's type, never the prompt or the answer. */
|
|
131
|
+
function apiWhy(e) {
|
|
132
|
+
const st = e && (e.status || (e.response && e.response.status));
|
|
133
|
+
const kind = e && ((e.error && e.error.error && e.error.error.type) || e.name || e.code);
|
|
134
|
+
return ['api', st ? String(st) : null, kind ? String(kind).slice(0, 60) : null].filter(Boolean).join(' ');
|
|
135
|
+
}
|
|
136
|
+
/* One short Claude call. With a key it goes to the API; when that call fails (a spent balance, a model the account cannot use, no network),
|
|
137
|
+
* it does not end in silence: the same prompt goes to a background read-only Claude run, and the audit records why the API failed
|
|
138
|
+
* (`pmos ai log next-prompt`); for 15 minutes after, PMOS goes straight to the background run. `ctx.createMessage` replaces the SDK in tests. */
|
|
139
|
+
async function ask(ctx, prompt, { job, key, run }) {
|
|
105
140
|
if (typeof ctx.askClaude === 'function') return ctx.askClaude(prompt, { job, key });
|
|
106
141
|
const k = apiKey(ctx);
|
|
107
|
-
if (k
|
|
108
|
-
|
|
142
|
+
if (k && !downOf(ctx, k)) {
|
|
143
|
+
const failed = (why, auth) => { apiDown.set(ctx.runtimeDir, { until: Date.now() + DOWN_MS, why, auth: !!auth, key: keyId(k) }); AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'api-error', error: why }); };
|
|
144
|
+
try { const t = await viaApi(k, prompt, envOf(ctx), ctx.createMessage || null); if (t && String(t).trim()) return t; failed('api empty answer', false); }
|
|
145
|
+
catch (e) { failed(apiWhy(e), e && (e.status === 401 || e.status === 403)); }
|
|
146
|
+
}
|
|
147
|
+
return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`,
|
|
148
|
+
(why) => AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'run-error', error: String(why).slice(0, 160) }));
|
|
109
149
|
}
|
|
110
150
|
|
|
111
151
|
/* ── what a card cites ───────────────────────────────────────────────────────────────────── */
|
|
@@ -182,7 +222,7 @@ async function next(ctx, b) {
|
|
|
182
222
|
const k = 'n:' + key + ':' + crypto.createHash('sha1').update(reply + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
183
223
|
return once(k, async () => {
|
|
184
224
|
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
185
|
-
const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key });
|
|
225
|
+
const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key, run });
|
|
186
226
|
const cards = NP.parseNext(answer, { moves, place });
|
|
187
227
|
if (!cards.length) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : {}) }); return { ok: true, on: true, cards: [] }; }
|
|
188
228
|
const rel = keep(ctx.runtimeDir, 'reply', reply);
|
|
@@ -200,7 +240,7 @@ async function sharpen(ctx, b) {
|
|
|
200
240
|
const k = 's:' + key + ':' + crypto.createHash('sha1').update(typed + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
201
241
|
return once(k, async () => {
|
|
202
242
|
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
203
|
-
const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key });
|
|
243
|
+
const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key, run });
|
|
204
244
|
const out = NP.parseSharpen(answer, typed);
|
|
205
245
|
if (!out) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : { same: true }) }); return { ok: true, on: true, text: null }; }
|
|
206
246
|
const first = typed.split('\n')[0], rel = keep(ctx.runtimeDir, 'typed', typed);
|
|
@@ -227,7 +267,7 @@ async function handle(req, res, url, ctx) {
|
|
|
227
267
|
if (req.method !== 'GET') return send(bad(405, 'GET or POST only'));
|
|
228
268
|
const P = prefs(ctx), env = envOf(ctx);
|
|
229
269
|
const next = P.next && !AG.off(NP.JOB_NEXT, { env, settingsDir: ctx.runtimeDir }), sh = P.sharpen && !AG.off(NP.JOB_SHARPEN, { env, settingsDir: ctx.runtimeDir });
|
|
230
|
-
return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api } });
|
|
270
|
+
return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api, ...(P.apiDown ? { apiDown: P.apiDown } : {}) } });
|
|
231
271
|
}
|
|
232
272
|
if (what !== 'next' && what !== 'sharpen') return send(bad(404, 'no such next-prompt route'));
|
|
233
273
|
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
@@ -245,4 +285,4 @@ async function handle(req, res, url, ctx) {
|
|
|
245
285
|
catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
|
|
246
286
|
}
|
|
247
287
|
|
|
248
|
-
module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, prefs, setPref, lineRef, replayCases, next, sharpen };
|
|
288
|
+
module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, MODELS, prefs, setPref, lineRef, replayCases, next, sharpen };
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* lib/harness-health.js of(): { level, line, issues } or null; all is the last Refresh all
|
|
6
6
|
* (lib/refresh-run.js allState) or null; derived is the content built from stored reads
|
|
7
7
|
* (refresh-run derived: the brief, core documents, the scan, and the reads each was built from).
|
|
8
|
-
* Each source row has a group: 'machine' (read on this machine) or 'connector'
|
|
8
|
+
* Each source row has a group: 'machine' (read on this machine) or 'connector',
|
|
9
|
+
* and `heal` while Claude repairs it by itself or it needs the PM (lib/source-heal.js view())
|
|
9
10
|
* POST /api/sources/refresh { ws, source } → { ok, source, started: 'rebuild'|'session', session? }
|
|
10
11
|
* POST /api/sources/refresh-all { ws } → { ok, joined, started, already, failed, all } every source that is on, then the
|
|
11
12
|
* Briefing from those reads (briefing-source-flow, slice 3); a press while one runs joins it
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
*/
|
|
19
20
|
const guard = require('./pm-guard.js');
|
|
20
21
|
const freshness = require('./freshness.js');
|
|
22
|
+
const heal_ = require('./source-heal.js');
|
|
21
23
|
|
|
22
24
|
function wsOf(ctx, id) {
|
|
23
25
|
const w = ctx.getWs(id || '');
|
|
@@ -33,7 +35,9 @@ async function handle(req, res, url, ctx) {
|
|
|
33
35
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
34
36
|
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
35
37
|
if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
|
|
36
|
-
|
|
38
|
+
let heal = {}; try { heal = ctx.runtimeDir ? heal_.viewAll(ctx.runtimeDir) : {}; } catch { heal = {}; }
|
|
39
|
+
// self-heal (lib/source-heal.js): a row Claude is repairing, or that needs the PM, carries its repair
|
|
40
|
+
const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) }).map((r) => (heal[r.id] ? { ...r, heal: heal[r.id] } : r));
|
|
37
41
|
let health = null; try { health = ctx.health ? ctx.health() : null; } catch { health = null; }
|
|
38
42
|
let all = null; try { all = ctx.refreshAllState ? ctx.refreshAllState() : null; } catch { all = null; }
|
|
39
43
|
let derived = []; try { derived = ctx.refreshDerived ? ctx.refreshDerived(w.id) : []; } catch { derived = []; }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* source-heal.js — Claude repairs a source that is behind by itself, and asks the PM only when the PM must act
|
|
3
|
+
* (feedback batch 26 item 7, Wawan 2026-11-03: "any issue with connection should self diagnose and resolve unless necessary for
|
|
4
|
+
* the user to take action").
|
|
5
|
+
*
|
|
6
|
+
* diagnose(error, { result }) → cause why a read failed: one of CAUSES' keys
|
|
7
|
+
* step(x) → { h, start?, note?, tell? } one tick for one source: what to do now, and its new repair record
|
|
8
|
+
* view(h, name) → { state, cause, why, tried, action, attempts, next_at } | null what /api/sources shows on the row
|
|
9
|
+
* MAX · BACKOFF_MIN · CAUSES
|
|
10
|
+
*
|
|
11
|
+
* THE REPAIR. A source that is on and behind (stale, failed or never read) is read again by Claude, without a press. Attempt 1 starts
|
|
12
|
+
* at once; the next ones wait BACKOFF_MIN minutes (longer after a rate limit). After each attempt the cause is diagnosed from the
|
|
13
|
+
* run's end (the log's error), and a read that ended "done" while the source is still behind counts as failed ("saved nothing new"):
|
|
14
|
+
* a source is fresh only when a read proved it. A cause the PM must fix (a connector that is not connected, a sign-in that still
|
|
15
|
+
* fails after one more try) stops the repair at once; any other cause stops after MAX attempts. Then the record is `needs-you`,
|
|
16
|
+
* with the one action and what Claude tried. The schedule keeps running all the time, so a source the PM fixed turns fresh on its
|
|
17
|
+
* next read and the record becomes `repaired`.
|
|
18
|
+
*
|
|
19
|
+
* A source that is off is left alone (the PM chose it). A source that is read only on a press has no schedule to be past, so its
|
|
20
|
+
* one action is the press.
|
|
21
|
+
*
|
|
22
|
+
* PURE but for viewAll(dir), which reads <state>/source-heal.json for /api/sources (any server, not only the one that runs the
|
|
23
|
+
* schedules). lib/refresh-run.js holds the records, starts the reads, and writes each attempt to the refresh log (trigger 'repair',
|
|
24
|
+
* with the attempt and the last cause in its note).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const MAX = 5;
|
|
28
|
+
const BACKOFF_MIN = Object.freeze([0, 2, 10, 30, 120]); // minutes before attempt n+1, by the attempts made so far
|
|
29
|
+
const RATE_MIN = Object.freeze([0, 15, 60, 120, 240]); // the same after a rate limit
|
|
30
|
+
const KEEP_REPAIRED_MS = 24 * 3600 * 1000; // a repaired record is kept this long, then dropped
|
|
31
|
+
|
|
32
|
+
const CONNECTORS = 'https://claude.ai/settings/connectors';
|
|
33
|
+
/* cause → why (the words after "because"), user: the PM must act, after: attempts before that is sure (1 = the first failure tells) */
|
|
34
|
+
const CAUSES = Object.freeze({
|
|
35
|
+
'claude-signin': { why: 'Claude is signed out on this computer', user: true, after: 2 },
|
|
36
|
+
'connector-missing': { why: 'its connector is not connected', user: true, after: 1 },
|
|
37
|
+
'sign-in': { why: 'its sign-in has expired', user: true, after: 2 },
|
|
38
|
+
'rate-limit': { why: 'it asked Claude to slow down', user: false },
|
|
39
|
+
network: { why: 'the network or the proxy did not answer', user: false },
|
|
40
|
+
'connector-failed': { why: 'its connector did not start', user: false },
|
|
41
|
+
interrupted: { why: 'the read was stopped before it ended', user: false },
|
|
42
|
+
empty: { why: 'the read ended but saved nothing new', user: false },
|
|
43
|
+
skill: { why: 'the read stopped with an error', user: false },
|
|
44
|
+
manual: { why: 'it is read only when you press ↻', user: true, after: 0 },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Why a read failed, from the error its run ended with. The order matters: Claude's own sign-in first, then a missing connector. */
|
|
48
|
+
function diagnose(error, { result } = {}) {
|
|
49
|
+
const e = String(error || '').toLowerCase();
|
|
50
|
+
if (result === 'done') return 'empty';
|
|
51
|
+
if (/claude is signed out|not logged in|please run \/login|type \/login|invalid api key/.test(e)) return 'claude-signin';
|
|
52
|
+
if (/not connected|connector (is )?(missing|not (found|installed|available|configured|enabled))|no (\w+ )?(connector|mcp server)|no such tool|tools? .{0,40}not (available|found)|mcp server .{0,40}not (found|configured)/.test(e)) return 'connector-missing';
|
|
53
|
+
if (/\b429\b|rate.?limit|too many requests|quota|throttl/.test(e)) return 'rate-limit';
|
|
54
|
+
if (/\b401\b|unauthori[sz]ed|token (has )?expired|expired (token|session|credentials)|re-?authori[sz]|re-?authenticat|sign.?in (has )?expired|reconnect|invalid[_ ]grant|oauth/.test(e)) return 'sign-in';
|
|
55
|
+
if (/econn|etimedout|enotfound|eai_again|network|proxy|socket hang up|tunnel|\b50[234]\b|unreachable|\bdns\b|fetch failed/.test(e)) return 'network';
|
|
56
|
+
if (/failed to connect|mcp.{0,40}(error|failed)|connector.{0,40}(failed|error)/.test(e)) return 'connector-failed';
|
|
57
|
+
if (result === 'interrupted' || /stopped by you|restarted|session was gone/.test(e)) return 'interrupted';
|
|
58
|
+
return 'skill';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const behindOf = (row) => !!row && ['stale', 'failed', 'never'].includes(row.state);
|
|
62
|
+
const waitMin = (cause, n) => { const L = cause === 'rate-limit' ? RATE_MIN : BACKOFF_MIN; return L[Math.min(n, L.length - 1)]; };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One tick for one source. Every field is plain data:
|
|
66
|
+
* x { now, row (lib/freshness.js row), on, manual, busy (a run is going, or the schedule's own retry or a press waits),
|
|
67
|
+
* last (the source's last run in the log: { start_at, end_at, result, error }), h (its repair record or null), canStart }
|
|
68
|
+
* → { h, start?: true, note?, tell?: { cause, why } } h null drops the record
|
|
69
|
+
*/
|
|
70
|
+
function step(x) {
|
|
71
|
+
const { now, row } = x;
|
|
72
|
+
let h = x.h ? { ...x.h, attempts: (x.h.attempts || []).slice() } : null;
|
|
73
|
+
if (!row || !x.on) return { h: null };
|
|
74
|
+
if (!behindOf(row)) {
|
|
75
|
+
if (h && h.state !== 'repaired' && (h.attempts.length || h.state === 'needs-you')) return { h: { ...h, state: 'repaired', repaired_at: now, open: null, next_at: null } };
|
|
76
|
+
if (h && h.state === 'repaired' && now - (h.repaired_at || 0) < KEEP_REPAIRED_MS) return { h };
|
|
77
|
+
return { h: null };
|
|
78
|
+
}
|
|
79
|
+
if (x.manual) return { h: { state: 'needs-you', cause: 'manual', attempts: [], since: (h && h.since) || now } };
|
|
80
|
+
if (!h || h.state === 'repaired' || h.cause === 'manual') h = { state: 'repairing', since: now, attempts: [], open: null, cause: null, next_at: null };
|
|
81
|
+
|
|
82
|
+
// the attempt that is open ends when its run ends in the log
|
|
83
|
+
if (h.open) {
|
|
84
|
+
const l = x.last;
|
|
85
|
+
const ended = l && l.end_at && Date.parse(l.start_at || l.end_at) >= h.open.at - 1000;
|
|
86
|
+
if (!ended) {
|
|
87
|
+
if (now - h.open.at < 30 * 60000 || x.busy) return { h: { ...h, state: 'repairing' } };
|
|
88
|
+
h.attempts.push({ at: h.open.at, result: 'lost', cause: 'interrupted' }); // no run came of it in 30 minutes
|
|
89
|
+
} else {
|
|
90
|
+
const cause = diagnose(l.error, { result: l.result });
|
|
91
|
+
h.attempts.push({ at: h.open.at, result: l.result, cause, error: l.error ? String(l.error).slice(0, 300) : null });
|
|
92
|
+
}
|
|
93
|
+
h.open = null; h.cause = h.attempts[h.attempts.length - 1].cause;
|
|
94
|
+
}
|
|
95
|
+
if (x.busy) return { h: { ...h, state: h.state === 'needs-you' ? 'needs-you' : 'repairing' } };
|
|
96
|
+
if (h.state === 'needs-you') return { h }; // the schedule keeps reading; a good read makes it repaired
|
|
97
|
+
|
|
98
|
+
const n = h.attempts.length;
|
|
99
|
+
const c = h.cause && CAUSES[h.cause];
|
|
100
|
+
const same = n >= (c && c.after || 1) && h.attempts.slice(-(c && c.after || 1)).every((a) => a.cause === h.cause);
|
|
101
|
+
if ((c && c.user && same) || n >= MAX) {
|
|
102
|
+
return { h: { ...h, state: 'needs-you', next_at: null, told_at: h.told_at || now }, tell: h.told_at ? null : { cause: h.cause, why: c ? c.why : '' } };
|
|
103
|
+
}
|
|
104
|
+
const next = n ? h.attempts[n - 1].at + waitMin(h.cause, n) * 60000 : now;
|
|
105
|
+
if (now < next || !x.canStart) return { h: { ...h, state: 'repairing', next_at: Math.max(next, now) } };
|
|
106
|
+
h.open = { at: now };
|
|
107
|
+
return { h: { ...h, state: 'repairing', next_at: null }, start: true, note: `Repair ${n + 1} of ${MAX}` + (c ? `: the last read failed because ${c.why}` : `: ${row.state === 'never' ? 'never read' : row.state === 'failed' ? 'the last read failed' : 'past its schedule'}`) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hm = (ms) => { const d = new Date(ms); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
|
|
111
|
+
|
|
112
|
+
/** What the Sources row and the banner show. `name` is the source's name ('Jira'). */
|
|
113
|
+
function view(h, name = 'It') {
|
|
114
|
+
if (!h) return null;
|
|
115
|
+
const c = h.cause ? CAUSES[h.cause] : null;
|
|
116
|
+
const n = (h.attempts || []).length;
|
|
117
|
+
const tried = n ? `Claude read it again ${n === 1 ? 'once' : n + ' times'} since ${hm(h.attempts[0].at)}${c ? `, and ${c.why}` : ''}.` : '';
|
|
118
|
+
let action = null;
|
|
119
|
+
if (h.state === 'needs-you') {
|
|
120
|
+
if (h.cause === 'claude-signin') action = { label: 'Sign in to Claude', how: 'Open Terminal, run claude, type /login and sign in.', sec: 'claude' };
|
|
121
|
+
else if (h.cause === 'connector-missing') action = { label: 'Connect ' + name, how: 'Turn on its connector in your Claude connectors.', href: CONNECTORS };
|
|
122
|
+
else if (h.cause === 'sign-in') action = { label: 'Reconnect ' + name, how: 'Sign in to its connector again in your Claude connectors.', href: CONNECTORS };
|
|
123
|
+
else action = { label: 'Read ' + name + ' again', how: h.cause === 'manual' ? 'You chose to read it only when you press ↻.' : 'Claude stopped after ' + n + ' tries.', read: true };
|
|
124
|
+
}
|
|
125
|
+
return { state: h.state, cause: h.cause || null, why: c ? c.why : '', tried, action, attempts: n, next_at: h.next_at ? new Date(h.next_at).toISOString() : null,
|
|
126
|
+
...(h.repaired_at ? { repaired_at: new Date(h.repaired_at).toISOString() } : {}) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const NAME = Object.freeze({ calendar: 'your calendar', jira: 'Jira', 'my-work': 'Jira', tracker: 'the tracker files', tools: 'your tools' }); // who the PM reconnects
|
|
130
|
+
const FILE = 'source-heal.json';
|
|
131
|
+
/** Every repair record as view() shows it, keyed by its lib/freshness.js id. A missing or torn file is no repair. */
|
|
132
|
+
function viewAll(dir) {
|
|
133
|
+
let x = {};
|
|
134
|
+
try { x = JSON.parse(require('fs').readFileSync(require('path').join(dir, FILE), 'utf8')) || {}; } catch { return {}; }
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const [id, h] of Object.entries(x)) if (h && typeof h === 'object') out[h.fresh || id] = view(h, NAME[h.fresh || id] || id);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { viewAll, FILE, NAME, diagnose, step, view, MAX, BACKOFF_MIN, RATE_MIN, CAUSES, CONNECTORS };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.519.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.519.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -524,7 +524,7 @@ function scaled(rows, M, o) {
|
|
|
524
524
|
const msO = { sp, reach: Math.max(1, Math.round(40 / pxDay)), mode: o.msMode || 'pinned', noLabel: !!(C && C.msOn) }; // a row's milestone ◇s closer than ~40 px merge (batch 11 item 3); the Filter's milestones (batch 13 item 4)
|
|
525
525
|
const ov = overlay(W, X, now, sprint, rel), pxPct = (tpx * (W.fixed ? 1 : ratio)) / 100;
|
|
526
526
|
const ra = rel && day(rel.iso), sa = sprint && day(sprint.start), sb = sprint && day(sprint.end);
|
|
527
|
-
// an undated
|
|
527
|
+
// an undated initiative's track says so beside today, where the view opens
|
|
528
528
|
const ND = `<span class="tg-nd" style="left:${P(Math.min(90, Math.max(0, X(now))))}">No dates</span>`;
|
|
529
529
|
const askBtn = none.length ? `<a class="bd-chip tg-ask" id="askDates" href="${esc(o.askHref(none))}">${SV('M4 5h16v11h-9l-5 4v-4H4z')}Ask to propose dates</a>` : '';
|
|
530
530
|
const ticks = W.ticks.map((k) => `<span style="left:${P(X(k.at))}" data-y="${k.y}" data-base="${esc(k.base)}"${k.plain ? ' data-plain' : ''}><b>${esc(k.label)}</b><small>${esc(k.sub)}</small></span>`).join('');
|
|
@@ -533,23 +533,19 @@ function scaled(rows, M, o) {
|
|
|
533
533
|
const back = `<button type="button" class="tg-tback" data-today hidden aria-label="Back to today">${SV(CHL)}<span>Today</span>${SV(CHR)}</button>`;
|
|
534
534
|
const attrs = `data-span="${sp}" data-from="${+W.from}" data-to="${+W.to}" data-ratio="${W.fixed ? 1 : ratio.toFixed(5)}" data-now="${+now}"${W.fixed ? ' data-fixed' : ''}`;
|
|
535
535
|
const style = `--tw:${Math.round(tpx * (W.fixed ? 1 : ratio))}px`;
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
//
|
|
541
|
-
const
|
|
542
|
-
// a product with no dated initiative keeps its head and its initiatives as "No dates" rows, so the product is not missing from the Board
|
|
543
|
-
// (batch 14 item 2: pmos-microsites, all undated outside its program, had no group at all); those rows leave the foot's names
|
|
544
|
-
const keepOf = (R) => { const k = R.filter((r) => onList.has(r.key) || r.inProgram); return k.length ? k : R.slice(); };
|
|
545
|
-
const shownNd = new Set(o.groups ? o.groups.filter((g) => g.kind !== 'program' && !g.rows.some((r) => onList.has(r.key) || r.inProgram)).flatMap((g) => g.rows.map((r) => r.key)) : []);
|
|
546
|
-
const footNone = none.filter((r) => !shownNd.has(r.key));
|
|
536
|
+
// every undated initiative is a row that says "No dates" beside today, in every grouping and in no program too (batch 26 item 8, Wawan:
|
|
537
|
+
// the foot alone hid them; this replaces batch 8 item 4's program-only rule and design B6). By product copies a program's row
|
|
538
|
+
// ({ ...r, inProgram }), so a row is matched by key, not by identity (batch 11 item 12)
|
|
539
|
+
const shownKeys = new Set(o.groups ? o.groups.flatMap((g) => (g.kind === 'program' ? g.products.flatMap((x) => x.rows) : g.rows).map((r) => r.key)) : rows.map((r) => r.key));
|
|
540
|
+
// the foot keeps the count and "Ask to propose dates"; it names only an undated initiative no group draws (none, as the Board groups today)
|
|
541
|
+
const footNone = none.filter((r) => !shownKeys.has(r.key));
|
|
547
542
|
// a timeline reads in time (batch 11 item 22): each group's rows by start, then end; undated ones last. The health dot still says late or at risk
|
|
548
543
|
const at = (r) => { const x = extentOf(r, now); return x ? [+x.a, +x.b] : [Infinity, Infinity]; };
|
|
549
544
|
// a pressed column title sorts each group by that column instead (StsA8Board, as in the List)
|
|
550
545
|
const byTime = (L) => (C && C.order ? L.slice().sort(C.order) : L.map((r) => [at(r), r]).sort((p, q) => p[0][0] - q[0][0] || p[0][1] - q[0][1]).map((x) => x[1]));
|
|
551
546
|
list.splice(0, list.length, ...byTime(list));
|
|
552
|
-
const
|
|
547
|
+
const flat = list.concat(none);
|
|
548
|
+
const G = o.groups ? o.groups.map((g) => (g.kind === 'program' ? { ...g, products: g.products.filter((x) => x.rows.length).map((x) => ({ ...x, rows: byTime(x.rows) })) } : { ...g, rows: byTime(g.rows) })).filter((g) => g.rows.length || g.kind === 'program') : null;
|
|
553
549
|
const empty = '<p class="tg-empty">No initiative on screen has a date on record.</p>';
|
|
554
550
|
const ext = (r) => { const x = extentOf(r, now); return x ? ` data-a="${X(x.a).toFixed(3)}" data-b="${X(x.b).toFixed(3)}"` : ''; };
|
|
555
551
|
// a product's head carries its lane from Quarter on (batch 11 item 17); Sprint on a phone, and a page that gives no head cell, keep the plain head
|
|
@@ -561,9 +557,9 @@ function scaled(rows, M, o) {
|
|
|
561
557
|
if (g.kind !== 'program') return lane(g) + (o.folded(g.key) ? '' : g.rows.map(one).join(''));
|
|
562
558
|
const st = o.progState(g.program), shut = o.folded(g.key), tr = shut ? rollUp(g.rows, progSpan(g), X, W, o, msO, pxPct, now) : '';
|
|
563
559
|
return `<div class="tgq-r tg-pg${shut ? ' closed' : ''}" data-prow="${esc(g.program.id)}"><div class="tgq-n tg-pgn">${progName(g, st, o, shut ? offDots(g.rows, now) : '')}</div><div class="tg-p">${ov}${tr}</div></div>` + (shut ? '' : g.products.map((x) => x.rows.map(one).join('')).join(''));
|
|
564
|
-
}).join('') :
|
|
560
|
+
}).join('') : flat.map(one).join('');
|
|
565
561
|
const top = `<div class="tgq-h"><div class="tg-p">${ticks}${pill}</div></div>`;
|
|
566
|
-
const below = none.length ? `<div class="tg-none"
|
|
562
|
+
const below = none.length ? `<div class="tg-none"><span>${none.length} ${none.length === 1 ? 'has' : 'have'} no dates</span>${askBtn}</div>` : '';
|
|
567
563
|
// the Deliveries lane (batch 13 item 4, XcalmA6Phone): a phone shows the next deliveries above the rows (o.lane draws it; none: nothing)
|
|
568
564
|
const lanePh = o.lane ? o.lane({ W, X, rows: list, groups: G, now, phone: true, pxPct }) || '' : '';
|
|
569
565
|
return { html: lanePh + `<div class="tg tg-s tgq" ${attrs}><div class="tg-scw"><div class="tg-sc" tabindex="0" role="group" aria-label="Timeline" style="${style}"><div class="tg-in">${top}<div class="tg-body">${body || empty}</div></div></div>${back}<i class="tg-fade l" aria-hidden="true"></i><i class="tg-fade r" aria-hidden="true"></i></div></div>${below}`, none };
|
|
@@ -581,11 +577,11 @@ function scaled(rows, M, o) {
|
|
|
581
577
|
if (g.kind !== 'program') return lane(g) + (o.folded(g.key) ? '' : g.rows.map((r) => one(r, ' tg-gr')).join(''));
|
|
582
578
|
const lp = g.products[g.products.length - 1], last = lp ? lp.rows[lp.rows.length - 1] : null;
|
|
583
579
|
return progRow(g, o.progState(g.program), X, W, ov, o, msO, pxPct, now) + (o.folded(g.key) ? '' : g.products.map((x) => (g.products.length > 1 ? o.subHtml(x.name) : '') + x.rows.map((r) => one(r, ' tg-kid' + (r === last ? ' last' : ''))).join('')).join(''));
|
|
584
|
-
}).join('') :
|
|
580
|
+
}).join('') : flat.map((r) => one(r)).join('');
|
|
585
581
|
// the sprint band and the release keep their labels under the axis on Quarter, where a week is wide enough to read them
|
|
586
582
|
const axl = sp === 'quarter' ? `${sa && sb && sb > W.from && sa < W.to ? `<span class="tg-axl sb" style="left:${P(X(sa))}">Jira ${esc(sprint.name.toLowerCase())} · ${esc(md(sa))} – ${esc(md(sb))}</span>` : ''}${ra && ra > W.from && ra < W.to ? `<span class="tg-axl rl" style="left:${P(X(ra))}">${SV(FLAG)}${esc(rel.name)} · ${esc(md(ra))}</span>` : ''}` : '';
|
|
587
583
|
const axis = axl ? `<div class="tg-ax"><span class="tg-n"></span><div class="tg-p">${axl}</div></div>` : '';
|
|
588
|
-
const foot = none.length ? `<div class="tg-ft"
|
|
584
|
+
const foot = none.length ? `<div class="tg-ft"><span class="tg-none"><b>${none.length} ${none.length === 1 ? 'has' : 'have'} no dates${footNone.length ? ':' : ''}</b>${footNone.length ? ` <span>${esc(footNone.slice(0, 4).map((r) => r.title).join(', '))}${footNone.length > 4 ? ' and ' + (footNone.length - 4) + ' more' : ''}</span>` : ''}${askBtn}</span></div>` : '';
|
|
589
585
|
// the Deliveries lane (batch 13 item 4, XcalmA6Lane): one row under the axis, a list per column (o.lane draws it; none: nothing)
|
|
590
586
|
const laneH = o.lane ? o.lane({ W, X, rows: list, groups: G, now, phone: false, pxPct }) || '' : '';
|
|
591
587
|
// the columns' width is the name column's (--nw: the sticky block, the left fade and the Deliveries fold all read it)
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* ago(iso, now) → '2 min ago' · '1 hour ago' · '5 days ago' · ''
|
|
5
5
|
* sourceName(row) → the name a source shows: 'Jira', 'Calendar', 'Repository', …
|
|
6
6
|
* staleOf(sources, now) → { id, name, text: 'Jira 5 days old', ago, state } | null the source furthest behind (failed, then the oldest)
|
|
7
|
+
* bannerOf(sources, now) → { id, name, title: 'Reconnect Jira.', body, text, action } | null the one source the PM must act on
|
|
8
|
+
* (self-heal, feedback batch 26 item 7): null while Claude repairs a source by itself, or after it did
|
|
7
9
|
* sourceRows(sources, now) → [{ id, name, sub, when, risk, act: 'read' | 'connect' | '' }] the Sources table (EvSources)
|
|
8
10
|
* partsOf(id) → what may be out of date when that source is old ('Board and the Jira-linked initiatives')
|
|
9
11
|
* fabLabel(here, open, stale, news) → 'Open the menu. You are in Home. 3 new for you. Jira 5 days old' (the button says both dots, FmGrill 13)
|
|
@@ -44,11 +46,26 @@ export function staleOf(sources, now = Date.now()) {
|
|
|
44
46
|
return { id: s.id, name, text, ago: ago(s.as_of, now), state: s.state };
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/** The one source the PM must act on, or null. The server repairs a source that is behind by itself (lib/source-heal.js) and marks a
|
|
50
|
+
* row `heal.state: 'needs-you'` only when the PM must act; until then, and after a repair, there is no banner. The body says what is
|
|
51
|
+
* old and why, what it makes old, and what Claude already tried. It never says a source is fresh: only a read says that. */
|
|
52
|
+
export function bannerOf(sources, now = Date.now()) {
|
|
53
|
+
const L = (sources || []).filter((s) => s && s.heal && s.heal.state === 'needs-you' && s.heal.action);
|
|
54
|
+
if (!L.length) return null;
|
|
55
|
+
const rank = (s) => (s.heal.action.read ? 1 : 0); // a connection to fix leads a read to press
|
|
56
|
+
L.sort((a, b) => rank(a) - rank(b) || (Date.parse(a.as_of || '') || 0) - (Date.parse(b.as_of || '') || 0));
|
|
57
|
+
const s = L[0], h = s.heal, name = sourceName(s);
|
|
58
|
+
const when = s.as_of ? name + ' was last read ' + ago(s.as_of, now) : name + ' was never read';
|
|
59
|
+
const body = when + (h.why ? ' because ' + h.why : '') + '. ' + partsOf(s.id) + ' may be out of date.' + (h.tried ? ' ' + h.tried : '');
|
|
60
|
+
return { id: s.id, name, title: h.action.label + '.', body, text: h.action.label, action: h.action, cause: h.cause };
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
/** The Sources table: one row per source, in the order the server lists them. */
|
|
48
64
|
export function sourceRows(sources, now = Date.now()) {
|
|
49
65
|
return (sources || []).filter(Boolean).map((s) => {
|
|
50
66
|
const behind = s.state === 'stale' || s.state === 'failed' || s.state === 'never';
|
|
51
|
-
const
|
|
67
|
+
const mend = behind && s.heal && s.heal.state === 'repairing';
|
|
68
|
+
const when = s.state === 'off' ? 'Not connected' : s.state === 'building' ? 'Reading now' : mend ? 'Reading again' : s.state === 'never' ? 'Never read'
|
|
52
69
|
: s.state === 'failed' ? 'Could not be read' : s.id === 'repository' && s.state === 'fresh' ? 'Watching' : ago(s.as_of, now) || 'Never read';
|
|
53
70
|
return { id: s.id, name: sourceName(s), sub: SRC_SUB[s.id] || s.label || '', when, risk: behind, act: s.state === 'off' ? 'connect' : behind && s.id !== 'repository' ? 'read' : '' };
|
|
54
71
|
});
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* One button, bottom left on desktop and bottom right on a phone, opens the menu: the three places, Home, Board and Intelligence; then a
|
|
6
6
|
* tray of three icons, Search (⌘K on a desk), Guide and Settings. It is the one way between places, and it holds nothing else: New for you is a fold
|
|
7
7
|
* in Your day, Sources are Settings › Connectors, Theme is Settings › General, and More is gone (feedback batch 16 item 5).
|
|
8
|
-
* The button keeps its two dots on every page: blue when something is new for you, amber when a source
|
|
8
|
+
* The button keeps its two dots on every page: blue when something is new for you, amber when a source needs you (Claude repairs the rest by itself). Open, the blue dot sits on
|
|
9
9
|
* the Home row (where Your day is) and the amber dot on the Settings icon; the button's own name says both ("Open the menu. 3 new for you.
|
|
10
10
|
* Jira is stale.").
|
|
11
11
|
* Search pm/cozy-search.js: across the repository and every worktree; Ctrl K (Cmd K) or "/" opens it from any page
|
|
12
12
|
* Guide pm/premium-vw-guide.js: the user guide's Start here, in the Viewer of the page you are on (initiative guide-in-app)
|
|
13
|
-
* Settings pm/cozy-settings.js; it opens at Connectors while a source
|
|
13
|
+
* Settings pm/cozy-settings.js; it opens at Connectors while a source needs you, so that one is the first thing there
|
|
14
14
|
* A page that passes opts.here() runs it when its own place is pressed, in place of a reload (Home: Menu > Home closes the page that is open).
|
|
15
15
|
* The counts come from GET /api/news-for-you and GET /api/sources, read when the page opens and every two minutes while it is shown; each
|
|
16
16
|
* read of New for you is passed on as the event 'cz:news-for-you' (detail: the answer), so Home's Your day and Good morning follow it.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* call, the menu open, a desk, Settings > Adler not Full, or after Quiet after: no ears (zero is silence).
|
|
21
21
|
*/
|
|
22
22
|
import { esc, svg, ICON, wsQ } from './cozy-evui.js';
|
|
23
|
-
import { fabLabel,
|
|
23
|
+
import { fabLabel, bannerOf } from './cozy-evmodel.js';
|
|
24
24
|
import { mountToastFaces, loadPrefs, adlerPeek, asleep } from './cozy-adler.js'; // Adler (batch 16 item 2): every toast carries his S face; Settings > Adler is read once
|
|
25
25
|
|
|
26
26
|
export const PLACES = [
|
|
@@ -134,7 +134,7 @@ export function mountNav(here, doc = document, opts = {}) {
|
|
|
134
134
|
if (doc.hidden) return;
|
|
135
135
|
const [n, s] = await Promise.all([getj('/api/news-for-you' + wsQ()), getj('/api/sources' + wsQ())]);
|
|
136
136
|
if (n && n.ok) { N.news = n.count || 0; doc.dispatchEvent(new CustomEvent('cz:news-for-you', { detail: n })); }
|
|
137
|
-
if (s && s.ok) { N.stale =
|
|
137
|
+
if (s && s.ok) { N.stale = bannerOf(s.sources); doc.dispatchEvent(new CustomEvent('cz:sources', { detail: s })); } // Your day's failed source (AdP5)
|
|
138
138
|
if (menu.hidden || !menu.contains(doc.activeElement)) draw();
|
|
139
139
|
};
|
|
140
140
|
read();
|
|
@@ -143,6 +143,6 @@ export function mountNav(here, doc = document, opts = {}) {
|
|
|
143
143
|
doc.addEventListener('cz:news', (e) => { if (e.detail && Number.isFinite(e.detail.count)) { N.news = e.detail.count; draw(); } else read(); });
|
|
144
144
|
doc.addEventListener('cz:calls', (e) => { const n = e.detail && Number(e.detail.count); N.calls = Number.isFinite(n) && n > 0 ? n : 0; draw(); });
|
|
145
145
|
doc.addEventListener('adler:prefs', () => { ears.innerHTML = ''; draw(); });
|
|
146
|
-
window.addEventListener('pm:sources', (e) => { const d = e.detail; if (d && d.ok) { N.stale =
|
|
146
|
+
window.addEventListener('pm:sources', (e) => { const d = e.detail; if (d && d.ok) { N.stale = bannerOf(d.sources); draw(); } });
|
|
147
147
|
return w;
|
|
148
148
|
}
|