create-pmos 0.527.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 (37) hide show
  1. package/package.json +32 -16
  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 +31 -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 +1 -0
  11. package/workspace/lib/bg-queue.js +5 -2
  12. package/workspace/lib/okr-goals.js +23 -3
  13. package/workspace/lib/routes-morning-goals.js +5 -1
  14. package/workspace/news-viewers-test.js +2 -1
  15. package/workspace/package-lock.json +2 -2
  16. package/workspace/package.json +1 -1
  17. package/workspace/product-lane-test.js +1 -1
  18. package/workspace/program-charter-test.js +2 -2
  19. package/workspace/public/pm/bd-ini-panel.js +1 -1
  20. package/workspace/public/pm/bd-span.js +10 -7
  21. package/workspace/public/pm/cozy-board.css +1 -0
  22. package/workspace/public/pm/cozy-search.js +10 -17
  23. package/workspace/public/pm/cozy-settings.js +8 -2
  24. package/workspace/public/pm/cozy-today.css +30 -14
  25. package/workspace/public/pm/good-morning.js +30 -17
  26. package/workspace/public/pm/home-app.js +11 -1
  27. package/workspace/public/pm/home-data.js +4 -1
  28. package/workspace/public/pm/home-day.js +12 -5
  29. package/workspace/public/pm/premium-board.js +7 -5
  30. package/workspace/public/pm/premium-initiative-head.js +9 -1
  31. package/workspace/public/pm/premium-insights.js +5 -3
  32. package/workspace/public/pm/premium-report-read.js +11 -4
  33. package/workspace/public/pm/program-model.js +2 -1
  34. package/workspace/public/premium-board.html +4 -4
  35. package/workspace/server.js +1 -1
  36. package/workspace/task-milestones-test.js +2 -2
  37. 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.527.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,37 @@
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
+ },
35
66
  {
36
67
  "v": "0.527.0",
37
68
  "at": "2026-11-04T11:15:45.443Z",
@@ -398,21 +429,6 @@
398
429
  "k": "new"
399
430
  }
400
431
  ]
401
- },
402
- {
403
- "v": "0.508.0",
404
- "at": "2026-11-03T12:39:33.903Z",
405
- "f": 4,
406
- "prs": [
407
- {
408
- "n": 1196,
409
- "t": "Decision memory keeps the guard's audit in the per-user folder",
410
- "s": "Decision memory keeps the guard's audit in the per-user folder",
411
- "k": "fix",
412
- "h": "Decision checks stay private",
413
- "d": "The record of what the decision guard checked went to a shared place. Now it stays in your own folder."
414
- }
415
- ]
416
432
  }
417
433
  ]
418
434
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@e3e4aeb7
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-04T11:15:42Z"}
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-04T11:15:42Z"}
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-04T11:15:42Z"}
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,35 @@
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
+ },
2
33
  {
3
34
  "v": "0.527.0",
4
35
  "at": "2026-11-04T11:15:45.443Z",
@@ -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,7 @@ 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.
61
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.
62
63
 
63
64
  ## Send a dashboard as a report
@@ -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 };
@@ -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 };
@@ -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.527.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.527.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.527.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' }),
@@ -51,7 +51,10 @@ import { stageName } from './stage-chip.js'; // W1: the one stage
51
51
 
52
52
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
53
53
  const day = (v) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v || '')); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; };
54
- export const md = (d) => MON[d.getMonth()] + ' ' + d.getDate();
54
+ /** Month and day only, for the axis ticks: the axis says the year once on its own (tickOf). */
55
+ const mdn = (d) => MON[d.getMonth()] + ' ' + d.getDate();
56
+ /** A date in words: "Nov 3", and the short year when it is not this year ("Jun 10 ’27", workspace grill W4). */
57
+ export const md = (d, now = new Date()) => mdn(d) + (d.getFullYear() !== now.getFullYear() ? ' ’' + String(d.getFullYear()).slice(2) : '');
55
58
  /** A month and the short year: "Nov ’26" (2 yr, 5 yr and the edge tabs). */
56
59
  export const my = (d) => MON[d.getMonth()] + ' ’' + String(d.getFullYear()).slice(2);
57
60
  const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
@@ -72,7 +75,7 @@ const colAdd = (u, d, n) => (u === 'w' ? addDays(d, 7 * n) : u === 'm' ? addMont
72
75
  /** A column's head: its label, the part under it that carries no year, and its year (the first column on screen shows it, bd-scroll.js). */
73
76
  function tickOf(u, d) {
74
77
  const y = d.getFullYear(), q = Math.floor(d.getMonth() / 3) + 1, yy = '’' + String(y).slice(2);
75
- if (u === 'w') return { at: d, label: md(d), sub: d.getMonth() === 0 && d.getDate() <= 7 ? String(y) : '', base: '', y };
78
+ if (u === 'w') return { at: d, label: mdn(d), sub: d.getMonth() === 0 && d.getDate() <= 7 ? String(y) : '', base: '', y };
76
79
  if (u === 'm') { const qs = d.getMonth() % 3 === 0 ? 'Q' + q : ''; return { at: d, label: MON[d.getMonth()], sub: qs ? qs + (q === 1 ? ' ' + yy : '') : '', base: qs, y }; }
77
80
  if (u === 'q') return { at: d, label: 'Q' + q, sub: q === 1 ? yy : '', base: '', y };
78
81
  return { at: d, label: String(y), sub: 'H1 · H2', base: 'H1 · H2', y, plain: true };
@@ -88,7 +91,7 @@ export function windowOf(span, now, phone, sprint, dates) {
88
91
  const a = sprint && day(sprint.start), b = sprint && day(sprint.end);
89
92
  const from = a && b && b > a ? a : mon, to = a && b && b > a ? addDays(b, 1) : addDays(mon, 14);
90
93
  const n = Math.round((to - from) / 86400000), step = phone ? Math.max(2, Math.ceil(n / 4)) : n > 16 ? 2 : 1;
91
- const ticks = []; for (let i = 0; i < n; i += step) { const d = addDays(from, i); ticks.push({ at: d, label: md(d), sub: '', base: '', y: d.getFullYear() }); }
94
+ const ticks = []; for (let i = 0; i < n; i += step) { const d = addDays(from, i); ticks.push({ at: d, label: mdn(d), sub: '', base: '', y: d.getFullYear() }); }
92
95
  return { from, to, ticks, cols: ticks.length, fixed: true };
93
96
  }
94
97
  const S = SCALE[span] || SCALE.quarter, u = S.unit, cols = phone ? S.ph : S.cols;
@@ -197,7 +200,7 @@ function extentOf(r, now) {
197
200
  }
198
201
 
199
202
  /** A date as the calm timeline says it (batch 13 item 4): "Nov 3", and the year only when it is not this year ("Jun 10 ’27"). */
200
- export const dy = (d, now) => md(d) + (d.getFullYear() !== now.getFullYear() ? ' ’' + String(d.getFullYear()).slice(2) : '');
203
+ export const dy = (d, now) => mdn(d) + (d.getFullYear() !== now.getFullYear() ? ' ’' + String(d.getFullYear()).slice(2) : '');
201
204
  /** What lands at a row's end: its end milestone's name, else the ◆ stage of its own workflow the end stands for, else '' (no labelled ◇). */
202
205
  const landsOf = (r, et) => (et ? et.name : endStage(r));
203
206
  /** The one label at a bar's end (XcalmA1Desk): what lands, then when; tone '' | 'risk' | 'late' | 'q' colours the date. At the right edge, with
@@ -205,7 +208,7 @@ const landsOf = (r, et) => (et ? et.name : endStage(r));
205
208
  function endLabel(at, name, d, tone, now, pxPct, hover, slip) {
206
209
  const date = dy(d, now), est = (name.length + date.length + 1) * 6.6 + 26;
207
210
  const flip = pxPct && (100 - at) * pxPct < est;
208
- const tip = name + ' · ' + md(d) + (d.getFullYear() !== now.getFullYear() ? ', ' + d.getFullYear() : '') + (hover && hover.length ? '. ' + hover.join('. ') : '');
211
+ const tip = name + ' · ' + mdn(d) + (d.getFullYear() !== now.getFullYear() ? ', ' + d.getFullYear() : '') + (hover && hover.length ? '. ' + hover.join('. ') : '');
209
212
  return `<span class="tg-endl${flip ? ' l' : ''}" style="left:${P(at)}" title="${esc(tip)}"><span class="nm">${esc(name)}</span> <b${tone ? ` class="${tone}"` : ''}>${esc(date)}</b>${slip || ''}</span>`;
210
213
  }
211
214
  /** A slip's words for the hover (PvaAC4): "Planned May 20 → Jun 10 (+3 wk)". */
@@ -278,7 +281,7 @@ function draftBar(r, X, phone, fmt, now, pxPct) {
278
281
 
279
282
  const MIN_PX = 8; // a hold or a gap narrower than this closes up (XtiA4Long, XtiAGrill 12)
280
283
  /** A hold's dates: "Oct 12 to Mar 1, 2027" (the year when it ends in another one). */
281
- export const holdRange = (h) => { const a = day(h.from), b = day(h.to || h.back); return md(a) + (b ? ' to ' + md(b) + (b.getFullYear() !== a.getFullYear() ? ', ' + b.getFullYear() : '') : ''); };
284
+ export const holdRange = (h) => { const a = day(h.from), b = day(h.to || h.back); return md(a) + (b ? ' to ' + mdn(b) + (b.getFullYear() !== a.getFullYear() ? ', ' + b.getFullYear() : '') : ''); };
282
285
  /** A hold's words for its ⏸ button and its menu (XtiAGrill 18): "On hold, Oct 12 to Mar 1, 2027, waits on the SMS vendor contract". */
283
286
  export function holdWords(x, now) {
284
287
  const h = x.h, why = h.reason ? ', ' + h.reason.replace(/\.$/, '').replace(/^./, (c) => c.toLowerCase()) : '';
@@ -443,7 +446,7 @@ function offDots(rows, now) {
443
446
  function rollUp(rows, sp, X, W, o, ms, pxPct, now) {
444
447
  if (!sp || sp.hi <= W.from || sp.lo >= W.to) return '';
445
448
  const L = laneMilestones(rows, now, ms.reach);
446
- const end = o.phone ? '' : `<span class="tg-endl" style="left:${P(X(sp.hi))}" title="${esc('Last end ' + md(sp.hi) + ', ' + sp.hi.getFullYear())}"><b>${esc(my(sp.hi))}</b></span>`;
449
+ const end = o.phone ? '' : `<span class="tg-endl" style="left:${P(X(sp.hi))}" title="${esc('Last end ' + mdn(sp.hi) + ', ' + sp.hi.getFullYear())}"><b>${esc(my(sp.hi))}</b></span>`;
447
450
  return progLine({ rows }, sp, X, now) + laneMarks(L, X, o.phone, pxPct, ms) + end;
448
451
  }
449
452
  /** The product's head row on the timeline: the name cell (tile, name, goal dots, count when folded, the fold) and the lane. Open or folded it
@@ -118,6 +118,7 @@ body.bd-page main.bd-main { position: relative; max-width: none; margin: 16px; p
118
118
  /* the calmer top bar (batch 24 item 7, canvas BbB): one pill for what needs a look, the legend behind (i), Jira as Adler's face */
119
119
  .bd-chip.bd-need { background: var(--cz-well); border-color: transparent; gap: 10px; padding: 0 10px 0 12px; font-weight: var(--fw-strong); color: var(--cz-ink); }
120
120
  .bd-chip.bd-need > span { display: inline-flex; align-items: center; gap: 5px; }
121
+ .bd-chip.bd-need > .bd-nsep { color: var(--cz-ink3); font-weight: var(--fw-regular); margin: 0 -4px; }
121
122
  .bd-chip.bd-need.on { background: var(--cz-bluebg); color: var(--cz-blueink); }
122
123
  .bd-needm { min-width: 220px; }
123
124
  .bd-needm .bd-mlbl, .bd-lgdm .bd-mlbl { font: var(--fw-strong) var(--fs-badge)/var(--lh-badge) var(--cz-sans); color: var(--cz-ink3); padding: 6px 10px 4px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; }
@@ -1,7 +1,7 @@
1
1
  /* pm/cozy-search.js — Search (initiative premium-uncovered-features, E1; canvas EvSearch, and EvPhone on the right).
2
2
  *
3
3
  * One palette over the page: the query, the kind chips with their counts (All, Initiatives, Documents, Slides, Reports, Artifacts, Meetings)
4
- * and the worktree picker ("main + 3 worktrees"); the results grouped (Initiatives · Documents, slides and reports · Artifacts and designs ·
4
+ * (no worktree chip since workspace grill W4); the results grouped (Initiatives · Documents, slides and reports · Artifacts and designs ·
5
5
  * Meetings), and last "Ask Claude about …", which starts a chat in Home. ↑ ↓ move, ↵ opens, Esc closes. On a phone it is the whole screen.
6
6
  * openSearch({ go?, q? }) go: the page's own opener ({ ini } | { ses } | { doc }), used before a page change
7
7
  * closeSearch()
@@ -10,18 +10,16 @@
10
10
  * any other file read-only from its worktree (GET /api/find/file). Nothing here writes. */
11
11
  import { esc, svg, ICON, wsParam, wsQ, skelRows, cantRead, failedItem } from './cozy-evui.js';
12
12
  const ARROW = { up: 'M12 19V5M6 11l6-6 6 6', down: 'M12 5v14M6 13l6 6 6-6', enter: 'M20 5v7a3 3 0 0 1-3 3H5M9 11l-4 4 4 4' }; // the footer's keys, drawn (R28-13: no glyph as a control)
13
- import { SEARCH_KINDS, PHONE_KINDS, searchGroups, resultSub, resultTag, wtChip } from './cozy-evmodel.js';
13
+ import { SEARCH_KINDS, PHONE_KINDS, searchGroups, resultSub, resultTag } from './cozy-evmodel.js';
14
14
  import { open as vwOpen, current as vwCurrent } from './cozy-viewer.js';
15
15
  import { nameOf as sessionName } from './home-data.js';
16
16
  import { adler } from './cozy-adler.js'; // session names A: the PM's name wins, then Claude's
17
17
 
18
18
  const PHONE = 760;
19
19
  const isPhone = () => innerWidth <= PHONE;
20
- const S = { el: null, q: '', kind: 'all', at: 0, rows: [], res: null, busy: false, err: '', wt: null, wtOpen: false, back: null, go: null, seq: 0, boards: null, sessions: null, t: 0 };
20
+ const S = { el: null, q: '', kind: 'all', at: 0, rows: [], res: null, busy: false, err: '', wt: null, back: null, go: null, seq: 0, boards: null, sessions: null, t: 0 };
21
21
  const KIND_ICON = { initiative: ICON.flag, document: ICON.doc, slides: ICON.slides, report: ICON.report, artifact: ICON.design, file: ICON.file, meeting: ICON.meet, session: ICON.session };
22
22
  async function getj(p) { try { const r = await fetch(p, { credentials: 'same-origin' }); const j = await r.json().catch(() => null); return j || { ok: false, error: 'the server did not answer' }; } catch { return { ok: false, error: 'the server did not answer' }; } }
23
- const WT_KEY = 'pmos.search.wt';
24
- const wtPick = () => { try { const v = JSON.parse(localStorage.getItem(WT_KEY) || 'null'); return Array.isArray(v) ? v : null; } catch { return null; } };
25
23
 
26
24
  function mount() {
27
25
  if (S.el && S.el.isConnected) return S.el;
@@ -31,7 +29,7 @@ function mount() {
31
29
  + `<div class="ev-sp-in">${svg(ICON.search, 20)}<label for="evQ" class="ev-vh">Search PMOS</label><input id="evQ" type="text" autocomplete="off" spellcheck="false" placeholder="Search initiatives, documents, slides, reports, artifacts and meetings" role="combobox" aria-expanded="true" aria-controls="evRes" aria-autocomplete="list"><span class="ev-kbd">Esc</span><button type="button" class="ev-ib x" data-ev="close" aria-label="Close search">${svg(ICON.close, 18)}</button></div>`
32
30
  + '<div class="ev-sp-chips" id="evChips" role="group" aria-label="Kind"></div>'
33
31
  + '<div class="ev-sp-list" id="evRes" role="listbox" aria-label="Results"></div>'
34
- + `<div class="ev-sp-ft"><span><span class="ev-kbd" role="img" aria-label="Arrow up">${svg(ARROW.up, 12)}</span> <span class="ev-kbd" role="img" aria-label="Arrow down">${svg(ARROW.down, 12)}</span> move</span><span><span class="ev-kbd" role="img" aria-label="Enter">${svg(ARROW.enter, 12)}</span> open</span><span class="ev-r">Searches the repo and every worktree. Opens in the Viewer when it can.</span></div>`
32
+ + `<div class="ev-sp-ft"><span><span class="ev-kbd" role="img" aria-label="Arrow up">${svg(ARROW.up, 12)}</span> <span class="ev-kbd" role="img" aria-label="Arrow down">${svg(ARROW.down, 12)}</span> move</span><span><span class="ev-kbd" role="img" aria-label="Enter">${svg(ARROW.enter, 12)}</span> open</span><span class="ev-r">Searches your work. Opens in the Viewer when it can.</span></div>`
35
33
  + '</div>';
36
34
  document.body.append(...d.childNodes);
37
35
  S.el = document.getElementById('evSearch');
@@ -42,13 +40,11 @@ function mount() {
42
40
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const n = S.rows.length; if (n) { S.at = (S.at + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; drawList(); } }
43
41
  else if (e.key === 'Enter') { e.preventDefault(); const r = S.rows[S.at]; if (r) go(r); }
44
42
  });
45
- S.el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (S.wtOpen) { S.wtOpen = false; drawChips(); return; } closeSearch(); } });
43
+ S.el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSearch(); } });
46
44
  S.el.addEventListener('click', (e) => {
47
45
  const b = e.target.closest('button, input[type="checkbox"]'); if (!b) return;
48
46
  if (b.dataset.ev === 'close') { closeSearch(); return; }
49
47
  if (b.dataset.evKind) { S.kind = b.dataset.evKind; S.at = 0; draw(); q.focus(); return; }
50
- if ('evWt' in b.dataset) { S.wtOpen = !S.wtOpen; drawChips(); return; }
51
- if (b.dataset.evWtId) { const all = (S.res && S.res.worktrees || []).filter((w) => !w.main).map((w) => w.id); const cur = new Set(S.wt || all); if (b.checked) cur.add(b.dataset.evWtId); else cur.delete(b.dataset.evWtId); S.wt = [...cur]; try { localStorage.setItem(WT_KEY, JSON.stringify(S.wt)); } catch { /* no storage */ } run(); return; }
52
48
  if (b.dataset.evI != null) { const r = S.rows[+b.dataset.evI]; if (r) go(r); }
53
49
  });
54
50
  return S.el;
@@ -59,7 +55,7 @@ export function openSearch(o = {}) {
59
55
  S.go = typeof o.go === 'function' ? o.go : null;
60
56
  if (!el.hidden) { el.querySelector('#evQ').focus(); return el; }
61
57
  const ae = document.activeElement; S.back = ae && ae !== document.body ? ae : null;
62
- S.wt = wtPick(); S.wtOpen = false; S.kind = 'all'; S.at = 0;
58
+ S.wt = null; S.kind = 'all'; S.at = 0; // every worktree: with no chip there is no pick to keep (workspace grill W4)
63
59
  if (o.q != null) S.q = String(o.q);
64
60
  el.hidden = false; document.getElementById('evSpScrim').hidden = false; el.setAttribute('data-esc-layer', '');
65
61
  const q = el.querySelector('#evQ'); q.value = S.q; q.focus(); q.select();
@@ -104,12 +100,9 @@ const countsOf = (rows) => { const c = { all: rows.length }; for (const r of row
104
100
 
105
101
  function drawChips() {
106
102
  const box = S.el && S.el.querySelector('#evChips'); if (!box) return;
107
- const rows = allRows(), c = countsOf(rows), K = isPhone() ? PHONE_KINDS : SEARCH_KINDS, wts = (S.res && S.res.worktrees) || [];
108
- const on = (w) => !S.wt || S.wt.includes(w.id);
109
- const shown = wts.map((w) => ({ ...w, on: w.main || on(w) }));
110
- box.innerHTML = K.map(([k, l]) => `<button type="button" class="ev-chip${S.kind === k ? ' on' : ''}" data-ev-kind="${k}" aria-pressed="${S.kind === k}">${esc(l)}${!isPhone() && S.q.trim().length >= 2 ? ' ' + (c[k] || 0) : ''}</button>`).join('')
111
- + `<button type="button" class="ev-chip gh ev-wt" data-ev-wt aria-expanded="${S.wtOpen}" aria-haspopup="true" title="Which worktrees Search reads">${svg(ICON.branch, 14)}${esc(wtChip(shown))}</button>`
112
- + `<div class="ev-wtm" role="group" aria-label="Worktrees"${S.wtOpen ? '' : ' hidden'}>${shown.map((w) => `<label><input type="checkbox" ${w.main ? 'checked disabled' : `data-ev-wt-id="${esc(w.id)}"${w.on ? ' checked' : ''}`}><span>${esc(w.main ? 'main' : w.name)}</span><small>${w.main ? 'always' : w.error ? esc(w.error) : esc(w.id)}</small></label>`).join('')}</div>`;
103
+ const rows = allRows(), c = countsOf(rows), K = isPhone() ? PHONE_KINDS : SEARCH_KINDS;
104
+ // no worktree chip and no "main" chip (workspace grill W4): Search reads every worktree, and the footer says "Searches your work"
105
+ box.innerHTML = K.map(([k, l]) => `<button type="button" class="ev-chip${S.kind === k ? ' on' : ''}" data-ev-kind="${k}" aria-pressed="${S.kind === k}">${esc(l)}${!isPhone() && S.q.trim().length >= 2 ? ' ' + (c[k] || 0) : ''}</button>`).join('');
113
106
  }
114
107
  function rowHtml(r, i) {
115
108
  const tag = resultTag(r), sub = resultSub(r);
@@ -130,7 +123,7 @@ function drawList() {
130
123
  if (ask) S.rows.push(ask);
131
124
  S.at = Math.min(S.at, Math.max(0, S.rows.length - 1));
132
125
  let h = '', i = 0;
133
- if (q.length < 2) h = '<p class="ev-sp-empty">Type two letters or more. Search reads the initiatives, the documents, slides and reports, the artifacts and designs, and the meetings, in the repository and every worktree.</p>';
126
+ if (q.length < 2) h = '<p class="ev-sp-empty">Type two letters or more. Search reads the initiatives, the documents, slides and reports, the artifacts and designs, and the meetings.</p>';
134
127
  else if (S.err) h = cantRead({ what: 'Search', why: S.err, sources: false });
135
128
  else if (S.busy && !groups.length) h = skelRows(3);
136
129
  // nothing found: Adler with his magnifier (canvas AdU4); the words stay the same