create-pmos 0.526.0 → 0.528.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 (43) hide show
  1. package/package.json +45 -31
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/adler-moments-test.js +4 -3
  7. package/workspace/changelog.json +44 -0
  8. package/workspace/ev-everywhere-test.js +2 -6
  9. package/workspace/guide/home.md +7 -6
  10. package/workspace/guide/reports-set-up.md +2 -0
  11. package/workspace/lib/bg-queue.js +5 -2
  12. package/workspace/lib/briefing-premium-agent.js +9 -2
  13. package/workspace/lib/okr-goals.js +23 -3
  14. package/workspace/lib/report-chat.js +31 -4
  15. package/workspace/lib/routes-briefing-premium.js +18 -1
  16. package/workspace/lib/routes-morning-goals.js +5 -1
  17. package/workspace/news-viewers-test.js +2 -1
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/product-lane-test.js +1 -1
  21. package/workspace/program-charter-test.js +2 -2
  22. package/workspace/public/pm/bd-ini-panel.js +1 -1
  23. package/workspace/public/pm/bd-span.js +10 -7
  24. package/workspace/public/pm/cozy-board.css +1 -0
  25. package/workspace/public/pm/cozy-search.js +10 -17
  26. package/workspace/public/pm/cozy-settings.js +8 -2
  27. package/workspace/public/pm/cozy-today.css +30 -14
  28. package/workspace/public/pm/good-morning.js +30 -17
  29. package/workspace/public/pm/home-app.js +11 -1
  30. package/workspace/public/pm/home-chat.js +6 -2
  31. package/workspace/public/pm/home-data.js +4 -1
  32. package/workspace/public/pm/home-day.js +12 -5
  33. package/workspace/public/pm/premium-board.js +7 -5
  34. package/workspace/public/pm/premium-initiative-head.js +9 -1
  35. package/workspace/public/pm/premium-insights.js +5 -3
  36. package/workspace/public/pm/premium-report-read.js +11 -4
  37. package/workspace/public/pm/premium-report-tab.js +15 -3
  38. package/workspace/public/pm/program-model.js +2 -1
  39. package/workspace/public/pm/report-flow.js +7 -1
  40. package/workspace/public/premium-board.html +4 -4
  41. package/workspace/server.js +1 -1
  42. package/workspace/task-milestones-test.js +2 -2
  43. package/workspace/timeline-holds-test.js +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.526.0",
3
+ "version": "0.528.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,50 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.528.0",
37
+ "at": "2026-11-04T12:00:44.809Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1229,
42
+ "t": "Dates carry their year, and plain words across the workspace (workspace grill W4)",
43
+ "s": "Dates carry their year, and plain words across the workspace (workspace grill W4)",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1241,
48
+ "t": "Good morning on live data — a stale source said once, a quiet quarter, goal tags, say-it-once folds",
49
+ "s": "Good morning on live data — a stale source said once, a quiet quarter, goal tags, say-it-once folds",
50
+ "k": "fix"
51
+ },
52
+ {
53
+ "n": 1239,
54
+ "t": "The Lab's background record leaves out a chat's own runs",
55
+ "s": "The Lab's background record leaves out a chat's own runs",
56
+ "k": "fix"
57
+ },
58
+ {
59
+ "n": 1242,
60
+ "t": "Report reader no longer flickers while a draft is written",
61
+ "s": "Report reader no longer flickers while a draft is written",
62
+ "k": "fix"
63
+ }
64
+ ]
65
+ },
66
+ {
67
+ "v": "0.527.0",
68
+ "at": "2026-11-04T11:15:45.443Z",
69
+ "f": 4,
70
+ "prs": [
71
+ {
72
+ "n": 1237,
73
+ "t": "Chat refinements keep drafting the open report",
74
+ "s": "Chat refinements keep drafting the open report",
75
+ "k": "fix"
76
+ }
77
+ ]
78
+ },
35
79
  {
36
80
  "v": "0.526.0",
37
81
  "at": "2026-11-04T10:37:51.951Z",
@@ -385,36 +429,6 @@
385
429
  "k": "new"
386
430
  }
387
431
  ]
388
- },
389
- {
390
- "v": "0.508.0",
391
- "at": "2026-11-03T12:39:33.903Z",
392
- "f": 4,
393
- "prs": [
394
- {
395
- "n": 1196,
396
- "t": "Decision memory keeps the guard's audit in the per-user folder",
397
- "s": "Decision memory keeps the guard's audit in the per-user folder",
398
- "k": "fix",
399
- "h": "Decision checks stay private",
400
- "d": "The record of what the decision guard checked went to a shared place. Now it stays in your own folder."
401
- }
402
- ]
403
- },
404
- {
405
- "v": "0.507.0",
406
- "at": "2026-11-03T12:20:44.537Z",
407
- "f": 4,
408
- "prs": [
409
- {
410
- "n": 1194,
411
- "t": "Rehearse a plan before you approve it (pmos rehearse)",
412
- "s": "Rehearse a plan before you approve it (pmos rehearse)",
413
- "k": "new",
414
- "h": "Rehearse a plan",
415
- "d": "Moving a date or cutting a task can quietly break a milestone. Now, before you approve a plan, Claude walks through the change and shows what it breaks or helps. Nothing changes until you say yes."
416
- }
417
- ]
418
432
  }
419
433
  ]
420
434
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@dbaab974
1
+ kernel-source: pmos@b9f6f671
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-04T10:37:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T12:00:41Z"}
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-04T10:37:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T12:00:41Z"}
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-04T10:37:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T12:00:41Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -4,7 +4,7 @@
4
4
  * The real Home from the real server, at 390 px with touch; the board and briefing are the fixtures, and GET /api/sources is stubbed with
5
5
  * Jira failed. POST /api/sources/refresh is recorded.
6
6
  * E1 the ears peek over the menu button while a call is the PM's, and the button's name says it; the menu open, no ears
7
- * F1 Your day opens on the rain cloud with Adler honest, "Jira isn't answering.", and no second face
7
+ * F1 Your day (Good morning) says "Jira isn't answering." in one quiet line with its read-again icon; no rain cloud
8
8
  * F2 its icon reads Jira again (POST /api/sources/refresh with source jira)
9
9
  * P1 a pull from the top of Your day lets Adler peek up, says it reads again, and reads Home again
10
10
  * S1 Settings > Updates: a face on the updates card; General: one face only
@@ -90,8 +90,9 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
90
90
 
91
91
  /* F1-F2 */
92
92
  await page.click('#hmList [data-hm-day]');
93
- const f1 = await until(() => page.evaluate(() => { const b = document.querySelector('#czViewer .yd-open'); if (!b || !b.querySelector('.k-rain')) return null; return { line: b.querySelector('.yd-ht b').textContent, faces: b.querySelectorAll('svg.ad-svg').length, sync: !!b.querySelector('[data-yd="sync"][aria-label="Read Jira again"]') }; }), 15000);
94
- ok('F1 Your day opens on the rain cloud, Adler honest, "Jira isn\'t answering.", and no second face', f1 && f1.line === 'Jira isn’t answering.' && f1.faces === 0 && f1.sync, JSON.stringify(f1));
93
+ // Good morning (batch 27 item 10, frames G1-G3): a failed source is one quiet line under the lead, never the lead and never the rain cloud
94
+ const f1 = await until(() => page.evaluate(() => { const b = document.querySelector('#czViewer .gm-lead .yd-open'); const q = b && b.querySelector('.gm-src'); if (!q) return null; return { quiet: q.querySelector('span').textContent, line: b.querySelector('.yd-ht b').textContent, rain: !!b.querySelector('.k-rain, .yd-scene'), sync: !!q.querySelector('[data-yd="sync"][aria-label="Read Jira again"]') }; }), 15000);
95
+ ok('F1 Your day says "Jira isn\'t answering." in one quiet line with its read-again icon; no rain cloud, and the lead is the day\'s own line', f1 && /^Jira isn’t answering\./.test(f1.quiet) && !/answering/.test(f1.line) && !f1.rain && f1.sync, JSON.stringify(f1));
95
96
  await shot(page, 'sync-fail');
96
97
  await page.click('#czViewer [data-yd="sync"]');
97
98
  const f2 = await until(() => posts.find((p) => p.path === '/api/sources/refresh'));
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "v": "0.528.0",
4
+ "at": "2026-11-04T12:00:44.809Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1229,
9
+ "t": "Dates carry their year, and plain words across the workspace (workspace grill W4)",
10
+ "s": "Dates carry their year, and plain words across the workspace (workspace grill W4)",
11
+ "k": "fix"
12
+ },
13
+ {
14
+ "n": 1241,
15
+ "t": "Good morning on live data — a stale source said once, a quiet quarter, goal tags, say-it-once folds",
16
+ "s": "Good morning on live data — a stale source said once, a quiet quarter, goal tags, say-it-once folds",
17
+ "k": "fix"
18
+ },
19
+ {
20
+ "n": 1239,
21
+ "t": "The Lab's background record leaves out a chat's own runs",
22
+ "s": "The Lab's background record leaves out a chat's own runs",
23
+ "k": "fix"
24
+ },
25
+ {
26
+ "n": 1242,
27
+ "t": "Report reader no longer flickers while a draft is written",
28
+ "s": "Report reader no longer flickers while a draft is written",
29
+ "k": "fix"
30
+ }
31
+ ]
32
+ },
33
+ {
34
+ "v": "0.527.0",
35
+ "at": "2026-11-04T11:15:45.443Z",
36
+ "f": 4,
37
+ "prs": [
38
+ {
39
+ "n": 1237,
40
+ "t": "Chat refinements keep drafting the open report",
41
+ "s": "Chat refinements keep drafting the open report",
42
+ "k": "fix"
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "v": "0.526.0",
4
48
  "at": "2026-11-04T10:37:51.951Z",
@@ -101,12 +101,8 @@ const put = (root, rel, text) => { fs.mkdirSync(path.dirname(path.join(root, rel
101
101
  const sl = await until(async () => { const r = await pg.evaluate(() => [...document.querySelectorAll('#evRes .ev-mi[data-ev-i]')].map((b) => b.textContent)); return r.length && !r.some((t) => /Usage billing PRD/.test(t)) ? r : null; });
102
102
  ok('S1 the Slides chip keeps only the deck', !!sl && sl.some((t) => /Billing launch deck/.test(t)), JSON.stringify(sl));
103
103
  await pg.click('#evChips [data-ev-kind="all"]');
104
- await pg.click('[data-ev-wt]');
105
- const wts = await until(() => pg.evaluate(() => [...document.querySelectorAll('.ev-wtm label')].map((l) => l.textContent)));
106
- ok('S2 the worktree chip lists main and the billing-spec worktree', !!wts && wts.some((t) => /^main/.test(t)) && wts.some((t) => /billing-spec/.test(t)), JSON.stringify(wts));
107
- await pg.click('.ev-wtm input[data-ev-wt-id]');
108
- ok('S2 leaving the worktree out drops its draft', !!(await until(() => pg.evaluate(() => { const r = [...document.querySelectorAll('#evRes .ev-mi')].map((b) => b.textContent); return r.some((t) => /Usage billing PRD/.test(t)) && !r.some((t) => /Billing spec draft/.test(t)); }))));
109
- await pg.click('.ev-wtm input[data-ev-wt-id]'); // back in, so the next run of this suite starts the same
104
+ // workspace grill W4: no worktree chip; Search reads every worktree and the footer says so in the PM's words
105
+ ok('S2 no worktree chip, and the footer says "Searches your work"', await pg.evaluate(() => !document.querySelector('[data-ev-wt]') && /Searches your work/.test((document.querySelector('.ev-sp-ft') || {}).textContent || '')));
110
106
  await until(() => pg.evaluate(() => [...document.querySelectorAll('#evRes .ev-mi')].some((b) => /Billing spec draft/.test(b.textContent))));
111
107
  await pg.click('#evRes .ev-mi:has-text("Billing spec draft")');
112
108
  ok('S3 a draft opens in the Viewer, read from its worktree', !!(await until(() => pg.evaluate(() => { const v = document.querySelector('#czViewer:not([hidden])'); return v && /Usage meters per seat/.test(v.textContent); }))), await pg.evaluate(() => (document.querySelector('#czViewer') || {}).textContent || ''));
@@ -17,17 +17,17 @@ Home, the Board, Insights and the Viewer use one typeface, Plus Jakarta Sans, wi
17
17
  - Press **Good morning** to open **Your day** in the Viewer. This is the same on a computer and on a phone. To open the calendar, press the calendar icon in Your day's header. A program's milestone changes that wait on you are the first line of the program's card.
18
18
  - A blue dot after **Good morning** means that something is new for you. It is under **Since yesterday** in Your day.
19
19
  - Your day is the **Good morning** page. It shows the date and greeting, then the quarter, then two columns. On a computer it opens wide.
20
- - **The quarter.** One bar with all goals of the products Home reads: reached, on track, still reading, at risk and will miss, each with its count. A black tick marks your line, for example "line 18". One sentence says how many goals are on track or better, and one line says why. **By product** opens Intelligence. With no goals, there is no bar. With no line, there is no tick, and Claude can propose a line with ✦: press ✓ to keep it, or ✕ to not set one this quarter. A product that could not be read is named and is not counted.
21
- - **The left column.** Claude's lead, with one line of what Claude did for you. **Your call**: the one decision that needs you, and what Yes does when that is known. **Your move**: the top 3 items of the ranked list, each with the goal it moves and its due day (orange only when late). Then one line: **N more in Your move**, **N drafted for you** and **N can wait**. Press one to open its fold under the page. **Since yesterday**: up to 3 new items, then **New for you · N** to see all of them. ✓✓ marks all of them seen.
20
+ - **The quarter.** One bar with all goals of the products Home reads: reached, on track, still reading, at risk and will miss, each with its count. A black tick marks your line, for example "line 18". One sentence says how many goals are on track or better, and one line says why. When no goal has a reading yet, the sentence says that instead, for example "3 goals, none read yet." **By product** opens Intelligence. With no goals, there is no bar. With no line, there is no tick, and Claude can propose a line with ✦: press ✓ to keep it, or ✕ to not set one this quarter. A product that could not be read is named and is not counted.
21
+ - **The left column.** Claude's lead, with one line of what Claude did for you. When a source such as Jira cannot be read, one quiet line under the lead says so and what it shows instead, with an icon to read it again. The source never takes the lead's place. **Your call**: the one decision that needs you, and what Yes does when that is known. **Your move**: the top 3 items of the ranked list, each with the goal it moves and its due day (orange only when late). An item shows its goal when its initiative is anchored to a key result that a goal reads. Then one line: **N more in Your move**, **N drafted for you** and **N can wait**. Press one to open its fold under the page. A fold shows only after you press its line, so it is not said twice. **Since yesterday**: up to 3 new items, then **New for you · N** to see all of them. ✓✓ marks all of them seen.
22
22
  - **The right column.** Today's agenda, not to scale: a line at the time now, each meeting with its prep note, ✦ picks in free time, and "3 h free" for a long free stretch. The meeting where your call is decided has an orange edge. Under it, **Notes since yesterday**: meeting notes with their main lines, and recordings with no initiative. When Claude finds the initiative a recording belongs to, press the link icon to link it.
23
23
  - **In the hour before a meeting**, the meeting's prep leads: what to agree, the points, and the record icon. The quarter shows only when it moved since the morning. Past meetings fade, with their notes under them.
24
24
  - **On a phone or in a narrow Viewer**, the page is one column: the quarter, the lead, your call, Your move, the agenda, then Since yesterday.
25
- - Under the page, the folds stay: the rest of **Your move**, **Drafted for you**, **Can wait**, **Now** (on a phone) and **This week** (the Weekly review).
25
+ - Under the page are **Now** (on a phone) and **This week** (the Weekly review). The rest of **Your move**, **Drafted for you** and **Can wait** open there from their line.
26
26
  - **Drafted for you** lists what Claude drafted today and the weekly update. Press a row to open it: a finished draft opens what Claude wrote, and the weekly update opens the update.
27
27
  - Your day does not show the meeting on now or the next meeting. Home's meeting line shows them. On a phone, the **Now** card does not show while a meeting is in progress.
28
28
  - When a conversation in Mentions waits on your answer, Your day names the oldest one under its first line: who asked, where, how many more wait, and what they asked. Press its icon to open that conversation in Mentions.
29
29
  - **Since yesterday** shows only when something is new: tasks given to you, and work that finished while you were away. It shows the 3 newest items, then **New for you · N**. Press an item to open it in the Viewer. Press **New for you · N** to see all of them. Press ✓✓ in its header to mark all of them seen. An item leaves when you open it or mark it seen. Mentions are not here: **Mentions** shows each one once.
30
- - After 17:00, Your day is the end of the day. The quarter says how it moved since the morning. **Today made possible** lists what moved today, and **Still yours** lists what is left. The right column shows tomorrow's meetings.
30
+ - After 17:00, Your day is the end of the day. The quarter shows only when it moved since the morning, and then says how. **Today made possible** lists what moved today. Decisions caught in today's notes count only after you confirm them; until then one line says how many wait, and it opens your notes. **Your call** stays while it is open. **Still yours** lists what is left. The right column shows tomorrow's meetings.
31
31
  - A **program** comes first, above the products. Its header shows the program icon, its name, the next milestone (◆ and the date) and how many of its initiatives are ready for that milestone. Inside it, the program's own tasks come first, then its initiatives under a small heading for each product. Each initiative shows once. Press the program's name to open the program in the Viewer. The chat then talks about the program. Press the chevron to close or open the group.
32
32
  - The **product picker** is the chip on the top line. It shows the tiles of the ticked products, overlapped, with **+N** after 3, or **All** when every product is ticked. It shows even with one product. A git worktree of a repository is not a product: it does not appear in the list. Its repository stands for it. Hold the pointer on it to read the names. Press it to open the list: **All products**, then each product with its tile, its name and one line (for example "3 initiatives · 1 of 2 goals"), then **Add a product**. Press a product to tick or untick it; the list stays open, and the last ticked product stays ticked. The pick applies when the list closes (Escape, a press outside, or the chip again). On a phone the list is a sheet at the bottom. The pick is one saved setting (`ws.briefing.include`) that Home, the Board and Insights share, so a product that you untick on one page goes from all three. With **All** ticked, a product that you add later is ticked too. The **Product** row in Settings opens the same list.
33
33
  - A program with no open initiative yet still shows, with "No initiatives yet" or "No open initiatives". A program shows only when it holds work of a ticked product. Home reads again when you come back to its tab, so a program made on the Board shows at once.
@@ -162,8 +162,9 @@ Some features are still not designed. Home keeps each one usable as a plain card
162
162
  ## Search, Guide and Settings
163
163
 
164
164
  - **Guide** (the **?** icon in the menu) opens this guide in the Viewer.
165
- - **Search** (Ctrl K or ⌘K, or `/`) finds initiatives, documents, sessions and files in the repository and its worktrees.
166
- - **Settings** holds the workspace settings. **General** holds the theme (Light, Dark or Auto). **Connectors** shows when PMOS last read each source. A source that is old or failed has an amber dot and ↻ to read it again. ↻ in the Connectors header reads every source again. While a source is old, an amber box at the top of Settings names it, with ↻.
165
+ - **Search** (Ctrl K or ⌘K, or `/`) finds initiatives, documents, sessions and files in your work. It always reads the repository and all its worktrees, so it has no worktree picker.
166
+ - **Dates** show the year only when the year is not this year, for example "Jun 23 ’27". New for you shows a due date as "due 25 Nov".
167
+ - **Settings** holds the workspace settings. **General** holds the theme (Light, Dark or Auto) and **Product**, which names the product the page reads. **Connectors** shows when PMOS last read each source. A source that is old or failed has an amber dot and ↻ to read it again. ↻ in the Connectors header reads every source again. While a source is old, an amber box at the top of Settings names it, with ↻.
167
168
 
168
169
  ## The other pages
169
170
 
@@ -58,6 +58,8 @@ Each cycle is a document: Draft or Issued, with its output, its copy state and w
58
58
  - PMOS writes to Google or to Confluence only when you press **Create the copy** or **Update the copy**.
59
59
  - A download happens only when you press a download item.
60
60
  - You issue a report. An issued report is frozen.
61
+ - While a draft is written, the report waits until the file is still, then shows it once. The slides do not flicker, and the scroll position stays.
62
+ - When a report has an open draft, keep talking in the chat. Each change you ask for, such as a date, a wording or a look, goes to that draft by itself. You do not press anything. The draft updates beside the chat, and Claude says in one line what it passed. An issued report does not change. Change its template for the next cycle.
61
63
 
62
64
  ## Send a dashboard as a report
63
65
 
@@ -138,9 +138,12 @@ const ORIGIN_JOB = Object.freeze({ drive: 'driver', grill: 'grill', propose: 'pr
138
138
  impact: 'impact', coredoc: 'coredocs', 'core-docs': 'coredocs', 'scan-web': 'scan', 'scan-repo': 'scan', 'scan-suggest': 'scan',
139
139
  'scan-research-web': 'scan', 'scan-research-repo': 'scan', 'report-find': 'finder', 'ms-review': 'milestones', 'next-step': 'prepare',
140
140
  'idle-lab-proto': 'idle-lab', 'idle-lab-scan': 'idle-lab' });
141
+ // Runs a chat makes for the PM while they use it, not background work: briefing-ask is the read-only run behind a chat (its suggested next
142
+ // prompt, a quick ask). Wawan, 2026-11-04: the Lab's "What Claude did in the background" listed "chat: next prompt" fourteen times.
143
+ const NOT_BACKGROUND = new Set(['briefing-ask']);
141
144
  function jobOfOrigin(origin) {
142
145
  const o = String(origin || '');
143
- if (!o || /^chat:/.test(o)) return null;
146
+ if (!o || /^chat:/.test(o) || NOT_BACKGROUND.has(o.split(':')[0])) return null;
144
147
  const head = o.split(':')[0];
145
148
  return ORIGIN_JOB[head] || head || null;
146
149
  }
@@ -192,7 +195,7 @@ function create(host = {}) {
192
195
  for (const raw of text.split('\n')) {
193
196
  if (!raw.trim()) continue;
194
197
  let l; try { l = JSON.parse(raw); } catch { continue; }
195
- if (!l || l.kind !== 'done') continue;
198
+ if (!l || l.kind !== 'done' || NOT_BACKGROUND.has(l.job)) continue; // a line written before the chat's own runs were left out
196
199
  if (ws && l.ws && l.ws !== ws) continue;
197
200
  if (Date.parse(l.at) < from) continue;
198
201
  const row = { job: l.job, title: l.title, at: l.at };
@@ -16,6 +16,7 @@ const crypto = require('crypto');
16
16
  const headless = require('./headless.js');
17
17
  const Goal = require('./goal-chat.js');
18
18
  const ReportChat = require('./report-chat.js');
19
+ const REPORT_KEYS = new Set(); // chats that carried the report rule: their later messages carry the short reminder (item 9, batch 27)
19
20
  const ProgramChat = require('./program-chat.js');
20
21
  const PastChat = require('./past-chat.js');
21
22
  const WorkflowChat = require('./workflow-chat.js');
@@ -200,7 +201,7 @@ function openingFor(row, text, extra = []) {
200
201
  }
201
202
  /** sessions: { alive(id) → bool, spawn(spec) → session row, type(id, text) → bool (Enter after, `/` left as typed) }.
202
203
  * iniInfo: () → lib/initiative-chat.js infoOf answer | null, read only when an init: chat needs it (its opening, a message about a stage). */
203
- function chat({ runtimeDir, key, text, model, context, sessions, wsId, iniInfo, now = new Date() }) {
204
+ function chat({ runtimeDir, key, text, model, context, sessions, wsId, iniInfo, reportInfo, now = new Date() }) {
204
205
  if (!runtimeDir) return fail(409, 'this workspace has no state folder to keep the conversation in');
205
206
  if (!KEY_RE.test(String(key || ''))) return fail(400, 'key must name a row');
206
207
  const q = String(text == null ? '' : text).trim();
@@ -210,6 +211,8 @@ function chat({ runtimeDir, key, text, model, context, sessions, wsId, iniInfo,
210
211
  const live = chatSessionOf(runtimeDir, key);
211
212
  const alive = !!(live && sessions.alive(live));
212
213
  let infoRead = false, infoVal = null;
214
+ let linked = null;
215
+ const reportOf = () => { try { return typeof reportInfo === 'function' ? reportInfo() : null; } catch { return null; } };
213
216
  const info = () => { if (!infoRead) { infoRead = true; try { infoVal = typeof iniInfo === 'function' ? iniInfo() : null; } catch { infoVal = null; } } return infoVal; };
214
217
  // slice 4 (PRD L2): an ended chat resumes its own Claude Code conversation when the file for it is still there
215
218
  const prev = alive ? null : chatRecordOf(runtimeDir, key);
@@ -227,7 +230,11 @@ function chat({ runtimeDir, key, text, model, context, sessions, wsId, iniInfo,
227
230
  // bug 2026-10-25: in a live initiative chat, a message about a stage, a workflow or a move carries the initiative's workflow and stage, read now
228
231
  else if (alive && /^init:/.test(key) && IniChat.isAsk(q)) msg = IniChat.wrap(q, info(), iniId(key));
229
232
  // premium-reports-from-chat R1: a message about a report, a chart or a dashboard carries the pmos-report rule (lib/report-chat.js)
230
- else if (ReportChat.isAsk(q)) msg = ReportChat.wrap(q);
233
+ // Wawan, 2026-11-04: a chat with a set-up report open beside it carries that report on every message, so feedback in any words reaches the draft (lib/report-chat.js)
234
+ else if (!q.startsWith('/') && (linked = reportOf())) { msg = ReportChat.wrap(q, linked); }
235
+ else if (ReportChat.isAsk(q)) { msg = ReportChat.wrap(q); REPORT_KEYS.add(runtimeDir + '|' + key); }
236
+ // a refinement later in a chat that carried the report rule (it need not say "report") is still about that report's draft
237
+ else if (REPORT_KEYS.has(runtimeDir + '|' + key) && !q.startsWith('/')) msg = ReportChat.remind(q);
231
238
  // bug 2026-10-24: a message about a program carries the program rule (lib/program-chat.js), so the chat names the real press
232
239
  else if (ProgramChat.isAsk(q)) msg = ProgramChat.wrap(q);
233
240
  const extra = goal ? { goal } : {};
@@ -154,12 +154,31 @@ function read(root) {
154
154
  function matchAnchor(anchor, objectives) {
155
155
  const s = String(anchor || '').replace(/\*\*/g, '').trim();
156
156
  if (!s || /^(health|domain):/i.test(s)) return null;
157
+ // by name as well as by id (workspace grill W4): an anchor that names a key result's words is that key result, even when its id is
158
+ // missing, names two key results, or is a number from an older file that now belongs to another key result
159
+ const named = byName(s, objectives);
157
160
  const km = new RegExp('(?:^|[^A-Za-z0-9])' + KR + END).exec(s);
158
- if (!km) return null;
161
+ if (!km) return named;
159
162
  const om = new RegExp('(?:^|[^A-Za-z0-9])' + OBJ + END).exec(s.slice(0, km.index + 1));
160
163
  const kid = krId(km[1]), oid = om ? objId(om[1]) : null;
161
164
  const hits = [];
162
165
  for (const o of objectives || []) { if (oid && o.id !== oid) continue; const k = o.krs.find((x) => x.id === kid); if (k) hits.push({ o, k }); }
166
+ if (named) return named;
167
+ return hits.length === 1 ? hits[0] : null;
168
+ }
169
+ /** Words compared as words: lower case, letters and digits only, one space between. */
170
+ const words = (t) => String(t || '').toLowerCase().replace(/\([^)]*\)/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
171
+ /** The one key result whose name the text holds, or whose name holds the text (two words and 12 letters or more, so "KR2" or one word never match); null
172
+ * when none or more than one does. Ids are taken out of the text first, so only the words compare. PURE. */
173
+ function byName(text, objectives) {
174
+ const t = ' ' + words(String(text || '').replace(new RegExp('\\b(?:' + OBJ + '|' + KR + ')' + END, 'g'), ' ')) + ' ';
175
+ if (t.trim().length < 8) return null;
176
+ const part = t.trim().length >= 12 && t.trim().split(' ').length >= 2; // a part of a name: two words or more, so one word never joins
177
+ const hits = [];
178
+ for (const o of objectives || []) for (const k of o.krs || []) {
179
+ const n = words(k.title);
180
+ if (n.length >= 8 && (t.includes(' ' + n + ' ') || (part && (' ' + n + ' ').includes(t)))) hits.push({ o, k });
181
+ }
163
182
  return hits.length === 1 ? hits[0] : null;
164
183
  }
165
184
 
@@ -181,7 +200,8 @@ function build({ okr, goals, initiatives, records, today }) {
181
200
  const t = Date.parse(String(today || '').slice(0, 10)) || Date.now();
182
201
  const P = okr.period ? Object.assign({}, okr.period, { day: t >= okr.period.from && t <= okr.period.to ? Math.round((t - okr.period.from) / DAY) + 1 : 0, days: Math.round((okr.period.to - okr.period.from) / DAY) + 1, fromAt: iso(okr.period.from), toAt: iso(okr.period.to) }) : null;
183
202
  const moversOf = new Map();
184
- for (const r of initiatives || []) { const m = matchAnchor(r.anchor, okr.objectives); if (!m) continue; const key = m.o.id + '::' + m.k.id; if (!moversOf.has(key)) moversOf.set(key, []); moversOf.get(key).push(r); }
203
+ // an initiative with no anchor that resolves, titled with a key result's name, moves that key result (workspace grill W4: the join is by id and by name)
204
+ for (const r of initiatives || []) { const m = matchAnchor(r.anchor, okr.objectives) || (/^(health|domain):/i.test(String(r.anchor || '').trim()) ? null : byName(r.title, okr.objectives)); if (!m) continue; const key = m.o.id + '::' + m.k.id; if (!moversOf.has(key)) moversOf.set(key, []); moversOf.get(key).push(r); }
185
205
  const objectives = okr.objectives.map((o) => ({
186
206
  id: o.id, token: o.token, title: o.title,
187
207
  krs: o.krs.map((k) => {
@@ -197,4 +217,4 @@ function build({ okr, goals, initiatives, records, today }) {
197
217
  return { files: okr.files, period: P, objectives, total: krs.length, read: krs.filter((k) => k.read).length, needs, readers: [...used] };
198
218
  }
199
219
 
200
- module.exports = { read, parse, periodOf, dueOf, sourceOf, needOf, matchAnchor, readerOf, build };
220
+ module.exports = { read, parse, periodOf, dueOf, sourceOf, needOf, matchAnchor, byName, readerOf, build };
@@ -26,8 +26,13 @@ const RULE = [
26
26
  ' When the PM says how it should look, or asks it to follow a file (a draft deck, a sample), add "design": "the look, in the PM\'s words" and, for a file, "sample": "<its path in the workspace>" (a .pdf, .pptx, .docx, .png or .jpg). Once the PM sets it up, the tab adds the sample and starts that look.',
27
27
  ' The report view asks a few questions first, with your picks filled in: the format, who reads it, when it goes out, the look and the sources. The PM changes any of them, then presses Draft it. Say it like that: "A few questions before I draft. My picks are filled in on the right. Change any, then press Draft it." Never say Create, Save or any other button.',
28
28
  ' The report then drafts in its own session, not in this chat. Never describe contents the block does not carry.',
29
+ ' Once the PM has pressed Draft it, the report is SET UP (its setup line is in report-setups.jsonl, found by name) and the proposal is closed. From then on follow the next rule, never "press Draft it".',
29
30
  '- A report that is set up already: {"kind":"report","id":"<its id>"} and, only when the PM asked for it, "schedule": {…} or "off", "sections": ["the new section titles, in order"] or "structure": "the change to its sections, in the PM\'s words", "design": "the change to its look, in the PM\'s words".',
30
- ' A change to the draft of a set-up report (a date, a slide, a wording): {"kind":"report","id":"<its id>","change":"the change, in the PM\'s words"}. The PM\'s press on the chip passes it to the report\'s own session, which changes the draft; nothing is issued. Do not change the draft yourself.',
31
+ ' A change to the draft of a set-up report (a date, a slide, a wording): {"kind":"report","id":"<its id>","change":"the change, in the PM\'s words"}. The page passes it to the report\'s own session by itself, with no press, and the draft updates beside this chat; nothing is issued. Do not change the draft yourself.',
32
+ ' Every refinement the PM gives about a set-up report (the tab shows "Draft · <date>"), in this chat or later in it, is such a change: end with the change block, with the whole change in it, and say in ONE line what you passed, e.g. "Passed to the Nov 9 draft: tender stays open with no award date; Feature implementation not started."',
33
+ ' Never say "press Draft it", "the proposal is unchanged" or "the report drafts in its own session" for a set-up report. If the draft needs something you do not know (a date), ask it once in the same answer and pass the change with what is known: the draft marks the unknown, it does not wait.',
34
+ ' A look change to the open draft (a palette, tables, a Gantt style) is such a change too. Put the paths of any file the PM attached (a sample PDF, a screenshot) in the change text, so the report\'s session reads them. Several changes in a row each pass; say the draft is updating, never "the draft stays as it is".',
35
+ ' An issued report (its latest cycle is issued) is frozen: pass nothing, and say the change goes into the template for the next cycle.',
31
36
  ' The set-up reports are the setup lines of report-setups.jsonl (planning/ or pmos/state/); a later change line wins, a remove line deletes one.',
32
37
  '- A chart of one measure: {"kind":"chart","proposal":{"measure":"<KR metric id>","title":"short title","question":"what it answers","form":"line|bars|number|meter","range":null,"target":null,"better":"higher|lower","note":""}}',
33
38
  ' measure is the id of a KR metric record (planning/records/kr-metrics.jsonl). range is how many of the latest readings to draw, or null for all. A meter needs a target.',
@@ -38,7 +43,29 @@ const RULE = [
38
43
  'If the message is not about a report, a chart or a dashboard, answer it and add no block.',
39
44
  ].join('\n');
40
45
 
41
- /** The message as PMOS types it into the session: the rule, then the PM's words. */
42
- function wrap(text) { return MARK + ' ' + RULE + '\n\n' + SAYS + String(text == null ? '' : text); }
46
+ const REMIND = 'This chat is about a report. If it is set up (report-setups.jsonl, by name) every refinement is a change to its open draft: end with {"kind":"report","id":"<its id>","change":"the whole change, in the PM\'s words"} in a pmos-report block, the page passes it by itself, and say in one line what you passed and that the draft is updating (put the paths of attached files in the change). Never say "the draft stays as it is" or "press Draft it" or "the proposal is unchanged" for a set-up report; ask anything unknown once and pass what is known. An issued report is frozen: pass nothing. Same rules as before.';
43
47
 
44
- module.exports = { MARK, isAsk, wrap, RULE };
48
+ /** A later message in a chat that already carried the rule (the PM refines without saying "report"): the short reminder, then the PM's words. */
49
+ function remind(text) { return MARK + ' ' + REMIND + '\n\n' + SAYS + String(text == null ? '' : text); }
50
+
51
+ /** The report this chat set up (Wawan, 2026-11-04: "I expect the drafting continue when the feedback continue from chat"). Once the PM pressed
52
+ * Draft it, the report is set up and its own session drafts; a later message in the same chat is about that draft whatever words it uses, so it
53
+ * carries this note with the report's id. Without it, "keep the tender open" went as typed, and Claude sent the PM back to Draft it. */
54
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
55
+ function linkedNote(rep) {
56
+ const id = String(rep.id), name = String(rep.name || id).replace(/\s+/g, ' ').slice(0, 80);
57
+ return [
58
+ `This chat set up the report "${name}" (report id ${id}). The PM pressed Draft it already: the report is set up and its own session drafts it.${rep.draft ? ' Its latest draft: ' + String(rep.draft).slice(0, 200) + '.' : ''}`,
59
+ 'Read every message here as feedback on that draft unless it is plainly about something else. Never tell the PM to press Draft it again, and never say the proposal is unchanged.',
60
+ `When the PM asks for a change to the draft in any words (a date, a status, a row, a slide, a wording, a point to keep open), answer in a few lines and end with {"kind":"report","id":"${id}","change":"the change, in the PM's words, complete"}. Put every point of the PM's message in "change"; it is all the report's session reads.`,
61
+ 'PMOS passes the block to the report\'s session as your answer lands, and the draft beside this chat changes. Say so in one line. When you need an answer from the PM first, ask it and add no block.',
62
+ ].join('\n');
63
+ }
64
+ /** The message as PMOS types it into the session: the rule, then the PM's words. With rep ({ id, name, draft? }), the note on the report this
65
+ * chat set up comes between them. */
66
+ function wrap(text, rep) {
67
+ const linked = rep && ID_RE.test(String(rep.id || '')) ? '\n' + linkedNote(rep) : '';
68
+ return MARK + ' ' + RULE + linked + '\n\n' + SAYS + String(text == null ? '' : text);
69
+ }
70
+
71
+ module.exports = { MARK, isAsk, wrap, remind, RULE, REMIND, linkedNote };
@@ -83,6 +83,23 @@ function iniInfoOf(ctx, key, wsId) {
83
83
  return null;
84
84
  }
85
85
 
86
+ /** The set-up report open beside this chat, for lib/report-chat.js wrap: { id, name, draft } | null. Wawan, 2026-11-04: feedback in the chat,
87
+ * in any words, is about the draft beside it. The page names the report (pm/premium-report-tab.js reportBeside); it counts only while it is set
88
+ * up, read in the chat's own workspace, then the others. */
89
+ function reportInfoOf(ctx, wsId, rid) {
90
+ const id = String(rid || ''); if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(id)) return null;
91
+ const RS = require('./report-setups.js');
92
+ const list = ctx.workspaceList ? ctx.workspaceList() : [];
93
+ const first = wsId && ctx.getWs ? ctx.getWs(wsId) : null;
94
+ for (const w of [first, ...list.filter((x) => !first || x.id !== first.id)].filter((x) => x && x.root)) {
95
+ let st; try { st = RS.stateOf(w.root); } catch { continue; }
96
+ const s = st.setups.get(id); if (!s) continue;
97
+ const last = (s.drafted || []).filter((d) => d && d.path).slice(-1)[0];
98
+ return { id, name: s.name || id, draft: last ? last.path : null };
99
+ }
100
+ return null;
101
+ }
102
+
86
103
  async function post(req, res, url, ctx) {
87
104
  const act = url.pathname.slice('/api/briefing-premium/'.length);
88
105
  const got = await ctx.readJson(req);
@@ -125,7 +142,7 @@ async function post(req, res, url, ctx) {
125
142
  if (!att.ok) { ctx.json(res, { ok: false, error: att.error }, att.status); return true; }
126
143
  // batch 20 item 13: the PM can start a grill any time by asking for it in an initiative's chat; it also runs by itself
127
144
  const g2 = grillAsk(ctx, b.key, b.text, wsId);
128
- const a = (ctx.chatSessions ? Ag.chat : Ag.ask)({ runtimeDir, key: b.key, text: Files.withFiles(g2 ? g2.text : b.text, att.paths), context: b.context, sessions: ctx.chatSessions, model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^ins:/.test(String(b.key || '')) ? InsP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk || (/^src:/.test(String(b.key || '')) && !(b.key === 'src:this-page' && url.searchParams.get('ws'))) ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId, iniInfo: () => iniInfoOf(ctx, b.key, wsId) });
145
+ const a = (ctx.chatSessions ? Ag.chat : Ag.ask)({ runtimeDir, key: b.key, text: Files.withFiles(g2 ? g2.text : b.text, att.paths), context: b.context, sessions: ctx.chatSessions, reportInfo: () => reportInfoOf(ctx, wsId, b.report), model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^ins:/.test(String(b.key || '')) ? InsP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk || (/^src:/.test(String(b.key || '')) && !(b.key === 'src:this-page' && url.searchParams.get('ws'))) ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId, iniInfo: () => iniInfoOf(ctx, b.key, wsId) });
129
146
  ctx.json(res, a.body, a.status);
130
147
  return true;
131
148
  }
@@ -44,7 +44,11 @@ async function read(ctx, req, want) {
44
44
  for (const p of await productsOf(ctx, req, want)) {
45
45
  const m = await modelOf(ctx, req, p.ws);
46
46
  if (!m) { missing.push(p.name); continue; }
47
- products.push({ ws: p.ws, name: (m.board && m.board.name && m.board.name !== 'Intelligence' ? m.board.name : p.name), goals: m.goals || [], model: m });
47
+ // the goal an initiative moves: the goal's own moves, and the OKR file's link (an initiative anchored to a key result that this goal reads)
48
+ const via = new Map();
49
+ for (const o of (m.okrFile && m.okrFile.objectives) || []) for (const k of o.krs || []) if (k && k.goal) for (const r of k.moves || []) if (r && r.id) (via.get(k.goal) || via.set(k.goal, []).get(k.goal)).push({ id: r.id });
50
+ const goals = (m.goals || []).map((g) => (g && via.has(g.id) ? { ...g, moves: [...(g.moves || []), ...via.get(g.id)] } : g));
51
+ products.push({ ws: p.ws, name: (m.board && m.board.name && m.board.name !== 'Intelligence' ? m.board.name : p.name), goals, model: m });
48
52
  }
49
53
  const list = MG.collect(products);
50
54
  const dir = ctx.runtimeDir || null;
@@ -152,7 +152,8 @@ const ROWS = { 'psle-article-paywall-lock': { title: 'PSLE Article Paywall Lock'
152
152
  await sleep(800);
153
153
  await p.click('[data-hm-day]');
154
154
  await until(() => p.evaluate(() => !!document.querySelector('#czViewer [data-yd="fin"]')));
155
- await p.evaluate(() => { const f = document.querySelector('#czViewer [data-czs-fold="drafts"]'); if (f && f.getAttribute('aria-expanded') !== 'true') f.click(); });
155
+ // Good morning says "N drafted for you" once: its line opens the fold (batch 27 item 10)
156
+ await p.click('#czViewer [data-yd="gm-fold"][data-fold="drafts"]');
156
157
  await p.click('#czViewer [data-yd="fin"]');
157
158
  await until(() => p.evaluate(() => /Project Ascent status summary/.test((document.querySelector('#czVwTitle') || {}).textContent || '')));
158
159
  const t = await vwText(p);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.526.0",
3
+ "version": "0.528.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.526.0",
9
+ "version": "0.528.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.526.0",
3
+ "version": "0.528.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",
@@ -122,7 +122,7 @@ function model() {
122
122
  a && a.tm.length === 3 && a.tmc.length === 1 && /^tg-tmc dn|^tg-tmc/.test(a.tmc[0]) && a.tmc[0].endsWith('|2') && !a.tml.length, JSON.stringify(a));
123
123
  ok('L the same ◇: late is orange, the Evaluate task IS the end ◇ (said once), a row\'s only milestone IS its end (no Deliver, hotfix 2)',
124
124
  a && /^tg-tm late\|Design review · Passkey sign-in · Oct 16 · late$/.test(a.tm[0]) && a.tm.some((x) => /KPI evaluated · Scarlet/.test(x)) && !a.tm.some((x) => /Deliver · Passkey/.test(x)) && !a.tm.some((x) => /Committed/.test(x)), JSON.stringify(a && a.tm));
125
- ok('H a single ◇\'s hover names the milestone and its initiative', a && a.tm.some((x) => x.endsWith('|Feature delivered · Scarlet — ST What’s The News · Jan 29')), JSON.stringify(a && a.tm));
125
+ ok('H a single ◇\'s hover names the milestone and its initiative', a && a.tm.some((x) => /\|Feature delivered · Scarlet — ST What’s The News · Jan 29(?: ’\d\d)?$/.test(x)), JSON.stringify(a && a.tm));
126
126
  ok('L the other product\'s lane: Mar 2 and Mar 6 (Recovery\'s end) merge; Email OTP has no milestone, so its end is Deliver Jun 30', b && b.tmc.length === 1 && b.tm.length === 1 && /Deliver · Email OTP · Jun 30/.test(b.tm[0]), JSON.stringify(b));
127
127
  const lg = await p.evaluate(() => (document.querySelector('.tg-lgd') || {}).textContent || '');
128
128
  ok('L the legend gets no new entry', !/lane|product/i.test(lg), lg);
@@ -134,10 +134,10 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
134
134
  await p.click('#czViewer [data-pc="pick"][data-m="pilot-live"]');
135
135
  const mn = await until(() => p.evaluate(() => { const m = document.querySelector('#czViewer .pc-menu'); return m ? { hd: [...m.querySelectorAll('.pc-mhd')].map((x) => x.textContent), items: [...m.querySelectorAll('.pc-mi')].map((x) => x.querySelector('.t').textContent + (x.querySelector('.r') ? ' ' + x.querySelector('.r').textContent : '') + (x.getAttribute('aria-checked') === 'true' ? ' ✓' : '')) } : null; }));
136
136
  ok('P the picker lists its initiatives per product with their ◆ Deliver days, the chosen ones ticked, then "None: I tick it myself"', !!mn && mn.hd.join('|') === 'Done when these pass ◆ Deliver|IDPF|Microsites'
137
- && mn.items.includes('Descope feature parity Nov 12 ✓') && mn.items.includes('Microsite sign-in on CIAM Nov 14 ✓') && mn.items.includes('Legacy IdP sunset Feb 20') && mn.items[mn.items.length - 1] === 'None: I tick it myself', JSON.stringify(mn));
137
+ && mn.items.includes('Descope feature parity Nov 12 ✓') && mn.items.includes('Microsite sign-in on CIAM Nov 14 ✓') && mn.items.some((x) => /^Legacy IdP sunset Feb 20(?: ’\d\d)?$/.test(x)) && mn.items[mn.items.length - 1] === 'None: I tick it myself', JSON.stringify(mn));
138
138
  await p.click('#czViewer .pc-menu [data-ini="legacy-idp-sunset"]');
139
139
  const wv = await until(() => p.evaluate(() => { const w = document.querySelector('#czViewer .pc-lnw:has([data-m="pilot-live"]) .pc-warn'); return w ? w.textContent : null; }));
140
- ok('P picking an initiative whose ◆ Deliver is later than the milestone warns in amber, and Save stays', wv === 'Legacy IdP sunset is set to deliver on Feb 20, after this date.' && (await head(p)).save, String(wv));
140
+ ok('P picking an initiative whose ◆ Deliver is later than the milestone warns in amber, and Save stays', /^Legacy IdP sunset is set to deliver on Feb 20(?: ’\d\d)?, after this date\.$/.test(String(wv)) && (await head(p)).save, String(wv));
141
141
  await shot(p, 'MsB3Pick-built');
142
142
  await p.click('#czViewer .pc-menu [data-ini="legacy-idp-sunset"]');
143
143
  await p.keyboard.press('Escape');
@@ -175,7 +175,7 @@ function dashFor(r) {
175
175
  function iniBody(r) {
176
176
  const h = dashFor(r);
177
177
  if (h && h.shows()) return h.html(() => '');
178
- const md = (v) => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][+v.slice(5, 7) - 1] + ' ' + +v.slice(8, 10);
178
+ const md = (v) => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][+v.slice(5, 7) - 1] + ' ' + +v.slice(8, 10) + (+v.slice(0, 4) !== new Date().getFullYear() ? ' ’' + v.slice(2, 4) : '');
179
179
  const self = !!((O.M() || {}).board || {}).self, M = O.M() || {};
180
180
  // what left the ⋯ for the thing it changes (batch 16 item 5c): the workflow chip, the dashed +, the stage chip, the dates chip, Jira's ↻
181
181
  return viewHtml(viewOf(r), { add: !!(r.id && O.addTask), adding: adding === r.key, addHint: adding === r.key && addDates ? md(addDates.start) + ' to ' + md(addDates.due) : '', openTask: !!O.openTask, quick: !!(O.setTask && O.setDates), hold: !!O.hold, tail: VW.czSections(boardSections(r), { key: 'board' }),