create-pmos 0.495.0 → 0.497.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 (52) hide show
  1. package/package.json +57 -33
  2. package/payload/AGENTS.md +2 -0
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/scripts/workflow-state.js +7 -19
  7. package/payload/state/discoveries.md +1 -1
  8. package/payload/state/log.md +1 -1
  9. package/workspace/adler-moments-test.js +3 -1
  10. package/workspace/board-programs-test.js +6 -6
  11. package/workspace/board-shared-ui-test.js +2 -2
  12. package/workspace/briefing-premium-adddoc-test.js +1 -48
  13. package/workspace/briefing-premium-board-test.js +18 -19
  14. package/workspace/changelog.json +56 -0
  15. package/workspace/guide/adler.md +1 -1
  16. package/workspace/guide/briefing-premium.md +1 -0
  17. package/workspace/guide/claude-proposes.md +6 -2
  18. package/workspace/guide/data-freshness.md +7 -1
  19. package/workspace/guide/home.md +1 -1
  20. package/workspace/guide/published-board.md +1 -1
  21. package/workspace/guide/workflows.md +4 -3
  22. package/workspace/lib/board-premium.js +17 -2
  23. package/workspace/lib/engine/workflow-state.js +7 -19
  24. package/workspace/lib/initiative-chat.js +4 -1
  25. package/workspace/lib/proposals.js +18 -4
  26. package/workspace/lib/routes-board-premium.js +2 -2
  27. package/workspace/lib/routes-proposals.js +14 -2
  28. package/workspace/lib/split-merge-suggest.js +37 -3
  29. package/workspace/lib/split-merge.js +10 -4
  30. package/workspace/lib/task-cli.js +5 -1
  31. package/workspace/lib/vendor/scripts/workflow-state.js +7 -19
  32. package/workspace/lib/workflow-cli.js +70 -15
  33. package/workspace/package-lock.json +2 -2
  34. package/workspace/package.json +1 -1
  35. package/workspace/premium-board-actions-test.js +7 -9
  36. package/workspace/premium-core-doc-issue-test.js +9 -38
  37. package/workspace/public/pm/chat-turn-end.js +19 -0
  38. package/workspace/public/pm/cozy-board.css +17 -5
  39. package/workspace/public/pm/home-app.js +21 -5
  40. package/workspace/public/pm/home-chat.js +5 -0
  41. package/workspace/public/pm/home-ini.js +7 -0
  42. package/workspace/public/pm/home-today.js +11 -4
  43. package/workspace/public/pm/load-shapes.css +57 -0
  44. package/workspace/public/pm/load-shapes.js +57 -0
  45. package/workspace/public/pm/premium-board.js +82 -57
  46. package/workspace/public/pm/readonly.js +3 -3
  47. package/workspace/public/pm/split-merge.js +3 -2
  48. package/workspace/public/premium-board.html +7 -10
  49. package/workspace/public/premium.html +1 -0
  50. package/workspace/split-merge-ui-test.js +1 -1
  51. package/workspace/test-lists/chat-turn-end.json +9 -0
  52. package/workspace/test-lists/page-open-speed.json +1 -1
@@ -33,7 +33,7 @@ Each mood comes from real state. When nothing happens, Adler is calm or he rests
33
33
  - **Search.** Adler with a telescope when nothing matches.
34
34
  - **Initiative mission.** The telemetry card at the top of an initiative uses the same art: the rocket on its pad (a blueprint, then built, then lift-off), the planet, the dish and the clouds. The rocket flies a path that runs from left to right in time. While Discover is open, Adler stands at the telescope; when Discover is done, he is gone. On a festival, the day's things hang in the sky in place of the clouds. When the news is bad (the initiative is late) the festival goes. With **Face only** or **Off**, neither he nor the festival shows.
35
35
  - **Welcome.** Adler says hello once on the first step, and shows his thinking face while PMOS reads your folder.
36
- - **Board.** His small face on the Jira chip: calm when Jira was read in the last day and a half, honest when it was not, and his thinking face while a press reads it again. He heads **New asks**: needs you while asks wait. When a delivery is missed, his honest face stands once in the Deliveries lane's header; hover it to see which. When you open a milestone that was delivered, he celebrates once in the panel (at most once a week); after that his face is calm. The diamond on the timeline stays plain, and nothing is sent to anyone.
36
+ - **Board.** His small face is the Jira control at the end of the top bar: calm when Jira was read in the last day and a half, honest (with an amber dot) when it was not, and his thinking face while a press reads it again. Hold the pointer on it to see when Jira was read. He heads **New asks**: needs you while asks wait. When a delivery is missed, his honest face stands once in the Deliveries lane's header; hover it to see which. When you open a milestone that was delivered, he celebrates once in the panel (at most once a week); after that his face is calm. The diamond on the timeline stays plain, and nothing is sent to anyone.
37
37
  - **Program charter.** His face on the check line, with his thinking face while Claude checks the milestones.
38
38
  - **Stage card.** While a session works on a step, that step shows Adler with his thinking face instead of a spinner. When every step is done, Adler stands by the **Move to** button. Your own steps keep their rings.
39
39
  - **Sessions.** Only the session that asks you something shows his face in the list. The other sessions keep their dots. In that session's card, he asks the question.
@@ -325,6 +325,7 @@ Not drawn, because no record backs them: answers and ideas an agent files by its
325
325
  Board is the fourth place on the rail, after Chat, at `/premium-board.html`. It reads what the production Initiatives page reads, for one board at a time, and shows a switcher only when more than one board is registered. The first time it takes a few seconds, because the routes derive every initiative. The page says "Reading the board" with the seconds, shows nothing in place of a guess, and asks again.
326
326
 
327
327
  - **The header** counts the board's open initiatives: how many are on track, at risk, late and on hold, with a bar. A zero is shown.
328
+ - **The top bar** has two tabs, **Initiatives** and **Shared**. The Board has no Documents tab: open an initiative's **Documents** fold, or use Search's **Documents** filter. On the Timeline, one chip shows the span (Sprint, Quarter, Half, Year, 2 yr or 5 yr); press it to pick another. One pill shows what needs a look: a dot and a number for new asks, late, at risk, blocked and idle. A count of zero is not shown, and the pill is not shown when all counts are zero. Press the pill to see the rows. Press a row to open the asks or that count's trend, where **Show only** filters the Board; press a row that filters to stop the filter. On the Timeline, the **(i)** icon shows what the bars mean. Jira is Adler's small face at the end of the row. It has an amber dot when Jira was not read in the last day and a half. Hold the pointer on it to see when Jira was read, and press it to read Jira again.
328
329
  - **The view chip** in the Initiatives head says which view is shown (List, By stage or Timeline); press it to pick another.
329
330
  - **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.
330
331
  - **By stage** has one column for each stage of the board's workflow with the board's own count and its first five initiatives.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Claude proposes: the one ✦ on each initiative"
4
4
  description: "What Claude finds when you add an initiative that already lives elsewhere, the new dates, done and tidy names it proposes for late tasks, the clean-up of the work that is not in an initiative, how you keep or drop them in the Viewer, Undo, the pmos propose commands, and how Claude grills an initiative by itself (pmos grill)."
5
- timestamp: 2026-11-01
5
+ timestamp: 2026-11-02
6
6
  ---
7
7
 
8
8
  # Claude proposes: the one ✦ on each initiative
@@ -21,7 +21,11 @@ Press the ✦ to open "Claude proposes N" in the Viewer. On a desk the chat stay
21
21
 
22
22
  ✓ in the Viewer's header keeps everything Claude is sure of. Check these and Not placed are never part of it. Each proposal has its own ✓ and ✕, and ⌄ shows its items. ✕ on one item leaves just that item out. × closes the Viewer and keeps or drops nothing. One toast says what changed, with Undo for the whole batch.
23
23
 
24
- ✕ means gone: a dropped proposal comes back only when its evidence changes or the date passes again. A Jira ticket keeps Jira's name and dates: a proposal for one becomes a drafted comment in your outbox. A draft plan is never changed.
24
+ ✕ means gone: a dropped proposal comes back only when its evidence changes or the date passes again. A ✕ stays with the facts, not with how the work is laid out. These do not bring a dropped proposal back: you edit, rename or move a task, you switch the initiative's workflow, Claude words a grill finding again, or a new day starts. A new date proposal for the same late task comes back only when the task misses a new due date. A grill finding comes back only when its sources change.
25
+
26
+ The same is true for Claude's suggestion to split an initiative or to merge it into another. ✕ on "Split off …?" stays while most of the tasks it would move are the tasks you said no to. You can add, edit or re-date tasks and the suggestion stays away. Claude suggests a split again only when a different group of work stands on its own. ✕ on "Same work as …?" stays while the merge is into the same initiative.
27
+
28
+ A Jira ticket keeps Jira's name and dates: a proposal for one becomes a drafted comment in your outbox. A draft plan is never changed.
25
29
 
26
30
  ## The commands
27
31
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Data freshness: how the workspace keeps up to date, and how old each source is"
4
- timestamp: 2026-11-02
4
+ timestamp: 2026-11-03
5
5
  ---
6
6
 
7
7
  # Data freshness
@@ -17,6 +17,10 @@ The workspace checks each registered repository every 10 seconds for a change: a
17
17
 
18
18
  To stop the checks, turn off **Repository and initiatives** in Settings › Data refresh. This stops them for every registered repository. The pages then show what was read last, and **Refresh now** reads it again. Turn it on to start the checks again.
19
19
 
20
+ ## After a chat turn
21
+
22
+ When Claude finishes a turn in a Home chat, Home reads its records again at once, so the list and the Viewer show what Claude changed: a renamed task, a new date, a moved stage. You do not need to refresh the page. Open folds, the Viewer's scroll and the Timeline's place stay as they were. A document open in the Viewer already reads its file again when the file changes on disk.
23
+
20
24
  ## Jira, calendar, My work and tracker items
21
25
 
22
26
  The workspace holds no password or token, and it never calls these services. An agent session reads each one with your own connection, on the schedule set in **Settings › Data refresh**, or when you press **Refresh**:
@@ -111,6 +115,8 @@ A refresh waits for the sources it uses. The briefing waits for work assigned to
111
115
  - **Quiet start**: a server starts no background run in its first 5 minutes, so the first pages load quickly. A missed refresh catches up after that.
112
116
  - **Warm start**: 5 seconds after PMOS starts, it reads what Home and the Board show once, so your first page opens as fast as later ones. This read writes nothing you would see as read or seen. `PMOS_WARM=off` turns it off.
113
117
  - **Fresh answers, asked once**: when two parts of a page ask for the same thing at the same moment, the server is asked once and both get the same answer. Nothing is kept after it arrives, so the next look always reads again.
118
+ - **Last view while Home reads**: Home shows the list from your last visit at once, with a hollow ring next to "Good morning". You can read that list, but you cannot use it until the new read is in. Then the ring goes away. Before a section has data, it shows grey shapes with no names and no counts. Each section fills in when its own data arrives. If the Viewer was open on your last visit, its space stays empty, so the chat does not change width. Adler's face next to the greeting thinks while Home reads, and changes to handled when the read is in. If the read fails, it changes to honest.
119
+ - **Board while it reads**: the toolbar and the tabs show at once, with grey rows below them. If the read takes more than 2 seconds, the line "Still reading the board." shows. There is no progress bar and no percent.
114
120
 
115
121
  ### The refresh log
116
122
 
@@ -152,7 +152,7 @@ Some features are still not designed. Home keeps each one usable as a plain card
152
152
 
153
153
  ## The other pages
154
154
 
155
- - **Board.** New initiative takes links and files, starts in Triage and asks only the questions that the material does not answer. **Later** keeps the questions as the next step. The Board also has List, By stage, Documents and spans. On a phone, **Metrics** and **Cancelled and archived** are in every view.
155
+ - **Board.** New initiative takes links and files, starts in Triage and asks only the questions that the material does not answer. **Later** keeps the questions as the next step. The Board also has List, By stage and a span picker. Documents are in each initiative's Documents fold and in Search. On a phone, **Metrics** and **Cancelled and archived** are in every view.
156
156
  - **Meetings.** Replay with screen captures and kept points, Focus, a presenter view, Add material, the After view, a Day, Week and Month calendar, and Notes.
157
157
  - **Reports.** The Brief, the report states, a document reader and a live page reader.
158
158
  - **Insights.** A goal's detail with its chart and readings, targets on each tile, Library flows and a Portfolio view.
@@ -37,7 +37,7 @@ Only you can publish, republish or turn off a link, with a press on the page. An
37
37
 
38
38
  ## The Shared tab
39
39
 
40
- **Shared** is a tab of the Board, beside **Initiatives** and **Documents**. It lists the boards that people published to your teams, and your own links. On a phone it is a chip after **Docs**. The tab shows a number when there are open copies. Before you read a team, the number is your own open links.
40
+ **Shared** is a tab of the Board, beside **Initiatives**. It lists the boards that people published to your teams, and your own links. On a phone it is the last chip. The tab shows a number when there are open copies. Before you read a team, the number is your own open links.
41
41
 
42
42
  **Join a team.** The person who makes a team gets an invite link and sends it to you. It looks like `https://boards.example.com/join#Team.password`. Open **Shared**, paste the link in the field and press **Join**. PMOS asks the host first. It keeps the team only if the password works. You do not need to set the host address first: the invite link has it.
43
43
 
@@ -66,7 +66,7 @@ To use fewer stages, copy Product feature and make the copy simpler. For example
66
66
  - **Claude's proposal.** Claude shows where each old stage goes, once, as a ✦ in **New for you**. For example: Triage, Discover, Plan and Prioritize go into Discovery. Each row shows how many initiatives and tasks go into that stage. Press ✓ to move all the initiatives. Press ✕ to leave them where they are.
67
67
  - **The bridge.** The page of the new workflow shows **Replaces Product feature**: each old stage, with the stage it goes into. Change a stage there. The change is saved at once, and every later switch from the old workflow uses it.
68
68
  - **One initiative.** On the initiative, open the workflow picker and pick the new workflow. The dialog shows the old stages in each new stage. **Change where a stage goes** shows one picker for each old stage. **This initiative** or **All N** sets which initiatives move.
69
- - **What moves.** An initiative's stage and its tasks move together. Ticked items from the old stages count in the new stage. The new stage counts its days from the day its first old stage began. The stage card shows the days in each old stage.
69
+ - **What moves.** An initiative's stage and its tasks move together. Each task goes into the stage that its old stage goes into, on Home, on the Board and in `pmos task list`. A task goes under **Not in a stage** only when it had no stage before the move. Ticked items from the old stages count in the new stage. The new stage counts its days from the day its first old stage began. The stage card shows the days in each old stage.
70
70
  - **Jira tickets.** The tickets go under the stage whose output is Jira tickets, whatever its name.
71
71
  - **Undo.** The toast has **Undo**, and the workflow picker keeps **Undo the move** for 7 days. Undo puts back the workflow, each stage and each task together. An initiative that changed stage after the move stays where it is.
72
72
 
@@ -74,8 +74,9 @@ To use fewer stages, copy Product feature and make the copy simpler. For example
74
74
 
75
75
  An agent writes a draft or a reading with these commands, and moves an initiative along its workflow when you ask. It never saves, copies or archives a workflow: those are your presses on the page.
76
76
 
77
- - `pmos workflow list` names the workflows of this repository and marks the default one.
78
- - `pmos workflow show <id>` shows one workflow, its stages numbered.
77
+ - `pmos workflow list` names every workflow you can pick and marks the default one: the ones of this repository, then the ones you saved in Settings › Workflows (marked **saved in** and the workspace name) and in your other open workspaces.
78
+ - `pmos workflow show <id>` shows one workflow, its stages numbered. It finds a workflow in any of those libraries.
79
+ - An initiative chat reads workflows only through these commands. It does not search the repository's files for a workflow, and it does not use a lane, such as `feature`, in place of the initiative's workflow. When it cannot find a workflow, it says so and asks you.
79
80
  - `pmos workflow draft --file <path>` writes the draft that the New workflow chat shows. `--note` adds a line.
80
81
  - `pmos workflow show-draft` shows the draft that is open.
81
82
  - `pmos workflow judge --board <id> --id <initiative> --stage <key> --runs yes|no|unsure --why <reason> --cite <where>` records PMOS's reading of an Only when stage. **unsure** makes PMOS ask you. `--undo` takes a reading back.
@@ -239,7 +239,20 @@ const stageFields = (s) => (s && typeof s === 'object' && s.key ? { key: String(
239
239
  function wfFields(r) {
240
240
  const wf = r.wf && r.wf.id ? { id: String(r.wf.id), name: clip(r.wf.name || r.wf.id, 60), icon: r.wf.icon || 'flag', ...(r.wf.setBy ? { setBy: r.wf.setBy, why: clip(r.wf.why || '', 300) } : {}) } : PF;
241
241
  const ended = r.ended && (r.ended.as === 'wontdo' || r.ended.as === 'archived') ? { as: r.ended.as, reason: r.ended.reason || '', note: clip(r.ended.note || '', 300), at: r.ended.at || null } : null;
242
- return { wf, wfStage: stageFields(r.wfStage), wfStages: Array.isArray(r.wfStages) ? r.wfStages.map(stageFields).filter(Boolean).slice(0, 40) : null, ended };
242
+ // batch 24 item 5: the stage map of the last workflow switch (old stage key → stage now) and its Undo. Without them a Home or Board view put
243
+ // every task of an old stage under "Not in a stage" after the PM agreed the map (the full row had them; this copy dropped them).
244
+ const wfMap = mapFields(r.wfMap);
245
+ const wfSwitch = r.wfSwitch && typeof r.wfSwitch.batch === 'string' ? { batch: r.wfSwitch.batch, at: r.wfSwitch.at || null, from: r.wfSwitch.from || null } : null;
246
+ return { wf, wfStage: stageFields(r.wfStage), wfStages: Array.isArray(r.wfStages) ? r.wfStages.map(stageFields).filter(Boolean).slice(0, 40) : null, ended,
247
+ ...(wfMap ? { wfMap } : {}), ...(wfSwitch ? { wfSwitch } : {}) };
248
+ }
249
+ /** A stage map as the row carries it: { old key: stage key }, short strings only (a key is never cut: a cut key matches nothing), at most 60
250
+ * keys; null when empty. */
251
+ function mapFields(m) {
252
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return null;
253
+ const out = {};
254
+ for (const [k, v] of Object.entries(m).slice(0, 60)) if (k && k.length <= 80 && typeof v === 'string' && v && v.length <= 80) out[k] = v;
255
+ return Object.keys(out).length ? out : null;
243
256
  }
244
257
  /** The holds of a row as the timeline draws them (feedback batch 9 item 8): from, to (null while open), the return date and the reason. */
245
258
  const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -333,7 +346,9 @@ function build(f) {
333
346
  }
334
347
  // every task, not the first 60: the Board's panel and Home count the same tasks (feedback batch 6 item 1: Board said 56 done, Home 61)
335
348
  for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.tasks) r.tasks = (taskBy.get(r.id) || []).slice(0, TASK_MAX);
336
- for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.milestones) r.milestones = (msBy.get(r.id) || []).sort((a, b) => a.date.localeCompare(b.date)).slice(0, 40);
349
+ // a milestone under a stage of the workflow the initiative ran before a switch sits in the stage that switch put it in (row.wfMap)
350
+ const msIn = (r, m) => { const keys = (r.wfStages || []).map((s) => s.key); return m.sec && r.wfMap && !keys.includes(m.sec) && keys.includes(r.wfMap[m.sec]) ? { ...m, sec: r.wfMap[m.sec] } : m; };
351
+ for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.milestones) r.milestones = (msBy.get(r.id) || []).map((m) => msIn(r, m)).sort((a, b) => a.date.localeCompare(b.date)).slice(0, 40);
337
352
  for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.track) {
338
353
  r.track = trackOf(r, tkBy.get(r.id), todayIso, relOfIni.get(r.id)); r.theme = themeOf(r.anchor);
339
354
  if (epicBy.has(r.id) && !r.epics.includes(epicBy.get(r.id))) r.epics.unshift(epicBy.get(r.id));
@@ -1438,11 +1438,11 @@ function documentDuplicates(root, opts) {
1438
1438
  return { ok: true, profile, archive, groups };
1439
1439
  }
1440
1440
  /**
1441
- * Jira keys the repository holds for an initiative (S4): the Jira tab's draft; the record's `jira:` keys; in the
1442
- * record's text, a Jira link (`…/browse/KEY`) or a key on a line that says Jira; and a Jira link in its found
1443
- * documents. A bare key elsewhere is not read: "UTF-8" and "ISO-8601" have the same shape.
1441
+ * Jira keys a handoff record holds for an initiative (S4): the Jira tab's draft (`- jira: KEY` lines) and the
1442
+ * record's `jira:` / `jira_key:` / `jira_keys:` / `jira_epic:` / `jira_issue_key:` frontmatter. Body text and found documents are not
1443
+ * read: a key a PM mentions in prose ("see Jira LOY-41", an epic link in a spec) is a reference, not a handoff.
1444
1444
  */
1445
- function jiraKeysOf(root, L, id, fm, found) {
1445
+ function jiraKeysOf(L, id, fm) {
1446
1446
  const keys = [];
1447
1447
  const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
1448
1448
  try {
@@ -1451,21 +1451,9 @@ function jiraKeysOf(root, L, id, fm, found) {
1451
1451
  if (m && JIRA_KEY.test(m[1].toUpperCase())) keys.push(m[1].toUpperCase());
1452
1452
  }
1453
1453
  } catch { /* no draft */ }
1454
- for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
1454
+ for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic', 'jira_issue_key']) {
1455
1455
  for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1456
1456
  }
1457
- const fromText = (text, lines) => {
1458
- for (const m of String(text).matchAll(/\/browse\/([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1459
- if (lines) for (const line of String(text).split('\n')) {
1460
- if (!/jira/i.test(line)) continue;
1461
- for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1462
- }
1463
- };
1464
- try { fromText(fs.readFileSync(path.join(L.initiatives, `${id}.md`), 'utf8'), true); } catch { /* no record text */ }
1465
- for (const rel of found ? [].concat(found.brief, found.draft, found.review, found.handoff) : []) {
1466
- if (!SCAN_TEXT.test(rel)) continue;
1467
- try { fromText(fs.readFileSync(path.join(root, rel), 'utf8').slice(0, 200000), false); } catch { /* unreadable */ }
1468
- }
1469
1457
  return [...new Set(keys)];
1470
1458
  }
1471
1459
 
@@ -1483,7 +1471,7 @@ function jiraKeysOf(root, L, id, fm, found) {
1483
1471
  * handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
1484
1472
  * When the record names no file for brief, draft or review, or names one that does not exist, a
1485
1473
  * document the repository scan found for the id completes the step (scan-documents S3); Jira keys
1486
- * in the repository complete the handoff (S4). Found evidence never blocks and never fails CI.
1474
+ * in the Jira tab's draft or the record's `jira:` frontmatter complete the handoff (S4), never a key in body text. Found evidence never blocks and never fails CI.
1487
1475
  * Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
1488
1476
  * existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
1489
1477
  * CI-blocking in either track: none can be true before the PR exists.
@@ -1564,7 +1552,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1564
1552
  if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
1565
1553
  else {
1566
1554
  const to = String(fm.handed_off || '').trim();
1567
- const keys = to ? [] : jiraKeysOf(root, L, id, fm, found);
1555
+ const keys = to ? [] : jiraKeysOf(L, id, fm);
1568
1556
  steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
1569
1557
  : keys.length ? step('handoff', 'handed off', 'done', `handed off to Jira — ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ` and ${keys.length - 5} more` : ''}`)
1570
1558
  : foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
@@ -13,7 +13,9 @@
13
13
  * from lib/routes-boards.js initiativeWorkflow's answer
14
14
  * rule(info, id) → the rule's lines, with the workflow and stage when info is known
15
15
  * isAsk(text) → whether a message is about a stage, a workflow, a move or a task
16
- * wrap(text, info, id) → the message as PMOS types it into a live session */
16
+ * wrap(text, info, id) → the message as PMOS types it into a live session
17
+ * Batch 24 item 8: a chat in a product repository searched the repo for "Simpler Feature", found nothing (the library is the home
18
+ * workspace's workflows.jsonl, lib/workflow-cli.js) and used the feature LANE (D56) instead. The rule now says where workflows are read. */
17
19
 
18
20
  // The first line of every message PMOS wraps. The page reads it to show the PM's turn as typed (public/pm/goal-chat.js shownUser).
19
21
  const MARK = '[PMOS initiative]';
@@ -38,6 +40,7 @@ function rule(info, id) {
38
40
  out.push('Its stages: ' + info.stages.map((s, i) => `${i + 1}. ${s.name} [${s.key}]${s.status === 'done' ? ' done' : s.status === 'current' ? ' current' : s.status === 'skipped' ? ' skipped' : ''}`).join('; ') + '.');
39
41
  } else out.push(`Run \`pmos workflow of ${ini}\` to see the workflow this initiative runs and its current stage.`);
40
42
  out.push(
43
+ `Read workflows only through pmos: \`pmos workflow of ${ini}\` gives this initiative's workflow and its stages, \`pmos workflow show <workflow id>\` gives any workflow's stages, and \`pmos workflow list\` names every workflow the PM can pick, the ones saved in Settings included. The workflows are not files in this repository: never search the files for one. If pmos cannot find a workflow, tell the PM so and ask. A workflow is not a lane: never use a lane (feature, discovery, chore, incident, content, prototype) in place of the initiative's workflow.`,
41
44
  `The workflow stage is on the initiative: the stage card and the Board show it. \`pmos workflow of ${ini}\` reads it. When the PM asks to move the initiative, run \`pmos workflow move ${ini} --stage <key> --why "<reason>"\`. When the PM asks to change its workflow, run \`pmos workflow switch ${ini} --to <workflow id>\`. Both write what the page's press writes, under your name.`,
42
45
  'There is one kind of task: the ones the PM sees in the initiative\'s Tasks section. The stage card\'s To finish entries and the older breakdown items are tasks too. '
43
46
  + 'A task\'s state is To do, In progress, Done, Won\'t do or Archived. The Tasks section groups the tasks under the stages of this initiative\'s workflow: a task\'s group is its place in the breakdown, never its state. When the PM asks to add, close, group or change a task, '
@@ -24,7 +24,10 @@
24
24
  * end { ini, run, ok, stopped? } the run ended (stopped: the PM pressed ■)
25
25
  * propose { ini, board, run, id, group, type, section, title, why, sources: [{ label, ref }], items: [item], data }
26
26
  * an agent filed it (`pmos propose …`); the last line for an id wins, so a newer run replaces what it files again
27
- * drop { ini, sig } ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date passes again
27
+ * drop { ini, sig, facts? } ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date
28
+ * passes again. `facts` (batch 24 item 6) are what the ✕ rests on, not where the work sits: a date proposal's facts are its tasks
29
+ * and the due dates they missed, a grill finding's are its angle and its sources. A ✕ holds while they hold, so a workflow switch
30
+ * (new section keys, so new ids and signatures), a reworded reason or a new day (new computed dates) does not bring it back
28
31
  * keep { ini, board, batch, ids, wrote: [write] } ✓: what was written, each write with what it was before, for Undo and History
29
32
  * undo { batch }
30
33
  * epoch { } the first scan of lib/proposal-runs.js: only initiatives made after it are looked for
@@ -148,6 +151,16 @@ function clean(group, type, input) {
148
151
  const idOf = (ini, group, type, key) => 'pr-' + hash([ini, group, type, key || ''].join('|'));
149
152
  /** A proposal's signature: what it says and what it rests on. ✕ drops this; new evidence (a new signature) is proposed again. */
150
153
  function sigOf(p) { return 's:' + hash([p.id, p.why, JSON.stringify((p.sources || []).map((s) => s.ref || s.label)), JSON.stringify((p.items || []).map((x) => [x.id, x.due, x.start, x.to]))].join('|')); }
154
+ /** What a ✕ rests on, by fact rather than by id (batch 24 item 6): [string]. Dates: each task and the due date it missed (not its section,
155
+ * not the computed new date). Grill: the angle, its key and its sources (not the wording). Other groups: [] (their signature is already
156
+ * their content). */
157
+ function factsOf(p, x) {
158
+ if (!p) return [];
159
+ if (p.group === 'dates') return (x ? [x] : p.items || []).filter((y) => y && y.task).map((y) => factOfTask(p.ini, y.task, y.from && y.from.due));
160
+ if (p.group === 'grill') return ['f:' + hash(['grill', p.ini, p.type, p.id, JSON.stringify((p.sources || []).map((s) => s.ref || s.label).sort())].join('|'))];
161
+ return [];
162
+ }
163
+ const factOfTask = (ini, task, due) => 'f:' + hash(['dates', ini, task, due || ''].join('|'));
151
164
  function itemSig(p, x) { return 'i:' + hash([p.id, x.id || x.task || x.ref || x.title || '', x.due || '', x.start || '', JSON.stringify(x.to == null ? '' : x.to)].join('|')); }
152
165
 
153
166
  /* ── rule 5: one standard for a task's name ── */
@@ -182,7 +195,7 @@ function fold(lines) {
182
195
  else if (l.kind === 'src' && ini) { const L = looks.get(ini); if (L && L.run === l.run && str(l.source)) L.sources.set(l.source, { state: l.state === 'done' ? 'done' : 'reading', n: Number(l.n) || 0, what: str(l.what) }); }
183
196
  else if (l.kind === 'end' && ini) { const L = looks.get(ini); if (L && L.run === l.run) { L.ended = true; L.ok = !!l.ok; L.stopped = !!l.stopped; L.endAt = l.at || ''; } }
184
197
  else if (l.kind === 'propose' && ini && str(l.id)) props.set(l.id, { id: l.id, ini, board: str(l.board), run: str(l.run), group: l.group, type: l.type, section: str(l.section) || 'sure', title: str(l.title), why: str(l.why), sources: Array.isArray(l.sources) ? l.sources : [], items: Array.isArray(l.items) ? l.items.filter(isObj) : [], data: isObj(l.data) ? l.data : {}, at: l.at || '', by: l.by || null });
185
- else if (l.kind === 'drop' && str(l.sig)) drops.add(l.sig);
198
+ else if (l.kind === 'drop' && str(l.sig)) { drops.add(l.sig); for (const f of Array.isArray(l.facts) ? l.facts : []) if (/^f:[0-9a-f]{10}$/.test(String(f))) drops.add(f); }
186
199
  else if (l.kind === 'keep' && str(l.batch)) kept.push({ batch: l.batch, ini, board: str(l.board), ids: Array.isArray(l.ids) ? l.ids : [], wrote: Array.isArray(l.wrote) ? l.wrote.filter(isObj) : [], at: l.at || '', by: l.by || null });
187
200
  else if (l.kind === 'undo' && str(l.batch)) undone.add(l.batch);
188
201
  }
@@ -292,7 +305,7 @@ function datesOf(ini, T, o = {}) {
292
305
  sources: lead ? lead.p.sources : [], ...(noReason && owners.length ? { ask: { owner: owners[0] } } : {}) };
293
306
  p.sig = 'd:' + hash([id, ...items.map((x) => x.task + x.from.due)].join('|')); // rule 7: the date passing again is a new signature
294
307
  if (o.drops && o.drops.has(p.sig)) continue;
295
- p.items = p.items.filter((x) => !(o.drops && o.drops.has(itemSig(p, x))));
308
+ p.items = p.items.filter((x) => !(o.drops && (o.drops.has(itemSig(p, x)) || o.drops.has(factOfTask(ini, x.task, x.from.due)))));
296
309
  if (!p.items.length) continue;
297
310
  out.push(p);
298
311
  }
@@ -320,6 +333,7 @@ function viewOf(F, ini, o = {}) {
320
333
  if (p.group === 'status') p.items = p.items.filter((x) => T.has(x.task) && !T.get(x.task).closed);
321
334
  if (p.group === 'names') p.items = p.items.filter((x) => T.has(x.task) && T.get(x.task).title !== x.to).map((x) => ({ ...x, from: T.get(x.task).title, ...(T.get(x.task).jira ? { jira: true } : {}) }));
322
335
  if (!p.items.length && p.type !== 'stage' && p.group !== 'grill') continue;
336
+ if (p.group === 'grill' && factsOf(p).some((f) => F.drops.has(f))) continue;
323
337
  props.push(p);
324
338
  }
325
339
  // Grill findings (lib/grill.js): 3 show, the best first (rank); the rest wait folded and come up as the PM keeps or drops one
@@ -373,6 +387,6 @@ function decorate(rows, F, items, o = {}) {
373
387
  }
374
388
 
375
389
  module.exports = {
376
- LEDGER, GROUPS, TYPES, SOURCES, SOURCE_NAMES, RISK_DAYS, ledgerFile, read, readLines, append, fold, clean, idOf, sigOf, itemSig, tidyName, taskOf, lateOf,
390
+ LEDGER, GROUPS, TYPES, SOURCES, SOURCE_NAMES, RISK_DAYS, ledgerFile, read, readLines, append, fold, clean, idOf, sigOf, itemSig, factsOf, tidyName, taskOf, lateOf,
377
391
  datesOf, viewOf, foundOf, historyOf, decorate, keptIds, newBatch, dn, isoN, md, hash, workday,
378
392
  };
@@ -155,11 +155,11 @@ module.exports = {
155
155
  }
156
156
  } catch { missing.push('Claude\'s proposals'); }
157
157
  // Claude's suggestion to split an initiative or merge two (batch 11 item 14): made from the tasks and tickets on every read, never a timer;
158
- // the PM's ✕ Not now hides one until its tasks change. A failure here only leaves the suggestions out.
158
+ // the PM's ✕ hides one while its facts hold (the same group, the same partner). A failure here only leaves the suggestions out.
159
159
  try {
160
160
  const w = ctx.getWs ? ctx.getWs(pick.ws) : null;
161
161
  let dismissed = null;
162
- try { const f = w ? boards.foldOf(ctx, w) : null; dismissed = f ? f.suggestNo : null; } catch { dismissed = null; }
162
+ try { const f = w ? boards.foldOf(ctx, w) : null; dismissed = f ? Suggest.dismissedOf(f.events) : null; } catch { dismissed = null; }
163
163
  const items = its && its.body && Array.isArray(its.body.items) ? its.body.items : [];
164
164
  const open = ((model.initiatives && model.initiatives.list && model.initiatives.list.rows) || []).filter((r) => r && r.id && r.status !== 'done' && r.status !== 'on hold');
165
165
  const got = Suggest.suggest(open, items, { dismissed });
@@ -8,7 +8,8 @@
8
8
  * POST /api/proposals/keep { ws, board, initiative, ids?: [id] | all: true, choices?: { <from>><to>: 'keep' | 'ignore' } }
9
9
  * → { ok, batch, said, counts, props } ✓ one proposal, or ✓ in the header (all: every one Claude is sure of;
10
10
  * Check these and a Jira ticket's comment never ride on it). One batch, one toast, one Undo.
11
- * POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7)
11
+ * POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7); the line
12
+ * also keeps its facts (PR.factsOf), so a re-sectioned task or a new day does not bring it back
12
13
  * POST /api/proposals/undo { ws, board, batch } → { ok, back, kept } the whole batch; a task changed since keeps its change
13
14
  * POST /api/proposals/stop { ws, board, initiative } → { ok } ■ Stop: the look ends; what was found so far stays proposed
14
15
  * POST /api/proposals/look { ws, board, initiative, why?: 'populate' | 'check' } → { ok, started } Claude looks now (the New initiative
@@ -258,7 +259,18 @@ const WRITES = {
258
259
  '/api/proposals/drop': (ctx, res, body, g, p) => {
259
260
  const sig = String(body.sig || ''), ini = String(body.initiative || '');
260
261
  if (!/^[sdi]:[0-9a-f]{10}$/.test(sig) || !ini) return fail(ctx, res, 400, 'sig names a proposal or one item of it; initiative is required');
261
- PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig });
262
+ // what the ✕ rests on (batch 24 item 6): a date proposal's tasks and the dates they missed, a grill finding's angle and sources, so a
263
+ // workflow switch, a reworded reason or a new day does not bring it back. A failure here keeps the plain signature.
264
+ let facts = [];
265
+ try {
266
+ const v = view(ctx, p, ini, null, { grillAll: true });
267
+ for (const x of v ? v.groups.flatMap((gr) => gr.props) : []) {
268
+ if (x.sig === sig) { facts = PR.factsOf(x); break; }
269
+ const it = (x.items || []).find((y) => y && y.sig === sig);
270
+ if (it) { facts = PR.factsOf(x, it); break; }
271
+ }
272
+ } catch { facts = []; }
273
+ PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig, ...(facts.length ? { facts } : {}) });
262
274
  ctx.json(res, { ok: true });
263
275
  },
264
276
  '/api/proposals/undo': (ctx, res, body, g, p) => {
@@ -5,8 +5,14 @@
5
5
  * suggest(rows, items, { dismissed, today }) → Map<initiative id, suggestion>
6
6
  * rows the Board model's open rows (lib/board-premium.js rowOf: id, title, anchor, kr, epics, end, wfStage, status)
7
7
  * items the board's work items (routes-boards itemsOf): PMOS tasks and Jira tickets, each with ini, status, owner, start, end, epic
8
- * dismissed Map<id, { kind, fp }> the PM's ✕ Not now (the boards ledger's ini-suggest lines); the same fingerprint stays hidden
8
+ * dismissed Map<id, [{ kind, fp, moves?, into? }]> the PM's ✕ (dismissedOf(the boards ledger's events): the last of each kind; one
9
+ * entry alone is read too). A REJECTION HOLDS WHILE ITS FACTS HOLD (batch 24 item 6): a split stays hidden while most of the group it
10
+ * would move is the group the PM said no to; a merge stays hidden while it merges into the same initiative. Editing, re-dating,
11
+ * re-sectioning (a workflow switch) or adding a task does not bring it back; a different group or a different partner does.
12
+ * A ✕ written before this rule has no group: it hides that kind on that initiative.
9
13
  * fingerprint(list) → the 16 hex characters a suggestion is known by: it changes when the tasks or tickets it was made from change
14
+ * rejected(s, d) → true when the ✕ d still covers the suggestion s
15
+ * dismissedOf(events) → the dismissed map from the boards ledger's folded events
10
16
  *
11
17
  * It runs on every read of the Board, so it follows the tasks and tickets as they change, never a timer. It only suggests: the card's ✓ is
12
18
  * the PM's approval (lib/split-merge.js writes nothing without a page press). One card per initiative; a merge wins over a split.
@@ -155,8 +161,36 @@ function suggest(rows, items, { dismissed } = {}) {
155
161
  const s = splitOf(r, workBy.get(r.id) || []);
156
162
  if (s) out.set(r.id, s);
157
163
  }
158
- if (dismissed && dismissed.size) for (const [id, s] of [...out]) { const d = dismissed.get(id); if (d && d.fp === s.fp) out.delete(id); }
164
+ if (dismissed && dismissed.size) for (const [id, s] of [...out]) {
165
+ const ds = [].concat(dismissed.get(id) || []);
166
+ if (ds.some((d) => rejected(s, d))) out.delete(id);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** The PM's ✕ lines from the boards ledger's events (fold().events): Map<id, [{ kind, fp, moves?, into? }]>, the last of each kind. */
172
+ function dismissedOf(events) {
173
+ const out = new Map();
174
+ for (const e of events || []) {
175
+ if (!e || e.kind !== 'ini-suggest' || e.answer !== 'dismiss' || typeof e.id !== 'string' || typeof e.fp !== 'string') continue;
176
+ const kind = e.of === 'merge' ? 'merge' : 'split', d = { kind, fp: e.fp };
177
+ if (Array.isArray(e.moves)) d.moves = e.moves.filter((x) => typeof x === 'string' && x);
178
+ if (typeof e.into === 'string' && e.into) d.into = e.into;
179
+ out.set(e.id, (out.get(e.id) || []).filter((x) => x.kind !== kind).concat([d]));
180
+ }
159
181
  return out;
160
182
  }
161
183
 
162
- module.exports = { suggest, fingerprint, openWork };
184
+ /** True when the PM's ✕ d still covers the suggestion s: the same facts, not the same fingerprint. */
185
+ function rejected(s, d) {
186
+ if (!d || !s || d.kind !== s.kind) return false;
187
+ if (d.fp && d.fp === s.fp) return true;
188
+ if (s.kind === 'merge') return d.into ? d.into === s.into : true;
189
+ if (!Array.isArray(d.moves)) return true; // a ✕ from before the rule: it named no group
190
+ const was = new Set(d.moves), now = [...(s.tasks || []), ...(s.tickets || [])];
191
+ if (!now.length) return true;
192
+ const same = now.filter((x) => was.has(x)).length;
193
+ return same * 2 >= now.length; // most of what would move is what the PM said no to
194
+ }
195
+
196
+ module.exports = { suggest, fingerprint, openWork, rejected, dismissedOf };
@@ -7,7 +7,7 @@
7
7
  * merge(ctx, w, body, actor) POST /api/initiatives/merge { ws, from, into, anchor? }
8
8
  * → { ok, from, into, title, counts: { tasks, tickets, docs, chats, milestones }, undo }
9
9
  * undo(ctx, w, body, actor) POST /api/initiatives/sm-undo { ws, undo } → { ok, kind, from, to }
10
- * dismiss(ctx, w, body, actor) POST /api/initiatives/suggest { ws, id, of: split|merge, fp } Claude's suggestion, ✕ Not now
10
+ * dismiss(ctx, w, body, actor) POST /api/initiatives/suggest { ws, id, of: split|merge, fp, moves?, into? } Claude's suggestion, ✕ Not now
11
11
  *
12
12
  * Claude only suggests (lib/split-merge-suggest.js); every write here is a page press (pm-guard requirePagePress in the caller), so nothing
13
13
  * splits or merges without the PM's ✓ or the editor's own button. All of it is file mode:
@@ -204,7 +204,8 @@ function split(ctx, w, body, actor) {
204
204
  const own = folded.iniWf && folded.iniWf.get(k0);
205
205
  const oldTpl = folded.iniWorkflows && folded.iniWorkflows.get(k0);
206
206
  if (oldTpl) R.append(file, actor, { kind: 'ini-workflow', board: board.id, id, template: oldTpl.template, stages: oldTpl.stages, moved: null });
207
- if (own) R.append(file, actor, { kind: 'ini-wf', board: board.id, id, wf: own.wf, from: own.wf, moved: null, why });
207
+ // the stage map goes with it (batch 24 item 5): the tasks it takes keep the stage the last switch put them in
208
+ if (own) R.append(file, actor, { kind: 'ini-wf', board: board.id, id, wf: own.wf, from: own.wf, moved: null, why, ...(own.map ? { map: own.map } : {}) });
208
209
  if (at.st) R.append(file, actor, { kind: 'move', board: board.id, id, from: null, to: own ? at.st.key : at.st.name, why, warnings: [] });
209
210
  for (const t of tasks) { R.append(file, actor, { kind: 'task-update', board: t.board, task: t.id, initiative: id, why }); undo.tasks.push(t.id); }
210
211
  undo.items = copyItems(w.root, items, id, actor);
@@ -364,14 +365,19 @@ function undo(ctx, w, body, actor) {
364
365
  return { ok: true, kind: line.kind === 'ini-split' ? 'split' : 'merge', from, to, fromTitle: line.fromTitle, toTitle: line.toTitle, ...(note ? { note } : {}) };
365
366
  }
366
367
 
367
- /** ✕ Not now on Claude's suggestion: hidden until the tasks or tickets it was made from change (a new fingerprint). */
368
+ /** ✕ on Claude's suggestion: it stays rejected while its facts hold (the same group would move, the same partner to merge into). */
368
369
  function dismiss(ctx, w, body, actor) {
369
370
  const R = Boards(), file = R.ledgerFile(ctx, w);
370
371
  if (!file) return refuse(409, R.NO_STATE || 'this workspace has no state folder');
371
372
  const id = String(body.id || ''), fp = String(body.fp || '');
372
373
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(id)) return refuse(400, 'id names an initiative');
373
374
  if (!/^[0-9a-f]{8,64}$/.test(fp)) return refuse(400, 'fp is the suggestion\'s fingerprint');
374
- R.append(file, actor, { kind: 'ini-suggest', id, of: body.of === 'merge' ? 'merge' : 'split', fp, answer: 'dismiss' });
375
+ const of = body.of === 'merge' ? 'merge' : 'split', line = { kind: 'ini-suggest', id, of, fp, answer: 'dismiss' };
376
+ // the facts the ✕ rests on, so an edit, a new date or a new section does not bring the suggestion back (batch 24 item 6)
377
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/;
378
+ if (of === 'split' && Array.isArray(body.moves)) line.moves = body.moves.map(String).filter((x) => ID.test(x)).slice(0, 2000);
379
+ if (of === 'merge' && typeof body.into === 'string' && ID.test(body.into)) line.into = body.into;
380
+ R.append(file, actor, line);
375
381
  return { ok: true };
376
382
  }
377
383
 
@@ -150,7 +150,11 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
150
150
  const F = boards.foldOf(ctx, w), S = F.sections || new Map(), MS = F.milestones || new Map(), PN = F.pins || new Map();
151
151
  const pinOf = (tid) => (PN.has(tid) ? { pinned: PN.get(tid) } : {}); // the PM's pin on the Board timeline (batch 13 item 4); unset = the rule
152
152
  const msOf = (tid, own) => (MS.has(tid) ? MS.get(tid) : !!own); // the PM's ◇ flag (feedback batch 11 item 3)
153
- const stageOf = (tid, from) => (S.has(tid) ? S.get(tid) : (from && from.stage) || ''); // the group the Tasks section shows it under
153
+ // the group the Tasks section shows it under: a stage of the workflow it ran before a switch reads as the stage that switch put it in
154
+ // (row.wfMap, batch 24 item 5), as on the page
155
+ const keys = r.ok ? (r.row.wfStages || []).map((s) => s.key) : [], M = (r.ok && r.row.wfMap) || {};
156
+ const placed = (k) => (k && !keys.includes(k) && keys.includes(M[k]) ? M[k] : k);
157
+ const stageOf = (tid, from) => placed(S.has(tid) ? S.get(tid) : (from && from.stage) || '');
154
158
  const own = [...(F.tasks || new Map()).values()].filter((t) => t.initiative === id).map((t) => ({ ...t, stage: stageOf(t.id, t.from), milestone: msOf(t.id), ...pinOf(t.id) }));
155
159
  const items = TI.rowsOf(root, new Set([id]), board).map((x) => ({ id: x.uid, board, kind: 'Task', title: x.title, state: x.status, owner: x.owner, start: x.start || null, due: x.end, store: 'item', stage: stageOf(x.uid), milestone: msOf(x.uid, x.milestone), ...pinOf(x.uid) }));
156
160
  return own.concat(items);
@@ -1438,11 +1438,11 @@ function documentDuplicates(root, opts) {
1438
1438
  return { ok: true, profile, archive, groups };
1439
1439
  }
1440
1440
  /**
1441
- * Jira keys the repository holds for an initiative (S4): the Jira tab's draft; the record's `jira:` keys; in the
1442
- * record's text, a Jira link (`…/browse/KEY`) or a key on a line that says Jira; and a Jira link in its found
1443
- * documents. A bare key elsewhere is not read: "UTF-8" and "ISO-8601" have the same shape.
1441
+ * Jira keys a handoff record holds for an initiative (S4): the Jira tab's draft (`- jira: KEY` lines) and the
1442
+ * record's `jira:` / `jira_key:` / `jira_keys:` / `jira_epic:` / `jira_issue_key:` frontmatter. Body text and found documents are not
1443
+ * read: a key a PM mentions in prose ("see Jira LOY-41", an epic link in a spec) is a reference, not a handoff.
1444
1444
  */
1445
- function jiraKeysOf(root, L, id, fm, found) {
1445
+ function jiraKeysOf(L, id, fm) {
1446
1446
  const keys = [];
1447
1447
  const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
1448
1448
  try {
@@ -1451,21 +1451,9 @@ function jiraKeysOf(root, L, id, fm, found) {
1451
1451
  if (m && JIRA_KEY.test(m[1].toUpperCase())) keys.push(m[1].toUpperCase());
1452
1452
  }
1453
1453
  } catch { /* no draft */ }
1454
- for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
1454
+ for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic', 'jira_issue_key']) {
1455
1455
  for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1456
1456
  }
1457
- const fromText = (text, lines) => {
1458
- for (const m of String(text).matchAll(/\/browse\/([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1459
- if (lines) for (const line of String(text).split('\n')) {
1460
- if (!/jira/i.test(line)) continue;
1461
- for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1462
- }
1463
- };
1464
- try { fromText(fs.readFileSync(path.join(L.initiatives, `${id}.md`), 'utf8'), true); } catch { /* no record text */ }
1465
- for (const rel of found ? [].concat(found.brief, found.draft, found.review, found.handoff) : []) {
1466
- if (!SCAN_TEXT.test(rel)) continue;
1467
- try { fromText(fs.readFileSync(path.join(root, rel), 'utf8').slice(0, 200000), false); } catch { /* unreadable */ }
1468
- }
1469
1457
  return [...new Set(keys)];
1470
1458
  }
1471
1459
 
@@ -1483,7 +1471,7 @@ function jiraKeysOf(root, L, id, fm, found) {
1483
1471
  * handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
1484
1472
  * When the record names no file for brief, draft or review, or names one that does not exist, a
1485
1473
  * document the repository scan found for the id completes the step (scan-documents S3); Jira keys
1486
- * in the repository complete the handoff (S4). Found evidence never blocks and never fails CI.
1474
+ * in the Jira tab's draft or the record's `jira:` frontmatter complete the handoff (S4), never a key in body text. Found evidence never blocks and never fails CI.
1487
1475
  * Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
1488
1476
  * existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
1489
1477
  * CI-blocking in either track: none can be true before the PR exists.
@@ -1564,7 +1552,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1564
1552
  if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
1565
1553
  else {
1566
1554
  const to = String(fm.handed_off || '').trim();
1567
- const keys = to ? [] : jiraKeysOf(root, L, id, fm, found);
1555
+ const keys = to ? [] : jiraKeysOf(L, id, fm);
1568
1556
  steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
1569
1557
  : keys.length ? step('handoff', 'handed off', 'done', `handed off to Jira — ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ` and ${keys.length - 5} more` : ''}`)
1570
1558
  : foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)