create-pmos 0.497.0 → 0.498.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 (33) hide show
  1. package/package.json +32 -14
  2. package/payload/AGENTS.md +1 -0
  3. package/payload/KIT-VERSION +1 -1
  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 +28 -0
  7. package/payload/state/discoveries.md +1 -1
  8. package/workspace/briefing-premium-test.js +2 -2
  9. package/workspace/changelog.json +31 -0
  10. package/workspace/guide/briefing-premium.md +4 -2
  11. package/workspace/guide/home.md +3 -3
  12. package/workspace/guide/launcher.md +7 -1
  13. package/workspace/lib/briefing-premium.js +38 -12
  14. package/workspace/lib/engine/workflow-state.js +28 -0
  15. package/workspace/lib/git-identity-cli.js +67 -0
  16. package/workspace/lib/git-identity.js +93 -0
  17. package/workspace/lib/pmos-verbs.js +3 -0
  18. package/workspace/lib/routes-boards.js +8 -3
  19. package/workspace/lib/vendor/scripts/workflow-state.js +28 -0
  20. package/workspace/package-lock.json +2 -2
  21. package/workspace/package.json +1 -1
  22. package/workspace/public/pm/bd-ini-panel.js +1 -1
  23. package/workspace/public/pm/home-app.js +4 -2
  24. package/workspace/public/pm/home-chat.js +2 -2
  25. package/workspace/public/pm/home-ini.js +1 -1
  26. package/workspace/public/pm/home-need.js +1 -1
  27. package/workspace/public/pm/ini-edit.js +82 -19
  28. package/workspace/public/pm/ini-tasks.js +6 -1
  29. package/workspace/public/pm/ini-view-model.js +3 -1
  30. package/workspace/public/pm/ini-view.css +31 -7
  31. package/workspace/public/pm/premium-board.js +2 -2
  32. package/workspace/stage-card-tasks-test.js +4 -3
  33. package/workspace/test-lists/git-identity.json +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.497.0",
3
+ "version": "0.498.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -32,6 +32,37 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.498.0",
37
+ "at": "2026-11-03T02:50:09.805Z",
38
+ "f": 3,
39
+ "prs": [
40
+ {
41
+ "n": 1176,
42
+ "t": "Commit as the repo's own git identity; ask the PM once when it has none",
43
+ "s": "Commit as the repo's own git identity; ask the PM once when it has none",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1178,
48
+ "t": "Full task names wrap; add-task chips for stage, type, dates, owner",
49
+ "s": "Full task names wrap; add-task chips for stage, type, dates, owner",
50
+ "k": "new"
51
+ },
52
+ {
53
+ "n": 1179,
54
+ "t": "Each product repo pushes with its own GitHub login",
55
+ "s": "Each product repo pushes with its own GitHub login",
56
+ "k": "new"
57
+ },
58
+ {
59
+ "n": 1177,
60
+ "t": "A workflow routes the initiative; blocked steps ask only as ✦ proposals",
61
+ "s": "A workflow routes the initiative; blocked steps ask only as ✦ proposals",
62
+ "k": "fix"
63
+ }
64
+ ]
65
+ },
35
66
  {
36
67
  "v": "0.497.0",
37
68
  "at": "2026-11-03T02:04:11.405Z",
@@ -378,19 +409,6 @@
378
409
  "s": "Batch 21: feedback fixes (held for one merge)"
379
410
  }
380
411
  ]
381
- },
382
- {
383
- "v": "0.478.0",
384
- "at": "2026-11-01T08:32:21.933Z",
385
- "f": 3,
386
- "prs": [
387
- {
388
- "n": 1138,
389
- "t": "Shared-board milestone keeps its place; CI runs only the suites a PR reaches",
390
- "s": "Shared-board milestone keeps its place; CI runs only the suites a PR reaches",
391
- "k": "fix"
392
- }
393
- ]
394
412
  }
395
413
  ]
396
414
  }
package/payload/AGENTS.md CHANGED
@@ -89,6 +89,7 @@ Every initiative carries a `type` — the lane picks what the gate requires **be
89
89
  - **Existing surface → the PM needs the understanding too.** That retrieval warms *you*. When the work touches something that already exists — the test is *would specifying this honestly require reading code that already exists?* — run `skills/explain-surface.skill` on that region **before** the brief/PRD is authored, in a fresh context and **not** by the agent that will write it. Standard for existing surfaces, skipped for greenfield. Without it, the agent acquires the understanding and the **PM approves a spec built on facts they never saw**.
90
90
  4. Anchor: the initiative file must exist at stage `intake`/`spec` with its **lane's** spec/eval artifacts authored **before** build (eval-first, D13, generalized per lane — see the lane table above; `feature` = PRD + rubric; the incident lane alone records post-hoc). Contract per lane: `feature` = the rubric's sprint-contract section with the PM's explicit `PM_approved: true`; `discovery` = the PM-approved spike brief (question + timebox); `chore` = the stated intent; `content` = the PM-approved content brief; `prototype` = the PM-approved quick brief (the judgement exercised + the specimen format) — and **no rubric**, which is the decision, not an omission. No work before the lane's approval. When you question the PM to reach any of these, follow `skills/requirements-elicitation.skill`. Feature-lane `test_method`s SHOULD be executable checks written before build (D58) — wire `templates/product-ci-starter.yml` so tier 1 is never silently empty.
91
91
  5. Branch `initiative/<id>`; execute at control altitude; one commit per artifact.
92
+ - **Commit as the repository's own git identity, and push with its own GitHub login.** Never pass an author on the command line (`-c user.name=…`, `-c user.email=…`, `--author`, `GIT_AUTHOR_*` or `GIT_COMMITTER_*`), and never use a name, email or login from your account, the session or another repository. The PM uses different identities and GitHub logins for different products. Run `pmos git-identity` (or `node "$PMOS_BIN" git-identity`) before the first commit or push in a repository, and again if `git commit` fails with "Author identity unknown" or `git push` fails with "Repository not found". It says what this repository uses, or lists what the PM uses in the other repositories. If something is missing, ask the PM once which identity and GitHub login this product uses, offering those. Save the answer with `pmos git-identity set --name "…" --email "…" --login <login>` (this repository only, never `--global`), then commit and push. After that, `git push` uses that login by itself; run `gh` in this repository as `GH_TOKEN="$(gh auth token --user <login>)" gh …`. Never run `gh auth switch`. Do not leave the PM's files staged.
92
93
  6. Self-evaluate against the contract — self-eval is unreliable; it is an input to the gates, never a verdict.
93
94
  7. Close the gates: **Quality** = this repo's CI plus the kit gate (`pmos/scripts/gate-initiative.sh`
94
95
  + `pmos/planning/evals/check_rubrics.py`); **Review** = score against the rubric with the evaluator skill (adversarial, outcome-only, default-to-refute) and append to `evals.jsonl`; **Acceptance** = the human PM merges and disposes — final and non-delegable. Never merge your own PR.
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@4a32332c
1
+ kernel-source: pmos@1a05518b
2
2
  assembled: 2026-11-03
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-11-03T02:04:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T02:50:05Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-11-03T02:04:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T02:50:05Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -556,6 +556,29 @@ function profileOf(root) {
556
556
  } catch { return 'product-code'; }
557
557
  }
558
558
 
559
+ /*
560
+ * workflowOf(root, id) — the workflow an initiative runs, when its repository runs initiatives on workflows (Boards, Settings ›
561
+ * Workflows). The workspace gives every repository it keeps state for a default board, so every initiative there runs a workflow:
562
+ * an `ini-wf` line in the boards ledger (<state dir>/boards.jsonl, the dirs workspace/server.js stateDirFor reads) names its
563
+ * own, else it runs its board's (Product feature by default). A repository runs on workflows when it holds that ledger or a
564
+ * pmos/state/ dir. PMOS itself holds neither (its state is the workspace's own), so it still routes by lane and R0 still binds it.
565
+ * → the workflow's id, '' (its board's), or null (no workflows). The ledger is read again when its size or mtime changes.
566
+ */
567
+ const wfMemo = new Map();
568
+ function workflowOf(root, id) {
569
+ const f = [['pmos', 'state'], ['state'], ['.pmos']].map((rel) => path.join(root, ...rel, 'boards.jsonl')).find((x) => fs.existsSync(x));
570
+ const on = !!f || fs.existsSync(path.join(root, 'pmos', 'state'));
571
+ if (!on) return null;
572
+ let key = '';
573
+ try { const st = f ? fs.statSync(f) : null; key = st ? st.size + ':' + st.mtimeMs : '-'; } catch { key = '?'; }
574
+ let m = wfMemo.get(root);
575
+ if (!m || m.key !== key) { // a held module sees a new line on the next read (the in-process freshness rule)
576
+ m = { key, wf: new Map() };
577
+ for (const l of f ? jsonl.readJsonl(f).rows : []) if (l && l.kind === 'ini-wf' && typeof l.id === 'string' && typeof l.wf === 'string' && l.wf) m.wf.set(l.id, l.wf);
578
+ wfMemo.set(root, m);
579
+ }
580
+ return m.wf.get(id) || '';
581
+ }
559
582
  /** The lanes a repository's initiatives may declare — what the lane picker offers and the lane write accepts. */
560
583
  function laneNames(profile) { return laneDefs.laneNames(profile === 'pm-artifacts' ? 'pm-artifacts' : 'product-code'); }
561
584
  function lanesFor(root) {
@@ -735,6 +758,7 @@ function computeWorkflowDerived(id, opts) {
735
758
  // ── route ─ the lane is chosen and the two intake questions are answered ─────────────────────
736
759
  const sRoute = specimenRoute(fm.specimen_test);
737
760
  const fRoute = surfaceRoute(fm.surface_check);
761
+ const onWf = workflowOf(root, id);
738
762
  {
739
763
  const missing = [];
740
764
  if (sRoute === 'undeclared' || sRoute === 'malformed') missing.push('specimen_test');
@@ -742,6 +766,10 @@ function computeWorkflowDerived(id, opts) {
742
766
  if (!missing.length) {
743
767
  steps.push(step('route', 'route', 'done',
744
768
  `${lane} lane · specimen test ${sRoute} · surface ${fRoute}`));
769
+ } else if (onWf !== null) {
770
+ // Batch 25 item 6: an initiative on a workflow is routed. The workflow's stages say what comes first, and PMOS proposes a
771
+ // switch when the BRD says another workflow fits, so the two intake questions are not asked of it again.
772
+ steps.push(step('route', 'route', 'done', `${onWf ? `runs the ${onWf} workflow` : 'runs its board\'s workflow'} — the workflow routes it`));
745
773
  } else if (effLane !== 'feature') {
746
774
  // The routing questions are asked of feature work only.
747
775
  steps.push(step('route', 'route', 'done', `${lane} lane — routing questions are for features only (${CUT_NOTE})`));
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-11-03T02:04:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T02:50:05Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -172,11 +172,11 @@ const vw = () => { const v = document.querySelector('#czViewer:not([hidden])');
172
172
  await p.click(`.hm-r[data-id="n:${k4}"] .hm-rm`);
173
173
  await until(() => p.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-czs-act="start"]')));
174
174
  const s0 = await p.evaluate(() => { const v = document.querySelector('#czViewer'); return { line: (v.querySelector('.czs-line') || {}).textContent || '', offer: (v.querySelector('.nv-offer') || {}).textContent || '', cost: (v.querySelector('.nv-cost') || {}).textContent || '', pri: (v.querySelector('.czs-pri') || {}).textContent || '', own: (v.querySelector('.nv-own') || {}).textContent || '' }; });
175
- ok('W8 a blocked row says what is missing, offers ✦ what Claude drafts, says what waiting costs, and holds ONE verb', s0.line === 'Blocked: the spec is missing' && /^I can do the spec/.test(s0.offer) && /^If it waits: /.test(s0.cost) && s0.pri === 'Do the spec' && /Yours to unblock · New feature/.test(s0.own), JSON.stringify(s0));
175
+ ok('W8 a blocked row says what is missing, offers ✦ what Claude drafts (batch 25 item 6: a concrete proposal), says what waiting costs, and holds ONE verb', s0.line === 'Blocked: the spec is missing' && /^I can draft the PRD/.test(s0.offer) && /^If it waits: /.test(s0.cost) && s0.pri === 'Draft the PRD' && /Yours to unblock · New feature/.test(s0.own), JSON.stringify(s0));
176
176
  await p.click('[data-czs-act="start"]');
177
177
  const sp = await until(() => { const l = A.posts.filter((x) => x.path === '/api/agent/start'); return l.length ? l : null; });
178
178
  ok('W8 Start posts one agent start for that row', !!sp && sp.length === 1, JSON.stringify(A.posts.map((x) => [x.path, x.body])));
179
- const s1 = await until(() => p.evaluate(() => { const v = document.querySelector('#czViewer:not([hidden])'); const l = v && v.querySelector('.czs-line'); return l && /^Doing the spec/.test(l.textContent) ? { stop: !!v.querySelector('[data-czs-act="stop"][aria-label="Stop the agent"]'), chat: !!v.querySelector('[data-czs-act="talk"]'), pri: !!v.querySelector('.czs-pri') } : null; }));
179
+ const s1 = await until(() => p.evaluate(() => { const v = document.querySelector('#czViewer:not([hidden])'); const l = v && v.querySelector('.czs-line'); return l && /^Drafting the PRD/.test(l.textContent) ? { stop: !!v.querySelector('[data-czs-act="stop"][aria-label="Stop the agent"]'), chat: !!v.querySelector('[data-czs-act="talk"]'), pri: !!v.querySelector('.czs-pri') } : null; }));
180
180
  ok('W8 after Start the same card says what the agent does now, with Stop and its chat and no other verb (OvA1After)', !!s1 && s1.stop && s1.chat && !s1.pri, JSON.stringify(s1));
181
181
  const mint = await T.nonce(BASE);
182
182
  const writes = A.posts.filter((x) => /briefing-premium\/(decide|put-off)|\/api\/agent\/start/.test(x.path));
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "v": "0.498.0",
4
+ "at": "2026-11-03T02:50:09.805Z",
5
+ "f": 3,
6
+ "prs": [
7
+ {
8
+ "n": 1176,
9
+ "t": "Commit as the repo's own git identity; ask the PM once when it has none",
10
+ "s": "Commit as the repo's own git identity; ask the PM once when it has none",
11
+ "k": "fix"
12
+ },
13
+ {
14
+ "n": 1178,
15
+ "t": "Full task names wrap; add-task chips for stage, type, dates, owner",
16
+ "s": "Full task names wrap; add-task chips for stage, type, dates, owner",
17
+ "k": "new"
18
+ },
19
+ {
20
+ "n": 1179,
21
+ "t": "Each product repo pushes with its own GitHub login",
22
+ "s": "Each product repo pushes with its own GitHub login",
23
+ "k": "new"
24
+ },
25
+ {
26
+ "n": 1177,
27
+ "t": "A workflow routes the initiative; blocked steps ask only as ✦ proposals",
28
+ "s": "A workflow routes the initiative; blocked steps ask only as ✦ proposals",
29
+ "k": "fix"
30
+ }
31
+ ]
32
+ },
2
33
  {
3
34
  "v": "0.497.0",
4
35
  "at": "2026-11-03T02:04:11.405Z",
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "The premium Briefing (/premium.html)"
4
4
  description: "The Briefing page at /premium.html: the hero sentence, What you need to do, Today, the dock, and what each one reads from the repository."
5
- timestamp: 2026-11-02
5
+ timestamp: 2026-11-03
6
6
  ---
7
7
 
8
8
  # The premium Briefing
@@ -133,7 +133,9 @@ The list is the console's **Needs you**: the same rows, in the same order, from
133
133
 
134
134
  Each row is a thing that waits for you. The order is fixed: blocked work first, then decisions only you can make, then what has waited longest. The first row is tagged "Do this first".
135
135
 
136
- A row has two lines: a verb-first title, then the subject and what the repository says is next, joined by a dot. When the line is too long it ends in "…"; the full text is in the row's dialog. "If you skip", which says what stays blocked, is in the dialog too. Press a row to open it. Detail opens first, and the conversation sits beside it. On a phone, the Detail tab is first.
136
+ A row has two lines: a verb-first title, then the subject and what the repository says is next, joined by a dot.
137
+
138
+ **Blocked work is Claude's ✦ proposal.** A blocked initiative shows as a row only when Claude can propose a way through. The title names the proposal (for example, **Draft the PRD**, **Anchor it** or **Start a prototype**), and the second line says what Claude will do: "✦ I'll draft the PRD with its done-criteria…". The initiative page and the chat show the same proposal ("Also waiting on you: ✦ Draft the PRD · Review"), not a question. Press the proposal to approve it, and Claude starts an agent session that does it. Two blocks are never a row: routing, because the initiative's workflow routes it, and an order violation in git history, which no draft can fix. When the line is too long it ends in "…"; the full text is in the row's dialog. "If you skip", which says what stays blocked, is in the dialog too. Press a row to open it. Detail opens first, and the conversation sits beside it. On a phone, the Detail tab is first.
137
139
 
138
140
  The header counts "N of M done". A row counts as done when the record behind it changes, so a row clears by itself. Nothing on this page presses Done for you. The count starts again each day and is kept on this device.
139
141
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Home (/premium.html) and the designed features"
4
4
  description: "Home in the Agreed design: the list grouped by product, the Viewer, the place menu, Your day and its New for you fold, Search, and the initiative, meeting, report, Board and Insights features designed after it."
5
- timestamp: 2026-11-01
5
+ timestamp: 2026-11-03
6
6
  ---
7
7
 
8
8
  # Home
@@ -103,8 +103,8 @@ Press an initiative's name in the list, or open `?ini=<id>`. The list stays as i
103
103
  - **Set a Jira ticket's dates.** Press the dates of a Jira ticket in the List, or drag its bar on the Timeline, as you do for a task. PMOS keeps the start and due days and shows them in place of the sprint end. Jira does not change. The status and the name stay Jira's.
104
104
  - **Tasks** show one row (one group) for each stage of the workflow, in order. Finished stages and later stages are never merged into one row; they start folded, and the current stage and any stage with a late task start open.
105
105
  - **Change a task where it is.** In the List, press a task's name. The row opens in place: change the name (Enter saves), the status, and the dates. Only one row is open at a time. Esc closes it. The ⋯ after the dates has **Milestone** (or **Make it a plain task**), **Move to a stage**, **Open the task card** and **Delete task**. A milestone has one date: its due day. Each change shows **Undo**.
106
- - In the List, a row shows the dates at the right. On a phone, it shows only the due day. A task with no dates shows no date; open the row to set them.
107
- - **Add a task to a stage.** Press **+** on an open stage. The field opens at the top of that stage. Enter adds the task and keeps the field open for the next one. **Dates** is optional. Esc closes the field.
106
+ - In the List, a task's full name always shows: a long name wraps onto more lines. A short prefix such as "Bus shelter:" stays in the name, in grey. The dates show at the right. In a narrow panel or on a phone, the dates show under the name. A task with no dates shows no date; open the row to set them.
107
+ - **Add a task to a stage.** Press **+** on an open stage. The field opens at the top of that stage, with one row of chips under it. **Stage** is the stage you pressed + on; press it to pick another stage of the workflow. **Task** can change to **Milestone**: the box becomes ◇ and **Date** takes one day. **Dates** and **Owner** are optional; with no owner, the task is yours. Enter adds the task and keeps the field open for the next one. Esc closes the field.
108
108
  - **Move a task between stages.** On a computer, drag the grip at the left of a row. On a phone, press and hold the task's name, then drag. Drop it on a stage name to put it last in that stage, or between two rows to put it there. The status and the dates do not change. The message shows **Undo**.
109
109
  - A linked Jira ticket opens in place too. Its name and status stay Jira's ("In Progress in Jira · read 4 min ago"). Its dates can change, and it can be a milestone. It has no **Delete**.
110
110
  - **Delete a task.** Press the caret under a task's box (on a phone, press and hold the box) and press the trash at the end of the status picker. The task goes at once and the message shows **Undo**. In the Task Viewer, the trash is the last icon of the Status row, and **Delete** is the last entry of its ⋯ menu. You can delete every PMOS task, also a task that Claude planned in the breakdown and a task that a stage's To finish list made. A deleted To finish task does not come back when the stage is read again or when the initiative moves back to that stage. A Jira ticket has no Delete: change it in Jira. A task deleted in a draft leaves only the draft.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Start the workspace without a terminal"
4
- timestamp: 2026-10-30
4
+ timestamp: 2026-11-03
5
5
  ---
6
6
 
7
7
  # Start the workspace without a terminal
@@ -31,3 +31,9 @@ Type `pmos launcher off`. This removes only launcher files that PMOS owns. It le
31
31
  ## Limits
32
32
 
33
33
  The login item starts the server once at login. Open the app if the server stops. Login does not enable remote access. Mac and Linux launcher output goes to `launcher.log` in the runtime state folder. Windows launchers run without a command window and use the server's normal logs.
34
+
35
+ ## Choose which git identity and GitHub login a repository uses
36
+
37
+ Some computers have no global git name and email, so each repository keeps its own. A computer can also have more than one GitHub login, and a private repository of one login is not found by the other. PMOS does not choose for you, because you can use a different identity and login for each product. When a repository has none, Claude asks you once and offers the ones from your other repositories.
38
+
39
+ Type `pmos git-identity` in the repository to see the identity it commits as and the GitHub login it pushes with. When one is missing, the result lists the ones your other repositories use. To save your choice for this repository only, type `pmos git-identity set --name "Your Name" --email you@example.com --login your-github-login`. To save only the login, type `pmos git-identity set --login your-github-login`. After that, `git push` in the repository uses that login, whichever login `gh` has active. Your global git settings do not change.
@@ -54,8 +54,32 @@ const STEP = {
54
54
  spec: { title: 'Put the spec ahead of the build', skip: 'the build stays ahead of the spec, so the order check keeps failing.' },
55
55
  contract: { title: 'Agree the contract', skip: 'the contract stays unagreed, so the run has no done-criteria to check.' },
56
56
  anchor: { title: 'Anchor the initiative', skip: 'the initiative stays unanchored, and unanchored work is rejected.' },
57
- route: { title: 'Route the initiative', skip: 'the initiative has no lane, so no lane rule can run.' },
57
+ route: { title: 'Route the initiative', skip: 'the initiative has no workflow, so its stages cannot run.' },
58
58
  };
59
+
60
+ /* What Claude proposes for a blocked step (batch 25 item 6). A blocked step reaches the PM only as a ✦ proposal: what Claude will do, in
61
+ * one sentence, that the PM approves (the Start write opens the agent session that does it). A step Claude cannot move by itself is not
62
+ * an ask: route (the initiative's workflow routes it, scripts/workflow-state.js), and an order violation (git history; no draft fixes it).
63
+ * `what` is the agent's task and starts with the verb the press names (pm/home-need.js plainStep makes the label from it).
64
+ * → { label, say, what } | null */
65
+ const PROPOSE = {
66
+ route: () => null,
67
+ anchor: (st) => (/only the PM adds/.test(st.next || '')
68
+ ? { label: 'Anchor it again', say: 'I’ll anchor it again to a goal or health budget you already have, and say which and why.', what: 'anchor it again to a KR or health budget the OKR documents already declare, set parent_kr in the record, and say which and why' }
69
+ : { label: 'Anchor it', say: 'I’ll pick the goal it moves from your goals and add it as its anchor, and say which and why.', what: 'anchor it to the KR or health budget it moves, from the OKR documents: set parent_kr in the record and say which and why' }),
70
+ prototype: (st) => (/cannot follow it/.test(st.next || '') ? null
71
+ : /prototype lane first/.test(st.next || '')
72
+ ? { label: 'Start a prototype', say: 'I’ll start a prototype: a quick brief and two or three specimens you can look at.', what: 'start a prototype with a quick brief and two or three divergent specimens (skills/core/prototype.skill), then set predecessor in the record' }
73
+ : { label: 'Link its prototype', say: 'I’ll link it to its prototype, or start one if there is none.', what: 'link its prototype by setting predecessor in the record to the prototype initiative, or start a prototype if there is none' }),
74
+ spec: (st) => (/^O[12]$/.test(st.code || st.rule || '') || /earlier commit than the build/.test(st.next || '') ? null
75
+ : /intent/.test(st.next || '')
76
+ ? { label: 'Draft the intent', say: 'I’ll write the one-paragraph intent from what the initiative already says.', what: 'draft the one-paragraph intent from what the initiative already says and put it in the record\'s status' }
77
+ : /rubric/.test(st.next || '')
78
+ ? { label: 'Draft the rubric', say: 'I’ll draft the done-criteria rubric from the PRD, for you to review.', what: 'draft the rubric from the PRD (skills/core/eval-rubric.skill), then set rubric_path' }
79
+ : { label: 'Draft the PRD', say: 'I’ll draft the PRD with its done-criteria from what the initiative already says, for you to review.', what: 'draft the PRD with its done-criteria (skills/core/prd.skill) from what the initiative already says, then set prd_path' }),
80
+ contract: () => ({ label: 'Draft the contract', say: 'I’ll draft the done-criteria and how we test them, for you to approve.', what: 'draft the contract with its done-criteria and test method, for the PM to approve, and record it in the runs ledger' }),
81
+ };
82
+ const proposalOf = (st) => { try { return PROPOSE[st.key] ? PROPOSE[st.key](st) : null; } catch { return null; } };
59
83
  const stepOf = (key) => STEP[key] || { title: 'Unblock the ' + key + ' step', skip: 'the ' + key + ' step stays blocked.' };
60
84
 
61
85
  /* The options a decision offers. They name the choices the repository's own rules give (D41 for a tripwire, D60 for a prototype); none is marked
@@ -99,26 +123,28 @@ function blockedRows(f) {
99
123
  for (const i of f.initiatives) {
100
124
  try {
101
125
  if (!live(i)) continue;
102
- const st = (i.steps || []).find((s) => s.status === 'blocked' && s.blocking !== false) || (i.steps || []).find((s) => s.status === 'blocked');
126
+ // the first blocked step Claude can propose a way through, the blocking ones first; a step with no proposal is not an ask
127
+ const blocked = (i.steps || []).filter((s) => s.status === 'blocked');
128
+ const st = [...blocked.filter((s) => s.blocking !== false), ...blocked.filter((s) => s.blocking === false)].find((s) => proposalOf(s));
103
129
  if (!st) continue;
104
- const m = stepOf(st.key);
130
+ const m = stepOf(st.key), pr = proposalOf(st);
105
131
  out.push({
106
- kind: 'blocked', key: 'blocked:' + i.id, title: m.title, subject: clip(i.title, 90),
107
- prepared: st.next ? 'Next step: ' + clip(st.next, 140) : 'No next step is on record.',
132
+ kind: 'blocked', key: 'blocked:' + i.id, title: pr.label, subject: clip(i.title, 90),
133
+ prepared: '✦ ' + pr.say, propose: { label: pr.label, say: pr.say },
108
134
  skip: 'If you skip: ' + m.skip,
109
135
  from: [i.id], stepKey: st.key, order: [-(i.blockers || []).length, i.id],
110
- detail: { id: i.id, step: st.key, rule: st.rule || null, next: st.next == null ? null : String(st.next), record: i.record || null, ...whoOf(i) },
136
+ detail: { id: i.id, step: st.key, rule: st.rule || null, next: pr.what, record: i.record || null, ...whoOf(i) },
111
137
  dialog: tryD(() => ({
112
138
  eye: 'Unblock', logTitle: 'Read from the repository',
113
139
  log: ['Read the initiative record' + (i.record ? ' (' + i.record + ')' : ''), 'Ran the workflow order rules on it', 'Found the step that blocks it: ' + st.key],
114
140
  say: i.id + ' is blocked at the ' + st.key + ' step.' + (st.rule ? ' The rule it does not meet: ' + clip(st.rule, 200) : ''),
115
141
  hold: START_NOTE, head: 'The way through',
116
142
  facts: [['Blocked at', st.key], ['Initiative', i.id], ['The check', st.rule && clip(st.rule, 200)], ['Record', i.record]].filter((x) => x[1]),
117
- opts: st.next ? [{ k: 'next', t: clip(st.next[0].toUpperCase() + st.next.slice(1), 140), d: 'The next step on record.' }] : [],
118
- sel: st.next ? 'next' : null,
119
- nodef: st.next ? '' : 'No next step is on record.',
120
- write: st.next ? { kind: 'start', initiative: i.id, what: clip(st.next, 200), ...(i.ws ? { ws: i.ws } : {}) } : undefined,
121
- prim: { next: 'Start: ' + clip(st.next || '', 40) },
143
+ opts: [{ k: 'next', t: pr.label, d: '✦ ' + pr.say }],
144
+ sel: 'next',
145
+ nodef: '',
146
+ write: { kind: 'start', initiative: i.id, what: clip(pr.what, 200), ...(i.ws ? { ws: i.ws } : {}) },
147
+ prim: { next: '✦ ' + pr.label },
122
148
  pane: { label: 'Initiative record', file: i.record || i.id, h: clip(i.title, 90), rows: fmRows(i.fm) },
123
149
  })),
124
150
  });
@@ -528,7 +554,7 @@ function goalOf(A, raw) {
528
554
  async function readInitiatives({ root, engine }) {
529
555
  const wf = engine ? await run(process.execPath, [engine, '--json', '--root', root], { cwd: root, nice: 10 }) : null;
530
556
  let list;
531
- try { list = JSON.parse(wf).map((x) => ({ id: x.id, title: x.title, lane: x.lane, stage: x.stage, accepted: !!x.accepted, stopped: !!x.stopped, legacy: !!x.legacy, anchor: x.anchor, last_activity: x.last_activity, blockers: x.blockers || [], next: x.next, record: x.record, steps: (x.steps || []).map((s) => ({ key: s.key, status: s.status, next: s.next, rule: (s.basis && s.basis.rule) || s.rule, blocking: s.blocking })) })); } catch { return null; }
557
+ try { list = JSON.parse(wf).map((x) => ({ id: x.id, title: x.title, lane: x.lane, stage: x.stage, accepted: !!x.accepted, stopped: !!x.stopped, legacy: !!x.legacy, anchor: x.anchor, last_activity: x.last_activity, blockers: x.blockers || [], next: x.next, record: x.record, steps: (x.steps || []).map((s) => ({ key: s.key, status: s.status, next: s.next, rule: (s.basis && s.basis.rule) || s.rule, code: s.rule || null, blocking: s.blocking })) })); } catch { return null; }
532
558
  let A = null;
533
559
  try { A = require('./initiative-rows.js').anchorsOf(root); } catch { A = null; }
534
560
  for (const i of list) {
@@ -556,6 +556,29 @@ function profileOf(root) {
556
556
  } catch { return 'product-code'; }
557
557
  }
558
558
 
559
+ /*
560
+ * workflowOf(root, id) — the workflow an initiative runs, when its repository runs initiatives on workflows (Boards, Settings ›
561
+ * Workflows). The workspace gives every repository it keeps state for a default board, so every initiative there runs a workflow:
562
+ * an `ini-wf` line in the boards ledger (<state dir>/boards.jsonl, the dirs workspace/server.js stateDirFor reads) names its
563
+ * own, else it runs its board's (Product feature by default). A repository runs on workflows when it holds that ledger or a
564
+ * pmos/state/ dir. PMOS itself holds neither (its state is the workspace's own), so it still routes by lane and R0 still binds it.
565
+ * → the workflow's id, '' (its board's), or null (no workflows). The ledger is read again when its size or mtime changes.
566
+ */
567
+ const wfMemo = new Map();
568
+ function workflowOf(root, id) {
569
+ const f = [['pmos', 'state'], ['state'], ['.pmos']].map((rel) => path.join(root, ...rel, 'boards.jsonl')).find((x) => fs.existsSync(x));
570
+ const on = !!f || fs.existsSync(path.join(root, 'pmos', 'state'));
571
+ if (!on) return null;
572
+ let key = '';
573
+ try { const st = f ? fs.statSync(f) : null; key = st ? st.size + ':' + st.mtimeMs : '-'; } catch { key = '?'; }
574
+ let m = wfMemo.get(root);
575
+ if (!m || m.key !== key) { // a held module sees a new line on the next read (the in-process freshness rule)
576
+ m = { key, wf: new Map() };
577
+ for (const l of f ? jsonl.readJsonl(f).rows : []) if (l && l.kind === 'ini-wf' && typeof l.id === 'string' && typeof l.wf === 'string' && l.wf) m.wf.set(l.id, l.wf);
578
+ wfMemo.set(root, m);
579
+ }
580
+ return m.wf.get(id) || '';
581
+ }
559
582
  /** The lanes a repository's initiatives may declare — what the lane picker offers and the lane write accepts. */
560
583
  function laneNames(profile) { return laneDefs.laneNames(profile === 'pm-artifacts' ? 'pm-artifacts' : 'product-code'); }
561
584
  function lanesFor(root) {
@@ -735,6 +758,7 @@ function computeWorkflowDerived(id, opts) {
735
758
  // ── route ─ the lane is chosen and the two intake questions are answered ─────────────────────
736
759
  const sRoute = specimenRoute(fm.specimen_test);
737
760
  const fRoute = surfaceRoute(fm.surface_check);
761
+ const onWf = workflowOf(root, id);
738
762
  {
739
763
  const missing = [];
740
764
  if (sRoute === 'undeclared' || sRoute === 'malformed') missing.push('specimen_test');
@@ -742,6 +766,10 @@ function computeWorkflowDerived(id, opts) {
742
766
  if (!missing.length) {
743
767
  steps.push(step('route', 'route', 'done',
744
768
  `${lane} lane · specimen test ${sRoute} · surface ${fRoute}`));
769
+ } else if (onWf !== null) {
770
+ // Batch 25 item 6: an initiative on a workflow is routed. The workflow's stages say what comes first, and PMOS proposes a
771
+ // switch when the BRD says another workflow fits, so the two intake questions are not asked of it again.
772
+ steps.push(step('route', 'route', 'done', `${onWf ? `runs the ${onWf} workflow` : 'runs its board\'s workflow'} — the workflow routes it`));
745
773
  } else if (effLane !== 'feature') {
746
774
  // The routing questions are asked of feature work only.
747
775
  steps.push(step('route', 'route', 'done', `${lane} lane — routing questions are for features only (${CUT_NOTE})`));
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ /* git-identity-cli.js — `pmos git-identity [set …]` (feedback batch 25 item 1, lib/git-identity.js).
3
+ *
4
+ * pmos git-identity this repository's identity and push login, or what is missing and
5
+ * the ones the PM uses elsewhere
6
+ * pmos git-identity set --name <n> --email <e> [--login <gh>] save the PM's choice in this repository's own git config (never --global)
7
+ * pmos git-identity set --login <gh> save only the GitHub login that pushes this repository
8
+ *
9
+ * An agent runs the first when a commit fails with "Author identity unknown" or a push fails with "Repository not found", asks the
10
+ * PM once which identity and GitHub login this product uses, and runs `set` with the answer. Nothing here picks one by itself. The
11
+ * other repositories are the ones the workspace hosts (workspaces.json in the state folder). Exit codes: 0 done (or all is set),
12
+ * 1 something is missing or refused, 2 usage. */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const G = require('./git-identity.js');
16
+ const stateRoot = require('./state-root');
17
+
18
+ const COMMANDS = { 'git-identity': ['set'] };
19
+ const USAGE = [
20
+ 'usage: pmos git-identity [set [--name <name> --email <email>] [--login <github-login>]]',
21
+ ' pmos git-identity the identity and GitHub login this repository uses, or the ones you use elsewhere',
22
+ ' pmos git-identity set --name <n> --email <e> --login <gh> save your choice for this repository only',
23
+ ' pmos git-identity set --login <gh> save only the GitHub login that pushes this repository',
24
+ ];
25
+
26
+ function hostedRoots(env) {
27
+ let reg = [];
28
+ try { reg = JSON.parse(fs.readFileSync(path.join(stateRoot.defaultRoot({ env }), 'workspaces.json'), 'utf8')); } catch { reg = []; }
29
+ return Array.isArray(reg) ? reg.filter((x) => x && typeof x.root === 'string').map((x) => x.root) : [];
30
+ }
31
+ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; }
32
+
33
+ const ghLine = (login) => `GH_TOKEN="$(gh auth token --user ${login})" gh …`;
34
+
35
+ function run(argv, { say = console.log, cwd = process.cwd(), env = process.env, git = 'git' } = {}) {
36
+ const sub = argv[1];
37
+ if (sub === 'help' || sub === '--help') { USAGE.forEach((l) => say(l)); return 0; }
38
+ if (!G.isRepo(cwd, git)) { say('This folder is not a git repository.'); return 1; }
39
+ if (sub === 'set') {
40
+ const name = flag(argv, '--name'); const email = flag(argv, '--email'); const login = flag(argv, '--login');
41
+ if ((name == null) !== (email == null) || (name == null && login == null)) { USAGE.forEach((l) => say(l)); return 2; }
42
+ const r = G.set(cwd, { name: name == null ? undefined : name, email: email == null ? undefined : email, login: login == null ? undefined : login }, { git });
43
+ if (!r.ok) { say(`Not saved: ${r.error}.`); return 1; }
44
+ if (name != null) say(`This repository now commits as ${r.name} <${r.email}>.`);
45
+ if (login != null) { say(`git push here now uses the GitHub login ${r.login}, whichever gh login is active.`); say(`Run gh here as: ${ghLine(r.login)}`); }
46
+ return 0;
47
+ }
48
+ if (sub !== undefined) { USAGE.forEach((l) => say(l)); return 2; }
49
+ const have = G.identity(cwd, { git });
50
+ const login = G.pushLogin(cwd, { git });
51
+ const okId = !!(have.name && have.email);
52
+ const needLogin = !login && G.githubHttps(cwd, { git }); // an ssh or non-GitHub origin does not use the login
53
+ if (okId) say(`This repository commits as ${have.name} <${have.email}>.`);
54
+ if (login) { say(`It pushes with the GitHub login ${login}. Run gh here as: ${ghLine(login)}`); }
55
+ if (okId && !needLogin) return 0;
56
+ say(okId ? 'It has no saved GitHub login, so git push uses whichever gh login is active and can fail with "Repository not found".'
57
+ : 'This repository has no git identity, so commits fail.');
58
+ say('Ask the PM once which ' + (okId ? 'GitHub login' : needLogin ? 'identity and GitHub login' : 'identity') + ' this product uses, then run:');
59
+ say(' pmos git-identity set' + (okId ? '' : ' --name "<name>" --email "<email>"') + (needLogin ? ' --login <github-login>' : ''));
60
+ const list = G.choices(cwd, hostedRoots(env), { git });
61
+ if (!list.length) { say('No other repository here has one to offer.'); return 1; }
62
+ say('Used in the other repositories:');
63
+ list.forEach((c, i) => say(` ${i + 1}. ${c.name} <${c.email}>${c.login ? ', login ' + c.login : ''} — ${c.repos.map((r) => path.basename(r)).join(', ')}`));
64
+ return 1;
65
+ }
66
+
67
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ /* git-identity.js — which git author identity a product repository commits as (feedback batch 25 item 1).
3
+ *
4
+ * identity(root, { git }) → { name, email } as git resolves it in `root` (local, then global), either may be null
5
+ * localIdentity(root, { git }) → { name, email } set in `root`'s own .git/config only, either may be null
6
+ * pushLogin(root, { git }) → the GitHub login this repository pushes with (`pmos.pushLogin` in its own config), or null
7
+ * choices(root, roots, { git }) → [{ name, email, login, repos: [root…] }] the distinct identities (and push logins) of the other repositories
8
+ * set(root, { name, email, login }, { git }) → { ok, name, email, login } | { ok: false, error } writes `root`'s own config, never --global
9
+ *
10
+ * Why: a Mac with no global identity keeps one per repository, and the PM uses different ones for different products (a personal
11
+ * one, a work one). A product repository seeded from another had none, so the agent could not commit and left the PM's files staged.
12
+ * PMOS never inherits or guesses an identity: committing as the wrong person cannot be taken back once pushed. The agent asks the PM
13
+ * once which identity this product uses, offering choices() (`pmos git-identity`), and saves the answer with set()
14
+ * (`pmos git-identity set`). The kit's AGENTS.md says so.
15
+ *
16
+ * The push login (batch 25, after #1176): the PM's Mac has more than one gh login (wawancsui, wsetiawan-pm), and a private repository
17
+ * of one account is "not found" to the other. set() with a login saves it as `pmos.pushLogin` and gives the repository its own
18
+ * credential helper for https://github.com: the inherited helpers are reset, and the one left asks gh for that login's token
19
+ * (`gh auth token --user <login>`), so `git push` there uses that login whichever gh login is active. `gh` commands run in the
20
+ * repository take GH_TOKEN from the same call (lib/git-identity-cli.js prints the line). The login is asked once, with the identity. */
21
+ const cp = require('child_process');
22
+ const path = require('path');
23
+
24
+ function cfg(git, root, args) {
25
+ try {
26
+ const out = cp.execFileSync(git, ['config', ...args], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true,
27
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
28
+ return String(out).trim() || null;
29
+ } catch { return null; }
30
+ }
31
+ function isRepo(root, git = 'git') {
32
+ try { cp.execFileSync(git, ['rev-parse', '--git-dir'], { cwd: root, stdio: 'ignore', timeout: 5000, windowsHide: true }); return true; } catch { return false; }
33
+ }
34
+ function identity(root, { git = 'git' } = {}) { return { name: cfg(git, root, ['user.name']), email: cfg(git, root, ['user.email']) }; }
35
+ function localIdentity(root, { git = 'git' } = {}) { return { name: cfg(git, root, ['--local', 'user.name']), email: cfg(git, root, ['--local', 'user.email']) }; }
36
+
37
+ /** The identities the PM set in the other repositories, each once, with the repositories that use it, in the order first seen. */
38
+ function choices(root, roots, { git = 'git' } = {}) {
39
+ const self = root ? path.resolve(root) : null;
40
+ const seen = new Map();
41
+ for (const r of roots || []) {
42
+ if (!r || path.resolve(r) === self) continue;
43
+ const id = localIdentity(r, { git });
44
+ if (!id.name || !id.email) continue;
45
+ const login = pushLogin(r, { git });
46
+ const k = id.name + '\n' + id.email + '\n' + (login || '');
47
+ if (!seen.has(k)) seen.set(k, { name: id.name, email: id.email, login, repos: [] });
48
+ const e = seen.get(k); if (!e.repos.includes(path.resolve(r))) e.repos.push(path.resolve(r));
49
+ }
50
+ return [...seen.values()];
51
+ }
52
+
53
+ /** True when origin is an https GitHub URL: the only case the saved login's credential helper serves (an ssh origin uses ssh keys). */
54
+ function githubHttps(root, { git = 'git' } = {}) { return /^https:\/\/([^@/]+@)?github\.com\//i.test(cfg(git, root, ['--get', 'remote.origin.url']) || ''); }
55
+ function pushLogin(root, { git = 'git' } = {}) { return cfg(git, root, ['--local', 'pmos.pushLogin']); }
56
+
57
+ const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; // a GitHub login: letters, digits and hyphens, so it is safe inside the helper's shell line
58
+ const HELPER_KEY = 'credential.https://github.com.helper';
59
+ const helperFor = (login) => `!f() { test "$1" = get || exit 0; echo username=${login}; echo "password=$(gh auth token --user ${login} --hostname github.com)"; }; f`;
60
+
61
+ const ONE_LINE = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x1f\x7f]/.test(v);
62
+ const EMAIL = /^[^\s@<>]+@[^\s@<>]+$/;
63
+
64
+ /** Save the PM's choice in this repository only: the identity (name and email together), the push login, or both. Refuses a folder
65
+ * that is not a repository, a malformed name, email or login, and a call with nothing to save. */
66
+ function set(root, { name, email, login } = {}, { git = 'git' } = {}) {
67
+ if (!root || !isRepo(root, git)) return { ok: false, error: 'this folder is not a git repository' };
68
+ const ident = name != null || email != null;
69
+ if (!ident && login == null) return { ok: false, error: 'give --name and --email, --login, or all three' };
70
+ if (ident) {
71
+ if (!ONE_LINE(name, 120) || /[<>]/.test(name)) return { ok: false, error: 'give a name of 1 to 120 characters on one line' };
72
+ if (!ONE_LINE(email, 254) || !EMAIL.test(email.trim())) return { ok: false, error: 'give an email address, for example you@example.com' };
73
+ }
74
+ if (login != null && !LOGIN.test(String(login).trim())) return { ok: false, error: 'give a GitHub login: letters, digits and hyphens, at most 39' };
75
+ if (ident) {
76
+ cfg(git, root, ['--local', 'user.name', name.trim()]);
77
+ cfg(git, root, ['--local', 'user.email', email.trim()]);
78
+ }
79
+ if (login != null) {
80
+ const l = String(login).trim();
81
+ cfg(git, root, ['--local', 'pmos.pushLogin', l]);
82
+ cfg(git, root, ['--local', '--unset-all', HELPER_KEY]);
83
+ cfg(git, root, ['--local', '--add', HELPER_KEY, '']); // an empty value drops the helpers inherited from the global config
84
+ cfg(git, root, ['--local', '--add', HELPER_KEY, helperFor(l)]);
85
+ }
86
+ const now = localIdentity(root, { git });
87
+ if (ident && (now.name !== name.trim() || now.email !== email.trim())) return { ok: false, error: 'git did not save the identity' };
88
+ const l = pushLogin(root, { git });
89
+ if (login != null && l !== String(login).trim()) return { ok: false, error: 'git did not save the push login' };
90
+ return { ok: true, name: now.name, email: now.email, login: l };
91
+ }
92
+
93
+ module.exports = { identity, localIdentity, pushLogin, githubHttps, choices, set, isRepo, helperFor };
@@ -98,6 +98,9 @@ const VERBS = Object.freeze({
98
98
  // Not a pm-os-absorb verb (run: null): the Grill and Forecast build (F-A) added it through the same table.
99
99
  forecast: Object.freeze({ module: 'forecast-cli.js', run: null, guide: 'forecast.md',
100
100
  help: 'pmos forecast baseline|baseline-off|turned … — a goal forecast\'s kept baseline, and the forecasts that turned (pmos forecast help)' }),
101
+ // Not a pm-os-absorb verb (run: null): feedback batch 25 item 1 added it through the same table.
102
+ 'git-identity': Object.freeze({ module: 'git-identity-cli.js', run: null, guide: 'launcher.md',
103
+ help: 'pmos git-identity [set --name <n> --email <e> --login <gh>] — which git identity and GitHub login this repository uses; save your choice for it' }),
101
104
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
102
105
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
103
106
  });
@@ -26,7 +26,8 @@
26
26
  * POST /api/boards/settings { ws, board, buildDeliver?, jira?, offer?, defTpl? } → { ok, board } jira: one key or a list
27
27
  * POST /api/boards/ini-workflow { ws, board, id, template?, stages, moveTo? } → { ok, row, moved }
28
28
  * POST /api/boards/ini-epics { ws, board, id, epics: [Jira key] } → { ok, row } the Jira epics of an initiative ([] unlinks)
29
- * POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner?, notes?, section? } → { ok, item } also `pmos task add`
29
+ * POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner?, notes?, section?, milestone? } → { ok, item } also `pmos task add`
30
+ * (milestone: true adds it as a milestone, ◇ with one date: the due day, no start; batch 25 item 5)
30
31
  * (section: the stage it goes under, as tasks/edit's section: the + on a stage of the Tasks list, batch 18 item 16)
31
32
  * POST /api/boards/tasks/order { ws, board, initiative, section, tasks: [id] } → { ok, from } the PM's order inside one stage (a drag in the
32
33
  * Tasks list, batch 18 item 16): a task-order line; tasks [] takes the order out; Undo sends `from` back
@@ -1284,6 +1285,7 @@ const WRITES = {
1284
1285
  if (body.epic != null && body.epic !== '' && !ONE_LINE(body.epic, 80)) return fail(ctx, res, 400, 'epic is one line of at most 80 characters');
1285
1286
  if (body.notes != null && (typeof body.notes !== 'string' || body.notes.length > 4000)) return fail(ctx, res, 400, 'notes are text of at most 4000 characters');
1286
1287
  if (body.start && body.due && body.start > body.due) return fail(ctx, res, 400, 'the start is after the due day');
1288
+ if (body.milestone != null && typeof body.milestone !== 'boolean') return fail(ctx, res, 400, 'milestone is true or false');
1287
1289
  if (!p.file) return fail(ctx, res, 409, NO_STATE);
1288
1290
  const base = needRows(ctx, res, p.w);
1289
1291
  if (!base) return;
@@ -1296,11 +1298,14 @@ const WRITES = {
1296
1298
  if (body.section && !st.some((x) => x.key === body.section)) return fail(ctx, res, 400, `"${body.section}" is not a stage of this initiative's workflow: one of ${st.map((x) => x.key).join(', ')}, or ''`);
1297
1299
  }
1298
1300
  const id = `task-${crypto.randomBytes(5).toString('hex')}`;
1299
- const start = body.start || (body.due && body.due < B.shiftDay(today(), 1) ? body.due : B.shiftDay(today(), 1));
1300
- const due = body.due || B.shiftDay(start, 7);
1301
+ // a milestone has one date, the due day, and no start (the ◇ of the Viewer's add field, batch 25 item 5)
1302
+ const ms = body.milestone === true;
1303
+ const start = ms ? '' : body.start || (body.due && body.due < B.shiftDay(today(), 1) ? body.due : B.shiftDay(today(), 1));
1304
+ const due = body.due || (ms ? body.start || B.shiftDay(today(), 8) : B.shiftDay(start, 7));
1301
1305
  append(p.file, g.actor, { kind: 'task', board: p.board.id, task: id, type: body.type || 'Task', initiative: body.initiative, title: body.title.trim(), epic: body.epic ? String(body.epic).trim() : null,
1302
1306
  owner: body.owner ? String(body.owner).trim() : actorLib.pageName(g.actor.by), state: 'To do', start, due, ...(body.notes && body.notes.trim() ? { notes: body.notes.replace(/\s+$/, '') } : {}) });
1303
1307
  if (typeof body.section === 'string') append(p.file, g.actor, { kind: 'task-section', board: p.board.id, task: id, section: body.section, why: body.section ? 'placed under ' + body.section : 'placed under no stage' });
1308
+ if (ms) append(p.file, g.actor, { kind: 'task-milestone', board: p.board.id, task: id, milestone: true, why: 'added as a milestone' });
1304
1309
  const t = B.taskOf(B.fold(readLedger(ctx, p.w)), id);
1305
1310
  ctx.json(res, { ok: true, item: B.rollupItems([B.taskItem(t)])[0] });
1306
1311
  },
@@ -556,6 +556,29 @@ function profileOf(root) {
556
556
  } catch { return 'product-code'; }
557
557
  }
558
558
 
559
+ /*
560
+ * workflowOf(root, id) — the workflow an initiative runs, when its repository runs initiatives on workflows (Boards, Settings ›
561
+ * Workflows). The workspace gives every repository it keeps state for a default board, so every initiative there runs a workflow:
562
+ * an `ini-wf` line in the boards ledger (<state dir>/boards.jsonl, the dirs workspace/server.js stateDirFor reads) names its
563
+ * own, else it runs its board's (Product feature by default). A repository runs on workflows when it holds that ledger or a
564
+ * pmos/state/ dir. PMOS itself holds neither (its state is the workspace's own), so it still routes by lane and R0 still binds it.
565
+ * → the workflow's id, '' (its board's), or null (no workflows). The ledger is read again when its size or mtime changes.
566
+ */
567
+ const wfMemo = new Map();
568
+ function workflowOf(root, id) {
569
+ const f = [['pmos', 'state'], ['state'], ['.pmos']].map((rel) => path.join(root, ...rel, 'boards.jsonl')).find((x) => fs.existsSync(x));
570
+ const on = !!f || fs.existsSync(path.join(root, 'pmos', 'state'));
571
+ if (!on) return null;
572
+ let key = '';
573
+ try { const st = f ? fs.statSync(f) : null; key = st ? st.size + ':' + st.mtimeMs : '-'; } catch { key = '?'; }
574
+ let m = wfMemo.get(root);
575
+ if (!m || m.key !== key) { // a held module sees a new line on the next read (the in-process freshness rule)
576
+ m = { key, wf: new Map() };
577
+ for (const l of f ? jsonl.readJsonl(f).rows : []) if (l && l.kind === 'ini-wf' && typeof l.id === 'string' && typeof l.wf === 'string' && l.wf) m.wf.set(l.id, l.wf);
578
+ wfMemo.set(root, m);
579
+ }
580
+ return m.wf.get(id) || '';
581
+ }
559
582
  /** The lanes a repository's initiatives may declare — what the lane picker offers and the lane write accepts. */
560
583
  function laneNames(profile) { return laneDefs.laneNames(profile === 'pm-artifacts' ? 'pm-artifacts' : 'product-code'); }
561
584
  function lanesFor(root) {
@@ -735,6 +758,7 @@ function computeWorkflowDerived(id, opts) {
735
758
  // ── route ─ the lane is chosen and the two intake questions are answered ─────────────────────
736
759
  const sRoute = specimenRoute(fm.specimen_test);
737
760
  const fRoute = surfaceRoute(fm.surface_check);
761
+ const onWf = workflowOf(root, id);
738
762
  {
739
763
  const missing = [];
740
764
  if (sRoute === 'undeclared' || sRoute === 'malformed') missing.push('specimen_test');
@@ -742,6 +766,10 @@ function computeWorkflowDerived(id, opts) {
742
766
  if (!missing.length) {
743
767
  steps.push(step('route', 'route', 'done',
744
768
  `${lane} lane · specimen test ${sRoute} · surface ${fRoute}`));
769
+ } else if (onWf !== null) {
770
+ // Batch 25 item 6: an initiative on a workflow is routed. The workflow's stages say what comes first, and PMOS proposes a
771
+ // switch when the BRD says another workflow fits, so the two intake questions are not asked of it again.
772
+ steps.push(step('route', 'route', 'done', `${onWf ? `runs the ${onWf} workflow` : 'runs its board\'s workflow'} — the workflow routes it`));
745
773
  } else if (effLane !== 'feature') {
746
774
  // The routing questions are asked of feature work only.
747
775
  steps.push(step('route', 'route', 'done', `${lane} lane — routing questions are for features only (${CUT_NOTE})`));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.497.0",
3
+ "version": "0.498.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.497.0",
9
+ "version": "0.498.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.497.0",
3
+ "version": "0.498.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -205,7 +205,7 @@ function wire(body, key) {
205
205
  hold: (i, b) => { const r = O.rowOf(key); if (r && O.hold) O.hold(r, i, b); },
206
206
  addCancel: () => { adding = null; addTo = ''; addDates = null; redraw(); },
207
207
  // opt (batch 18 item 16): the + on a stage, with the dates its field picked
208
- addTask: async (title, inp, opt) => { const r = O.rowOf(key); if (!r || !O.addTask) return; inp.disabled = true; const ok = await O.addTask(r, title, opt ? opt.section : addTo, opt ? (opt.start || opt.due ? opt : null) : addDates, opt ? opt.name : ''); inp.disabled = false; if (ok && !opt) { inp.value = ''; inp.focus(); } },
208
+ addTask: async (title, inp, opt) => { const r = O.rowOf(key); if (!r || !O.addTask) return; inp.disabled = true; const ok = await O.addTask(r, title, opt ? opt.section : addTo, opt ? (opt.start || opt.due || opt.milestone || opt.owner ? opt : null) : addDates, opt ? opt.name : ''); inp.disabled = false; if (ok && !opt) { inp.value = ''; inp.focus(); } },
209
209
  primary: (act, b) => {
210
210
  const r = O.rowOf(key); if (!r) return;
211
211
  if (act === 'grill') O.grillOpen(r, b);
@@ -509,7 +509,7 @@ function iniViewOf(id) {
509
509
  const need = H.needsOf(S.D).find((r) => r.ini === id);
510
510
  const p = programOf(S.D.programs || [], row.ws || (B.board || {}).ws || '', id);
511
511
  return iniView(rowFor({ ...row, trail: t || row.trail || null }), { steps: B.steps || STEPS, today: B.today, usual: WFM.usualOf(S.D.trail, row),
512
- ask: need ? { title: need.name, n: H.needsOf(S.D).filter((r) => r.ini === id).length } : null, program: p ? { id: p.id, title: p.title } : null, jiraReadAt: ((B.jira || {}).readAt) || '',
512
+ ask: need ? { title: need.name, n: H.needsOf(S.D).filter((r) => r.ini === id).length, propose: (need.row && need.row.propose) || null } : null, program: p ? { id: p.id, title: p.title } : null, jiraReadAt: ((B.jira || {}).readAt) || '',
513
513
  // the card (batch 18 item 1): Claude's line while a session works on it, the stakeholders
514
514
  claude: (() => { const w = S.D.sessions.find((s) => H.iniOfSession(s) === id && s.shown === 'working'); return w ? 'I’m working on “' + H.nameOf(w) + '”. I’ll say here when it is ready.' : ''; })(),
515
515
  people: (((S.D.desk && S.D.desk.stakeholders) || {})[id] || []) });
@@ -867,7 +867,9 @@ function alsoOf(ini) {
867
867
  // in an initiative's chat only that initiative's own next step shows; another initiative's is not "also" this conversation's
868
868
  const other = H.needsOf(S.D).filter((r) => !(S.sel && S.sel === r.id));
869
869
  const n = ini ? other.find((r) => r.ini === ini) : other[0];
870
- return n ? { text: n.name, go: () => select(n.id) } : null;
870
+ // a blocked step reaches the PM as Claude's ✦ proposal (batch 25 item 6): the line names what Claude will do, the press reviews it
871
+ const pr = n && n.row && n.row.propose;
872
+ return n ? { text: pr ? '✦ ' + pr.label : n.name, go: () => select(n.id), verb: pr ? 'Review' : 'Answer' } : null;
871
873
  }
872
874
  /* An ask's conversation (batch 18 item 2-A, OvA10Desk): the Viewer holds the summary and the one verb, so the chat asks a question instead
873
875
  * of saying it again. A decision's choices are pills that open the Viewer with that choice picked; it is recorded there. */
@@ -16,7 +16,7 @@
16
16
  * A loose session the host can place (host.suggestLink(target, firstText) → { id, title }) shows one chip: Link it (host.linkTo) or × (host.dismissLink).
17
17
  * Every write is the PM's press. mountChat(el, host) returns { open(target), refresh(), focus(), send(text) }.
18
18
  * target: { key?, sid?, title, scope: { text, icon, loose }, role: { name, initial }, intro?: { role, text, at, mood? (Adler as the avatar while the log is empty), pills: [{ label, primary, go }] },
19
- * ini?, rich, also?: { text, go } }
19
+ * ini?, rich, also?: { text, go, verb? ('Answer' when absent; 'Review' for Claude's ✦ proposal) } }
20
20
  * host: { esc, toast, pmGet, pmPost, roles(), seen(key), markSeen(key), onScope(btn), onLink(btn), onTo(btn), workItem(text, target),
21
21
  * viewerClosed(), reopenViewer(), head(target), touch(sid) (the PM opened, typed in or answered that session: the Live pill) } */
22
22
  import { mdHtml, userHtml, toolLine, webKind } from './chat-md.js';
@@ -400,7 +400,7 @@ export function mountChat(el, host) {
400
400
  const chipsOf = (items) => (chipsHtmlFn ? chipsHtmlFn(items) : '');
401
401
  function drawFoot(R) {
402
402
  const t = C.t, goal = C.th && C.th.goal;
403
- const also = t.also && !(C.conv && C.conv.ask) ? `<button type="button" class="hm-also" data-hm-also><i aria-hidden="true"></i><span><b>Also waiting on you:</b> ${esc(t.also.text)}</span><em>Answer</em></button>` : '';
403
+ const also = t.also && !(C.conv && C.conv.ask) ? `<button type="button" class="hm-also" data-hm-also><i aria-hidden="true"></i><span><b>Also waiting on you:</b> ${esc(t.also.text)}</span><em>${esc(t.also.verb || 'Answer')}</em></button>` : '';
404
404
  // while it works the PM talks as usual (Claude reads it after its tool call); a goal that stopped says how to go on (principle 8)
405
405
  const ph = C.conv && C.conv.ask ? 'Or tell ' + R.role.name + ' what to do instead' : goal && goal.state === 'stopped' && !running() ? 'Reply to go on, or /goal for a new goal' : t.placeholder || 'Message Claude';
406
406
  const sc = t.scope || { text: 'All work', icon: 'M5 21V4M5 4h12l-2 4 2 4H5' };
@@ -294,7 +294,7 @@ function wirePage(b, id) {
294
294
  const M = curM(); if (!M) return;
295
295
  inp.disabled = true;
296
296
  const D = opt ? opt : ADD_DATES || {};
297
- const r = await C.pmPost('/api/boards/tasks/add', { ...bw(M), initiative: id, title, ...(D.start ? { start: D.start } : {}), ...(D.due ? { due: D.due } : {}), ...(opt && opt.section != null ? { section: opt.section } : {}) });
297
+ const r = await C.pmPost('/api/boards/tasks/add', { ...bw(M), initiative: id, title, ...(D.start ? { start: D.start } : {}), ...(D.due ? { due: D.due } : {}), ...(opt && opt.section != null ? { section: opt.section } : {}), ...(opt && opt.milestone ? { milestone: true } : {}), ...(opt && opt.owner ? { owner: opt.owner } : {}) });
298
298
  inp.disabled = false;
299
299
  if (!r || r.ok === false) { say((r && r.error) || 'The task was not added.'); return; }
300
300
  const uid = r.item && r.item.uid;
@@ -74,7 +74,7 @@ function whyBlocked(step, rule, lane) {
74
74
  if (step === 'prototype') return 'This idea needs a prototype people can look at before a PRD says what to build.';
75
75
  if (step === 'contract') return 'The run needs its done-criteria agreed before it starts, so there is something to check it against.';
76
76
  if (step === 'anchor') return 'Every initiative names the goal it moves. Work with no goal is turned away.';
77
- if (step === 'route') return 'The initiative needs its lane, so the right rules run on it.';
77
+ if (step === 'route') return 'The initiative needs a workflow, so its stages can run.';
78
78
  return 'The ' + step + ' step is not done yet.';
79
79
  }
80
80
  const lineOf = (step, rule) => (/^O[12]$/.test(rule || '') ? 'Blocked: ' + (STEP_WORD[step] || 'the ' + step) + ' came after the build'
@@ -9,8 +9,11 @@
9
9
  * dashed Dates chip) and ⋯ right after the dates (StA8Desk)
10
10
  * ticketHtml(k, o) an unlinked Jira ticket's open row: the same, with Open the ticket in place of ⋯ (its place is Jira's)
11
11
  * plusHtml(sec, id, o) the + on an open stage's header: "New task in <stage>" (StA5Add)
12
- * addHtml(sec, id) the field at the top of that stage's list, with an optional Dates chip
13
- * press(b, d, root, H, e) a press this file owns → true: a title (opens the row), ⋯ and its menu, the +, the field's Dates chip
12
+ * addHtml(sec, id) the field at the top of that stage's list, and one chip row under it (batch 25 item 5, canvas
13
+ * TaskAddChips): Stage (the stage the + opened in; a picker of the initiative's workflow stages), Type (Task
14
+ * or Milestone: a milestone's box is ◇ and its Dates chip takes one date), Dates, Owner. Only what the task
15
+ * model stores: stage, milestone, start, due, owner
16
+ * press(b, d, root, H, e) a press this file owns → true: a title (opens the row), ⋯ and its menu, the +, the field's chips
14
17
  * wire(body) once per body: the name field (Enter or blur saves, Esc closes), the add field (Enter adds and stays open),
15
18
  * a press outside the open row closes it, and the drag (StA10Drag): a grip on hover on a desktop, a long press
16
19
  * on the title on a phone; the target stage lights up and a blue line shows where the task lands
@@ -29,14 +32,15 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&':
29
32
  const SVG = (d, w = 16, sw = 1.8) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
30
33
  const P = {
31
34
  more: 'M5 12h.01M12 12h.01M19 12h.01', plus: 'M12 5v14M5 12h14', check: 'M5 12l5 5 9-10', down: 'M7 10l5 5 5-5', right: 'M10 7l5 5-5 5', left: 'M14 7l-5 5 5 5',
32
- dia: 'M12 4l8 8-8 8-8-8z', cal: 'M4 5h16v15H4zM8 3v4M16 3v4M4 10h16', grip: 'M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01',
35
+ dia: 'M12 4l8 8-8 8-8-8z', stage: 'M12 3l9 5-9 5-9-5zM3 13l9 5 9-5', box: 'M6 5h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z',
36
+ who: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21c1-4 4-6 8-6s7 2 8 6', cal: 'M4 5h16v15H4zM8 3v4M16 3v4M4 10h16', grip: 'M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01',
33
37
  open: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6', bin: 'M5 7h14M10 7V4h4v3M7 7l1 13h8l1-13',
34
38
  };
35
39
 
36
40
  /* ── what is open, per initiative ── */
37
41
  const OPEN = new Map(); // initiative id → the uid of the row open in place
38
42
  const ADD = new Map(); // initiative id → the stage key whose + field is open
39
- const ADD_D = new Map(); // initiative id → { start, due } the add field's Dates chip picked
43
+ const ADD_D = new Map(); // initiative id → what the add field's chips picked: { start, due, section, ms, owner } (batch 25 item 5)
40
44
  const STAGES = new Map(); // initiative id → [{ key, name }] the stages a task can move to
41
45
  const VIEW = new Map(); // initiative id → the view the List drew last (the drag reads its stages' tasks)
42
46
  export const openOf = (id) => OPEN.get(id) || '';
@@ -94,14 +98,41 @@ export function plusHtml(sec, id, o) {
94
98
  return `<button type="button" class="iv-ib iv-plus${on ? ' on' : ''}" data-iv-plus="${esc(sec.key || '')}" data-nm="${esc(nm)}" aria-pressed="${on}" aria-label="${esc('New task in ' + nm)}" title="${esc('New task in ' + nm)}">${SVG(P.plus, 16, 2.2)}</button>`;
95
99
  }
96
100
  const mdIso = (iso) => { const n = TV.dn(iso); return n == null ? '' : TV.md(n); };
101
+ const initials = (n) => String(n || '').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('') || '?';
102
+ /** The add field's picks: the stage it adds into (the + it opened from, unless the Stage chip changed it) and that stage's name. */
103
+ function addTo(id, own, nm) {
104
+ const D = ADD_D.get(id) || {};
105
+ return D.section != null && D.section !== own ? { key: D.section, name: stageName(id, D.section) } : { key: own, name: nm };
106
+ }
107
+ /** The chip row under the add field (TaskAddChips): Stage, Type, Dates, Owner. A chip with nothing picked is dashed. */
108
+ function chipsHtml(id, D, to) {
109
+ const ms = !!D.ms;
110
+ const dt = ms ? mdIso(D.due) : D.start || D.due ? [mdIso(D.start), mdIso(D.due)].filter(Boolean).join(' – ') : '';
111
+ const car = SVG(P.down, 12, 2.2);
112
+ const stage = `<button type="button" class="iv-edc iv-adc on" data-iv-addst aria-haspopup="menu" aria-expanded="false" aria-label="${esc('Stage: ' + to.name + '. Change')}" title="Stage">${SVG(P.stage, 14, 2)}<b>${esc(to.name)}</b>${car}</button>`;
113
+ const type = `<button type="button" class="iv-edc iv-adc${ms ? ' ms' : ''}" data-iv-addty aria-haspopup="menu" aria-expanded="false" aria-label="${esc('Type: ' + (ms ? 'Milestone' : 'Task') + '. Change')}" title="Task or milestone">${SVG(ms ? P.dia : P.box, 14, 2)}${ms ? 'Milestone' : 'Task'}${car}</button>`;
114
+ const dates = `<button type="button" class="iv-edc iv-edd${dt ? '' : ' dash'}" data-iv-adddt data-s="${esc(ms ? '' : D.start || '')}" data-e="${esc(D.due || '')}" data-f="${ms ? 'due' : 'start'}"${ms ? ' data-one="1"' : ''} aria-haspopup="dialog" aria-label="${esc(dt ? (ms ? 'Date: ' : 'Dates: ') + dt + '. Change' : ms ? 'Date, optional' : 'Dates, optional')}" title="${ms ? 'Date, optional' : 'Dates, optional'}">${SVG(P.cal, 14, 2)}${esc(dt || (ms ? 'Date' : 'Dates'))}</button>`;
115
+ const owner = D.owner
116
+ ? `<button type="button" class="iv-edc iv-adc" data-iv-addow aria-haspopup="menu" aria-expanded="false" aria-label="${esc('Owner: ' + D.owner + '. Change')}" title="Owner"><span class="iv-av" aria-hidden="true">${esc(initials(D.owner))}</span>${esc(D.owner)}</button>`
117
+ : `<button type="button" class="iv-edc iv-adc dash" data-iv-addow aria-haspopup="menu" aria-expanded="false" aria-label="Owner, optional: you when none is picked" title="Owner, optional">${SVG(P.who, 14, 2)}Owner</button>`;
118
+ return stage + type + dates + owner;
119
+ }
120
+ const addBox = (ms) => (ms ? `<span class="iv-adms" aria-hidden="true">${SVG(P.dia, 20, 2)}</span>` : '<span class="iv-cb" aria-hidden="true"></span>');
97
121
  export function addHtml(sec, id) {
98
122
  if (addOf(id) !== (sec.key || '')) return '';
99
- const nm = sec.name || 'Not in a stage', D = ADD_D.get(id) || {};
100
- const dt = D.start || D.due ? [mdIso(D.start), mdIso(D.due)].filter(Boolean).join(' – ') : '';
101
- return `<form class="iv-addf iv-addin" data-esc-layer data-iv-addin="${esc(sec.key || '')}" data-nm="${esc(nm)}"><span class="iv-cb" aria-hidden="true"></span>`
102
- + `<input type="text" data-iv-addinp maxlength="200" autocomplete="off" placeholder="${esc('New task in ' + nm)}" aria-label="${esc('New task in ' + nm)}">`
123
+ const own = sec.key || '', nm = sec.name || 'Not in a stage', D = ADD_D.get(id) || {}, to = addTo(id, own, nm);
124
+ return `<form class="iv-addf iv-addin" data-esc-layer data-iv-addin="${esc(own)}" data-nm="${esc(nm)}">${addBox(D.ms)}`
125
+ + `<input type="text" data-iv-addinp maxlength="200" autocomplete="off" placeholder="${esc('New task in ' + to.name)}" aria-label="${esc('New task in ' + to.name)}">`
103
126
  + `<button type="submit" class="iv-go" aria-label="Add the task" title="Add (Enter). Esc closes.">${SVG(P.check, 16, 2.2)}</button></form>`
104
- + `<div class="iv-addd"><button type="button" class="iv-edc iv-edd${dt ? '' : ' dash'}" data-iv-adddt data-s="${esc(D.start || '')}" data-e="${esc(D.due || '')}" data-f="start" aria-label="${esc(dt ? 'Dates: ' + dt + '. Change' : 'Dates, optional')}" title="Dates, optional">${SVG(P.cal, 14, 2)}${esc(dt || 'Dates')}</button></div>`;
127
+ + `<div class="iv-addd">${chipsHtml(id, D, to)}</div>`;
128
+ }
129
+ /** The names an owner can be: You first, then everyone who owns a task of this initiative. */
130
+ function ownersOf(id) {
131
+ const v = VIEW.get(id), out = new Set(['You']);
132
+ for (const s of (v && v.tasks && v.tasks.sections) || []) for (const c of s.stages || [s]) {
133
+ for (const x of [...(c.top || []), ...(c.groups || []).flatMap((g) => g.rows), ...(c.done || [])]) if (x.owner && !x.jkey) out.add(x.owner);
134
+ }
135
+ return [...out];
105
136
  }
106
137
 
107
138
  /* ── the writes (Undo in the host's toast) ── */
@@ -173,9 +204,11 @@ export function press(b, d, root, H, e) {
173
204
  }
174
205
  if ('ivAdddt' in d) {
175
206
  e.preventDefault(); TOUCHED = Date.now();
176
- TV.datePop(b, '', { dates: (u, p) => { ADD_D.set(id, { ...(ADD_D.get(id) || {}), ...p }); const f = b.closest('.iv-wl'); redrawAdd(f, id); } });
207
+ const list = b.closest('.iv-wl');
208
+ TV.datePop(b, '', { dates: (u, p) => { ADD_D.set(id, { ...(ADD_D.get(id) || {}), ...p }); redrawAdd(list, id); } });
177
209
  return true;
178
210
  }
211
+ if ('ivAddst' in d || 'ivAddty' in d || 'ivAddow' in d) { e.preventDefault(); TOUCHED = Date.now(); addMenu(b, d, id); return true; }
179
212
  if (d.ivMore) { e.preventDefault(); TOUCHED = Date.now(); moreMenu(b, d.ivMore, id, v, H); return true; }
180
213
  if (b.closest('.iv-ed')) TOUCHED = Date.now();
181
214
  return false;
@@ -192,14 +225,43 @@ function focusLater(root, sel, select) {
192
225
  };
193
226
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(go); else go();
194
227
  }
195
- /** The add field's Dates chip again, after a pick, without a redraw (the typed words stay). */
228
+ /** The add field's chips, box and placeholder again after a pick, without a redraw (the typed words stay). */
196
229
  function redrawAdd(list, id) {
197
- const f = list && list.querySelector('.iv-addd'); if (!f) return;
198
- const D = ADD_D.get(id) || {}, dt = [mdIso(D.start), mdIso(D.due)].filter(Boolean).join(' – '), c = f.querySelector('[data-iv-adddt]');
199
- if (!c) return;
200
- c.dataset.s = D.start || ''; c.dataset.e = D.due || ''; c.classList.toggle('dash', !dt);
201
- c.setAttribute('aria-label', dt ? 'Dates: ' + dt + '. Change' : 'Dates, optional');
202
- c.innerHTML = SVG(P.cal, 14, 2) + esc(dt || 'Dates');
230
+ const f = list && list.querySelector('[data-iv-addin]'), row = list && list.querySelector('.iv-addd'); if (!f || !row) return;
231
+ const D = ADD_D.get(id) || {}, to = addTo(id, f.dataset.ivAddin, f.dataset.nm);
232
+ row.innerHTML = chipsHtml(id, D, to);
233
+ const box = f.firstElementChild; if (box && box.tagName === 'SPAN') box.outerHTML = addBox(D.ms);
234
+ const inp = f.querySelector('[data-iv-addinp]');
235
+ if (inp) { inp.placeholder = 'New task in ' + to.name; inp.setAttribute('aria-label', 'New task in ' + to.name); }
236
+ }
237
+ /** The Stage, Type and Owner chips' pickers, right under the chip. A pick changes only the add field; nothing is saved until ✓. */
238
+ function addMenu(b, d, id) {
239
+ const list = b.closest('.iv-wl'), f = list && list.querySelector('[data-iv-addin]'); if (!f) return;
240
+ const D = ADD_D.get(id) || {}, set = (p) => { ADD_D.set(id, { ...(ADD_D.get(id) || {}), ...p }); redrawAdd(list, id); const n = list.querySelector('[data-iv-addinp]'); if (n) n.focus({ preventScroll: true }); };
241
+ const item = (attr, on, inner) => `<button type="button" role="menuitemradio" class="o${on ? ' hl' : ''}" aria-checked="${on}" ${attr}><span class="ck" aria-hidden="true">${on ? SVG(P.check, 14, 2.4) : ''}</span>${inner}</button>`;
242
+ if ('ivAddst' in d) {
243
+ const to = addTo(id, f.dataset.ivAddin, f.dataset.nm).key;
244
+ let n = 0;
245
+ const html = `<div class="tkc-pop ivq-m ive-m" role="menu" aria-label="Stage">` + (STAGES.get(id) || []).map((s) => item(`data-ive-to="${esc(s.key)}"`, s.key === to, `${s.key ? `<span class="nn" aria-hidden="true">${++n}</span>` : ''}<span>${esc(s.name)}</span>`)).join('') + '</div>';
246
+ TV.openPop(b, html, (o) => { if (o.dataset.iveTo == null) return; TV.closePop(); set({ section: o.dataset.iveTo }); });
247
+ return;
248
+ }
249
+ if ('ivAddty' in d) {
250
+ const html = `<div class="tkc-pop ivq-m ive-m" role="menu" aria-label="Task or milestone">` + item('data-ive-ty="task"', !D.ms, `<span class="nn ic" aria-hidden="true">${SVG(P.box, 14, 2)}</span><span>Task</span>`)
251
+ + item('data-ive-ty="ms"', !!D.ms, `<span class="nn ic" aria-hidden="true">${SVG(P.dia, 14, 2)}</span><span>Milestone</span><span class="k">one date</span>`) + '</div>';
252
+ TV.openPop(b, html, (o) => {
253
+ const t = o.dataset.iveTy; if (!t) return; TV.closePop();
254
+ // a milestone has one date: the due day (or the start, when only a start was picked)
255
+ if (t === 'ms') set({ ms: true, due: D.due || D.start || '', start: '' }); else set({ ms: false });
256
+ });
257
+ return;
258
+ }
259
+ const names = ownersOf(id), cur = D.owner || '';
260
+ const html = `<div class="tkc-pop ivq-m ive-m" role="menu" aria-label="Owner">` + names.map((nm) => item(`data-ive-ow="${esc(nm === 'You' ? '' : nm)}"`, nm === 'You' ? !cur : nm === cur, `<span class="iv-av" aria-hidden="true">${esc(nm === 'You' ? 'Y' : initials(nm))}</span><span>${esc(nm)}</span>`)).join('')
261
+ + `<hr><form class="tkc-new ive-own"><input type="text" maxlength="80" placeholder="Someone else" aria-label="Owner's name" autocomplete="off"></form></div>`;
262
+ const el = TV.openPop(b, html, (o) => { if (o.dataset.iveOw == null) return; TV.closePop(); set({ owner: o.dataset.iveOw }); });
263
+ const form = el.querySelector('.ive-own');
264
+ if (form) form.addEventListener('submit', (e) => { e.preventDefault(); const v = form.querySelector('input').value.trim(); if (!v) return; TV.closePop(); set({ owner: v }); });
203
265
  }
204
266
  /** ⋯ of the open row (StA2bMore): Milestone (or Make it a plain task), Move to a stage, Open the task card (Open the ticket on a Jira row),
205
267
  * Delete (only where the task can be deleted; never a Jira row). Move to a stage turns the menu into the workflow's stages, ← goes back. */
@@ -256,9 +318,10 @@ export function wire(body) {
256
318
  if (f.dataset.ivRenf != null) { const inp = f.querySelector('[data-iv-ren]'), id = idOf(); OPEN.delete(id); if (!save(inp) && H().redraw) H().redraw(); return; }
257
319
  const inp = f.querySelector('[data-iv-addinp]'), t = inp ? inp.value.trim() : '', id = idOf();
258
320
  if (!t || !H().addTask) return;
259
- const D = ADD_D.get(id) || {};
321
+ // the chips' picks (batch 25 item 5): the stage, a milestone with its one date, the owner (none: you)
322
+ const D = ADD_D.get(id) || {}, to = addTo(id, f.dataset.ivAddin, f.dataset.nm);
260
323
  inp.disabled = true;
261
- Promise.resolve(H().addTask(t, inp, { section: f.dataset.ivAddin, name: f.dataset.nm, ...(D.start ? { start: D.start } : {}), ...(D.due ? { due: D.due } : {}) })).then(() => {
324
+ Promise.resolve(H().addTask(t, inp, { section: to.key, name: to.name, ...(D.start && !D.ms ? { start: D.start } : {}), ...(D.due ? { due: D.due } : {}), ...(D.ms ? { milestone: true } : {}), ...(D.owner ? { owner: D.owner } : {}) })).then(() => {
262
325
  // Enter keeps the field open for the next one (StA5Add)
263
326
  ADD_D.delete(id);
264
327
  const n = body.querySelector('[data-iv-addinp]'); if (n) { if (n === inp) n.value = ''; n.disabled = false; n.focus({ preventScroll: true }); }
@@ -157,10 +157,15 @@ const tkTitle = (t) => `<button type="button" class="iv-tt" data-ig-tk="${esc(t.
157
157
  /** A task linked to a Jira ticket it names (ini-view-model.js linkOf): the key once, in blue, then the rest of its title, the same in the List,
158
158
  * the Timeline and the Kanban (class lk). A key PMOS has not read stays plain text in the title (0.442.0 hotfix). */
159
159
  export function keyName(x) {
160
- if (!x.jkey) return esc(x.title);
160
+ if (!x.jkey) return prefixName(x.title);
161
161
  const rest = String(x.title || '').replace(new RegExp('[\\[(]?\\b' + x.jkey + '\\b[\\])]?\\s*[:\u2013\u2014-]?\\s*'), '').trim();
162
162
  return `<em class="lk">${esc(x.jkey)}</em>${rest ? ' ' + esc(rest) : ''}`;
163
163
  }
164
+ /** A title that starts with a short prefix ("Bus shelter: Confirm QR placement") keeps it, quieter (batch 25 item 4, canvas TaskNamesWrap). */
165
+ export function prefixName(t) {
166
+ const m = /^([^:]{1,32}):\s+(\S[\s\S]*)$/.exec(String(t == null ? '' : t));
167
+ return m && m[1].trim().split(/\s+/).length <= 4 && !/\/\/|\d{1,2}$/.test(m[1]) ? `<span class="iv-pf">${esc(m[1])}: </span>${esc(m[2])}` : esc(t);
168
+ }
164
169
  /** The Jira mark of a linked task, in place of its box: the status square the ticket rows draw (To do, In progress, Done). Jira owns the
165
170
  * status, so it marks nothing here. */
166
171
  export const jqMark = (x) => `<span class="iv-jq ig-sq ${x.closed ? 'done' : x.prog ? 'doing' : 'todo'}${mvCls(x.jmove)}" role="img" aria-label="${esc('Jira ' + x.jkey + ': ' + (movingOf(x.jmove) ? 'moving to ' + x.status : x.jst || x.status))}" title="${esc('Jira · ' + (movingOf(x.jmove) ? 'moving to ' + x.status : x.jst || x.status))}"></span>`;
@@ -302,7 +302,9 @@ export function iniView(r, ctx = {}) {
302
302
  // what waits on the PM: the track's, else the trail's (the Board merges the trail into the track; Home reads the row as sent)
303
303
  const you = t.you || (it.you && it.you.text) || null, youKind = t.youKind || (it.you && it.you.kind) || null, youWhy = t.youWhy || (it.you && it.you.why) || '';
304
304
  let wait = null;
305
- if (ctx.ask && ctx.ask.title) wait = { text: '“' + ctx.ask.title + '” needs your answer.', say: 'Can you answer “' + ctx.ask.title + '”? I’ll carry on once you do.', primary: { act: 'ask', label: 'Answer: ' + ctx.ask.title } };
305
+ // Claude's ✦ proposal for a blocked step (batch 25 item 6): what it will do, for the PM to approve; never a bare question
306
+ if (ctx.ask && ctx.ask.propose && ctx.ask.propose.say) wait = { text: '✦ ' + ctx.ask.propose.say, say: '✦ ' + ctx.ask.propose.say + ' Shall I go ahead?', primary: { act: 'ask', label: '✦ ' + ctx.ask.propose.label } };
307
+ else if (ctx.ask && ctx.ask.title) wait = { text: '“' + ctx.ask.title + '” needs your answer.', say: 'Can you answer “' + ctx.ask.title + '”? I’ll carry on once you do.', primary: { act: 'ask', label: 'Answer: ' + ctx.ask.title } };
306
308
  else if (you && youKind) wait = { text: youWhy, primary: youKind === 'drift' && it.links && it.links.prd ? { act: 'doc:' + it.links.prd, label: 'Open the PRD' } : youKind === 'commit' ? { act: 'commit', label: 'Send to Jira' } : { act: 'page', label: you } };
307
309
  else if (you) { const opn = tk.filter((k) => k.status !== 'Done').length; wait = { text: opn ? plural(opn, 'ticket is', 'tickets are') + ' still open, so the sign-off can wait until they close.' : 'Every release ticket is done.', primary: { act: 'page', label: you } }; }
308
310
  else if (ctx.grill && ctx.grill.open && !ctx.grill.later) wait = { text: 'PMOS asks what the BRD needs that the Why does not say yet.', primary: { act: 'grill', label: 'Answer the questions' } };
@@ -1193,12 +1193,24 @@ html.ad-still .mi-svg * { animation: none !important; }
1193
1193
  /* ── a task changed where it sits in the List (batch 18 item 16, agreed A; canvas In development St*) ──
1194
1194
  * One line per task (StA1Rows): the box, the title, its days at the right (a phone: the due day only), the owner's face on a desktop. A title
1195
1195
  * press opens the row in place (StA2Edit): the name field, then one quiet line: status, Start – Due, ⋯ right after the dates (StA8Desk). */
1196
- .iv-tr.q { align-items: center; position: relative; box-sizing: border-box; }
1197
- .iv-tr.q .iv-bxw { align-self: center; }
1198
- .iv-tr.q > .w { flex-direction: row; align-items: center; gap: 10px; min-width: 0; }
1199
- .iv-tr.q .iv-tn { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; }
1200
- .iv-tr.q .iv-tt { display: block !important; flex: 0 1 auto; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; font-weight: 600; }
1201
- .iv-tr.q .iv-rm { flex: none; display: inline-flex; align-items: center; gap: 0; min-height: 0; max-width: 55%; white-space: nowrap; }
1196
+ /* batch 25 item 4 (canvas TaskNamesWrap, agreed A): a task's name is never cut. It wraps onto as many lines as it needs; its days stay at the
1197
+ * right of the first line in a narrower column; a narrow panel puts them under the name. The prefix ("Bus shelter:") stays, quieter. */
1198
+ .iv-tr.q { align-items: flex-start; position: relative; box-sizing: border-box; padding: 11px 0 !important; }
1199
+ .iv-tr.q .iv-bxw { align-self: flex-start; margin-top: 2px; }
1200
+ .iv-tr.q > .w { flex-direction: row; align-items: flex-start; gap: 10px; min-width: 0; }
1201
+ .iv-tr.q .iv-tn { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: 6px; }
1202
+ .iv-tr.q .iv-tt { display: block !important; flex: 0 1 auto; text-align: left; white-space: normal; overflow-wrap: anywhere; min-width: 0; font-weight: 600; line-height: 22px; }
1203
+ .iv-tr.q .iv-tn > :not(.iv-tt) { margin-top: 4px; }
1204
+ .iv-tr.q .iv-rm { flex: none; display: inline-flex; align-items: center; gap: 0; min-height: 22px; max-width: 40%; white-space: nowrap; }
1205
+ .iv-pf { color: var(--cz-ink3); font-weight: 700; }
1206
+ @container iv (max-width: 520px) {
1207
+ .iv-tr.q > .w { flex-direction: column; gap: 0; }
1208
+ .iv-tr.q .iv-rm { max-width: 100%; flex-wrap: wrap; white-space: normal; }
1209
+ .iv-tr.q .iv-rm .iv-dc { margin-left: 0 !important; padding-left: 0 !important; }
1210
+ .iv-tr.q .iv-dst { display: inline; }
1211
+ /* the days sit under the name: their wider press area (a phone) reaches down, not up over the name */
1212
+ .iv-tr.q .iv-rm .iv-dc::after { top: 0; }
1213
+ }
1202
1214
  .iv-tr.q .iv-dc { margin: 0 !important; white-space: nowrap; }
1203
1215
  .iv-dms { display: inline-flex; margin-right: 4px; color: var(--cz-ink2); }
1204
1216
  .iv-rav { width: 24px; height: 24px; margin-left: 10px; background: var(--cz-well); color: var(--cz-ink2); }
@@ -1246,7 +1258,19 @@ html.ad-still .mi-svg * { animation: none !important; }
1246
1258
  .iv-addin { margin: 0 -14px 0 -46px; padding: 10px 14px 4px 46px; background: var(--cz-bluebg); gap: 10px; }
1247
1259
  .iv-addin .iv-cb { margin-top: 0 !important; border-style: solid; }
1248
1260
  .iv-addin input { border: 1.5px solid var(--cz-blue); }
1249
- .iv-addd { margin: 0 -14px 0 -46px; padding: 4px 14px 12px 46px; background: var(--cz-bluebg); }
1261
+ .iv-addd { margin: 0 -14px 0 -46px; padding: 4px 14px 12px 46px; background: var(--cz-bluebg); display: flex; flex-wrap: wrap; gap: 8px; }
1262
+ /* the add field's chips (batch 25 item 5, canvas TaskAddChips): Stage (picked, blue), Type, Dates, Owner; a milestone's box is ◇ */
1263
+ .iv .iv-adc { gap: 5px; }
1264
+ .iv .iv-adc.on { color: var(--cz-blueink) !important; box-shadow: 0 0 0 1.5px var(--cz-blue); }
1265
+ .iv .iv-adc.on b { font-weight: 700; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
1266
+ .iv .iv-adc.ms { color: var(--cz-blueink) !important; }
1267
+ .iv .iv-adc .iv-av { width: 20px; height: 20px; font-size: 11px; }
1268
+ .iv-adms { width: 20px; height: 20px; flex: none; display: inline-flex; align-items: center; justify-content: center; color: var(--cz-blue); }
1269
+ .tkc.ivq .ive-m .nn { width: 22px; height: 20px; border-radius: 7px; background: var(--cz-well); font-size: 11px; font-weight: 800; color: var(--cz-ink3); display: inline-flex; align-items: center; justify-content: center; flex: none; }
1270
+ .tkc.ivq .ive-m .nn.ic { background: transparent; color: var(--cz-ink2); }
1271
+ .tkc.ivq .ive-m .o.hl .nn:not(.ic) { background: var(--cz-bluebg); color: var(--cz-blueink); }
1272
+ .tkc.ivq .ive-m .iv-av { width: 22px; height: 22px; border-radius: 50%; background: var(--cz-well); color: var(--cz-ink2); font-size: 11px; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; flex: none; }
1273
+ .tkc.ivq .ive-own { padding: 4px 6px 6px; }
1250
1274
  /* the drag (StA10Drag): the row lifts and follows the pointer; its slot stays as a dashed ghost; the target stage lights up; a blue line marks the drop */
1251
1275
  .iv-tr.iv-ghost { outline: 1.5px dashed color-mix(in srgb, var(--cz-ink3) 50%, var(--cz-card)); outline-offset: -2px; opacity: .55; }
1252
1276
  .iv-fly { position: fixed; z-index: 1001; left: var(--fly-x, 0); top: var(--fly-y, 0); width: var(--fly-w, 320px); pointer-events: none; background: var(--cz-card); border-radius: 12px; padding: 7px 12px !important;
@@ -1142,8 +1142,8 @@ function openTask(r, k) {
1142
1142
  async function addTask(r, title, section, dates, name) {
1143
1143
  const body = { ws: r.ws || M.board.ws, board: r.boardId || M.board.id };
1144
1144
  // dates: a gap's + (batch 11 item 13) fills the task's start and due with the gap's days ahead; section: the stage it goes under (the
1145
- // add route places it, batch 18 item 16)
1146
- const res = await pmPost('/api/boards/tasks/add', { ...body, initiative: r.id, title, ...(dates && dates.start ? { start: dates.start } : {}), ...(dates && dates.due ? { due: dates.due } : {}), ...(section ? { section } : {}) }).catch(() => null);
1145
+ // add route places it, batch 18 item 16); the add field's chips also send milestone and owner in it (batch 25 item 5)
1146
+ const res = await pmPost('/api/boards/tasks/add', { ...body, initiative: r.id, title, ...(dates && dates.start ? { start: dates.start } : {}), ...(dates && dates.due ? { due: dates.due } : {}), ...(dates && dates.milestone ? { milestone: true } : {}), ...(dates && dates.owner ? { owner: dates.owner } : {}), ...(section ? { section } : {}) }).catch(() => null);
1147
1147
  if (!res || res.ok === false) { say((res && res.error) || 'The task was not added.'); return false; }
1148
1148
  const uid = res.item && res.item.uid;
1149
1149
  // the + on a stage (batch 18 item 16, StA5Add): "Added to Build" with Undo
@@ -135,15 +135,16 @@ const TICKETS = [{ key: 'IDPF-3387', title: 'Refactor the AppAuthService', statu
135
135
  const b1 = row('task-b1'), b4 = row('task-b4'), d1 = row('task-d1'), b2 = row('task-b2');
136
136
  const tt = b1 && b1.querySelector('.iv-tt').getBoundingClientRect(), dc = b1 && b1.querySelector('.iv-dc').getBoundingClientRect();
137
137
  return {
138
- oneLine: !!(tt && dc && Math.abs((tt.top + tt.height / 2) - (dc.top + dc.height / 2)) < 6 && b1.getBoundingClientRect().height <= 52),
139
- phoneDue: b1 && b1.querySelector('.iv-dc').innerText.trim(), startHidden: !vis(b1 && b1.querySelector('.iv-dst')),
138
+ // batch 25 item 4 (TaskNamesWrap, agreed A): a narrow panel puts the days under the full name, start and due
139
+ under: !!(tt && dc && dc.top >= tt.bottom - 2 && Math.abs(dc.left - tt.left) < 8), wraps: !!(b1 && getComputedStyle(b1.querySelector('.iv-tt')).whiteSpace === 'normal'),
140
+ phoneDue: b1 && b1.querySelector('.iv-dc').innerText.trim(), startShown: vis(b1 && b1.querySelector('.iv-dst')),
140
141
  undated: b4 && !b4.querySelector('.iv-dc'), ms: d1 && d1.querySelector('.iv-dc').innerText.trim(), msTitleGlyph: !!(d1 && d1.querySelector('.iv-tn .iv-ms')), msIcon: !!(d1 && d1.querySelector('.iv-dc .iv-dms svg')),
141
142
  plus: [...document.querySelectorAll('#czViewer .iv-plus')].map((b) => b.getAttribute('aria-label')), headPlus: !!document.querySelector('#czViewer .iv-th [data-iv-add]'),
142
143
  jira: b2 && !!b2.querySelector('.iv-jq.ig-sq') && !b2.querySelector('[data-iv-check]'),
143
144
  grips: [...document.querySelectorAll('#czViewer .iv-grip')].filter(vis).length,
144
145
  };
145
146
  });
146
- ok('S1 a phone row is one line: the title, its due day at the right (the start hidden)', R1.oneLine && R1.phoneDue === 'Nov 6' && R1.startHidden, JSON.stringify(R1));
147
+ ok('S1 a phone row: the whole title (it wraps, never cut), its days under it, start and due (batch 25 item 4)', R1.under && R1.wraps && /Nov 6$/.test(R1.phoneDue || '') && R1.startShown, JSON.stringify(R1));
147
148
  ok('S1 a row with no dates shows none (zero is silence)', R1.undated, JSON.stringify(R1));
148
149
  ok('S1 a milestone row says "◇ Nov 14", its ◇ once (not after the title)', R1.ms === 'Nov 14' && R1.msIcon && !R1.msTitleGlyph, JSON.stringify(R1));
149
150
  ok('S1 each open stage has its own icon-only + ("New task in …"); the Tasks header has none', R1.plus.join('|') === 'New task in Build|New task in Deliver' && !R1.headPlus, JSON.stringify(R1));
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "feedback batch 25 item 1: a hosted product repository with no git author identity gets the PM's from the other hosted repositories (lib/git-identity.js).",
3
+ "modules": ["git-identity.test.js"],
4
+ "seconds": { "git-identity.test.js": 2 }
5
+ }