create-pmos 0.502.0 → 0.503.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.502.0",
3
+ "version": "0.503.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -32,6 +32,31 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.503.0",
37
+ "at": "2026-11-03T05:42:41.752Z",
38
+ "f": 3,
39
+ "prs": [
40
+ {
41
+ "n": 1186,
42
+ "t": "A timeline end ◇ names the initiative's own workflow, never a fixed Deliver",
43
+ "s": "A timeline end ◇ names the initiative's own workflow, never a fixed Deliver",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1187,
48
+ "t": "Working card leads with the todo or turn goal, not the raw command",
49
+ "s": "Working card leads with the todo or turn goal, not the raw command",
50
+ "k": "new"
51
+ },
52
+ {
53
+ "n": 1188,
54
+ "t": "New meeting recording view, word fix in place (batch 25 item 11)",
55
+ "s": "New meeting recording view, word fix in place (batch 25 item 11)",
56
+ "k": "new"
57
+ }
58
+ ]
59
+ },
35
60
  {
36
61
  "v": "0.502.0",
37
62
  "at": "2026-11-03T04:51:26.562Z",
@@ -410,19 +435,6 @@
410
435
  "k": "fix"
411
436
  }
412
437
  ]
413
- },
414
- {
415
- "v": "0.483.0",
416
- "at": "2026-11-02T00:26:37.780Z",
417
- "f": 3,
418
- "prs": [
419
- {
420
- "n": 1144,
421
- "t": "Report building flow \"A+ on Home\" (batch 22 item 14)",
422
- "s": "Report building flow \"A+ on Home\" (batch 22 item 14)",
423
- "k": "new"
424
- }
425
- ]
426
438
  }
427
439
  ]
428
440
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@53b3f1f6
1
+ kernel-source: pmos@8b7c5275
2
2
  assembled: 2026-11-03
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-03T04:51:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T05:42:38Z"}
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-03T04:51:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T05:42:38Z"}
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-03T04:51:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T05:42:38Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -144,9 +144,10 @@ const json = (r, b, status = 200) => r.fulfill({ status, contentType: 'applicati
144
144
  const q = B.page;
145
145
  await until(() => q.evaluate(() => !!document.querySelector('#czViewer [data-a="maketask"]')), 15000);
146
146
  // shell A (XvAReplay): Next steps is a fold; open it so what the press says can be read
147
- await q.evaluate(() => { const f = document.querySelector('#czViewer [data-czs-fold="steps"]'); if (f && f.getAttribute('aria-expanded') !== 'true') f.click(); });
148
- ok('M4 each next step has Make task, and decisions and questions have none', await q.evaluate(() => document.querySelectorAll('#czViewer [data-a="maketask"]').length === 3));
149
- await tap(q, '[data-a="maketask"][data-i="0"]');
147
+ // batch 25 item 11 (MvC): Next steps is a part of the one strip under the kept points; open it so what the press says can be read
148
+ await q.evaluate(() => { const f = document.querySelector('#czViewer .vzr-fold [data-a="strip"][data-k="steps"]'); if (f && f.getAttribute('aria-expanded') !== 'true') f.click(); });
149
+ ok('M4 each next step has Make task, and decisions and questions have none', await until(() => q.evaluate(() => document.querySelectorAll('#czViewer .vzr-panel [data-a="maketask"]').length === 3)));
150
+ await tap(q, '.vzr-panel [data-a="maketask"][data-i="0"]');
150
151
  const pl = await until(() => posted(B.S, '/api/plan-items')[0]);
151
152
  const ev = pl && pl.body.events && pl.body.events[0];
152
153
  ok('M4 Make task posts one planned event in the first stage with the owner', ev && ev.ev === 'planned' && ev.initiative === 'viewer' && ev.stage === 'st1' && ev.owner === 'person:Ana' && ev.title === 'Add a print view for documents' && pl.body.ws === 'w1', JSON.stringify(pl && pl.body));
@@ -143,10 +143,9 @@ const json = (r, b, status = 200) => r.fulfill({ status, contentType: 'applicati
143
143
  /* R7 Replay */
144
144
  const C = await open('/premium-viewer.html?open=meeting&note=n1');
145
145
  const r = C.page;
146
- // Viewer kit (XvAReplay): no tabs; the replay card sits under the summary, the kept points are the "All N kept" fold, the lines the Transcript fold
147
- const unfold = async (id) => { if (await r.evaluate((k) => document.querySelector(`#czViewer [data-czs-fold="${k}"]`).getAttribute('aria-expanded') !== 'true', id)) await tap(r, `[data-czs-fold="${id}"]`); };
146
+ // batch 25 item 11 (MvC): no tabs; the kept points sit left, the timeline is docked, the lines are the Transcript part of the one strip
147
+ const unfold = async (id) => { if (await r.evaluate((k) => document.querySelector(`#czViewer .vzr-fold [data-a="strip"][data-k="${k}"]`).getAttribute('aria-expanded') !== 'true', id)) await tap(r, `.vzr-fold [data-a="strip"][data-k="${id}"]`); };
148
148
  ok('R7 a kept recording has the replay card under the summary', await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vzp-card .vzp-pl') && !document.querySelector('#czViewer [role="tablist"]')), 15000));
149
- await unfold('kept');
150
149
  const playing = () => r.evaluate(() => /^Pause/.test((document.querySelector('#czViewer .vzp-dt [data-a="play"]') || { getAttribute: () => '' }).getAttribute('aria-label') || ''));
151
150
  ok('R7 Replay shows the player, the strip and the kept points (MtReplay)', await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vzp-dt .vzp-strip') && document.querySelectorAll('#czViewer .vzp-kr').length > 0)));
152
151
  await tap(r, '.vzp-dt [data-a="play"]');
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "v": "0.503.0",
4
+ "at": "2026-11-03T05:42:41.752Z",
5
+ "f": 3,
6
+ "prs": [
7
+ {
8
+ "n": 1186,
9
+ "t": "A timeline end ◇ names the initiative's own workflow, never a fixed Deliver",
10
+ "s": "A timeline end ◇ names the initiative's own workflow, never a fixed Deliver",
11
+ "k": "fix"
12
+ },
13
+ {
14
+ "n": 1187,
15
+ "t": "Working card leads with the todo or turn goal, not the raw command",
16
+ "s": "Working card leads with the todo or turn goal, not the raw command",
17
+ "k": "new"
18
+ },
19
+ {
20
+ "n": 1188,
21
+ "t": "New meeting recording view, word fix in place (batch 25 item 11)",
22
+ "s": "New meeting recording view, word fix in place (batch 25 item 11)",
23
+ "k": "new"
24
+ }
25
+ ]
26
+ },
2
27
  {
3
28
  "v": "0.502.0",
4
29
  "at": "2026-11-03T04:51:26.562Z",
@@ -83,14 +83,15 @@ function install(page, o = {}) {
83
83
  page.route('**/api/notes/series?*', (r) => json(r, { ok: true, series: { key: 'viewer-sync', meetings: [{ id: 'n0', at: '2026-10-15T10:00:00Z', decided: 1, steps: 3 }, { id: 'n1', at: W.NOTE.meeting.start, decided: 2, steps: 3 }] } })),
84
84
  page.route('**/api/notes/search*', (r) => json(r, { ok: true, ids: ['n4'], hits: [{ id: 'n4', snip: '…which teams use saved views for teams every week…', open: true }] })),
85
85
  page.route('**/api/meeting-points/*', (r) => { // a correction and its Undo (the answers lib/meeting-learn.js gives)
86
- const b = post(r); if (/forget$/.test(new URL(r.request().url()).pathname)) return json(r, { ok: true });
86
+ const b = post(r); if (/forget$/.test(new URL(r.request().url()).pathname)) { S.terms = (S.terms || []).filter((x) => x.id !== b.lesson); return json(r, { ok: true }); }
87
+ if (/term$/.test(new URL(r.request().url()).pathname)) { const t = { id: 't0123456789', note: b.note, heard: b.heard, means: b.means }; S.terms = (S.terms || []).concat([t]); return json(r, { ok: true, term: t, terms: S.terms }); }
87
88
  const phrase = require('../lib/meeting-learn.js').phraseOf(b.orig && b.orig.text, b.text);
88
89
  S.edits = { ...(S.edits || {}), [b.id]: { kind: b.kind, text: b.text, orig: b.orig } };
89
90
  return json(r, { ok: true, edits: S.edits, lesson: b.learn === false ? null : { id: 'l0123456789', phrase, kind: b.kind, meaning: b.text, orig: b.orig }, check: [] });
90
91
  }),
91
92
  page.route('**/api/meeting-points*', (r) => {
92
93
  if (r.request().method() === 'POST') { const b = post(r); (b.ids || []).forEach((id) => { if (b.verdict) S.points[id] = b.verdict; else delete S.points[id]; }); return json(r, { ok: true, points: S.points }); }
93
- return json(r, { ok: true, points: S.points, edits: S.edits || {}, lessons: [] });
94
+ return json(r, { ok: true, points: S.points, edits: S.edits || {}, lessons: [], terms: S.terms || [] });
94
95
  }),
95
96
  page.route('**/api/notes/new', (r) => {
96
97
  const b = post(r), id = 'new' + (++S.made);
@@ -331,7 +331,7 @@ Board is the fourth place on the rail, after Chat, at `/premium-board.html`. It
331
331
  - **The view chip** in the Initiatives head says which view is shown (List, By stage or Timeline); press it to pick another.
332
332
  - **List** shows the late and at-risk initiatives first, then the board's now view, up to 100 loaded and 25 on screen at a time. A row shows the owner and lane, a status chip when it is not on track, how far through the workflow it is (and "derived" when the workflow engine placed it), a progress bar only when a record holds progress, and the planned finish only when an end date was set ("Not planned" otherwise). "Do this first" appears only when the first row is late.
333
333
  - **By stage** has one column for each stage of the board's workflow with the board's own count and its first five initiatives.
334
- - **List** and **Timeline** show ten tasks a page with Previous and Next; **By stage** shows six cards a column with Show more. **Timeline** draws a bar for each initiative that has dates, with the days left or over, and counts the rest. A bar runs from the earliest start to the latest due date of the initiative's breakdown items and its tasks. Every stage shows, Monitor and Evaluate too. Under a program, every initiative of the program has a row; one with no dates says **No dates** beside today. A product whose initiatives all have no dates keeps its row too, with each initiative saying **No dates**, so the product is never missing from the Board. In each group the rows run by start date, then end date, and the ones with no dates come last; the status dot still marks a late or at-risk initiative. With no dates on record it says so and draws nothing. A gap in the plan that overlaps a hold still leaves out the days outside the hold, also when the hold has no return date.
334
+ - **List** and **Timeline** show ten tasks a page with Previous and Next; **By stage** shows six cards a column with Show more. **Timeline** draws a bar for each initiative that has dates, with the days left or over, and counts the rest. A bar runs from the earliest start to the latest due date of the initiative's breakdown items and its tasks. Every stage shows, Monitor and Evaluate too. The ◇ at the end of a bar names a dated milestone task of the initiative. When the initiative has no dated milestone task, the ◇ names the ◆ stage of the initiative's own workflow: on Product feature that is Deliver, or Evaluate after Deliver. When the workflow has no ◆ stage, the bar ends with no ◇ and no label. A bar never shows Deliver for a workflow that has no Deliver stage, and never shows Deliver next to a named milestone. Under a program, every initiative of the program has a row; one with no dates says **No dates** beside today. A product whose initiatives all have no dates keeps its row too, with each initiative saying **No dates**, so the product is never missing from the Board. In each group the rows run by start date, then end date, and the ones with no dates come last; the status dot still marks a late or at-risk initiative. With no dates on record it says so and draws nothing. A gap in the plan that overlaps a hold still leaves out the days outside the hold, also when the hold has no return date.
335
335
  - **Sprint** appears when an agent session has read Jira for the board. Otherwise it says there is none. Press it to open the sprint's facts, with the sprint as a board in a second tab: **To do**, **In progress** and **Done** columns from the last Jira read, with the dates, the days left and the points. The sprint goal is the one the Jira status check writes from the sprint's tickets, as on the console's Sprints tab, with its summary; Jira's own goal shows under it only as a reference (**In Jira: "…"**). Until the check has written one, the tab says **No goal yet**. The active and next sprints are buttons at the top. The closed sprints are behind **Past sprints**, which is shut until you press it. Press any card to open its ticket in another tab. A ticket that is on no initiative of this board shows what the sprint read holds (title, status, points and its Jira link). The board is read only: Jira owns each ticket's status and sprint, so you move tickets in Jira. Tickets with no sprint are not in this read. **Coming up** shows the next release, from the releases ledger.
336
336
  - **Open the backlog** is in the Sprint dialog and on its sprint tab. It opens the board's Jira backlog in a tab beside the dialog: the open sprints (active, then next) and then **Backlog**, the tickets in no sprint and not done. A row shows the key, title, status, points and initiative, and why a blocked ticket is blocked. **All**, **Blocked** and **Bugs** filter it (Bugs appears only when the Jira read holds the issue type). A row opens its ticket. The list holds only the tickets the board's initiatives pushed to Jira or hold under their epics, in key order: the Jira read has no rank, assignee or capacity. You move and rank tickets in Jira. With no Jira read, the tab says so.
337
337
  - **Next release** shows the next release that carries an initiative of this board, the one planned after it, and the three latest past releases (**Released** when accepted, else **Past**). It reads the release records (`releases/releases.jsonl`, written from the console's Releases view); Jira fix versions alone do not make a release. With no release planned it says so and still lists the past ones. With none at all it says "No release is on record for this board, past or planned."
@@ -188,6 +188,7 @@ There is no My work page. The list on Home is your list:
188
188
  - **Initiative.** The Board opens with **New initiative**, in the open product.
189
189
  - **Sessions.** A session row names its agent. The coloured dot beside it is its state: blue waits on you, amber may be blocked, green is running. A session PMOS started itself (a skill or a background job) shows on Home only while it works or asks you something. Once it finishes its prompt, it leaves the list. On an initiative's page, a session that waits on you shows once, under **Needs you**, not again under **Sessions**. **Finished sessions** does not list an old run of a chat that is open again.
190
190
  - **One chat box.** Every conversation has the same box: **+** (attach a file, notes, the terminal, and **Link to an initiative** for a loose chat), **/** for commands, **@** inside an initiative, the To or In chip, the mic, and Send. A chat opens at its newest message.
191
+ - **While Claude works.** A line at the top of the chat shows what Claude is trying to do. If Claude keeps a to-do list, the line shows the to-do in progress and how many are done, for example **2 of 5 done**. Tap the line to see the full list. If Claude has no to-do list, the line shows Claude's newest one-line status and the time since your message. The line never shows raw commands. To see the commands, tap the terminal icon.
191
192
  - **A session's terminal.** **Watch its terminal** in **+**, or a session row such as **Claude sign-in**, opens the session's terminal in the Viewer. While the session runs, it is a real terminal: type in it to answer Claude or paste a code. When the session has ended, the Viewer shows it read only and says so.
192
193
  - **Setting up your first project opens by itself** until a registered folder holds an initiative or setup has finished. The `PMOS` folder that the one-line installer makes holds no initiative yet, so a new user who opens PMOS from the Start menu or the Dock still gets the setup conversation, not an empty Home.
193
194
  - **Signing in to Claude when you set up your first project.** If the login you choose is signed in already, PMOS does not open a terminal. It tests the login at once. Otherwise PMOS runs `claude auth login`: Claude opens your browser to sign in, and the terminal under the question shows only Claude's request for a code, if it needs one. Claude's theme and trust-this-folder screens do not appear. When the sign-in ends, PMOS tests the login by itself. **The Claude login on this computer** is the login your usual terminal uses (`~/.claude.json`); PMOS no longer points it at a different file. You choose between two logins only: **The Claude login on this computer** and **A separate login just for PMOS**. The test passes for a login that works in your terminal. Claude updates its own files when it runs, and the test does not count that as a changed setup.
@@ -238,9 +238,29 @@ Press **Replay** beside the player (in Calendar › Notes, or on the meeting's c
238
238
 
239
239
  The page reads only records on this machine: the note, the file Focus kept for the meeting, and the screen shots.
240
240
 
241
+ ### A recorded meeting in the Viewer
242
+
243
+ A meeting with a recording opens wide, in two columns. Press **Back to the side** in the header to make it narrow again: the columns then stack.
244
+
245
+ - **Left:** the summary (two lines; press **Read it all** for the rest), then the kept points. Each point is one line. The icon shows its kind: a green dot is a decision, a box is a next step, a question mark is an open question. The header counts each kind. A next step said within two minutes after a decision, in the same topic, shows under that decision, with **Make a task**.
246
+ - **Confirm:** press **✓** on a row to confirm it (press it again to undo). **Confirm N** in the header confirms every point that is left.
247
+ - **Right:** press a row to see its evidence: the screen at that moment, the quote, and the screen captures near it. Press **▶** to play from the point. The pen opens the edit card. **Not right** moves the point to **Left out**.
248
+ - **Under the points:** one row of parts. Press one to open it: **Left out**, **Next steps**, **Since the last one**, **Facts mentioned**, **Names and terms**, **Prep and material** and **Transcript**.
249
+ - **Bottom:** the timeline stays in view, with the full name of each chapter and a dot for each kept point.
250
+
251
+ The captures row shows every kept screen capture. It does not yet show only the shared screen: that needs Jev for Images, which PMOS does not have yet.
252
+
253
+ ### Fix a word that was heard wrong
254
+
255
+ In the summary, a quote, **Left out** or the transcript, select the word (double-click it, or drag over up to four words). The word becomes a field. Type the right spelling and press **✓** or Enter. The word shows fixed in every place this meeting shows it, and the message says how many places. **Undo** puts it back. **Names and terms** lists the people of the invite and the words you fixed.
256
+
257
+ Later notes spell the word your way: the fix goes to your agent with the notes it writes. In other meetings, the same heard words are underlined. Press one to fix it there too, with your fix already typed.
258
+
259
+ The note's own files do not change. The fix shows where PMOS draws the note.
260
+
241
261
  ### Fix or delete a key point
242
262
 
243
- In a meeting's Viewer, press a decision, a question or an action to open its edit card. Change the kind or the words, then press **Save**. PMOS remembers what you meant for the next notes, unless you press **Only fix this one**.
263
+ In a meeting's Viewer, press a decision, a question or an action, then press the pen to open its edit card. **Not right** is on the edit card too. Change the kind or the words, then press **Save**. PMOS remembers what you meant for the next notes, unless you press **Only fix this one**.
244
264
 
245
265
  To delete the point, press the trash on the edit card. The point leaves at once, with no question first. **Undo** shows for 8 seconds and puts it back. A deleted point stays deleted after a reload. It is not in **Left out** and it is not counted. A delete does not teach PMOS anything, and nothing is sent outside PMOS.
246
266
 
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ /* meeting-terms.js — a word heard wrong, fixed in place (batch 25 item 11, "Meeting recording view C": MvF, MvN).
3
+ *
4
+ * The PM selects a word in a meeting's summary, a quote or the transcript ("More engaged") and types what it is ("MoEngage"). It is kept
5
+ * as one of the lessons of lib/meeting-learn.js, of kind 'term': its phrase is the heard words, its meaning is the fix. So, with no new
6
+ * record, the agent's handoff already carries it for later notes (meeting-learn.js handoffLines: "“more engaged”: MoEngage") and its
7
+ * Undo is the lesson's forget. The page shows the fix in every place this note shows the words, and underlines the heard words in other
8
+ * notes with the fix as the guess. The note's own files are NOT rewritten: rewriting every stored copy of a word is not proven.
9
+ *
10
+ * term(dir, { note, heard, means, now }) → { ok, term, terms } | { ok: false, status, error }
11
+ * terms(dir) → [{ id, heard, means, note, at }] the word fixes that hold now; the latest fix of the same words in a note wins
12
+ * (forget: lib/meeting-learn.js forget({ lesson: <term id> }))
13
+ * One line of <runtime>/meeting-lessons.jsonl per fix, append-only, file mode. Nothing here sends anything. */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+ const ML = require('./meeting-learn.js');
18
+
19
+ const NOTE_ID = /^[A-Za-z0-9_-]{1,80}$/;
20
+ const MAX_WORD = 60;
21
+ const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
22
+ /** The heard words as a lesson's phrase: lower case, the same words meeting-learn.js mentions() matches. */
23
+ const phraseOf = (s) => (clean(s).toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}.'-]*/gu) || []).map((w) => w.replace(/[.'-]+$/, '')).filter(Boolean).join(' ');
24
+
25
+ function terms(dir) {
26
+ const out = [];
27
+ for (const l of ML.lessons(dir)) {
28
+ if (l.kind !== 'term' || !l.phrase || !l.meaning) continue;
29
+ const i = out.findIndex((x) => x.note === l.note && x.heard === l.phrase);
30
+ if (i >= 0) out.splice(i, 1);
31
+ out.push({ id: l.id, heard: l.phrase, means: l.meaning, note: l.note, at: l.at });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ function term(dir, body = {}) {
37
+ if (!dir) return { ok: false, status: 500, error: 'no runtime folder' };
38
+ const note = String(body.note || '');
39
+ if (!NOTE_ID.test(note)) return { ok: false, status: 400, error: 'note is the id of a meeting note' };
40
+ const raw = clean(body.heard), means = clean(body.means);
41
+ if (!raw || !means) return { ok: false, status: 400, error: 'heard and means are the words before and after the fix' };
42
+ if (raw.length > MAX_WORD || means.length > MAX_WORD) return { ok: false, status: 400, error: 'a word fix is a name or a term of at most ' + MAX_WORD + ' characters' };
43
+ const heard = phraseOf(raw);
44
+ if (!heard) return { ok: false, status: 400, error: 'heard names no word' };
45
+ if (heard === means) return { ok: false, status: 400, error: 'the fix is the same as what was heard' };
46
+ const at = new Date(body.now || Date.now()).toISOString();
47
+ const id = 'l' + crypto.createHash('sha1').update('term|' + note + '|' + heard + '|' + means + '|' + at).digest('hex').slice(0, 10);
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ fs.appendFileSync(path.join(dir, ML.FILE), JSON.stringify({ v: 1, t: 'lesson', id, phrase: heard, kind: 'term', meaning: means, orig: null, note, point: '', at }) + '\n');
50
+ return { ok: true, term: { id, heard, means, note, at }, terms: terms(dir) };
51
+ }
52
+
53
+ module.exports = { term, terms, MAX_WORD };
@@ -7,10 +7,13 @@
7
7
  * The GET also answers { edits, lessons }: the PM's corrections to the note's points and what PMOS learned (lib/meeting-learn.js).
8
8
  * POST /api/meeting-points/edit { note, id, kind, text, orig: { kind, text }, learn, items: [{ id, text }] } → { ok, edits, lesson, check }
9
9
  * a corrected point (XvAReplayEdit). learn false is "Only fix this one". check: the other points of the note that use the learned words.
10
- * POST /api/meeting-points/forget { lesson } → { ok } Undo of what PMOS learned
10
+ * POST /api/meeting-points/forget { lesson } → { ok } Undo of what PMOS learned, and of a word fix (its term id)
11
+ * POST /api/meeting-points/term { note, heard, means } → { ok, term, terms } a word fixed in place, in every place the note shows it
12
+ * The GET answers { terms } too: the word fixes that hold now (lib/meeting-terms.js).
11
13
  * The POSTs are page presses (lib/pm-guard.js requirePagePress). Handlers are pure over (q | body, ctx) so a test needs no server. */
12
14
  const MP = require('./meeting-points.js');
13
15
  const ML = require('./meeting-learn.js');
16
+ const MT = require('./meeting-terms.js');
14
17
  const guard = require('./pm-guard.js');
15
18
 
16
19
  const fail = (status, error) => ({ status, body: { ok: false, error } });
@@ -18,12 +21,12 @@ const fail = (status, error) => ({ status, body: { ok: false, error } });
18
21
  function read(q, ctx) {
19
22
  if (!/^[A-Za-z0-9_-]{1,80}$/.test(String(q.note || ''))) return fail(400, 'note is the id of a meeting note');
20
23
  const note = String(q.note);
21
- return { status: 200, body: { ok: true, points: MP.pointsOf(ctx.runtimeDir, note), edits: ML.editsOf(ctx.runtimeDir, note), lessons: ML.lessons(ctx.runtimeDir).filter((x) => x.phrase).slice(-30) } };
24
+ return { status: 200, body: { ok: true, points: MP.pointsOf(ctx.runtimeDir, note), edits: ML.editsOf(ctx.runtimeDir, note), lessons: ML.lessons(ctx.runtimeDir).filter((x) => x.phrase && x.kind !== 'term').slice(-30), terms: MT.terms(ctx.runtimeDir).slice(-200) } };
22
25
  }
23
26
  function learn(req, body, ctx, op) {
24
27
  const g = guard.requirePagePress(req, body);
25
28
  if (!g.ok) return fail(g.status, g.error);
26
- const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
29
+ const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : op === 'term' ? MT.term(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
27
30
  if (!r.ok) return fail(r.status || 400, r.error);
28
31
  return { status: 200, body: r };
29
32
  }
@@ -40,7 +43,7 @@ module.exports = {
40
43
  prefixes: ['/api/meeting-points'],
41
44
  read, write, learn,
42
45
  handle: async (req, res, url, ctx) => {
43
- const op = url.pathname === '/api/meeting-points/edit' ? 'edit' : url.pathname === '/api/meeting-points/forget' ? 'forget' : '';
46
+ const op = url.pathname === '/api/meeting-points/edit' ? 'edit' : url.pathname === '/api/meeting-points/forget' ? 'forget' : url.pathname === '/api/meeting-points/term' ? 'term' : '';
44
47
  if (op) {
45
48
  if (req.method !== 'POST') return respond(ctx, res, fail(405, 'POST only'));
46
49
  const got = await ctx.readJson(req);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.502.0",
3
+ "version": "0.503.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.502.0",
9
+ "version": "0.503.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.502.0",
3
+ "version": "0.503.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",
@@ -99,8 +99,8 @@ const look = () => {
99
99
  if (shots) await p.screenshot({ path: `${shots}/line-390.png` });
100
100
  await p.click('#hmPl [data-pl="toggle"]');
101
101
  const s1 = await until(async () => { const v = await p.evaluate(look); return v.open ? v : null; }) || await p.evaluate(look);
102
- ok('P2 a tap opens it in place: the goal, "1 of 4 points met · round 2 of 12", every point, Now and the terminal',
103
- /Bring the descope-feature-parity PRD/.test(s1.line || '') && /1 of 4 points met · round 2 of 12/.test(s1.line || '') && /Ready for Dita's review/.test(s1.line || '') && /Now\s*Edit · planning\/prd\/descope-parity.md/.test(s1.line || '') && s1.term && !s1.vw, JSON.stringify(s1));
102
+ ok('P2 a tap opens it in place: the goal, "1 of 4 points met · round 2 of 12", every point, Now in plain words (never the raw call) and the terminal',
103
+ /Bring the descope-feature-parity PRD/.test(s1.line || '') && /1 of 4 points met · round 2 of 12/.test(s1.line || '') && /Ready for Dita's review/.test(s1.line || '') && /Now\s*SSO goes on the out-of-scope list/.test(s1.line || '') && !/Edit · planning/.test(s1.line || '') && s1.term && !s1.vw, JSON.stringify(s1));
104
104
  if (shots) await p.screenshot({ path: `${shots}/line-390-open.png` });
105
105
  await p.click('#hmPl [data-pl="toggle"]');
106
106
  await p.evaluate(() => { const se = document.scrollingElement; se.scrollTop = 0; });
@@ -117,7 +117,7 @@ const look = () => {
117
117
  { const st = { conv: { turn: 'agent', messages: BASE } };
118
118
  const { p, ctx, errs } = await open(390, st);
119
119
  const s0 = await until(async () => { const v = await p.evaluate(look); return v.line ? v : null; }) || await p.evaluate(look);
120
- ok('P3 a session with no step list: "Working · m:ss", never an invented count', /Working · \d+:\d\d/.test(s0.line || '') && !/ of \d+ done/.test(s0.line || ''), JSON.stringify(s0));
120
+ ok('P3 a session with no step list: Claude\'s newest status line and the clock, never the raw call or an invented count (batch 25 item 14)', /SSO goes on the out-of-scope list; the date stays\.\s*\d+:\d\d/.test(s0.line || '') && !/Edit · planning/.test(s0.line || '') && !/ of \d+ done/.test(s0.line || ''), JSON.stringify(s0));
121
121
  st.conv = { turn: 'agent', messages: BASE, steps: [{ text: 'Read the funnel export', state: 'done' }, { text: 'Find the step that moved', state: 'now', call: 'Read · funnel.csv' }, { text: 'Write it up', state: 'next' }] };
122
122
  const s1 = await until(async () => { const v = await p.evaluate(look); return /Find the step/.test(v.line || '') ? v : null; }) || await p.evaluate(look);
123
123
  ok('P3 a session with its own steps: its step and "1 of 3 done"', /Find the step that moved/.test(s1.line || '') && /1 of 3 done/.test(s1.line || ''), JSON.stringify(s1));
@@ -43,7 +43,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
43
43
  const press = (p, sel) => p.$eval('#czViewer ' + sel, (b) => b.click());
44
44
  const folds = (p) => p.evaluate(() => [...document.querySelectorAll('#czViewer [data-czs-fold]')].map((x) => x.dataset.czsFold + (x.getAttribute('aria-expanded') === 'true' ? '*' : '')).join());
45
45
  const unfold = async (p, id) => { if (await p.evaluate((k) => document.querySelector(`#czViewer [data-czs-fold="${k}"]`).getAttribute('aria-expanded') !== 'true', id)) await press(p, `[data-czs-fold="${id}"]`); };
46
- const sum = (p) => p.evaluate(() => { const s = document.querySelector('#czViewer .czs-sum'); return s ? { line: s.querySelector('.czs-line').innerText, text: (s.querySelector('.czs-p') || {}).innerText || '', meta: (s.querySelector('.czs-meta') || {}).innerText || '', pri: (s.querySelector('[data-czs-act]') || {}).textContent || '' } : null; });
46
+ const sum = (p) => p.evaluate(() => { const s = [...document.querySelectorAll('#czViewer .czs-sum')].find((x) => x.offsetParent) || document.querySelector('#czViewer .czs-sum'); return s ? { line: (s.querySelector('.czs-line') || {}).innerText || '', text: (s.querySelector('.czs-p') || {}).innerText || '', meta: (s.querySelector('.czs-meta') || {}).innerText || '', pri: (s.querySelector('[data-czs-act]') || {}).textContent || '' } : null; });
47
47
 
48
48
  /* an ended meeting with notes and a recording */
49
49
  const A = await F.open(browser, BASE, '/premium-viewer.html?open=meeting&note=n1');
@@ -54,9 +54,12 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
54
54
 
55
55
  /* S1 summary first */
56
56
  const s1 = await sum(p);
57
- ok('S1 the summary: "N of M points kept · K to confirm", the overview, and one primary, Confirm K', /^\d+ of \d+ points kept · (\d+) to confirm$/.test(s1.line) && /opens next to the chat/.test(s1.text) && s1.pri === 'Confirm ' + s1.line.match(/(\d+) to confirm/)[1], JSON.stringify(s1));
58
- ok('S1 the summary is the body\'s first child, before the replay card and the folds', await p.evaluate(() => document.querySelector('#czViewer .cz-vw-body').firstElementChild.classList.contains('czs-sum') && !!document.querySelector('#czViewer .vzp-card .vzp-pl')));
59
- ok('S1 then the folds: All kept, Next steps, Since the last one, Facts, Prep and material, Transcript; no tabs', (await folds(p)).replace(/\*/g, '') === 'kept,steps,since,facts,prep,tr' && !(await p.$('#czViewer [role="tablist"]')), await folds(p));
57
+ /* batch 25 item 11 (MvC): the summary first in the left column, the kept points with Confirm N in their header, the evidence, one strip, the docked timeline */
58
+ ok('S1 the summary: the overview and the meta line, no primary (Confirm sits in the kept header)', /opens next to the chat/.test(s1.text) && !s1.pri, JSON.stringify(s1));
59
+ ok('S1 the left column starts with the summary, then the kept points: "N kept" and Confirm N; the evidence and the docked timeline', await p.evaluate(() => { const l = document.querySelector('#czViewer .vzr-l'); return !!l && l.firstElementChild.classList.contains('czs-sum') && /^\d+ kept$/.test(l.querySelector('.vzr-ph h3').textContent) && /^Confirm \d+$/.test(l.querySelector('[data-a="confirmall"]').textContent) && !!document.querySelector('#czViewer .vzr-ev') && !!document.querySelector('#czViewer .vzp-card .vzr-dock.vzp-pl'); }));
60
+ const chips = await p.evaluate(() => [...document.querySelectorAll('#czViewer .vzr-fold [data-a="strip"]')].map((x) => x.dataset.k).join());
61
+ ok('S1 one strip: Left out, Next steps, Since the last one, Facts, Names and terms, Prep and material, Transcript; no tabs, no checkbox, no filter', chips === 'left,steps,since,facts,names,prep,tr' && !(await p.$('#czViewer [role="tablist"]')) && !(await p.$('#czViewer .vzr [role="checkbox"]')) && !(await p.$('#czViewer [data-a="pf"]')), chips);
62
+ ok('S1 the Viewer opens wide for the two columns', await p.evaluate(() => document.getElementById('czViewer').offsetWidth >= 900 && getComputedStyle(document.querySelector('#czViewer .vzr-cols')).gridTemplateColumns.split(' ').length === 2));
60
63
  ok('S1 no Detail or Conversation switch, no #dlg; the note was read from GET /api/notes/one', await p.evaluate(() => !document.querySelector('#dlg, #dTabs, .stream')) && A.S.gets.some((g) => g.startsWith('/api/notes/one?id=n1')));
61
64
 
62
65
  /* S2 the header and ⋯ */
@@ -70,17 +73,17 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
70
73
  ok('S2 Copy as Markdown copies the notes', /^# Viewer sync/.test(clip || '') && /Diffs can be approved/.test(clip || ''), String(clip).slice(0, 200));
71
74
 
72
75
  /* S3 After */
73
- await unfold(p, 'since'); await unfold(p, 'facts');
74
- const after = await body(p);
75
- ok('S3 Since the last one and Facts mentioned, Matches and Not checked', /Raised again/.test(after) && /Matches/.test(after) && /Not checked/.test(after), after.slice(0, 600));
76
- ok('S3 the replay card has the screen thumbnails, with no Waiting for design marker', await until(() => p.evaluate(() => document.querySelectorAll('#czViewer .vzp-card .vzp-th').length > 0 && !document.querySelector('#czViewer [data-cz-placeholder]'))));
76
+ const strip = (k) => press(p, `.vzr-fold [data-a="strip"][data-k="${k}"]`);
77
+ await strip('since'); const since = await body(p); await strip('facts'); const facts = await body(p);
78
+ ok('S3 Since the last one and Facts mentioned, Matches and Not checked', /Raised again/.test(since) && /Matches/.test(facts) && /Not checked/.test(facts), facts.slice(0, 600));
79
+ ok('S3 the evidence has the screen captures, with no Waiting for design marker', await until(() => p.evaluate(() => document.querySelectorAll('#czViewer .vzp-card .vzp-th').length > 0 && !document.querySelector('#czViewer [data-cz-placeholder]'))));
77
80
  await press(p, '.vzp-card .vzp-th[data-a="seekto"]');
78
81
  ok('S3 a thumbnail moves the replay to its time', await until(() => p.evaluate(() => /^03:10/.test((document.querySelector('#czViewer [data-clk="pos"]') || {}).textContent || ''))), await p.evaluate(() => (document.querySelector('#czViewer [data-clk="pos"]') || {}).textContent));
79
- await unfold(p, 'tr');
80
- ok('S3 the Transcript fold shows the lines, Dita named', await until(async () => /The Viewer sits beside the chat now/.test(await body(p)) && /Dita/.test(await body(p))));
82
+ await strip('tr');
83
+ ok('S3 the Transcript in the strip shows the lines, Dita named', await until(async () => /The Viewer sits beside the chat now/.test(await body(p)) && /Dita/.test(await body(p))));
81
84
 
82
85
  /* S4 Make task: a work-item becomes V.4 Drafted in PMOS */
83
- await unfold(p, 'steps');
86
+ await strip('steps');
84
87
  await until(() => p.evaluate(() => !!document.querySelector('#czViewer [data-a="makewi"][data-i="0"]')));
85
88
  ok('S4 each step offers Make task and an icon-only work-item press', await p.evaluate(() => !!document.querySelector('#czViewer [data-a="maketask"][data-i="0"]') && !document.querySelector('#czViewer [data-a="makewi"][data-i="0"]').textContent.trim()));
86
89
  await press(p, '[data-a="makewi"][data-i="0"]');
@@ -90,9 +93,10 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
90
93
  ok('S4 Make task writes one plan item for the step', await until(() => A.S.posts.some((x) => x.path === '/api/plan-items' && JSON.stringify(x.body).includes('Move saved views'))));
91
94
 
92
95
  /* S5 a correction, and what PMOS learns (XvAReplayEdit) */
93
- await unfold(p, 'kept');
94
- const pid = await p.evaluate(() => { const b = document.querySelector('#czViewer [data-czs-id="kept"] [data-a="pedit"]'); if (b) b.click(); return b && b.dataset.id; });
95
- ok('S5 tapping a kept point opens the editor in place: the kinds, the words, what PMOS will remember', !!pid && await until(() => p.evaluate(() => !!document.querySelector('#czViewer .vzp-ed [data-a="etext"]') && document.querySelectorAll('#czViewer .vzp-ed [data-a="ekind"]').length === 3 && document.querySelector('#czViewer .vzp-ed [data-a="esave"]').disabled)), pid);
96
+ const pid = await p.evaluate(() => { const r = document.querySelectorAll('#czViewer .vzr-row [data-a="psel"]')[1]; r.click(); return r.dataset.id; });
97
+ ok('S5 pressing a row selects it and the evidence follows it: its time, its quote', await until(() => p.evaluate((id) => !!document.querySelector(`#czViewer .vzr-row.sel [data-a="psel"][data-id="${id}"]`) && !!document.querySelector(`#czViewer .vzr-ev [data-a="pedit"][data-id="${id}"]`), pid)), pid);
98
+ await press(p, '.vzr-ev [data-a="pedit"]');
99
+ ok('S5 Fix in the evidence opens the editor in place: the kinds, the words, what PMOS will remember', !!pid && await until(() => p.evaluate(() => !!document.querySelector('#czViewer .vzp-ed [data-a="etext"]') && document.querySelectorAll('#czViewer .vzp-ed [data-a="ekind"]').length === 3 && document.querySelector('#czViewer .vzp-ed [data-a="esave"]').disabled)), pid);
96
100
  const kNow = await p.evaluate(() => document.querySelector('#czViewer .vzp-ed [data-a="ekind"][aria-checked="true"]').dataset.k);
97
101
  const kNew = kNow === 'question' ? 'action' : 'question';
98
102
  await press(p, `.vzp-ed [data-a="ekind"][data-k="${kNew}"]`);
@@ -103,15 +107,22 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
103
107
  ok('S5 the page says Fixed and what it will remember, with Undo', await until(async () => /Fixed\./.test(await body(p)) && !!(await p.$('#czViewer .vz-msg [data-a="msgundo"]'))), (await body(p)).slice(0, 300));
104
108
  await press(p, '.vz-msg [data-a="msgundo"]');
105
109
  ok('S5 Undo forgets the lesson (POST /api/meeting-points/forget)', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points/forget' && x.body.lesson === 'l0123456789')), JSON.stringify(A.S.posts.map((x) => x.path)));
106
- await press(p, '.czs-sum [data-czs-act="confirmall"]');
107
- ok('S5 Confirm in the summary confirms what is left to confirm', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points' && x.body.verdict === 'confirmed' && x.body.ids.length > 0)), JSON.stringify(A.S.posts.filter((x) => x.path === '/api/meeting-points')));
110
+ /* MvF: a word heard wrong, selected in the quote, fixed in every place; Undo forgets it */
111
+ await p.evaluate(() => { const q = document.querySelector('#czViewer .vzr-ev .vzr-q'); const tn = [...q.childNodes].find((x) => x.nodeType === 3 && /print/.test(x.textContent)) || q.firstChild; const i = tn.textContent.indexOf('print'); const r = document.createRange(); r.setStart(tn, i); r.setEnd(tn, i + 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); q.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); });
112
+ ok('S5 a selected word becomes a field with ✓', await until(() => p.evaluate(() => { const f = document.querySelector('#czViewer .vzr-fe'); return !!f && f.dataset.w === 'print' && document.activeElement === f.querySelector('input'); })));
113
+ await p.fill('#czViewer .vzr-fe input', 'Print'); await p.press('#czViewer .vzr-fe input', 'Enter');
114
+ ok('S5 Enter posts the word fix and it shows fixed, with Undo', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points/term' && x.body.heard === 'print' && x.body.means === 'Print')) && await until(async () => !!(await p.$('#czViewer .vzr-fx')) && /Fixed/.test(await body(p)) && !!(await p.$('#czViewer .vz-msg [data-a="msgundo"]'))));
115
+ await press(p, '.vz-msg [data-a="msgundo"]');
116
+ ok('S5 Undo forgets the word fix', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points/forget' && x.body.lesson === 't0123456789')) && await until(async () => !(await p.$('#czViewer .vzr-fx'))));
117
+ await press(p, '[data-a="confirmall"]');
118
+ ok('S5 Confirm N in the kept header confirms what is left to confirm', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points' && x.body.verdict === 'confirmed' && x.body.ids.length > 0)), JSON.stringify(A.S.posts.filter((x) => x.path === '/api/meeting-points')));
108
119
 
109
120
  /* S8 series membership */
110
121
  await F.more(p, 'Not part of this series');
111
122
  ok('S8 ⋯ Not part of this series posts key off', await until(() => A.S.posts.some((x) => x.path === '/api/notes/series-member' && x.body.key === 'off')));
112
123
 
113
124
  /* S9 writes: only the presses */
114
- const allowed = ['/api/viewer-meeting/work-item', '/api/plan-items', '/api/notes/series-member', '/api/meeting-points/edit', '/api/meeting-points/forget', '/api/meeting-points'];
125
+ const allowed = ['/api/meeting-points/term', '/api/viewer-meeting/work-item', '/api/plan-items', '/api/notes/series-member', '/api/meeting-points/edit', '/api/meeting-points/forget', '/api/meeting-points'];
115
126
  ok('S9 the page wrote only what was pressed', A.S.posts.every((x) => allowed.includes(x.path)), JSON.stringify(A.S.posts.map((x) => x.path)));
116
127
  ok('S9 no page error', A.errors.length === 0, A.errors.join(' | '));
117
128
  await A.ctx.close();
@@ -10,6 +10,7 @@
10
10
  * · On a narrow Board the columns drop from right to left until the timeline keeps MIN_TRACK px. A phone has no columns (StsA3Phone).
11
11
  * Pure markup and state; pm/bd-span.js draws with it, pm/premium-board.js wires the menu and the sort. */
12
12
  import { esc, store } from './premium-shell.js';
13
+ import { endStage } from './bd-pins.js';
13
14
  import { stageOfRow, wfOf, wfIcon, catOfRow, catLabel, CAT_ORDER } from './bd-wf.js';
14
15
  import { offTone, md, dy } from './bd-span.js';
15
16
 
@@ -58,13 +59,15 @@ export function setSort(k) { sortBy = byK[k] ? k : null; }
58
59
  const midnight = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
59
60
  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; };
60
61
  /** A row's next milestone: the earliest milestone not done (a missed one first, it is the earliest), else the end the row stands for
61
- * (Deliver, or Evaluate past it). → { name, d, late, over } or null. over: days past the date. */
62
+ * (its workflow's ◆ stage: Deliver, or Evaluate past it, on Product feature; none on a workflow with no ◆ stage). → { name, d, late, over } or null. over: days past the date. */
62
63
  export function nextMs(r, now = new Date()) {
63
64
  const t = midnight(now);
64
65
  const M = (r.milestones || []).filter((m) => !m.done && day(m.date)).sort((a, b) => day(a.date) - day(b.date));
65
66
  let x = M.length ? { name: M[0].name, d: day(M[0].date) } : null;
66
67
  const k = r.track ? r.track.k : -1;
67
- if (!x && day(r.end) && k < 8 && !(r.track && r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)) x = { name: k >= 6 ? 'Evaluate' : 'Deliver', d: day(r.end) };
68
+ const es = endStage(r);
69
+ // a row with dated milestones is named by them (batch 25 item 13: never Deliver beside a named milestone); the plain end only without any
70
+ if (!x && es && !(r.milestones || []).some((m) => day(m.date)) && day(r.end) && k < 8 && !(r.track && r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)) x = { name: es, d: day(r.end) };
68
71
  if (!x) return null;
69
72
  const over = Math.round((t - x.d) / 864e5);
70
73
  return { ...x, late: over > 0, over: Math.max(0, over) };
@@ -12,7 +12,7 @@
12
12
  * · A phone shows the next 3 above the rows (missed first), with "All".
13
13
  * · Nothing to show (Filter = None, nothing pinned): no lane at all.
14
14
  * Pure apart from the remembered fold and wire(), which hooks the hover and the fold onto the Board's body. */
15
- import { shownMs, msMode } from './bd-pins.js';
15
+ import { shownMs, msMode, endStage } from './bd-pins.js';
16
16
  import { faceOr } from './cozy-adler.js'; // Adler once in the lane's header when a delivery is missed (batch 17 item 4-A, canvas AdM2)
17
17
  const TICK = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-10"/></svg>'; // the delivered mark, drawn (conformance R28-13: no glyph controls)
18
18
 
@@ -54,7 +54,7 @@ export function setFolded(on) {
54
54
  }
55
55
 
56
56
  const openHold = (r) => (Array.isArray(r.holds) ? r.holds : []).find((h) => h && day(h.from) && !day(h.to)) || null;
57
- const endName = (r) => (r.track && r.track.k >= 6 ? 'Evaluate' : 'Deliver');
57
+ const endName = endStage; // its workflow's ◆ stage; '' when the workflow has none (batch 25 item 13)
58
58
  /** The slip in words: "+1 wk", "+3d". */
59
59
  const slipWords = (n) => (n >= 7 ? '+' + Math.round(n / 7) + ' wk' : '+' + n + 'd');
60
60
 
@@ -83,7 +83,7 @@ export function entriesOf(o) {
83
83
  };
84
84
  const dated = (r.milestones || []).filter((m) => m && day(m.date));
85
85
  if (dated.length) { for (const m of shownMs(r, mode)) one(m.id, m.name, day(m.date), m.done, atEnd(day(m.date))); continue; }
86
- if (e) { one('', endName(r), e, !!(r.track && (r.track.k >= 6 && endName(r) === 'Deliver')), true); continue; }
86
+ if (e && endName(r)) { one('', endName(r), e, !!(r.track && (r.track.k >= 6 && endName(r) === 'Deliver')), true); continue; }
87
87
  // only Jira has a date (XcalmA6Grill 8): "≈" and the forecast; PMOS never presents a forecast as a commitment
88
88
  if (fc) out.push({ ...base, id: '', name: r.title, date: fc, done: false, late: false, risk: false, fc: true, planned: null, slip: 0 });
89
89
  }
@@ -7,7 +7,7 @@
7
7
  import { pmPost } from './http.js';
8
8
  import { esc } from './premium-shell.js';
9
9
  import * as Pop from './bd-pop.js';
10
- import { isPinned, shownMs, msMode, setMsMode, MS_MODES } from './bd-pins.js';
10
+ import { isPinned, shownMs, msMode, setMsMode, MS_MODES, endStage } from './bd-pins.js';
11
11
 
12
12
  const PIN = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 17v5M8 3h8l-1 5 3 4H6l3-4z"/></svg>';
13
13
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -27,9 +27,9 @@ function cardHtml(r, msId) {
27
27
  if (!L.length) return `<p class="bd-pc-h">${esc(r.title)}</p><p class="bd-amut bd-pc-sub">No milestone yet. Flag a task as a milestone ◇ in the initiative's Tasks.</p>`;
28
28
  const m0 = L.find((m) => m.id === msId) || null, on = shownMs(r).length;
29
29
  const head = m0 ? `<p class="bd-pc-h">${esc(m0.name)}</p><p class="bd-amut bd-pc-sub">${esc(when(m0.date))} · ${on} of ${L.length} ${L.length === 1 ? 'milestone' : 'milestones'} on the timeline</p>`
30
- : r.end && /^\d{4}-\d{2}-\d{2}/.test(String(r.end))
30
+ : r.end && /^\d{4}-\d{2}-\d{2}/.test(String(r.end)) && endStage(r)
31
31
  // the row's plain end ◇ (no milestone task stands for it): the card says what the row's label says, "Deliver Jun 9 ’27" (batch 14 item 1)
32
- ? `<p class="bd-pc-h">${esc(r.track && r.track.k >= 6 ? 'Evaluate' : 'Deliver')} ${esc(when(String(r.end).slice(0, 10)))}</p><p class="bd-amut bd-pc-sub">The end of ${esc(r.title)} · ${on} of ${L.length} ${L.length === 1 ? 'milestone' : 'milestones'} on the timeline</p>`
32
+ ? `<p class="bd-pc-h">${esc(endStage(r))} ${esc(when(String(r.end).slice(0, 10)))}</p><p class="bd-amut bd-pc-sub">The end of ${esc(r.title)} · ${on} of ${L.length} ${L.length === 1 ? 'milestone' : 'milestones'} on the timeline</p>`
33
33
  : `<p class="bd-pc-h">${esc(r.title)}</p><p class="bd-amut bd-pc-sub">${on} of ${L.length} ${L.length === 1 ? 'milestone' : 'milestones'} on the timeline</p>`;
34
34
  const row = (m) => {
35
35
  const p = isPinned(r, m), late = !m.done && m.date < today;