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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.517.0",
3
+ "version": "0.519.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,44 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.519.0",
37
+ "at": "2026-11-04T01:26:27.710Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1213,
42
+ "t": "Every undated initiative is a Timeline row with No dates",
43
+ "s": "Every undated initiative is a Timeline row with No dates",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1214,
48
+ "t": "Chat prompt suggestions run on claude -p by default, and never fail silently",
49
+ "s": "Chat prompt suggestions run on claude -p by default, and never fail silently",
50
+ "k": "fix"
51
+ },
52
+ {
53
+ "n": 1215,
54
+ "t": "Background runs read long connector results; guide fills the wide Viewer",
55
+ "s": "Background runs read long connector results; guide fills the wide Viewer",
56
+ "k": "fix"
57
+ }
58
+ ]
59
+ },
60
+ {
61
+ "v": "0.518.0",
62
+ "at": "2026-11-04T00:33:13.219Z",
63
+ "f": 4,
64
+ "prs": [
65
+ {
66
+ "n": 1211,
67
+ "t": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
68
+ "s": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
69
+ "k": "new"
70
+ }
71
+ ]
72
+ },
35
73
  {
36
74
  "v": "0.517.0",
37
75
  "at": "2026-11-04T00:17:56.633Z",
@@ -360,78 +398,6 @@
360
398
  "d": "A point captured from a meeting could not be removed. Now you can delete it from its edit card."
361
399
  }
362
400
  ]
363
- },
364
- {
365
- "v": "0.499.0",
366
- "at": "2026-11-03T03:30:26.339Z",
367
- "f": 4,
368
- "prs": [
369
- {
370
- "n": 1180,
371
- "t": "The context meter knows the default model is 1M",
372
- "s": "The context meter knows the default model is 1M",
373
- "k": "fix",
374
- "h": "The context meter reads right",
375
- "d": "The context meter assumed a smaller model and filled up too early. Now it knows the default model has a much larger window."
376
- },
377
- {
378
- "n": 1181,
379
- "t": "`pmos initiative list` names records on a document lane",
380
- "s": "pmos initiative list names records on a document lane",
381
- "k": "fix",
382
- "h": "Initiative lists name every record",
383
- "d": "Listing initiatives missed the records of document work. Now it names them too."
384
- },
385
- {
386
- "n": 1182,
387
- "t": "Claude login test passes for a signed-in new user; picker shows each login once",
388
- "s": "Claude login test passes for a signed-in new user; picker shows each login once",
389
- "k": "fix",
390
- "h": "Claude sign-in is checked properly",
391
- "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
- }
393
- ]
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
401
  }
436
402
  ]
437
403
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@2580bd8d
1
+ kernel-source: pmos@ae404bea
2
2
  assembled: 2026-11-04
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-11-04T00:17:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T01:26:24Z"}
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:17:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T01:26:24Z"}
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:17:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T01:26:24Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -113,7 +113,7 @@ const ITEMS = [
113
113
  const before = await rowsIn(p);
114
114
  await p.click('#ibBody [data-tsort="status"]'); await p.waitForTimeout(200);
115
115
  const byStatus = await rowsIn(p), aria = await p.evaluate(() => document.querySelector('#ibBody [data-tsort="status"]').getAttribute('aria-sort'));
116
- ok('S pressing Status puts late, at risk and on hold first', byStatus.slice(0, 3).join() === 'lightbox,magw,email-otp' && aria === 'ascending', JSON.stringify(byStatus));
116
+ ok('S pressing Status puts late, at risk and on hold first (the undated usage-billing is a row since batch 26 item 8)', byStatus.slice(0, 4).join() === 'lightbox,magw,usage-billing,email-otp' && aria === 'ascending', JSON.stringify(byStatus));
117
117
  await p.click('#ibBody [data-tsort="status"]'); await p.waitForTimeout(200);
118
118
  ok('S a second press goes back to time order', (await rowsIn(p)).join() === before.join(), JSON.stringify([before, await rowsIn(p)]));
119
119
  // P: the Columns menu
@@ -218,9 +218,9 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
218
218
  const row = [...d.querySelectorAll('.tg-r[data-row]')].find((r) => /Mobile OTP login/.test(r.textContent));
219
219
  return { member: !!row, nd: row ? (row.querySelector('.tg-nd') || {}).textContent : '', loose: [...d.querySelectorAll('.tg-r[data-row]')].some((r) => /Loose undated/.test(r.textContent)), none: T.none.length };
220
220
  });
221
- ok('T an undated initiative in a program keeps its row under the program and says "No dates"; an undated one outside a program is named in the foot only (batch 8 item 4)', nd.member && nd.nd === 'No dates' && !nd.loose && nd.none === 2, JSON.stringify(nd));
221
+ ok('T an undated initiative in a program keeps its row under the program and says "No dates"; an undated one outside a program is a row too (batch 8 item 4, batch 26 item 8)', nd.member && nd.nd === 'No dates' && nd.loose && nd.none === 2, JSON.stringify(nd));
222
222
  // batch 11 item 19: By product, a program's undated initiative keeps its "No dates" row under its product (it disappeared once grouped by
223
- // product); an undated one in no program is still named in the foot only
223
+ // product); since batch 26 item 8 an undated one in no program is a "No dates" row too
224
224
  const bnd = await p.evaluate(async () => {
225
225
  const S = await import('/pm/bd-span.js'), now = new Date(2026, 9, 26);
226
226
  const a = { key: 'board:a', id: 'a', title: 'Dated one', start: '2026-10-01', end: '2026-11-20', track: { key: 'build' } };
@@ -232,11 +232,11 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
232
232
  const d = document.createElement('div'); d.innerHTML = T.html;
233
233
  const rs = [...d.querySelectorAll('[data-row]')], row = rs.find((r) => r.dataset.row === 'board:b');
234
234
  const out = { phone, rows: rs.map((r) => r.dataset.row), nd: row ? (row.querySelector('.tg-nd') || {}).textContent : '', none: T.none.length };
235
- if (!(out.nd === 'No dates' && out.rows.join() === 'board:a,board:b' && out.none === 2)) return out;
235
+ if (!(out.nd === 'No dates' && out.rows.join() === 'board:a,board:b,board:c' && out.none === 2)) return out;
236
236
  }
237
237
  return true;
238
238
  });
239
- ok('T By product: a program\'s undated initiative keeps its "No dates" row under its product, desktop and phone; one in no program is in the foot only (batch 11 item 19)', bnd === true, JSON.stringify(bnd));
239
+ ok('T By product: a program\'s undated initiative keeps its "No dates" row under its product, desktop and phone; one in no program is a row too (batch 11 item 19, batch 26 item 8)', bnd === true, JSON.stringify(bnd));
240
240
  // batch 14 item 2: a product whose initiatives are all undated (outside a program) kept no group, so it was missing from the Board
241
241
  const pnd = await p.evaluate(async () => {
242
242
  const S = await import('/pm/bd-span.js'), now = new Date(2026, 9, 26);
@@ -249,8 +249,27 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
249
249
  const row = d.querySelector('[data-row="board:m"]');
250
250
  return { heads: [...d.querySelectorAll('[data-prod]')].map((x) => x.dataset.prod).join(), nd: row ? (row.querySelector('.tg-nd') || {}).textContent : '', loose: !!d.querySelector('[data-row="board:c"]'), foot: ((d.querySelector('.tg-ft .tg-none span') || {}).textContent || '') };
251
251
  });
252
- ok('T a product with only undated initiatives keeps its head and its "No dates" rows; they leave the foot, which still names the rest (batch 14 item 2)',
253
- pnd.heads === 'p:x,p:ms' && pnd.nd === 'No dates' && !pnd.loose && pnd.foot === 'Loose undated', JSON.stringify(pnd));
252
+ ok('T a product with only undated initiatives keeps its head and its "No dates" rows (batch 14 item 2)',
253
+ pnd.heads === 'p:x,p:ms' && pnd.nd === 'No dates' && pnd.loose, JSON.stringify(pnd));
254
+ // batch 26 item 8 (Wawan: "8 have no dates" sat in the foot and none was a row): every undated initiative is a "No dates" row beside today, in
255
+ // every grouping (Programs first, By product, no groups), desktop and phone; the foot keeps the count and "Ask to propose dates", with no names
256
+ const all = await p.evaluate(async () => {
257
+ const S = await import('/pm/bd-span.js'), now = new Date(2026, 9, 26);
258
+ const r = (k, s, e) => ({ key: 'board:' + k, id: k, title: 'Row ' + k, start: s, end: e, track: { key: 'discover' } });
259
+ const a = r('a', '2026-10-01', '2026-11-20'), u1 = r('u1', null, null), u2 = r('u2', null, null), u3 = r('u3', null, null);
260
+ const prog = [{ kind: 'program', key: 'p', program: { id: 'p', title: 'P', products: [] }, products: [{ name: 'X', rows: [u1] }], rows: [u1] }, { kind: 'product', key: 'x', rows: [a, u2, u3] }];
261
+ const byProd = [{ kind: 'product', key: 'x', rows: [a, { ...u1, inProgram: 'p' }, u2] }, { kind: 'product', key: 'y', rows: [u3] }];
262
+ const bad = [];
263
+ for (const [name, groups] of [['programs', prog], ['product', byProd], ['none', undefined]]) for (const phone of [false, true]) {
264
+ const T = S.timelineHtml([a, u1, u2, u3], { jira: null }, 'quarter', { now, groups, phone, askHref: () => '#ask' });
265
+ const d = document.createElement('div'); d.innerHTML = T.html;
266
+ const nd = ['u1', 'u2', 'u3'].map((k) => d.querySelector(`[data-row="board:${k}"] .tg-nd`)).filter((x) => x && x.textContent === 'No dates').length;
267
+ const ft = d.querySelector(phone ? '.tg-none' : '.tg-ft .tg-none'), txt = ft ? ft.textContent : '';
268
+ if (nd !== 3 || !d.querySelector('#askDates') || !/3 have no dates/.test(txt) || /Row u/.test(txt)) bad.push({ name, phone, nd, txt });
269
+ }
270
+ return bad;
271
+ });
272
+ ok('T every undated initiative is a "No dates" row in every grouping, desktop and phone, in no program too; the foot keeps the count and Ask to propose dates (batch 26 item 8)', !all.length, JSON.stringify(all));
254
273
  ok('T a legend in the chip row names done, planned, Milestone (Deliver, Evaluate and milestone tasks, batch 11 item 3) and today; no program milestone key while a program draws none (batch 13 item 4)', /Done in Jira/.test(t.lgd) && /To do/.test(t.lgd) && !/Program milestone/.test(t.lgd) && /Milestone/.test(t.lgd) && /Today/.test(t.lgd), t.lgd);
255
274
  await p.click('#ibBody .tg-pg .tg-pgo');
256
275
  // the program opens in the floating Viewer (shell A), which sits over the right of the timeline; close it before the next press
@@ -319,7 +338,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
319
338
  // the phone (TsCPhone, TsCPhoneSpan): the timeline is a view; the span buttons become one picker beside the product picker
320
339
  const { ctx, page: p, errors } = await open({ view: 'time', w: 390, h: 844, store: { 'pmos.board.pview': 'time', 'pmos.board.span': 'quarter' } });
321
340
  const ph = await p.evaluate(() => ({ spans: !document.querySelector('#bdSpans').hidden, pick: (document.querySelector('#bdSpanBtn') || {}).textContent || '', rows: [...document.querySelectorAll('#ibBody .tgq-r .tgq-n b')].map((b) => b.textContent), names: document.querySelectorAll('#ibBody .tgq-r[data-row] .tgq-n').length }));
322
- ok('S phone: one span picker beside the product, no span row; each row carries its name above its bar, Monitor and Evaluate rows too (batch 8 item 2)', !ph.spans && ph.pick === 'Quarter' && ph.names === 3 && ph.rows.includes('Legacy IdP sunset') && ph.rows.includes('Inline login for eShop checkout'), JSON.stringify(ph));
341
+ ok('S phone: one span picker beside the product, no span row; each row carries its name above its bar, Monitor and Evaluate rows too (batch 8 item 2), and the undated one as a "No dates" row (batch 26 item 8)', !ph.spans && ph.pick === 'Quarter' && ph.names === 4 && ph.rows.includes('Usage-based billing') && ph.rows.includes('Legacy IdP sunset') && ph.rows.includes('Inline login for eShop checkout'), JSON.stringify(ph));
323
342
  await p.click('#bdSpanBtn'); await p.click('#bdSpanMenu [data-spick="year"]'); await p.waitForTimeout(200);
324
343
  ok('S phone: the picker changes the span', await p.evaluate(() => document.querySelector('#ibBody .tg-s').dataset.span === 'year' && document.querySelector('#bdSpanBtn').textContent === 'Year' && localStorage.getItem('pmos.board.span') === 'year'));
325
344
  await p.evaluate(() => { const sc = document.querySelector('#ibBody .tg-sc'); sc.scrollLeft = sc.scrollWidth; }); await p.waitForTimeout(200);
@@ -337,7 +337,7 @@ const IVREAD = () => {
337
337
  * timeline spans C: TsAllSpans); the scroll itself is checked in board-programs-test.js (T) */
338
338
  await view(p, 'time');
339
339
  const tl = await p.evaluate(() => ({ spans: [...document.querySelectorAll('#bdSpans [data-sp]')].map((b) => b.textContent + (b.getAttribute('aria-checked') === 'true' ? '*' : '')).join(), shown: !document.querySelector('#bdSpans').hidden, rows: [...document.querySelectorAll('#ibBody .tg-r .tg-n.ini2 b')].map((b) => b.textContent), bars: document.querySelectorAll('#ibBody .tg-bar').length, none: (document.querySelector('.tg-none') || {}).textContent || '', ask: (document.querySelector('#askDates') || {}).href || '', hid: (document.querySelector('.tg-hid') || {}).textContent || '', lgd: (document.querySelector('.tg-lgd') || {}).textContent || '', today: (document.querySelector('#ibBody .tg-tpill') || {}).textContent === 'Today' }));
340
- ok('B6 the timeline has six spans and starts on Quarter, one row per initiative with dates; the others are named, with an ask to propose dates', tl.shown && tl.spans === 'Sprint,Quarter*,Half,Year,2 yr,5 yr' && tl.rows.length === 3 && tl.bars === 3 && /^4 have no dates: Usage-based billing, Audit log export, Refund reason codes, Docs search/.test(tl.none) && /\/premium\.html\?ask=board%3Aw1&q=Propose/.test(tl.ask), JSON.stringify(tl));
340
+ ok('B6 the timeline has six spans and starts on Quarter, one row per initiative; the 4 with no dates are rows too (batch 26 item 8), counted in the foot with an ask to propose dates', tl.shown && tl.spans === 'Sprint,Quarter*,Half,Year,2 yr,5 yr' && tl.rows.length === 7 && ['Usage-based billing', 'Audit log export', 'Refund reason codes', 'Docs search'].every((n) => tl.rows.includes(n)) && tl.bars === 3 && /^4 have no dates/.test(tl.none) && !/Docs search/.test(tl.none) && /\/premium\.html\?ask=board%3Aw1&q=Propose/.test(tl.ask), JSON.stringify(tl));
341
341
  ok('B6 Monitor and Evaluate show on the Quarter, with no hidden count in the footer (batch 8 item 2); the legend (PgTimeline: done, planned, the Jira forecast, Milestone (batch 11 item 3), today) and Today are drawn', tl.hid === '' && /Done in Jira/.test(tl.lgd) && /To do/.test(tl.lgd) && /Jira forecast/.test(tl.lgd) && /Milestone/.test(tl.lgd) && /Today/.test(tl.lgd) && tl.today, JSON.stringify(tl));
342
342
  ok('B6 the spans help popover went with the theme grouping (TsCGrill): the first head is a plain "Initiative"', await p.evaluate(() => !document.querySelector('#popRules') && !document.querySelector('#ibBody [data-rules]') && /^Initiative/.test(document.querySelector('#ibBody .tg-h .tg-n').textContent)));
343
343
  await p.click('#ibBody .tg-n[data-key="board:partner-rate-limits"]');
@@ -361,7 +361,7 @@ const IVREAD = () => {
361
361
  ok('B6 Year draws Quarter\'s rows (TsAllSpans): one row per initiative with its bar, months as columns; no themes, no Group chip; the chips and the legend stay', yr.rows.join() === tl.rows.join() && yr.bars === 3 && !yr.themes && !yr.group && yr.lgd && yr.jira && yr.heads.length >= 12 && yr.heads.every((h) => /^[A-Z][a-z]{2}$/.test(h)), JSON.stringify(yr));
362
362
  await spanTo(p, 'y5');
363
363
  const y5 = await p.evaluate(() => ({ rows: document.querySelectorAll('#ibBody .tg-q[data-span="y5"] .tg-n.ini2').length, heads: [...document.querySelectorAll('#ibBody .tg-h .tg-p > span[data-y] b')].map((b) => b.textContent), dates: [...document.querySelectorAll('#ibBody .tg-endl, #ibBody .tg-lab, #ibBody .tg-cms')].map((e) => e.innerText.replace(/\s+/g, ' ').trim()) })); // with the Next milestone column (batch 19 item 5) the column says it
364
- ok('B6 5 yr draws the same rows with years as columns, and each bar ends with what lands, then when (batch 13 item 4)', y5.rows === 3 && y5.heads.length >= 5 && y5.heads.every((h) => /^\d{4}$/.test(h)) && y5.dates.length === 3 && y5.dates.every((d) => /^[A-Z][\w ]* [A-Z][a-z]{2} \d{1,2}( ’\d\d)?( \+\d+d)?$/.test(d)), JSON.stringify(y5));
364
+ ok('B6 5 yr draws the same rows with years as columns, and each bar ends with what lands, then when (batch 13 item 4)', y5.rows === 7 && y5.heads.length >= 5 && y5.heads.every((h) => /^\d{4}$/.test(h)) && y5.dates.length === 3 && y5.dates.every((d) => /^[A-Z][\w ]* [A-Z][a-z]{2} \d{1,2}( ’\d\d)?( \+\d+d)?$/.test(d)), JSON.stringify(y5));
365
365
  const sp = [];
366
366
  for (const [k, sel, re] of [['sprint', '.sp[data-span="sprint"]', /Initiative and ticket.*Backlog · \d+ not in/], ['half', '.tg-q[data-span="half"]', /Self-serve onboarding v2/], ['y2', '.tg-q[data-span="y2"]', /Self-serve onboarding v2/]]) { await spanTo(p, k); sp.push(await p.evaluate(([q2, r]) => { const e = document.querySelector('#ibBody ' + q2); return !!e && !document.querySelector('#ibBody .bd-wtag') && new RegExp(r).test(e.textContent); }, [sel, re.source])); }
367
367
  ok('B6 Sprint (BdSpans: tickets and the backlog row), Half and 2 yr (Quarter\'s rows) are drawn, with no "Waiting for design" tag', sp.every(Boolean), JSON.stringify(sp));
@@ -369,7 +369,7 @@ const IVREAD = () => {
369
369
  {
370
370
  const f = F.facts(); f.rows = f.rows.map((r) => ({ ...r, start: null, end: null }));
371
371
  const R = await open({ model: BP.build(f) }); await view(R.page, 'time');
372
- ok('B6 with no dates anywhere it draws nothing and says so', await R.page.evaluate(() => document.querySelectorAll('#ibBody .tg-bar').length === 0 && /No initiative on screen has a date on record/.test(document.querySelector('#ibBody').textContent)));
372
+ ok('B6 with no dates anywhere it draws no bar; every initiative is a "No dates" row (batch 26 item 8)', await R.page.evaluate(() => document.querySelectorAll('#ibBody .tg-bar').length === 0 && document.querySelectorAll('#ibBody .tg-r[data-row]').length > 0 && [...document.querySelectorAll('#ibBody .tg-r[data-row]')].every((r) => !!r.querySelector('.tg-nd'))));
373
373
  await R.ctx.close();
374
374
  }
375
375
  await view(p, 'track');
@@ -166,7 +166,7 @@ const ITEMS = [
166
166
  lane: [...document.querySelectorAll('#ibBody .tg-lane.closed')].map((x) => ({ span: x.querySelectorAll('.tg-p .tg-span').length, end: (x.querySelector('.tg-p .tg-endl') || {}).textContent || '', dots: x.querySelectorAll('.tg-offd .cz-dot').length })),
167
167
  }));
168
168
  ok('G folded, a program shows the roll-up line, its ◇s and its end date', g.prog.length > 0 && g.prog.every((x) => x.span > 0 && x.dm > 0 && /’\d\d$/.test(x.end)), JSON.stringify(g));
169
- ok('G folded, a product shows the roll-up, its end date and one dot per initiative off track', g.lane.length > 0 && g.lane.some((x) => x.span > 0 && x.end && x.dots === 3), JSON.stringify(g));
169
+ ok('G folded, a product shows the roll-up, its end date and one dot per initiative off track (its undated one counts: it is a row since batch 26 item 8)', g.lane.length > 0 && g.lane.some((x) => x.span > 0 && x.end && x.dots === 4), JSON.stringify(g));
170
170
  await c2.close();
171
171
  }
172
172
 
@@ -179,7 +179,7 @@ const ITEMS = [
179
179
  dots: [...document.querySelectorAll('#ibBody .tgq-r[data-row] .tgq-n')].map((b) => (b.querySelector('.cz-dot') || {}).className || ''),
180
180
  scroll: document.documentElement.scrollWidth <= innerWidth + 1,
181
181
  }));
182
- ok('M the phone keeps the end labels, a dot only off track, and no sideways page scroll', m.labels >= 5 && m.dots.filter(Boolean).length === 3 && m.scroll, JSON.stringify(m));
182
+ ok('M the phone keeps the end labels, a dot only off track (the undated row too, batch 26 item 8), and no sideways page scroll', m.labels >= 5 && m.dots.filter(Boolean).length === 4 && m.scroll, JSON.stringify(m));
183
183
  ok('M no page error on the phone', errors.length === 0, errors.join(' | '));
184
184
  await ctx.close();
185
185
  }
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "v": "0.519.0",
4
+ "at": "2026-11-04T01:26:27.710Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1213,
9
+ "t": "Every undated initiative is a Timeline row with No dates",
10
+ "s": "Every undated initiative is a Timeline row with No dates",
11
+ "k": "fix"
12
+ },
13
+ {
14
+ "n": 1214,
15
+ "t": "Chat prompt suggestions run on claude -p by default, and never fail silently",
16
+ "s": "Chat prompt suggestions run on claude -p by default, and never fail silently",
17
+ "k": "fix"
18
+ },
19
+ {
20
+ "n": 1215,
21
+ "t": "Background runs read long connector results; guide fills the wide Viewer",
22
+ "s": "Background runs read long connector results; guide fills the wide Viewer",
23
+ "k": "fix"
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "v": "0.518.0",
29
+ "at": "2026-11-04T00:33:13.219Z",
30
+ "f": 4,
31
+ "prs": [
32
+ {
33
+ "n": 1211,
34
+ "t": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
35
+ "s": "Claude repairs a source that is behind by itself; the banner shows only when you must act",
36
+ "k": "new"
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "v": "0.517.0",
4
42
  "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-03
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**. While a source is old, an amber box at the top of Settings names it, and the place menu opens Settings at Connectors.
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.
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: start here"
4
- timestamp: 2026-11-03
4
+ timestamp: 2026-11-04
5
5
  ---
6
6
 
7
7
  # Guide: start here
8
8
 
9
9
  This guide describes the PMOS workspace as it works today. It ships with the app. When a change alters how the workspace looks or behaves, the same change updates this guide.
10
10
 
11
- To open the guide from any page, press the round menu button at the bottom of the page, then the **?** icon (Guide). The guide opens wide in the Viewer. Every page is in the list on the left, by group, with **Find a page** on top; a group folds and stays folded. Above each page, one chip for each of its sections takes you there. A link to another guide page opens that page in the same place. Press ← to go back. On a phone, or when the Viewer is narrow, the list icon beside the title shows the list.
11
+ To open the guide from any page, press the round menu button at the bottom of the page, then the **?** icon (Guide). The guide opens wide in the Viewer and uses its full width. Every page is in the list on the left, by group, with **Find a page** on top; a group folds and stays folded. Above each page, one chip for each of its sections takes you there. A link to another guide page opens that page in the same place. Press ← to go back. On a phone, or when the Viewer is narrow, the list icon beside the title shows the list.
12
12
 
13
13
  ## The screen
14
14
 
@@ -30,7 +30,7 @@ Each card names only the initiatives, tasks and PRs that the reply or your messa
30
30
 
31
31
  ## While you type
32
32
 
33
- When you stop typing for about 1.5 seconds on a rough prompt of three or more words, a small spinner card shows while Claude sharpens it. Then one lit **Sharper** card shows. Tap it to put the sharper text in the box. ↶ in the box puts back what you wrote. When the sharper version says the same as yours, no card shows.
33
+ When you stop typing for about 1.5 seconds on a rough prompt of three or more words, a small spinner card shows while Claude sharpens it. This takes a few seconds. Then one lit **Sharper** card shows. Tap it to put the sharper text in the box. ↶ in the box puts back what you wrote. When the sharper version says the same as yours, no card shows.
34
34
 
35
35
  The sliders icon on the Sharper card sets how Claude sharpens:
36
36
 
@@ -45,7 +45,7 @@ The same menu has the **Sharpen as I type** switch. Off means no spinner and no
45
45
  | Row | What it does |
46
46
  |---|---|
47
47
  | Suggest the next prompt | On by default. Off: no cards after a reply. |
48
- | Sharpen as I type | On when Claude's runs use your API key (Settings › Claude › API key), because a sharper version then shows in about 2 seconds. Without a key, each one is a background Claude run that takes 10 seconds or more, so it starts off. You can turn it on anyway. |
48
+ | Sharpen as I type | On by default. Off: no spinner and no Sharper card. |
49
49
  | Sharpen as | Polish, Prompt engineer or Your own. |
50
50
  | Your own rules | The rules for Your own, up to 1000 characters. |
51
51
 
@@ -57,6 +57,8 @@ The suggestions and the sharper prompt go through the same guardrail as every AI
57
57
  - **Proven only.** Each suggested move points to the line of PMOS code that makes it work, and PMOS checks that line before the card shows. An answer to the reply points to the line of the reply it answers. A card that PMOS cannot check is not shown.
58
58
  - **Replay-tested.** A new version shows nothing until its recorded cases pass, and those include cases that must be dropped.
59
59
  - **Audited.** Each run writes a line to the AI audit, with no text of your conversation in it.
60
+ - **Your own Claude session.** Both run as a short background `claude -p` run on your own Claude login, which can only read files. Your API key is not used unless you set `PMOS_NEXT_PROMPT_API=on`. Then, when a call on the key fails, the same request goes to the background run instead.
61
+ - **No silent failure.** When a run gives no card, the audit says why. `pmos ai log next-prompt` and `pmos ai log sharpen-prompt` show the last lines.
60
62
  - **Off switch.** `pmos ai off next-prompt` stops the suggestions after a reply. `pmos ai off sharpen-prompt` stops the Sharper card. `pmos ai off` stops every AI job.
61
63
 
62
64
  The reply and the words a card refers to are kept in your PMOS state folder (`next-prompt/`, the newest 30 files), outside every repository. Your choices for these rows are kept there too, in `next-prompt/prefs.json`.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Programs on the Board: All products, groupings and the program panel"
4
4
  description: "A program groups initiatives across products. How it shows on the Board (stage track, Kanban, timeline and List), the All products mode, the By product grouping, the program panel and the side panel width."
5
- timestamp: 2026-11-03
5
+ timestamp: 2026-11-04
6
6
  ---
7
7
 
8
8
  # Programs on the Board
@@ -22,6 +22,7 @@ A cancelled or archived initiative leaves its program's views: the band, the pro
22
22
  - To group by product only, open **Filter** and choose **By product**. A program member then shows a small layers icon and stays under its product in every view, the timeline included. A chip shows while By product is on.
23
23
  - **Kanban** shows programs as swimlanes, then products. Press a column header to fold the column to a strip with its count. An empty column starts folded. The Board remembers each fold.
24
24
  - The **timeline** draws a program row that brackets its initiatives' bars, on every span. Program milestones are diamonds. Milestones that are close together merge into one diamond with a count.
25
+ - On the timeline, **every initiative has a row**, in a program or not, in every grouping. An initiative with no dates says **No dates** beside today; in each group it comes after the dated ones. Under the chart, a line gives how many have no dates, with **Ask to propose dates**.
25
26
  - On the timeline, a **product's head row** also shows milestones. Nothing is added by hand: the row shows every milestone of the product's initiatives, which are each milestone task and each initiative's end diamond (its Deliver or Evaluate date). The diamonds are the same as on the initiative rows: open, filled when done, orange when late. Diamonds that are close together merge into one diamond with a count and the date range under it. Press it to see a list with the milestone, its initiative and the date. Press a line to open that initiative. Hold the pointer on a single diamond to see its milestone and initiative. A thin line goes from the first start to the last end of the product's initiatives. When the product is folded, the row stays the same, so it is a summary of the product. On a phone the diamonds have no dates; tap the row to see all the milestones in date order. A product inside a program has no such row, because the program row shows its milestones.
26
27
  - On the timeline, an initiative row shows only its **pinned** milestones. By default that is its last milestone. Press a diamond to see every milestone of the initiative, with how many are on the timeline. Press the pin beside a milestone to show it or to hide it. To see every milestone or none, open **Filter** and pick **Milestones on the timeline: Pinned**, **All** or **None**. The Board keeps your pick. From a terminal: `pmos task set <task> --pin`, `--no-pin`, or `--pin-default` to go back to the default.
27
28
  - The label at the end of an initiative row says what lands and when. When a milestone task is in the last milestone stage, or is on the end date, or is in the Deliver stage, or is so close to the end date that its diamond would sit on the end diamond, the label shows its name and its own date, and the end date of the initiative shows when you hold the pointer on the label. Otherwise the latest milestone task of the initiative names the end in the same way, even when it is months before the end date. Only an initiative with no dated milestone task shows **Deliver** (or **Evaluate**) and the end date. Press that end diamond: the card has the same words as the label, then the milestones of the initiative.
@@ -49,7 +49,7 @@ Under the update card, **Updates** shows what changed in the published kits. **T
49
49
 
50
50
  **API key.** Some runs of Claude, for example Grill, can use the Claude API and not a Claude session. Paste an Anthropic API key and press **Save** to turn this on. The key stays on this computer, in a file only you can read, outside every repository. PMOS never shows the key again: the row only says that a key is set. Press **Remove** to delete it. A key saved here is used before `ANTHROPIC_API_KEY`. A command that a run starts does not get the key. Only the computer that runs the workspace can change it. Set `PMOS_GRILL_API=off` to stop runs on the API.
51
51
 
52
- **Sharpen prompts.** Suggest the next prompt (on by default) shows up to two prompts above the chat box after a reply. Sharpen as I type offers a sharper version of a rough prompt. It is on when an API key is set, and off without one, because a background run is too slow to follow your typing. Sharpen as picks Polish, Prompt engineer (the default) or Your own, and Your own rules holds the rules for Your own. The same style and switch are in the menu on the chat's Sharper card. See [The next prompt, suggested and sharpened](next-prompt.md).
52
+ **Sharpen prompts.** Suggest the next prompt (on by default) shows up to two prompts above the chat box after a reply. Sharpen as I type offers a sharper version of a rough prompt. It is on by default and runs in your own Claude session. Sharpen as picks Polish, Prompt engineer (the default) or Your own, and Your own rules holds the rules for Your own. The same style and switch are in the menu on the chat's Sharper card. See [The next prompt, suggested and sharpened](next-prompt.md).
53
53
 
54
54
  ## Claude's checks: when Claude grills, and what it may spend
55
55
 
@@ -250,7 +250,17 @@ function initTools(line) {
250
250
  return o.tools.filter((t) => typeof t === 'string' && t.startsWith('mcp__'));
251
251
  }
252
252
  // The one line every run gets about a refused tool (fix 2026-11-02: a Jira refresh called the same denied tool again and again).
253
- const DENIED_RULE = 'A tool call that is refused in this run stays refused: never call that tool again. Carry on with the tools you have, and say in your answer what you could not read.';
253
+ const DENIED_RULE = 'A tool call that is refused in this run stays refused: never call that tool again. Carry on with the tools you have, and say in your answer what you could not read. When a tool result is too long and is saved to a file, open that file with Read, and use offset and limit for a long file. Never read it with a shell command: that is refused.';
254
+ // Hotfix 2026-11-04: a Jira search on the Mac returned more than Claude Code keeps inline, so its result went to a tool-results file; the run then
255
+ // tried `Bash F=<file> …` to parse it, Bash was refused, and the run failed. A background run keeps connector results inline up to this limit,
256
+ // and the rule above sends a saved result to Read. The PM's own MAX_MCP_OUTPUT_TOKENS, when set, wins.
257
+ const HEADLESS_ENV = Object.freeze({ MAX_MCP_OUTPUT_TOKENS: '100000' });
258
+ /** The run's environment with HEADLESS_ENV added where the PM has not set the variable. */
259
+ function headlessEnv(env) {
260
+ const out = { ...(env || {}) };
261
+ for (const [k, v] of Object.entries(HEADLESS_ENV)) if (!out[k]) out[k] = v;
262
+ return out;
263
+ }
254
264
  const DENIED_RE = /Permission to use ([\w.-]+) has been denied|requested permissions to use ([\w.-]+)/;
255
265
  /** Follows a run's stream and says when it calls a refused tool a second time: → { see(line) → tool name | null, denied: Map }. */
256
266
  function denialGuard(limit = 2) {
@@ -488,4 +498,4 @@ function runEnvOf(extra) {
488
498
  return out;
489
499
  }
490
500
 
491
- module.exports = { CONNECTOR_READ_KINDS, CONNECTOR_READ_SEED, DENIED_RULE, connectorReads, initTools, denialGuard, HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, REPORT_STRUCTURE_ORIGINS, REPORT_STRUCTURE_DRY_ORIGINS, METRIC_DRAFT_ORIGINS, IMPACT_ORIGINS, TOOLS, SKILL_WRITE_DIRS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, skillWriteDirs, timeoutMs, parseResult, streamLines, outcomeOf, signedOut, SIGNED_OUT_WHY, logLines, switchedOff, RUN_ENV, runEnvOf };
501
+ module.exports = { CONNECTOR_READ_KINDS, CONNECTOR_READ_SEED, DENIED_RULE, HEADLESS_ENV, headlessEnv, connectorReads, initTools, denialGuard, HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, REPORT_STRUCTURE_ORIGINS, REPORT_STRUCTURE_DRY_ORIGINS, METRIC_DRAFT_ORIGINS, IMPACT_ORIGINS, TOOLS, SKILL_WRITE_DIRS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, skillWriteDirs, timeoutMs, parseResult, streamLines, outcomeOf, signedOut, SIGNED_OUT_WHY, logLines, switchedOff, RUN_ENV, runEnvOf };
@@ -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
- if (result === 'failed' && scheduled && rules.tell === 'brief' && !next.retry_at) {
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 };