create-pmos 0.518.0 → 0.520.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.
Files changed (38) hide show
  1. package/package.json +39 -47
  2. package/payload/AGENTS.md +1 -1
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/board-columns-test.js +1 -1
  8. package/workspace/board-programs-test.js +26 -7
  9. package/workspace/briefing-premium-board-test.js +3 -3
  10. package/workspace/calm-timeline-test.js +2 -2
  11. package/workspace/changelog.json +38 -0
  12. package/workspace/guide/agent-capabilities.md +2 -1
  13. package/workspace/guide/descriptions.md +43 -0
  14. package/workspace/guide/index.md +3 -2
  15. package/workspace/guide/initiatives-roadmap.md +1 -0
  16. package/workspace/guide/next-prompt.md +4 -2
  17. package/workspace/guide/programs.md +2 -1
  18. package/workspace/guide/settings.md +1 -1
  19. package/workspace/lib/board-trail.js +8 -3
  20. package/workspace/lib/description-props.js +214 -0
  21. package/workspace/lib/headless.js +12 -2
  22. package/workspace/lib/initiative-cli.js +28 -2
  23. package/workspace/lib/initiative-writer.js +41 -1
  24. package/workspace/lib/proposals.js +13 -4
  25. package/workspace/lib/routes-next-prompt.js +67 -27
  26. package/workspace/lib/routes-proposals.js +5 -0
  27. package/workspace/package-lock.json +2 -2
  28. package/workspace/package.json +1 -1
  29. package/workspace/public/pm/bd-span.js +13 -17
  30. package/workspace/public/pm/cozy-settings.js +8 -7
  31. package/workspace/public/pm/ini-about.js +26 -11
  32. package/workspace/public/pm/ini-props.js +5 -3
  33. package/workspace/public/pm/ini-view-model.js +1 -1
  34. package/workspace/public/pm/ini-view.css +1 -0
  35. package/workspace/public/pm/ini-view.js +1 -1
  36. package/workspace/public/pm/premium-vw-guide.css +7 -2
  37. package/workspace/server.js +15 -1
  38. package/workspace/test-lists/initiative-description-field.json +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.518.0",
3
+ "version": "0.520.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.520.0",
37
+ "at": "2026-11-04T01:52:53.932Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1216,
42
+ "t": "Every initiative has its own description",
43
+ "s": "Every initiative has its own description",
44
+ "k": "new"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "v": "0.519.0",
50
+ "at": "2026-11-04T01:26:27.710Z",
51
+ "f": 4,
52
+ "prs": [
53
+ {
54
+ "n": 1213,
55
+ "t": "Every undated initiative is a Timeline row with No dates",
56
+ "s": "Every undated initiative is a Timeline row with No dates",
57
+ "k": "fix"
58
+ },
59
+ {
60
+ "n": 1214,
61
+ "t": "Chat prompt suggestions run on claude -p by default, and never fail silently",
62
+ "s": "Chat prompt suggestions run on claude -p by default, and never fail silently",
63
+ "k": "fix"
64
+ },
65
+ {
66
+ "n": 1215,
67
+ "t": "Background runs read long connector results; guide fills the wide Viewer",
68
+ "s": "Background runs read long connector results; guide fills the wide Viewer",
69
+ "k": "fix"
70
+ }
71
+ ]
72
+ },
35
73
  {
36
74
  "v": "0.518.0",
37
75
  "at": "2026-11-04T00:33:13.219Z",
@@ -358,52 +396,6 @@
358
396
  "d": "On a wide Viewer, Home's list could fold away and refuse to open again. Now the toggle opens and closes it every time."
359
397
  }
360
398
  ]
361
- },
362
- {
363
- "v": "0.500.0",
364
- "at": "2026-11-03T04:25:59.021Z",
365
- "f": 4,
366
- "prs": [
367
- {
368
- "n": 1183,
369
- "t": "Delete a captured meeting-note point from its edit card",
370
- "s": "Delete a captured meeting-note point from its edit card",
371
- "k": "new",
372
- "h": "Delete a meeting point",
373
- "d": "A point captured from a meeting could not be removed. Now you can delete it from its edit card."
374
- }
375
- ]
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
399
  }
408
400
  ]
409
401
  }
package/payload/AGENTS.md CHANGED
@@ -33,7 +33,7 @@ It refuses frontmatter, fenced and indented code, table rows, headings, delibera
33
33
 
34
34
  | Hosted tool (as skills name them) | File-mode equivalent |
35
35
  |---|---|
36
- | `create_initiative` | write `pmos/state/initiatives/<id>.md` (frontmatter: id, title, parent_kr, stage, status, **type** — the D56 lane, default `feature` — plus `intent` for the chore lane, prd_path, rubric_path, created; plus `outcome_metric` + `outcome_horizon` — the D57 declaration for post-ship success criteria. No initiative goes without one: after you create it, and once its PRD exists, run `pmos outcomes propose <id>` and show the cited draft; write it with `pmos outcomes declare` only after the PM's yes) |
36
+ | `create_initiative` | write `pmos/state/initiatives/<id>.md` (frontmatter: id, title, parent_kr, stage, status, **type** — the D56 lane, default `feature` — plus `intent` for the chore lane, prd_path, rubric_path, created; plus `outcome_metric` + `outcome_horizon` — the D57 declaration for post-ship success criteria. No initiative goes without one: after you create it, and once its PRD exists, run `pmos outcomes propose <id>` and show the cited draft; write it with `pmos outcomes declare` only after the PM's yes; plus `description` — one paragraph that says what the initiative is, shown in About before any PRD exists: `pmos initiative describe <id>` lists the cited lines to draft it from, and `pmos initiative describe <id> "<text>"` writes it only after the PM's yes) |
37
37
  | `save_spec` | the PRD file itself at `pmos/planning/prd/<id>-prd.md` (set `prd_path` on the initiative file) |
38
38
  | `get_spec` / `get_planning_artifact` | read `pmos/planning/…` |
39
39
  | `get_skill` | read `pmos/skills/<name>.skill` |
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@ed082f08
1
+ kernel-source: pmos@5c846c69
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:52:50Z"}
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:52:50Z"}
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:52:50Z"}
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.520.0",
4
+ "at": "2026-11-04T01:52:53.932Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1216,
9
+ "t": "Every initiative has its own description",
10
+ "s": "Every initiative has its own description",
11
+ "k": "new"
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "v": "0.519.0",
17
+ "at": "2026-11-04T01:26:27.710Z",
18
+ "f": 4,
19
+ "prs": [
20
+ {
21
+ "n": 1213,
22
+ "t": "Every undated initiative is a Timeline row with No dates",
23
+ "s": "Every undated initiative is a Timeline row with No dates",
24
+ "k": "fix"
25
+ },
26
+ {
27
+ "n": 1214,
28
+ "t": "Chat prompt suggestions run on claude -p by default, and never fail silently",
29
+ "s": "Chat prompt suggestions run on claude -p by default, and never fail silently",
30
+ "k": "fix"
31
+ },
32
+ {
33
+ "n": 1215,
34
+ "t": "Background runs read long connector results; guide fills the wide Viewer",
35
+ "s": "Background runs read long connector results; guide fills the wide Viewer",
36
+ "k": "fix"
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "v": "0.518.0",
4
42
  "at": "2026-11-04T00:33:13.219Z",
@@ -31,6 +31,7 @@ This page lists every job, so you know what runs and why. Each row links to the
31
31
  | [New dates, done and tidy names](claude-proposes.md) | Each morning, and when a date passes | The tasks of the initiative, and the evidence that work happened | The initiative's ✦ | `PMOS_PROPOSE=off` |
32
32
  | [Grill](claude-proposes.md) | Before an initiative leaves a stage, when a forecast turns at risk, when scope or dates move a lot, and once a month on running work with late work. Also when you write "grill this" in the initiative's chat. | The initiative, your stored research, your meeting notes and the decisions that stand | "Claude grilled it" under the initiative's ✦ | Settings › Claude's checks: Off. `PMOS_GRILL=off` |
33
33
  | [You decided this before](ask-the-past.md) | You add an initiative that repeats an earlier decision | `DECISIONS.md`, the decision records, the PM journal, `planning/out-of-scope/` and the initiative records | The initiative's ✦ | `pmos ai off past-repeat` |
34
+ | [Initiative descriptions](descriptions.md) | Once a day. At most 3 drafts a day for each product. | The PRD's first Problem or Why paragraph, or the record's own first paragraph | "Description" under the initiative's ✦ | `pmos ai off descriptions`. `PMOS_DESCRIPTIONS=off` |
34
35
  | [Expected outcomes](outcomes.md) | Once a day. At most 3 drafts a day for each product. | The PRD's success criteria, or the anchored key result | "Expected outcome" under the initiative's ✦. Today when a check is due. | `pmos ai off outcomes`. `PMOS_OUTCOMES=off` |
35
36
  | [Metric drafts](metric-drafts.md) | A product with no North Star, the first 14 days of a quarter, a program with no goal, and an initiative that gets a PRD | The last 8 weeks of real readings | The page of that product, program or initiative | `PMOS_METRIC_DRAFT=off` |
36
37
  | [Horizon scan](horizon-scan.md) | Daily by default, at most once a day. Scan now: at most 3 a day. | The public web for your kept watch list, and your repository | Report documents and proposals you approve | Cadence: Off. `PMOS_SCAN=off` |
@@ -70,4 +71,4 @@ When you ask an agent session to do a piece of work, it can finish it, including
70
71
  - `pmos ai log [<job>]`: the last audit lines. Each dropped proposal shows why.
71
72
  - `pmos ai off [<job>]` and `pmos ai on [<job>]`: turn every AI job, or one job, off or on. `PMOS_AI=off` in the environment wins over the setting.
72
73
 
73
- The AI jobs are `past-repeat`, `decision-memory`, `outcomes`, `drift`, `rehearse`, `next-prompt` and `sharpen-prompt`. A background run cannot change the switch.
74
+ The AI jobs are `past-repeat`, `decision-memory`, `outcomes`, `descriptions`, `drift`, `rehearse`, `next-prompt` and `sharpen-prompt`. A background run cannot change the switch.
@@ -0,0 +1,43 @@
1
+ ---
2
+ type: Guide
3
+ title: "Initiative descriptions"
4
+ description: "Every initiative says what it is in About, before any PRD exists. Where the description is kept, how Claude drafts it, how you keep one, how a PRD enriches it, and pmos initiative describe."
5
+ timestamp: 2026-11-04
6
+ ---
7
+
8
+ # Initiative descriptions
9
+
10
+ Every initiative has a description: one paragraph that says what it is. About, in the initiative's mission control, shows it. An initiative needs one long before its PRD is written.
11
+
12
+ ## Where it is kept
13
+
14
+ The description is the `description:` line in the initiative's record. It is not in the PRD. One paragraph, up to 700 characters.
15
+
16
+ ## What About shows
17
+
18
+ - The record has a description: About shows it.
19
+ - The PRD says more than the description: About also shows the PRD's paragraph once, below it, marked **From the PRD**.
20
+ - The record has no description: About shows the PRD's first Problem, Why or Background paragraph, marked **From the PRD**.
21
+ - Neither: About says "No description yet" and shows **Ask Claude to draft it**.
22
+
23
+ ## Claude drafts it
24
+
25
+ Once a day Claude drafts a description for an initiative that has none, and files it under the initiative's ✦ in **Check these**. The draft is the PRD's own paragraph, or the first paragraph of the record that says what the work is. Each draft cites the line it comes from. When there is nothing to read, Claude makes no draft. At most 3 drafts a day for each product.
26
+
27
+ When a PRD is written later and says more than the description, Claude drafts **A fuller description from the PRD**.
28
+
29
+ - ✓ saves the draft to the record. Undo puts back what was there.
30
+ - ✕ drops it. Claude does not draft the same one again.
31
+
32
+ ## Ask Claude to draft it
33
+
34
+ **Ask Claude to draft it** opens Home's chat with a prompt. Claude reads what it can, shows you a draft, and saves it after you say yes.
35
+
36
+ ## Commands
37
+
38
+ - `pmos initiative describe <id>`: the description, and the lines Claude can draft it from.
39
+ - `pmos initiative describe <id> "<text>"`: save the description. Claude runs this only after your yes.
40
+
41
+ ## Turn it off
42
+
43
+ `pmos ai off descriptions` stops the daily drafts. `pmos ai on descriptions` starts them again. `PMOS_DESCRIPTIONS=off` also stops them.
@@ -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
 
@@ -56,6 +56,7 @@ PMOS has three places: **Home**, **Board** and **Intelligence**. The round menu
56
56
  - [Claude proposes: the one ✦ on each initiative](claude-proposes.md): what Claude finds when you add an initiative, new dates, done and tidy names for late tasks, ✓, ✕, Undo, and `pmos propose`.
57
57
  - [The next prompt, suggested and sharpened](next-prompt.md): what the chat suggests after a reply, and the sharper version of a rough prompt.
58
58
  - [Ask about the past](ask-the-past.md): why something was decided or dropped, answered in the initiative chat from the records; "You decided this before" on a new initiative.
59
+ - [Initiative descriptions](descriptions.md): what About says about an initiative, where it is kept, Claude's drafts, and `pmos initiative describe`.
59
60
  - [Expected outcomes](outcomes.md): what each initiative should change once it ships, the date to check it, and `pmos outcomes`.
60
61
  - [Rehearse a plan](rehearse.md): what a date change or a cut breaks or helps, before you approve it, and `pmos rehearse`.
61
62
  - [Your impact](your-impact.md): what counts as your impact, the weekly check in Home's chat, the switch, and `pmos impact`. Private to you.
@@ -111,6 +111,7 @@ Every measurement needs a source: the file the value comes from and its commit,
111
111
  | `pmos initiative drop <proposal>` | Closes a proposal. |
112
112
  | `pmos initiative list` | Shows the Roadmap in rank order, with each lifecycle stage, check-by date and the open proposals. |
113
113
  | `pmos initiative export <id>` | Writes the breakdown as markdown to the terminal. |
114
+ | `pmos initiative describe <id> ["<text>"]` | Shows the description and the lines Claude can draft it from. With text, saves the description. See [Initiative descriptions](descriptions.md). |
114
115
 
115
116
  Every command takes `--json` and `--root <dir>`. The command does not accept `--by` or `--via`. `pmos initiative now`, `next` and `later` are retired: they write nothing, say what to use instead and exit 2.
116
117
 
@@ -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
 
@@ -163,7 +163,8 @@ function trail({ root, ids, rows = [], moves = [], now = Date.now(), io } = {})
163
163
  const evalNames = X.list ? X.list('planning/evals') : [];
164
164
  const rowBy = new Map(rows.map((r) => [r.id, r]));
165
165
  const fmCache = new Map();
166
- const fmOf = (id) => { if (!fmCache.has(id)) { const t = X.text('planning/initiatives/' + id + '.md'); fmCache.set(id, t == null ? null : { fm: frontmatter(t), text: t }); } return fmCache.get(id); };
166
+ // a kit product keeps its records in pmos/state/initiatives and its PRDs under pmos/ (initiative-description-field: About read none there)
167
+ const fmOf = (id) => { if (!fmCache.has(id)) { const t = X.text('planning/initiatives/' + id + '.md') ?? X.text('pmos/state/initiatives/' + id + '.md'); fmCache.set(id, t == null ? null : { fm: frontmatter(t), text: t }); } return fmCache.get(id); };
167
168
 
168
169
  // "usually N days": every record's ladder, and every run of board moves
169
170
  const samples = Object.fromEntries(STAGES.map((s) => [s, []]));
@@ -204,8 +205,10 @@ function trail({ root, ids, rows = [], moves = [], now = Date.now(), io } = {})
204
205
  const stage = wk ? null : stageKey(row.stage);
205
206
  const lane = String(row.lane || fm.lane || 'feature').toLowerCase();
206
207
  const evals = evalFiles(id, evalNames);
207
- const prdRel = pathOf(fm.prd_path), rubRel = pathOf(fm.rubric_path);
208
- const prdText = prdRel ? X.text(prdRel) : null, prdFm = frontmatter(prdText);
208
+ let prdRel = pathOf(fm.prd_path); const rubRel = pathOf(fm.rubric_path);
209
+ let prdText = prdRel ? X.text(prdRel) : null;
210
+ if (prdRel && prdText == null && !prdRel.startsWith('pmos/')) { const k = X.text('pmos/' + prdRel); if (k != null) { prdText = k; prdRel = 'pmos/' + prdRel; } }
211
+ const prdFm = frontmatter(prdText);
209
212
  const briefName = (X.list ? X.list('planning/briefs') : []).find((n) => n.startsWith(id) && n.endsWith('.md'));
210
213
  const jv = X.jira ? X.jira(id) : null;
211
214
  const tks = jv && jv.ok && Array.isArray(jv.tickets) ? jv.tickets : [];
@@ -276,6 +279,8 @@ function trail({ root, ids, rows = [], moves = [], now = Date.now(), io } = {})
276
279
  skipped, parts, next: parts ? (parts.find((p) => !p.done) || {}).name || null : null, you,
277
280
  links: { prd: prdRel, prdVersion: prdRel && (typeof prdFm.version === 'string' || typeof prdFm.version === 'number') && String(prdFm.version).trim() ? String(prdFm.version).trim().slice(0, 12) : null,
278
281
  rubric: rubRel, evaluation: evals[0] || null, epic: jv && jv.ok && jv.epic && jv.epic.key ? jv.epic.key : null },
282
+ // the record's own description (initiative-description-field): About shows it before the PRD's paragraph
283
+ desc: typeof fm.description === 'string' && fm.description.trim() ? fm.description.replace(/\\("|\\)/g, '$1').replace(/\s+/g, ' ').trim().slice(0, 700) : null,
279
284
  drafted, gate: jv && jv.ok ? jv.gate || null : null, drift: drift.map((t) => t.key), deps, theme: tkey, tickets,
280
285
  };
281
286
  }