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.
- package/package.json +57 -33
- package/payload/AGENTS.md +2 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +7 -19
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/adler-moments-test.js +3 -1
- package/workspace/board-programs-test.js +6 -6
- package/workspace/board-shared-ui-test.js +2 -2
- package/workspace/briefing-premium-adddoc-test.js +1 -48
- package/workspace/briefing-premium-board-test.js +18 -19
- package/workspace/changelog.json +56 -0
- package/workspace/guide/adler.md +1 -1
- package/workspace/guide/briefing-premium.md +1 -0
- package/workspace/guide/claude-proposes.md +6 -2
- package/workspace/guide/data-freshness.md +7 -1
- package/workspace/guide/home.md +1 -1
- package/workspace/guide/published-board.md +1 -1
- package/workspace/guide/workflows.md +4 -3
- package/workspace/lib/board-premium.js +17 -2
- package/workspace/lib/engine/workflow-state.js +7 -19
- package/workspace/lib/initiative-chat.js +4 -1
- package/workspace/lib/proposals.js +18 -4
- package/workspace/lib/routes-board-premium.js +2 -2
- package/workspace/lib/routes-proposals.js +14 -2
- package/workspace/lib/split-merge-suggest.js +37 -3
- package/workspace/lib/split-merge.js +10 -4
- package/workspace/lib/task-cli.js +5 -1
- package/workspace/lib/vendor/scripts/workflow-state.js +7 -19
- package/workspace/lib/workflow-cli.js +70 -15
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +7 -9
- package/workspace/premium-core-doc-issue-test.js +9 -38
- package/workspace/public/pm/chat-turn-end.js +19 -0
- package/workspace/public/pm/cozy-board.css +17 -5
- package/workspace/public/pm/home-app.js +21 -5
- package/workspace/public/pm/home-chat.js +5 -0
- package/workspace/public/pm/home-ini.js +7 -0
- package/workspace/public/pm/home-today.js +11 -4
- package/workspace/public/pm/load-shapes.css +57 -0
- package/workspace/public/pm/load-shapes.js +57 -0
- package/workspace/public/pm/premium-board.js +82 -57
- package/workspace/public/pm/readonly.js +3 -3
- package/workspace/public/pm/split-merge.js +3 -2
- package/workspace/public/premium-board.html +7 -10
- package/workspace/public/premium.html +1 -0
- package/workspace/split-merge-ui-test.js +1 -1
- package/workspace/test-lists/chat-turn-end.json +9 -0
- package/workspace/test-lists/page-open-speed.json +1 -1
package/workspace/guide/adler.md
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
package/workspace/guide/home.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1442
|
-
* record's
|
|
1443
|
-
*
|
|
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(
|
|
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
|
|
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(
|
|
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 }
|
|
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 ✕
|
|
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.
|
|
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
|
-
|
|
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 ✕
|
|
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]) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/** ✕
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1442
|
-
* record's
|
|
1443
|
-
*
|
|
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(
|
|
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
|
|
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(
|
|
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`)
|