create-pmos 0.517.0 → 0.518.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 +14 -42
- 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/changelog.json +13 -0
- package/workspace/guide/data-freshness.md +26 -4
- package/workspace/lib/refresh-run.js +39 -2
- 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/cozy-evmodel.js +18 -1
- package/workspace/public/pm/cozy-nav.js +5 -5
- package/workspace/public/pm/cozy-settings.js +15 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.518.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
"audit"
|
|
33
33
|
],
|
|
34
34
|
"pmosChanges": [
|
|
35
|
+
{
|
|
36
|
+
"v": "0.518.0",
|
|
37
|
+
"at": "2026-11-04T00:33:13.219Z",
|
|
38
|
+
"f": 4,
|
|
39
|
+
"prs": [
|
|
40
|
+
{
|
|
41
|
+
"n": 1211,
|
|
42
|
+
"t": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
|
|
43
|
+
"s": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
|
|
44
|
+
"k": "new"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
35
48
|
{
|
|
36
49
|
"v": "0.517.0",
|
|
37
50
|
"at": "2026-11-04T00:17:56.633Z",
|
|
@@ -391,47 +404,6 @@
|
|
|
391
404
|
"d": "The sign-in check could fail a user who was signed in, and the picker showed one login twice. Now it checks the real login and lists each one once."
|
|
392
405
|
}
|
|
393
406
|
]
|
|
394
|
-
},
|
|
395
|
-
{
|
|
396
|
-
"v": "0.498.0",
|
|
397
|
-
"at": "2026-11-03T02:56:03.824Z",
|
|
398
|
-
"f": 4,
|
|
399
|
-
"prs": [
|
|
400
|
-
{
|
|
401
|
-
"n": 1176,
|
|
402
|
-
"t": "Commit as the repo's own git identity; ask the PM once when it has none",
|
|
403
|
-
"s": "Commit as the repo's own git identity; ask the PM once when it has none",
|
|
404
|
-
"k": "fix",
|
|
405
|
-
"h": "Commits use the right name",
|
|
406
|
-
"d": "PMOS could commit under a guessed name and email. Now it uses the repo's own identity, and asks you once when there is none."
|
|
407
|
-
},
|
|
408
|
-
{
|
|
409
|
-
"n": 1178,
|
|
410
|
-
"t": "Full task names wrap; add-task chips for stage, type, dates, owner",
|
|
411
|
-
"s": "Full task names wrap; add-task chips for stage, type, dates, owner",
|
|
412
|
-
"k": "new",
|
|
413
|
-
"h": "Full task names, quicker new tasks",
|
|
414
|
-
"d": "Long task names were cut off. Now they wrap in full, and a new task has chips for its stage, type, dates and owner.",
|
|
415
|
-
"top": "Board and Timeline show full task names, and a new task takes its stage, dates and owner in one go."
|
|
416
|
-
},
|
|
417
|
-
{
|
|
418
|
-
"n": 1179,
|
|
419
|
-
"t": "Each product repo pushes with its own GitHub login",
|
|
420
|
-
"s": "Each product repo pushes with its own GitHub login",
|
|
421
|
-
"k": "new",
|
|
422
|
-
"h": "Each repo pushes as you",
|
|
423
|
-
"d": "Every product repo pushed with the same GitHub login. Now each one pushes with its own login.",
|
|
424
|
-
"top": "Setup is steadier: each repo pushes with its own GitHub login and commits with its own git identity."
|
|
425
|
-
},
|
|
426
|
-
{
|
|
427
|
-
"n": 1177,
|
|
428
|
-
"t": "A workflow routes the initiative; blocked steps ask only as ✦ proposals",
|
|
429
|
-
"s": "A workflow routes the initiative; blocked steps ask only as ✦ proposals",
|
|
430
|
-
"k": "fix",
|
|
431
|
-
"h": "Workflows guide the work",
|
|
432
|
-
"d": "A blocked step could interrupt you with a question. Now the initiative's workflow decides the next step, and a blocked one shows up only as a ✦ proposal."
|
|
433
|
-
}
|
|
434
|
-
]
|
|
435
407
|
}
|
|
436
408
|
]
|
|
437
409
|
}
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-04T00:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T00:33:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-04T00:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T00:33:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-04T00:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T00:33:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/changelog.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "0.518.0",
|
|
4
|
+
"at": "2026-11-04T00:33:13.219Z",
|
|
5
|
+
"f": 4,
|
|
6
|
+
"prs": [
|
|
7
|
+
{
|
|
8
|
+
"n": 1211,
|
|
9
|
+
"t": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
|
|
10
|
+
"s": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
|
|
11
|
+
"k": "new"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"v": "0.517.0",
|
|
4
17
|
"at": "2026-11-04T00:17:56.633Z",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Data freshness: how the workspace keeps up to date, and how old each source is"
|
|
4
|
-
timestamp: 2026-11-
|
|
4
|
+
timestamp: 2026-11-04
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Data freshness
|
|
@@ -55,7 +55,29 @@ The Sources list shows them as one row, **Your tools**. Its time is the oldest r
|
|
|
55
55
|
|
|
56
56
|
## Refresh all: the sources first, then the Briefing
|
|
57
57
|
|
|
58
|
-
On Home, the Board and Intelligence, the sources list is in **Settings › Connectors**. There is no Sources card in the place menu. Each source shows when PMOS last read it. A source that is old or failed has an amber dot and ↻: press ↻ to read it again. ↻ in the Connectors header is **Refresh all**.
|
|
58
|
+
On Home, the Board and Intelligence, the sources list is in **Settings › Connectors**. There is no Sources card in the place menu. Each source shows when PMOS last read it. A source that is old or failed has an amber dot and ↻: press ↻ to read it again. ↻ in the Connectors header is **Refresh all**. When a source needs you, an amber box at the top of Settings names it, and the place menu opens Settings at Connectors. Claude repairs the other problems by itself, so you do not see a box for them (see below).
|
|
59
|
+
|
|
60
|
+
## When a source cannot be read: Claude repairs it first
|
|
61
|
+
|
|
62
|
+
You do not need to watch the sources. When a source that is on is past its limit, never read, or its last read failed, Claude reads it again by itself, without a press. Calendar, Jira, My work, tracker items and Your tools are each repaired this way.
|
|
63
|
+
|
|
64
|
+
1. The first repair starts at once. The next ones wait 2, 10, 30 and 120 minutes. After a rate limit they wait 15, 60, 120 and 240 minutes. Claude tries at most 5 times. A repair waits while a read of that source is already going, in your quiet hours, and while the run limit is reached.
|
|
65
|
+
2. After each try, Claude finds why the read failed: the sign-in of the connector expired, the connector is not connected or did not start, the network or the proxy did not answer, the tool asked to slow down (rate limit), Claude is signed out on this computer, or the skill stopped with an error. A read that ends but saves nothing new also counts as a failure.
|
|
66
|
+
3. Each try is a line in the refresh log, with the trigger **repair** and a note such as "Repair 2 of 5: the last read failed because the network or the proxy did not answer".
|
|
67
|
+
|
|
68
|
+
While Claude repairs a source, the source row says **Reading again** with its amber dot, because its data is still old. There is no amber box. When a read proves that the source is up to date, the row shows its new time. PMOS says a source is up to date only when a read proved it.
|
|
69
|
+
|
|
70
|
+
The amber box shows only when you must act. It names the one action, links to the place where you do it, and says what Claude already tried. For example: **Reconnect Jira.** Jira was last read 3 days ago because its sign-in has expired. Board and the Jira-linked initiatives may be out of date. Claude read it again 2 times since 07:02.
|
|
71
|
+
|
|
72
|
+
| Why | When the box shows | The action |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| The connector is not connected | after the first try | **Connect** the source in your Claude connectors (claude.ai/settings/connectors) |
|
|
75
|
+
| The sign-in of the connector expired | after the same failure twice | **Reconnect** the source in your Claude connectors |
|
|
76
|
+
| Claude is signed out on this computer | after the same failure twice | **Sign in to Claude** in Settings › Claude (Terminal: run `claude`, type /login) |
|
|
77
|
+
| The network, a rate limit, a connector that did not start, or a skill error | after 5 tries | ↻ **Read it again** |
|
|
78
|
+
| The source is read only when you press Refresh | at once | ↻ **Read it again** |
|
|
79
|
+
|
|
80
|
+
The box also adds one item to Needs you, once. The schedule keeps running while the box shows, so after you fix the connection the next read clears the box. A source that you switched off is not repaired and shows no box. Its row still says how old it is.
|
|
59
81
|
|
|
60
82
|
**Refresh all** in Sources is one run, in a fixed order:
|
|
61
83
|
|
|
@@ -106,7 +128,7 @@ A refresh waits for the sources it uses. The briefing waits for work assigned to
|
|
|
106
128
|
|
|
107
129
|
- **Catch up**: a refresh missed while PMOS was closed runs once when PMOS starts. Off, the missed slot is skipped and written to the log.
|
|
108
130
|
- **Retry**: a failed scheduled refresh is tried once more after 15 or 60 minutes.
|
|
109
|
-
- **Tell me**: a failed scheduled refresh adds one item to Needs you on the Briefing.
|
|
131
|
+
- **Tell me**: a failed scheduled refresh adds one item to Needs you on the Briefing. While Claude repairs that source, the repair adds the item instead, once, and only when you must act.
|
|
110
132
|
- **Runs at once**: at most this many scheduled refreshes run together. A refresh you press is never held.
|
|
111
133
|
- No scheduled refresh starts in your quiet hours; the background jobs keep to the day check's working hours.
|
|
112
134
|
- **Deep work runs at night or when you step away.** Your impact, metric drafts and reads, Claude's morning checks of initiatives, and core document drafts run between 01:00 and 05:00. If PMOS was not running then, they run when you have not pressed anything or typed in a terminal for 30 minutes. Quiet hours hold them too. Each one still runs at most once a day.
|
|
@@ -120,4 +142,4 @@ A refresh waits for the sources it uses. The briefing waits for work assigned to
|
|
|
120
142
|
|
|
121
143
|
### The refresh log
|
|
122
144
|
|
|
123
|
-
Every background run is written to `refresh-log.jsonl` in your runtime folder on this computer: a start line and an end line with the result, the time it took, what it got and the error. A skipped slot writes a line with the reason. The start line records each input's state and when it last refreshed. A done refresh of Jira or of work assigned to you keeps a copy of what it read, so you can see what PMOS knew at any time. Lines and copies older than the keep setting (30, 90 or 365 days, or forever; 90 by default) are removed once a day. The log view filters by source and result, and **Export** downloads it as CSV.
|
|
145
|
+
Every background run is written to `refresh-log.jsonl` in your runtime folder on this computer: a start line and an end line with the result, the time it took, what it got and the error. A skipped slot writes a line with the reason. The start line records each input's state and when it last refreshed. A done refresh of Jira or of work assigned to you keeps a copy of what it read, so you can see what PMOS knew at any time. Lines and copies older than the keep setting (30, 90 or 365 days, or forever; 90 by default) are removed once a day. The repair of each source is kept in `source-heal.json` next to the log: each try, its time and why it failed. The log view filters by source and result, and **Export** downloads it as CSV.
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* run.refreshAll(ws) · run.allState() Refresh all (briefing-source-flow, slice 3): read every source that is on, then write the
|
|
14
14
|
* Briefing from those reads. One run at a time: a second press joins the run that is going.
|
|
15
15
|
* run.rows(ws) · run.setSource(id, patch) · run.setRules(patch) · run.ensureOn(id, ws) · run.settings() · run.log()
|
|
16
|
+
* run.heal() the repair of each source that is behind (lib/source-heal.js), by its lib/freshness.js id:
|
|
17
|
+
* { jira: { state: 'repairing'|'needs-you'|'repaired', cause, why, tried, action, … } }
|
|
16
18
|
*
|
|
17
19
|
* lib/refresh.js decides; this file holds the state between minutes and calls the host. The host is server.js:
|
|
18
20
|
* { stateDir, now?, homeWs(), proactive() → { quiet, dayCheck: { start, end, every } }, schedules: { read, write },
|
|
@@ -24,6 +26,7 @@
|
|
|
24
26
|
const fs = require('fs');
|
|
25
27
|
const path = require('path');
|
|
26
28
|
const R = require('./refresh.js');
|
|
29
|
+
const H = require('./source-heal.js');
|
|
27
30
|
|
|
28
31
|
const PRUNE_EVERY_MS = 24 * 3600 * 1000;
|
|
29
32
|
const LOST_MS = 6 * 3600 * 1000; // a run with no live session and no end after this long is written as interrupted
|
|
@@ -66,6 +69,11 @@ function create(host) {
|
|
|
66
69
|
const win = () => { const d = (host.proactive() || {}).dayCheck || {}; return { start: d.start || '08:00', end: d.end || '18:00' }; };
|
|
67
70
|
const quiet = () => { const q = (host.proactive() || {}).quiet; return q && q.start && q.end ? q : null; };
|
|
68
71
|
const log = () => R.foldLog(R.readLog(dir));
|
|
72
|
+
/* self-heal (feedback batch 26 item 7): one repair record per skill source that reads a tool, in <state>/source-heal.json */
|
|
73
|
+
const healFile = () => path.join(dir, H.FILE);
|
|
74
|
+
const readHeal = () => { try { const x = JSON.parse(fs.readFileSync(healFile(), 'utf8')); return x && typeof x === 'object' ? x : {}; } catch { return {}; } };
|
|
75
|
+
function writeHeal(x) { try { fs.mkdirSync(dir, { recursive: true }); const tmp = `${healFile()}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(x) + '\n'); fs.renameSync(tmp, healFile()); } catch (e) { warn('source repair state', e); } }
|
|
76
|
+
const healing = (id) => { const h = readHeal()[id]; return !!h && h.state !== 'repaired'; };
|
|
69
77
|
|
|
70
78
|
/* ── the state of each source between minutes ── */
|
|
71
79
|
const openOf = (st) => (st && st.open && typeof st.open === 'object' ? st.open : {});
|
|
@@ -143,7 +151,8 @@ function create(host) {
|
|
|
143
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);
|
|
144
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
|
|
145
153
|
if (result === 'done' && src && src.group === 'tools') afterRead(s.ws || null);
|
|
146
|
-
|
|
154
|
+
// 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
|
+
if (result === 'failed' && scheduled && rules.tell === 'brief' && !next.retry_at && !healing(id)) {
|
|
147
156
|
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); }
|
|
148
157
|
}
|
|
149
158
|
}
|
|
@@ -227,11 +236,39 @@ function create(host) {
|
|
|
227
236
|
}
|
|
228
237
|
start(src, ws, { trigger: d.trigger, slot: d.slot, note: d.note });
|
|
229
238
|
}
|
|
239
|
+
try { healTick(t, set); } catch (e) { warn('source repair', e); }
|
|
230
240
|
if (t - lastPrune > PRUNE_EVERY_MS) {
|
|
231
241
|
lastPrune = t;
|
|
232
242
|
try { const p = R.prune(dir, rules.keep, t); if (p.lines || p.copies) warn('refresh log', { message: `removed ${p.lines} lines and ${p.copies} copies older than ${rules.keep} days` }); } catch (e) { warn('refresh log prune', e); }
|
|
233
243
|
}
|
|
234
244
|
}
|
|
245
|
+
/** Read again, by itself, each source that is on and behind; diagnose each failed read; ask the PM only when the PM must act. */
|
|
246
|
+
function healTick(t, set) {
|
|
247
|
+
const heal = readHeal(); const before = JSON.stringify(heal);
|
|
248
|
+
const rowsByWs = new Map();
|
|
249
|
+
const rowOf = (ws, fid) => { if (!rowsByWs.has(ws)) { let r = []; try { r = host.freshRows(ws) || []; } catch (e) { warn('source repair rows', e); } rowsByWs.set(ws, r); } return rowsByWs.get(ws).find((x) => x.id === fid) || null; };
|
|
250
|
+
const f = log();
|
|
251
|
+
for (const src of R.SOURCES.filter((x) => x.run === 'skill' && x.fresh)) {
|
|
252
|
+
const x = set.sources[src.id] || {}; const cur = R.readState(dir); const st = cur[src.id] || {};
|
|
253
|
+
const ws = x.ws || host.homeWs();
|
|
254
|
+
const busy = running(st) || !!st.pending || !!st.waiting_since || (!!st.retry_at && st.retry_at > t);
|
|
255
|
+
const canStart = runningCount(cur) < Number(set.rules.max || 3) && !R.inQuiet(t, quiet());
|
|
256
|
+
const r = H.step({ now: t, row: rowOf(ws, src.fresh), on: !!x.on, manual: !!(x.s && x.s.kind === 'manual'), busy, last: f.lastRun(src.id), h: heal[src.id] || null, canStart });
|
|
257
|
+
if (r.h) heal[src.id] = { ...r.h, fresh: src.fresh }; else delete heal[src.id];
|
|
258
|
+
if (r.start) {
|
|
259
|
+
writeHeal(heal);
|
|
260
|
+
const s0 = start(src, ws, { trigger: 'repair', note: r.note });
|
|
261
|
+
if (!s0.ok) warn('source repair', { message: `${src.label} could not start: ${s0.error || 'no session'}` });
|
|
262
|
+
}
|
|
263
|
+
if (r.tell) {
|
|
264
|
+
const v = H.view(heal[src.id], H.NAME[src.fresh] || src.label);
|
|
265
|
+
try { host.tell({ source: src.id, label: src.label, error: `${v.why}. ${v.tried} ${v.action ? v.action.label + ': ' + v.action.how : ''}`.trim(), ws }); } catch (e) { warn('source repair note', e); }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (JSON.stringify(heal) !== before) writeHeal(heal);
|
|
269
|
+
}
|
|
270
|
+
/** What /api/sources shows per source: the repair view, keyed by the lib/freshness.js id. */
|
|
271
|
+
const healView = () => H.viewAll(dir);
|
|
235
272
|
function start(src, ws, { trigger, slot, note }) {
|
|
236
273
|
expect.set(src.id, { trigger, slot, note });
|
|
237
274
|
let r;
|
|
@@ -415,7 +452,7 @@ function create(host) {
|
|
|
415
452
|
last_run: l ? l.start_at : null, last_status: l ? (l.result === 'failed' ? 'fail' : l.result === 'done' ? 'ok' : 'started') : st.last_result || null };
|
|
416
453
|
}
|
|
417
454
|
|
|
418
|
-
return { 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 };
|
|
455
|
+
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 };
|
|
419
456
|
}
|
|
420
457
|
|
|
421
458
|
module.exports = { create };
|
|
@@ -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.518.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.518.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -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
|
}
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
* Adler's moments (batch 17 item 4-B, AdM4): one face per section, where Claude speaks. Updates: glasses while it checks or installs,
|
|
26
26
|
* holding the card when a version is ready, calm when up to date. General: holding the update card, else the Jira promise (only after
|
|
27
27
|
* Commit). A failure in the status line takes the honest face, and then the section's face steps back (one Adler per screen).
|
|
28
|
-
* The card at the bottom of General says when an update is ready. While a source
|
|
29
|
-
* it with
|
|
28
|
+
* The card at the bottom of General says when an update is ready. While a source needs you (lib/source-heal.js), an amber box at the top of every section names
|
|
29
|
+
* it with its one action and what Claude tried, and the place menu opens Settings at Connectors. Every write is the PM's press (pmPost carries the launch nonce).
|
|
30
30
|
* PMOS reads the sources and never posts to them.
|
|
31
31
|
* openSettings(section?) */
|
|
32
32
|
import { mountClaudeSetup } from './cozy-claude-setup.js';
|
|
33
33
|
import * as JL from './cozy-jl.js';
|
|
34
34
|
import { pmGet, pmPost, pressFetch } from './http.js';
|
|
35
35
|
import { esc, svg, ICON, toast, wsParam, wsQ } from './cozy-evui.js';
|
|
36
|
-
import { sourceRows,
|
|
36
|
+
import { sourceRows, bannerOf } from './cozy-evmodel.js';
|
|
37
37
|
import * as AD from './cozy-adler.js';
|
|
38
38
|
import * as SE from './cozy-seasons.js';
|
|
39
39
|
import { weeksOf, lineOf, storiesOf } from './upd-weeks.js';
|
|
@@ -182,11 +182,16 @@ function readsHtml() {
|
|
|
182
182
|
const L = T.lines ? [['Today', T.lines.readLine(s, ['calendar'])], ['Needs you', T.lines.readLine(s, ['initiatives', 'my-work', 'd:brief'])]].filter((x) => x[1]) : [];
|
|
183
183
|
return `${health}<div class="ev-reads">${rows}</div>${L.map(([k, v]) => `<p class="ev-small ev-mut ev-rl" data-reads="${esc(k)}"><b>${esc(k)}:</b> ${esc(v)}</p>`).join('')}`;
|
|
184
184
|
}
|
|
185
|
-
/** The amber box at the top of Settings
|
|
185
|
+
/** The amber box at the top of Settings, only when the PM must act on a source (self-heal, feedback batch 26 item 7). While Claude
|
|
186
|
+
* reads a source again by itself, or after it did, there is no box. The box names the one action and links to the exact place:
|
|
187
|
+
* the Claude connectors, Settings › Claude, or ↻ when only a press reads it. It says what Claude already tried. */
|
|
186
188
|
function staleHtml() {
|
|
187
|
-
const
|
|
188
|
-
if (!
|
|
189
|
-
|
|
189
|
+
const b = T.fresh && T.fresh.ok ? bannerOf(T.fresh.sources) : null;
|
|
190
|
+
if (!b) return '';
|
|
191
|
+
const a = b.action;
|
|
192
|
+
const act = a.read ? readBtn(b.id, a.label, 'ev-ib ev-ib-card')
|
|
193
|
+
: `<a class="ev-ib ev-ib-card" href="${esc(a.href || '#' + a.sec)}"${a.href ? ' target="_blank" rel="noopener"' : ` data-ev-sec="${esc(a.sec)}"`} aria-label="${esc(a.label)}" title="${esc(a.label)}">${svg(ICON.open, 18)}</a>`;
|
|
194
|
+
return `<div class="ev-warn ev-line ev-st-warn" id="srcWarn" data-cause="${esc(b.cause || '')}"><i class="ev-dot risk" aria-hidden="true"></i><p class="ev-grow"><b>${esc(b.title)}</b> ${esc(b.body)}${a.read ? '' : ' ' + esc(a.how)}</p>${act}</div>`;
|
|
190
195
|
}
|
|
191
196
|
function connectors() {
|
|
192
197
|
if (T.src === null) return readsHtml() + '<p class="ev-small ev-mut">Reading the connectors.</p>';
|
|
@@ -315,8 +320,8 @@ function draw() {
|
|
|
315
320
|
// a redraw replaces the nodes: keep focus on the same control (by its data-ev-* key), else on the open section, never on the body
|
|
316
321
|
const ae = document.activeElement, had = ae && el.contains(ae), key = had ? [...ae.attributes].find((a) => a.name.startsWith('data-ev-')) : null;
|
|
317
322
|
const n = { connectors: T.src === null ? '' : Object.keys(SRC).filter((k) => T.src[k]).length + T.conn.filter((c) => !c.source && T.con[c.name]).length, workspaces: T.ws ? T.ws.filter((x) => !x.linked).length : '', updates: ready() ? '<span class="ev-pill y">1</span>' : '' };
|
|
318
|
-
const stale = T.fresh && T.fresh.ok ?
|
|
319
|
-
el.querySelector('.ev-st-nav').innerHTML = '<h2 class="ev-h2">Settings</h2>' + SECTIONS.map(([k, l]) => PAGE_LINKS[k] ? `<a href="${PAGE_LINKS[k]}" class="ev-mi">${esc(l)}</a>` : `<a href="#${k}" class="ev-mi" data-ev-sec="${k}"${T.sec === k ? ' aria-current="page"' : ''}>${esc(l)}${k === 'connectors' && stale ? '<i class="ev-dot risk" aria-label="A source
|
|
323
|
+
const stale = T.fresh && T.fresh.ok ? bannerOf(T.fresh.sources) : null;
|
|
324
|
+
el.querySelector('.ev-st-nav').innerHTML = '<h2 class="ev-h2">Settings</h2>' + SECTIONS.map(([k, l]) => PAGE_LINKS[k] ? `<a href="${PAGE_LINKS[k]}" class="ev-mi">${esc(l)}</a>` : `<a href="#${k}" class="ev-mi" data-ev-sec="${k}"${T.sec === k ? ' aria-current="page"' : ''}>${esc(l)}${k === 'connectors' && stale ? '<i class="ev-dot risk" aria-label="A source needs you" role="img"></i>' : ''}${n[k] !== undefined && n[k] !== '' ? `<span class="ev-k">${typeof n[k] === 'number' ? n[k] : n[k]}</span>` : ''}</a>`).join('');
|
|
320
325
|
const label = (SECTIONS.find((x) => x[0] === T.sec) || SECTIONS[0])[1];
|
|
321
326
|
const all = T.sec === 'connectors' ? `<button type="button" class="ev-ib" data-ev-readall aria-label="Read every source again" title="Read all again"${T.reading ? ' disabled' : ''}>${svg(RD, 18)}</button>` : '';
|
|
322
327
|
el.querySelector('.ev-st-main').innerHTML = staleHtml() + `<div class="ev-top"><h3 class="ev-h2" id="evStTitle">${esc(label)}</h3>${all}<button type="button" class="ev-ib" data-ev-close aria-label="Close settings" title="Close settings">${svg(ICON.close, 18)}</button></div>`
|
|
@@ -512,6 +517,7 @@ async function load() {
|
|
|
512
517
|
export function openSettings(section) {
|
|
513
518
|
const el = mount();
|
|
514
519
|
if (section === 'dress') { T.sec = 'adler'; T.dress = true; } else if (section && BODY[section]) { T.sec = section; T.dress = false; }
|
|
520
|
+
else if (el.hidden) { T.sec = 'general'; T.dress = false; } // a fresh open with no section opens on General (R2), not on the section of the last visit
|
|
515
521
|
if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.wkMore = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; T.np = null; JL.reset(); }
|
|
516
522
|
el.hidden = false; document.getElementById('evStScrim').hidden = false; el.setAttribute('data-esc-layer', '');
|
|
517
523
|
draw();
|