create-pmos 0.518.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.518.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,31 @@
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
+ },
35
60
  {
36
61
  "v": "0.518.0",
37
62
  "at": "2026-11-04T00:33:13.219Z",
@@ -373,37 +398,6 @@
373
398
  "d": "A point captured from a meeting could not be removed. Now you can delete it from its edit card."
374
399
  }
375
400
  ]
376
- },
377
- {
378
- "v": "0.499.0",
379
- "at": "2026-11-03T03:30:26.339Z",
380
- "f": 4,
381
- "prs": [
382
- {
383
- "n": 1180,
384
- "t": "The context meter knows the default model is 1M",
385
- "s": "The context meter knows the default model is 1M",
386
- "k": "fix",
387
- "h": "The context meter reads right",
388
- "d": "The context meter assumed a smaller model and filled up too early. Now it knows the default model has a much larger window."
389
- },
390
- {
391
- "n": 1181,
392
- "t": "`pmos initiative list` names records on a document lane",
393
- "s": "pmos initiative list names records on a document lane",
394
- "k": "fix",
395
- "h": "Initiative lists name every record",
396
- "d": "Listing initiatives missed the records of document work. Now it names them too."
397
- },
398
- {
399
- "n": 1182,
400
- "t": "Claude login test passes for a signed-in new user; picker shows each login once",
401
- "s": "Claude login test passes for a signed-in new user; picker shows each login once",
402
- "k": "fix",
403
- "h": "Claude sign-in is checked properly",
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."
405
- }
406
- ]
407
401
  }
408
402
  ]
409
403
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@ed082f08
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:33:09Z"}
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:33:09Z"}
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:33:09Z"}
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,29 @@
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
+ },
2
27
  {
3
28
  "v": "0.518.0",
4
29
  "at": "2026-11-04T00:33:13.219Z",
@@ -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 };
@@ -2,16 +2,17 @@
2
2
  /* routes-next-prompt.js — the chat's suggested next prompt and its Sharper card over HTTP (batch 26 item 2, design B; lib/next-prompt.js).
3
3
  *
4
4
  * GET /api/next-prompt/prefs → { ok, next, sharpen, sharpenSet, style, rules, api }
5
- * next: suggestions after a reply are on. sharpen: Sharpen as I type is on now ('auto' is on when Claude's runs go to the API, where a
6
- * call takes about 2 s; a background `claude -p` run takes 10 s or more, too slow to follow typing). sharpenSet: 'auto'|'on'|'off'.
7
- * style: 'polish'|'engineer'|'own'. rules: whether Your own rules are written. api: a key for the Claude API is set.
5
+ * next: suggestions after a reply are on. sharpen: Sharpen as I type is on now (on unless the PM turned it off; 'auto' is off only
6
+ * while an opted-in API path is failing). sharpenSet: 'auto'|'on'|'off'. style: 'polish'|'engineer'|'own'. rules: whether Your own
7
+ * rules are written. api: the opted-in API path is in use (PMOS_NEXT_PROMPT_API=on and a key). apiDown: { why, auth } while it fails.
8
8
  * POST /api/next-prompt/prefs { key: 'next'|'sharpen'|'style'|'rules', value } → { ok } (saved in <runtime>/next-prompt/prefs.json)
9
9
  * POST /api/next-prompt/next { key, asked, reply, initiative?, stage?, product?, title? } → { ok, on, cards: [{ text, move, rec }] }
10
10
  * POST /api/next-prompt/sharpen { key, text, reply?, initiative?, title? } → { ok, on, text | null }
11
11
  *
12
12
  * Both POSTs are page presses (lib/pm-guard.js requirePagePress; an agent session header is refused) and run ONE short Claude call:
13
- * on the API with the PM's key when one is set (lib/anthropic-key.js, model PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), else a headless
14
- * read-only run (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…). The answer goes through the shared door, lib/ai-guard.js file():
13
+ * a background read-only `claude -p` run on the PM's own Claude login (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…), the
14
+ * default (Wawan 2026-11-04). Only with PMOS_NEXT_PROMPT_API=on does it go to the API with the PM's key (lib/anthropic-key.js, model
15
+ * PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), and a failed API call then falls back to the background run. The answer goes through the shared door, lib/ai-guard.js file():
15
16
  * the kill switch (`pmos ai off next-prompt`, `pmos ai off sharpen-prompt`, PMOS_AI=off), the replay gate (fixtures/next-prompt-replay.json,
16
17
  * recorded answers, never a model), the citation check and the audit (<runtime>/ai-audit.jsonl). A card with no verified source is never
17
18
  * shown. The reply and the typed text a card cites are kept in <runtime>/next-prompt/ (the newest 30 files), outside every repository.
@@ -61,14 +62,29 @@ function setPref(runtimeDir, key, value) {
61
62
  const get = (ctx, k) => { const v = readPrefs(ctx.runtimeDir)[k]; return PREFS[k](v) ? v : undefined; };
62
63
  const envOf = (ctx) => ctx.env || process.env;
63
64
 
65
+ /* Wawan 2026-11-04: "just default to normal claude session with -p". Both calls run as a background `claude -p` on the PM's own Claude login.
66
+ * The API key is used only when PMOS_NEXT_PROMPT_API=on opts in. */
64
67
  function apiKey(ctx) {
65
68
  const env = envOf(ctx);
66
- if (/^(off|0|false|no)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
69
+ if (!/^(on|1|true|yes)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
67
70
  try { return require('./anthropic-key.js').getKey(ctx.runtimeDir, env); } catch { return null; }
68
71
  }
72
+ /* The API's health, per state folder, in memory: after a failed call PMOS stops calling the API for DOWN_MS and uses the background run,
73
+ * so a spent balance or a refused key does not fail every reply again. A server restart, or a new key saved, tries the API again. */
74
+ const DOWN_MS = 15 * 60 * 1000;
75
+ const apiDown = new Map(); // runtimeDir → { until, why, auth, key }
76
+ function downOf(ctx, key) {
77
+ const d = apiDown.get(ctx.runtimeDir);
78
+ if (!d) return null;
79
+ if (d.until <= Date.now() || d.key !== keyId(key)) { apiDown.delete(ctx.runtimeDir); return null; }
80
+ return d;
81
+ }
82
+ const keyId = (k) => (k ? crypto.createHash('sha1').update(String(k)).digest('hex').slice(0, 12) : '');
69
83
  function prefs(ctx) {
70
- const set = get(ctx, 'sharpen') || 'auto', api = !!apiKey(ctx), style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
71
- return { next: get(ctx, 'next') !== false, sharpen: set === 'on' || (set === 'auto' && api), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api };
84
+ const set = get(ctx, 'sharpen') || 'auto', k = apiKey(ctx), down = k ? downOf(ctx, k) : null, api = !!k && !down;
85
+ const style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
86
+ return { next: get(ctx, 'next') !== false, sharpen: set !== 'off' && !(set === 'auto' && k && down), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api,
87
+ ...(down ? { apiDown: { why: down.why, auth: down.auth } } : {}) };
72
88
  }
73
89
  function offWhy(ctx, job) {
74
90
  const why = AG.off(job, { env: envOf(ctx), settingsDir: ctx.runtimeDir });
@@ -81,31 +97,55 @@ function offWhy(ctx, job) {
81
97
 
82
98
  /* ── the call ────────────────────────────────────────────────────────────────────────────── */
83
99
 
84
- async function viaApi(key, prompt, env) {
85
- const Anthropic = require('@anthropic-ai/sdk');
86
- const api = new Anthropic({ apiKey: key });
87
- const res = await api.messages.create({ model: String(env.PMOS_NEXT_PROMPT_MODEL || '').trim() || DEFAULT_MODEL, max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
88
- return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
100
+ // A model the account cannot use (404) tries the next one before the call counts as failed.
101
+ const MODELS = ['claude-sonnet-5-5', 'claude-sonnet-5', 'claude-haiku-5-5'];
102
+ async function viaApi(key, prompt, env, create = null) {
103
+ const make = create || ((o) => { const Anthropic = require('@anthropic-ai/sdk'); return new Anthropic({ apiKey: key }).messages.create(o); });
104
+ const want = String(env.PMOS_NEXT_PROMPT_MODEL || '').trim();
105
+ const models = want ? [want] : MODELS;
106
+ for (let i = 0; i < models.length; i++) {
107
+ try {
108
+ const res = await make({ model: models[i], max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
109
+ return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
110
+ } catch (e) { if (!(e && e.status === 404) || i === models.length - 1) throw e; }
111
+ }
112
+ return null;
89
113
  }
90
- function viaHeadless(ctx, prompt, origin) {
114
+ /** A background read-only Claude run → its answer text, or null after `fail(why)` says why it gave none. */
115
+ function viaHeadless(ctx, prompt, origin, fail = () => {}) {
91
116
  const headless = require('./headless.js');
92
- if (typeof ctx.spawnSession !== 'function' || headless.kindFor({ agent: 'claude', origin }) !== 'ask') return Promise.resolve(null);
117
+ if (typeof ctx.spawnSession !== 'function') { fail('no background run here'); return Promise.resolve(null); }
118
+ if (headless.kindFor({ agent: 'claude', origin }) !== 'ask') { fail('the run would not be read-only'); return Promise.resolve(null); }
93
119
  return new Promise((resolve) => {
94
- let settled = false; const done = (v) => { if (!settled) { settled = true; clearTimeout(t); resolve(v); } };
95
- const t = setTimeout(() => done(null), TIMEOUT_MS);
120
+ let settled = false; const done = (v, why) => { if (settled) return; settled = true; clearTimeout(t); if (v == null) fail(why); resolve(v); };
121
+ const t = setTimeout(() => done(null, 'the run took longer than ' + Math.round(TIMEOUT_MS / 1000) + ' s'), TIMEOUT_MS);
96
122
  let s = null;
97
123
  try {
98
124
  s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
99
- onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null); } });
100
- } catch { s = null; }
101
- if (!s || s.state === 'exited') done(null);
125
+ onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null, 'the run ended without an answer' + (d && d.note ? ': ' + String(d.note).slice(0, 80) : '')); } });
126
+ } catch (e) { s = null; }
127
+ if (!s || s.state === 'exited') done(null, 'the run did not start' + (s && s.note ? ': ' + String(s.note).slice(0, 80) : ''));
102
128
  });
103
129
  }
104
- async function ask(ctx, prompt, { job, key }) {
130
+ /** Why an API call failed, in words fit for the audit: the HTTP status and the error's type, never the prompt or the answer. */
131
+ function apiWhy(e) {
132
+ const st = e && (e.status || (e.response && e.response.status));
133
+ const kind = e && ((e.error && e.error.error && e.error.error.type) || e.name || e.code);
134
+ return ['api', st ? String(st) : null, kind ? String(kind).slice(0, 60) : null].filter(Boolean).join(' ');
135
+ }
136
+ /* One short Claude call. With a key it goes to the API; when that call fails (a spent balance, a model the account cannot use, no network),
137
+ * it does not end in silence: the same prompt goes to a background read-only Claude run, and the audit records why the API failed
138
+ * (`pmos ai log next-prompt`); for 15 minutes after, PMOS goes straight to the background run. `ctx.createMessage` replaces the SDK in tests. */
139
+ async function ask(ctx, prompt, { job, key, run }) {
105
140
  if (typeof ctx.askClaude === 'function') return ctx.askClaude(prompt, { job, key });
106
141
  const k = apiKey(ctx);
107
- if (k) { try { return await viaApi(k, prompt, envOf(ctx)); } catch { return null; } }
108
- return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`);
142
+ if (k && !downOf(ctx, k)) {
143
+ const failed = (why, auth) => { apiDown.set(ctx.runtimeDir, { until: Date.now() + DOWN_MS, why, auth: !!auth, key: keyId(k) }); AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'api-error', error: why }); };
144
+ try { const t = await viaApi(k, prompt, envOf(ctx), ctx.createMessage || null); if (t && String(t).trim()) return t; failed('api empty answer', false); }
145
+ catch (e) { failed(apiWhy(e), e && (e.status === 401 || e.status === 403)); }
146
+ }
147
+ return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`,
148
+ (why) => AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'run-error', error: String(why).slice(0, 160) }));
109
149
  }
110
150
 
111
151
  /* ── what a card cites ───────────────────────────────────────────────────────────────────── */
@@ -182,7 +222,7 @@ async function next(ctx, b) {
182
222
  const k = 'n:' + key + ':' + crypto.createHash('sha1').update(reply + '\n' + P.style + '\n' + P.rulesText).digest('hex');
183
223
  return once(k, async () => {
184
224
  AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
185
- const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key });
225
+ const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key, run });
186
226
  const cards = NP.parseNext(answer, { moves, place });
187
227
  if (!cards.length) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : {}) }); return { ok: true, on: true, cards: [] }; }
188
228
  const rel = keep(ctx.runtimeDir, 'reply', reply);
@@ -200,7 +240,7 @@ async function sharpen(ctx, b) {
200
240
  const k = 's:' + key + ':' + crypto.createHash('sha1').update(typed + '\n' + P.style + '\n' + P.rulesText).digest('hex');
201
241
  return once(k, async () => {
202
242
  AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
203
- const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key });
243
+ const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key, run });
204
244
  const out = NP.parseSharpen(answer, typed);
205
245
  if (!out) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : { same: true }) }); return { ok: true, on: true, text: null }; }
206
246
  const first = typed.split('\n')[0], rel = keep(ctx.runtimeDir, 'typed', typed);
@@ -227,7 +267,7 @@ async function handle(req, res, url, ctx) {
227
267
  if (req.method !== 'GET') return send(bad(405, 'GET or POST only'));
228
268
  const P = prefs(ctx), env = envOf(ctx);
229
269
  const next = P.next && !AG.off(NP.JOB_NEXT, { env, settingsDir: ctx.runtimeDir }), sh = P.sharpen && !AG.off(NP.JOB_SHARPEN, { env, settingsDir: ctx.runtimeDir });
230
- return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api } });
270
+ return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api, ...(P.apiDown ? { apiDown: P.apiDown } : {}) } });
231
271
  }
232
272
  if (what !== 'next' && what !== 'sharpen') return send(bad(404, 'no such next-prompt route'));
233
273
  if (req.method !== 'POST') return send(bad(405, 'POST only'));
@@ -245,4 +285,4 @@ async function handle(req, res, url, ctx) {
245
285
  catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
246
286
  }
247
287
 
248
- module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, prefs, setPref, lineRef, replayCases, next, sharpen };
288
+ module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, MODELS, prefs, setPref, lineRef, replayCases, next, sharpen };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.518.0",
3
+ "version": "0.519.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.518.0",
9
+ "version": "0.519.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.518.0",
3
+ "version": "0.519.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -524,7 +524,7 @@ function scaled(rows, M, o) {
524
524
  const msO = { sp, reach: Math.max(1, Math.round(40 / pxDay)), mode: o.msMode || 'pinned', noLabel: !!(C && C.msOn) }; // a row's milestone ◇s closer than ~40 px merge (batch 11 item 3); the Filter's milestones (batch 13 item 4)
525
525
  const ov = overlay(W, X, now, sprint, rel), pxPct = (tpx * (W.fixed ? 1 : ratio)) / 100;
526
526
  const ra = rel && day(rel.iso), sa = sprint && day(sprint.start), sb = sprint && day(sprint.end);
527
- // an undated program member's track says so beside today, where the view opens
527
+ // an undated initiative's track says so beside today, where the view opens
528
528
  const ND = `<span class="tg-nd" style="left:${P(Math.min(90, Math.max(0, X(now))))}">No dates</span>`;
529
529
  const askBtn = none.length ? `<a class="bd-chip tg-ask" id="askDates" href="${esc(o.askHref(none))}">${SV('M4 5h16v11h-9l-5 4v-4H4z')}Ask to propose dates</a>` : '';
530
530
  const ticks = W.ticks.map((k) => `<span style="left:${P(X(k.at))}" data-y="${k.y}" data-base="${esc(k.base)}"${k.plain ? ' data-plain' : ''}><b>${esc(k.label)}</b><small>${esc(k.sub)}</small></span>`).join('');
@@ -533,23 +533,19 @@ function scaled(rows, M, o) {
533
533
  const back = `<button type="button" class="tg-tback" data-today hidden aria-label="Back to today">${SV(CHL)}<span>Today</span>${SV(CHR)}</button>`;
534
534
  const attrs = `data-span="${sp}" data-from="${+W.from}" data-to="${+W.to}" data-ratio="${W.fixed ? 1 : ratio.toFixed(5)}" data-now="${+now}"${W.fixed ? ' data-fixed' : ''}`;
535
535
  const style = `--tw:${Math.round(tpx * (W.fixed ? 1 : ratio))}px`;
536
- // a program keeps its undated initiatives as rows that say "No dates" (feedback batch 8 item 4: initiatives just moved into a program
537
- // were missing under it); a product group keeps only the dated ones, and the foot names the rest
538
- // by key, not by identity: By product copies a program's row ({ ...r, inProgram }), and that copy is never in `list` (batch 11 item 12: a
539
- // program's initiatives were missing from their product on the timeline)
540
- // By product, a program's undated initiative keeps its "No dates" row under its product too (batch 11 item 19: they disappeared once grouped by product)
541
- const onList = new Set(list.map((r) => r.key));
542
- // a product with no dated initiative keeps its head and its initiatives as "No dates" rows, so the product is not missing from the Board
543
- // (batch 14 item 2: pmos-microsites, all undated outside its program, had no group at all); those rows leave the foot's names
544
- const keepOf = (R) => { const k = R.filter((r) => onList.has(r.key) || r.inProgram); return k.length ? k : R.slice(); };
545
- const shownNd = new Set(o.groups ? o.groups.filter((g) => g.kind !== 'program' && !g.rows.some((r) => onList.has(r.key) || r.inProgram)).flatMap((g) => g.rows.map((r) => r.key)) : []);
546
- const footNone = none.filter((r) => !shownNd.has(r.key));
536
+ // every undated initiative is a row that says "No dates" beside today, in every grouping and in no program too (batch 26 item 8, Wawan:
537
+ // the foot alone hid them; this replaces batch 8 item 4's program-only rule and design B6). By product copies a program's row
538
+ // ({ ...r, inProgram }), so a row is matched by key, not by identity (batch 11 item 12)
539
+ const shownKeys = new Set(o.groups ? o.groups.flatMap((g) => (g.kind === 'program' ? g.products.flatMap((x) => x.rows) : g.rows).map((r) => r.key)) : rows.map((r) => r.key));
540
+ // the foot keeps the count and "Ask to propose dates"; it names only an undated initiative no group draws (none, as the Board groups today)
541
+ const footNone = none.filter((r) => !shownKeys.has(r.key));
547
542
  // a timeline reads in time (batch 11 item 22): each group's rows by start, then end; undated ones last. The health dot still says late or at risk
548
543
  const at = (r) => { const x = extentOf(r, now); return x ? [+x.a, +x.b] : [Infinity, Infinity]; };
549
544
  // a pressed column title sorts each group by that column instead (StsA8Board, as in the List)
550
545
  const byTime = (L) => (C && C.order ? L.slice().sort(C.order) : L.map((r) => [at(r), r]).sort((p, q) => p[0][0] - q[0][0] || p[0][1] - q[0][1]).map((x) => x[1]));
551
546
  list.splice(0, list.length, ...byTime(list));
552
- const G = o.groups ? o.groups.map((g) => (g.kind === 'program' ? { ...g, products: g.products.filter((x) => x.rows.length).map((x) => ({ ...x, rows: byTime(x.rows) })) } : { ...g, rows: byTime(keepOf(g.rows)) })).filter((g) => g.rows.length || g.kind === 'program') : null;
547
+ const flat = list.concat(none);
548
+ const G = o.groups ? o.groups.map((g) => (g.kind === 'program' ? { ...g, products: g.products.filter((x) => x.rows.length).map((x) => ({ ...x, rows: byTime(x.rows) })) } : { ...g, rows: byTime(g.rows) })).filter((g) => g.rows.length || g.kind === 'program') : null;
553
549
  const empty = '<p class="tg-empty">No initiative on screen has a date on record.</p>';
554
550
  const ext = (r) => { const x = extentOf(r, now); return x ? ` data-a="${X(x.a).toFixed(3)}" data-b="${X(x.b).toFixed(3)}"` : ''; };
555
551
  // a product's head carries its lane from Quarter on (batch 11 item 17); Sprint on a phone, and a page that gives no head cell, keep the plain head
@@ -561,9 +557,9 @@ function scaled(rows, M, o) {
561
557
  if (g.kind !== 'program') return lane(g) + (o.folded(g.key) ? '' : g.rows.map(one).join(''));
562
558
  const st = o.progState(g.program), shut = o.folded(g.key), tr = shut ? rollUp(g.rows, progSpan(g), X, W, o, msO, pxPct, now) : '';
563
559
  return `<div class="tgq-r tg-pg${shut ? ' closed' : ''}" data-prow="${esc(g.program.id)}"><div class="tgq-n tg-pgn">${progName(g, st, o, shut ? offDots(g.rows, now) : '')}</div><div class="tg-p">${ov}${tr}</div></div>` + (shut ? '' : g.products.map((x) => x.rows.map(one).join('')).join(''));
564
- }).join('') : list.map(one).join('');
560
+ }).join('') : flat.map(one).join('');
565
561
  const top = `<div class="tgq-h"><div class="tg-p">${ticks}${pill}</div></div>`;
566
- const below = none.length ? `<div class="tg-none">${footNone.length ? `<span>${footNone.length} ${footNone.length === 1 ? 'has' : 'have'} no dates</span>` : ''}${askBtn}</div>` : '';
562
+ const below = none.length ? `<div class="tg-none"><span>${none.length} ${none.length === 1 ? 'has' : 'have'} no dates</span>${askBtn}</div>` : '';
567
563
  // the Deliveries lane (batch 13 item 4, XcalmA6Phone): a phone shows the next deliveries above the rows (o.lane draws it; none: nothing)
568
564
  const lanePh = o.lane ? o.lane({ W, X, rows: list, groups: G, now, phone: true, pxPct }) || '' : '';
569
565
  return { html: lanePh + `<div class="tg tg-s tgq" ${attrs}><div class="tg-scw"><div class="tg-sc" tabindex="0" role="group" aria-label="Timeline" style="${style}"><div class="tg-in">${top}<div class="tg-body">${body || empty}</div></div></div>${back}<i class="tg-fade l" aria-hidden="true"></i><i class="tg-fade r" aria-hidden="true"></i></div></div>${below}`, none };
@@ -581,11 +577,11 @@ function scaled(rows, M, o) {
581
577
  if (g.kind !== 'program') return lane(g) + (o.folded(g.key) ? '' : g.rows.map((r) => one(r, ' tg-gr')).join(''));
582
578
  const lp = g.products[g.products.length - 1], last = lp ? lp.rows[lp.rows.length - 1] : null;
583
579
  return progRow(g, o.progState(g.program), X, W, ov, o, msO, pxPct, now) + (o.folded(g.key) ? '' : g.products.map((x) => (g.products.length > 1 ? o.subHtml(x.name) : '') + x.rows.map((r) => one(r, ' tg-kid' + (r === last ? ' last' : ''))).join('')).join(''));
584
- }).join('') : list.map((r) => one(r)).join('');
580
+ }).join('') : flat.map((r) => one(r)).join('');
585
581
  // the sprint band and the release keep their labels under the axis on Quarter, where a week is wide enough to read them
586
582
  const axl = sp === 'quarter' ? `${sa && sb && sb > W.from && sa < W.to ? `<span class="tg-axl sb" style="left:${P(X(sa))}">Jira ${esc(sprint.name.toLowerCase())} · ${esc(md(sa))} – ${esc(md(sb))}</span>` : ''}${ra && ra > W.from && ra < W.to ? `<span class="tg-axl rl" style="left:${P(X(ra))}">${SV(FLAG)}${esc(rel.name)} · ${esc(md(ra))}</span>` : ''}` : '';
587
583
  const axis = axl ? `<div class="tg-ax"><span class="tg-n"></span><div class="tg-p">${axl}</div></div>` : '';
588
- const foot = none.length ? `<div class="tg-ft">${footNone.length ? `<span class="tg-none"><b>${footNone.length} ${footNone.length === 1 ? 'has' : 'have'} no dates:</b> <span>${esc(footNone.slice(0, 4).map((r) => r.title).join(', '))}${footNone.length > 4 ? ' and ' + (footNone.length - 4) + ' more' : ''}</span>${askBtn}</span>` : `<span class="tg-none">${askBtn}</span>`}</div>` : '';
584
+ const foot = none.length ? `<div class="tg-ft"><span class="tg-none"><b>${none.length} ${none.length === 1 ? 'has' : 'have'} no dates${footNone.length ? ':' : ''}</b>${footNone.length ? ` <span>${esc(footNone.slice(0, 4).map((r) => r.title).join(', '))}${footNone.length > 4 ? ' and ' + (footNone.length - 4) + ' more' : ''}</span>` : ''}${askBtn}</span></div>` : '';
589
585
  // the Deliveries lane (batch 13 item 4, XcalmA6Lane): one row under the axis, a list per column (o.lane draws it; none: nothing)
590
586
  const laneH = o.lane ? o.lane({ W, X, rows: list, groups: G, now, phone: false, pxPct }) || '' : '';
591
587
  // the columns' width is the name column's (--nw: the sticky block, the left fade and the Deliveries fold all read it)
@@ -270,18 +270,19 @@ function claudeSec() {
270
270
  return '<h4>Claude login and profile</h4><div data-claude-setup></div>'
271
271
  + trow('API key', keySub, keyRight)
272
272
  + (k.present && C.off ? trow('Runs on the API', 'Off: PMOS_GRILL_API is off in the environment.', '<span class="ev-pill">Off</span>') : '')
273
- + sharpenRows(k.present);
273
+ + sharpenRows();
274
274
  }
275
275
  /* Settings › Claude › Sharpen prompts (batch 26 item 2; pm/next-prompt-ui.js, lib/routes-next-prompt.js): the suggestions after a reply, Sharpen as I
276
- * type ('auto' is on when Claude runs on the API key, where a sharpen takes about 2 s), the style, and the PM's own rules for Your own.
276
+ * type (on by default: both run as a background `claude -p` in the PM's own Claude session, Wawan 2026-11-04), the style, and Your own rules.
277
277
  * The same style and switch are in the chat's Sharper card menu; a save here tells the chat (the event pmos:prompt-prefs). */
278
278
  const NPK = { 'prompt.next': ['nextSet', 'next'], 'prompt.sharpen': ['sharpenSet', 'sharpen'], 'prompt.style': ['style', 'style'], 'prompt.rules': ['rulesText', 'rules'] }; // row key → [GET field, POST key]
279
279
  const setVal = (k, d) => { const v = T.np ? T.np[NPK[k][0]] : undefined; return v !== undefined && v !== null ? v : d; };
280
- function sharpenRows(api) {
280
+ function sharpenRows() {
281
281
  const next = setVal('prompt.next', true) !== false, sh = setVal('prompt.sharpen', 'auto'), style = setVal('prompt.style', 'engineer'), rules = String(setVal('prompt.rules', '') || '');
282
- const on = sh === 'on' || (sh === 'auto' && api);
283
- const shSub = sh === 'auto' ? (api ? 'On, because Claude runs on your API key: the sharper version shows in about 2 seconds.' : 'Off for now: without an API key each one takes 10 seconds or more. Turn it on to use it anyway.')
284
- : on ? 'When you pause on a rough prompt, a sharper version shows above the box.' : 'Off. No spinner and no Sharper card.';
282
+ const on = sh !== 'off' && !(T.np && T.np.sharpen === false && sh === 'auto');
283
+ const down = T.np && T.np.apiDown;
284
+ const shSub = !on ? (down && down.auth ? 'Off for now: your API key was refused. Replace it under API key above.' : 'Off. No spinner and no Sharper card.')
285
+ : 'When you pause on a rough prompt, Claude writes a sharper version above the box. It runs in your Claude session and takes a few seconds.';
285
286
  const styleSub = style === 'polish' ? 'Clearer and shorter, your meaning kept.' : style === 'own' ? (rules.trim() ? 'Your rules below.' : 'Write your rules below first.') : 'Builds the best prompt for the job.';
286
287
  return '<h4>Sharpen prompts</h4>'
287
288
  + trow('Suggest the next prompt', 'After a reply, up to two prompts above the box. A tap puts one in the box. Nothing sends.', sw(next, 'data-ev-np="prompt.next"', 'Suggest the next prompt'))
@@ -380,7 +381,7 @@ async function press(e) {
380
381
  }
381
382
  if (t.dataset.evSeg === 'prompt.style') { await savePrompt('prompt.style', t.dataset.v, 'Saved. Sharper prompts follow this style.'); return; }
382
383
  if (t.dataset.evNp === 'prompt.next') { const on = setVal('prompt.next', true) === false; await savePrompt('prompt.next', on, on ? 'Saved. Claude suggests the next prompt after a reply.' : 'Saved. No suggestions after a reply.'); return; }
383
- if (t.dataset.evNp === 'prompt.sharpen') { const api = !!(T.cl && T.cl.key && T.cl.key.present), sh = setVal('prompt.sharpen', 'auto'), was = sh === 'on' || (sh === 'auto' && api); await savePrompt('prompt.sharpen', was ? 'off' : 'on', was ? 'Saved. Sharpen as I type is off.' : 'Saved. Sharpen as I type is on.'); return; }
384
+ if (t.dataset.evNp === 'prompt.sharpen') { const sh = setVal('prompt.sharpen', 'auto'), was = sh !== 'off' && !(T.np && T.np.sharpen === false && sh === 'auto'); await savePrompt('prompt.sharpen', was ? 'off' : 'on', was ? 'Saved. Sharpen as I type is off.' : 'Saved. Sharpen as I type is on.'); return; }
384
385
  if ('evRulesave' in t.dataset) { const ta = T.el.querySelector('#evRules'); await savePrompt('prompt.rules', ta ? ta.value.slice(0, 1000) : '', 'Saved. Your own rules are used when Sharpen as is Your own.'); return; }
385
386
  if (t.dataset.evSeg && t.dataset.evSeg.startsWith('adler.')) { const r = await AD.savePref(pmPost, t.dataset.evSeg, t.dataset.v); T.msg = r && r.ok ? 'Saved.' : (r && r.error) || 'Not saved.'; draw(); return; }
386
387
  if (t.dataset.evDress) {
@@ -1,10 +1,15 @@
1
+ @font-face{font-family:"Space Grotesk";src:url(/vendor/space-grotesk-var.woff2) format("woff2");font-weight:300 700;font-display:swap}
2
+ @font-face{font-family:"IBM Plex Mono";src:url(/vendor/ibm-plex-mono-400.woff2) format("woff2");font-weight:400;font-display:swap}
3
+ @font-face{font-family:"IBM Plex Mono";src:url(/vendor/ibm-plex-mono-500.woff2) format("woff2");font-weight:500;font-display:swap}
4
+ @font-face{font-family:"IBM Plex Mono";src:url(/vendor/ibm-plex-mono-700.woff2) format("woff2");font-weight:600 700;font-display:swap}
5
+ :root{--fw-regular:400;--fw-strong:600;--fs-field:16px;--fs-display:26px;--fs-page:26px;--fs-panel:18px;--fs-section:15px;--fs-row:15px;--fs-body:15px;--fs-meta:13px;--fs-label:13px;--fs-badge:13px;--lh-display:1.2;--lh-page:1.2;--lh-panel:1.3;--lh-section:1.35;--lh-row:1.4;--lh-body:1.6;--lh-meta:1.5;--lh-label:1.35;--lh-badge:1.2}
1
6
  /* premium-vw-guide.css — the guide in the Viewer (premium-vw-guide.js; canvas Exploring › User guide, option B): a page rail on the left,
2
7
  * the page on the right with its section chips. Under 720 px the rail hides and the list icon shows it over the page. Quiet: hairlines, one
3
8
  * well for the open page, no colour but the chips' hover. */
4
- .vwdoc.gd{padding:0}
9
+ .vwdoc.gd{padding:0;max-width:none}
5
10
  .gd-wrap{container-type:inline-size;display:flex;align-items:flex-start;gap:20px;position:relative}
6
11
  .gd-rail{width:248px;flex:none;position:sticky;top:0;max-height:calc(100vh - 140px);overflow:auto;padding:4px 4px 16px 0;display:flex;flex-direction:column;gap:2px}
7
- .gd-read{flex:1;min-width:0}
12
+ .gd-read{flex:1;min-width:0;max-width:none}
8
13
  .gd-tog{display:none}
9
14
  .gd-find{display:flex;align-items:center;gap:8px;height:40px;padding:0 10px;margin:0 0 6px;border-radius:10px;background:var(--well);color:var(--ink-3)}
10
15
  .gd-find .i{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;flex:none}
@@ -4475,7 +4475,7 @@ function startHeadless(base, workdir, kind, launchArgs, env, initialInput, addDi
4475
4475
  // grandchild keeps the output pipes open after the run is stopped.
4476
4476
  // Windows: `claude` installed by npm is claude.cmd, which spawn() cannot start by name (lib/win-command.js).
4477
4477
  const run = winCommand.resolve(base.agent, { env });
4478
- try { child = spawn(run.file, [...run.prefix, ...args], { cwd: workdir, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
4478
+ try { child = spawn(run.file, [...run.prefix, ...args], { cwd: workdir, env: headlessLib.headlessEnv(env), stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
4479
4479
  catch (e) {
4480
4480
  s.state = 'exited'; s.note = spawnFailureAdvice(e, base.agent, false);
4481
4481
  say(`\x1b[38;5;203m✗ ${s.note}\x1b[0m\n`); persist(s); pushSessions(); pushWorkspaces(); return s;