create-pmos 0.497.0 → 0.499.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 (40) hide show
  1. package/package.json +57 -26
  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 +56 -0
  10. package/workspace/guide/briefing-premium.md +4 -2
  11. package/workspace/guide/claude-proposes.md +1 -1
  12. package/workspace/guide/home.md +4 -4
  13. package/workspace/guide/launcher.md +7 -1
  14. package/workspace/lib/briefing-premium.js +38 -12
  15. package/workspace/lib/claude-readiness.js +29 -6
  16. package/workspace/lib/context-use.js +57 -15
  17. package/workspace/lib/engine/workflow-state.js +28 -0
  18. package/workspace/lib/git-identity-cli.js +67 -0
  19. package/workspace/lib/git-identity.js +93 -0
  20. package/workspace/lib/initiative-cli.js +16 -2
  21. package/workspace/lib/pmos-verbs.js +3 -0
  22. package/workspace/lib/routes-boards.js +8 -3
  23. package/workspace/lib/routes-onboard.js +1 -1
  24. package/workspace/lib/transcript-read.js +1 -1
  25. package/workspace/lib/vendor/scripts/workflow-state.js +28 -0
  26. package/workspace/package-lock.json +2 -2
  27. package/workspace/package.json +1 -1
  28. package/workspace/public/pm/bd-ini-panel.js +1 -1
  29. package/workspace/public/pm/cozy-onboard.js +15 -8
  30. package/workspace/public/pm/home-app.js +4 -2
  31. package/workspace/public/pm/home-chat.js +2 -2
  32. package/workspace/public/pm/home-ini.js +1 -1
  33. package/workspace/public/pm/home-need.js +1 -1
  34. package/workspace/public/pm/ini-edit.js +82 -19
  35. package/workspace/public/pm/ini-tasks.js +6 -1
  36. package/workspace/public/pm/ini-view-model.js +3 -1
  37. package/workspace/public/pm/ini-view.css +31 -7
  38. package/workspace/public/pm/premium-board.js +2 -2
  39. package/workspace/stage-card-tasks-test.js +4 -3
  40. package/workspace/test-lists/git-identity.json +5 -0
@@ -5,26 +5,55 @@ const path = require('path');
5
5
  /* context-use.js — how full a session's context window is, and whether Claude proposes to compact it (batch 13 item 8, canvas XctxA1Desk).
6
6
  *
7
7
  * of(seen, hints) → { used, window, pct, level, at } | null
8
- * seen = transcript-read's `context`: { used, peak, at } of the newest real (not subagent) model turn, or null
9
- * hints = model ids that can name the window: the model PMOS started the session with, the CLI's own setting
8
+ * seen = transcript-read's `context`: { used, peak, at, model } of the newest real (not subagent) model turn, or null
9
+ * hints = model ids that can name the window: the model PMOS started the session with, the CLI's own setting (cliHints, which
10
+ * also carries OFF_HINT when CLAUDE_CODE_DISABLE_1M_CONTEXT is set)
11
+ * native1M(model) → true for a model whose window is 1M with no [1m] suffix
10
12
  * advise(ctx, shown) → 'compact' | null
11
- * cliHints(root, home?) → the model the CLI itself is set to: `model` in ~/.claude and <root>/.claude settings, and ANTHROPIC_MODEL
13
+ * cliHints(root, home?) → the model the CLI itself is set to: `model` in ~/.claude and <root>/.claude settings, and ANTHROPIC_MODEL;
14
+ * OFF_HINT last when CLAUDE_CODE_DISABLE_1M_CONTEXT is set in the environment or in a settings file's `env`
12
15
  * The words under the wedge ("116k of 200k used · last turn 10:12") are drawn by pm/ctx-wedge.js, in the PM's own clock.
13
16
  *
14
17
  * THE NUMBER is what the next turn starts from: the newest reply's input + cache-read + cache-write tokens (what the model was sent) plus
15
- * its output. It is not a running total, so it drops after a compact. THE WINDOW is 1M when a hint names a [1m] model or a turn already
16
- * went past 200k, else 200k (the CLI's default). With no turn read, or an agent that is not Claude, there is nothing: no %, no advice.
18
+ * its output. It is not a running total, so it drops after a compact. THE WINDOW is 1M when a turn already went past 200k, when a hint
19
+ * names a [1m] model, or when the model is one the CLI runs with 1M by itself (batch 25 item 8: Claude Code's model docs list Fable,
20
+ * Opus 4.7 and later, Sonnet 5 and later and Haiku 5.5; the CLI's default model, Opus 5.5, is one of them). The model of the newest turn
21
+ * in the transcript decides; with none read, the first hint does. CLAUDE_CODE_DISABLE_1M_CONTEXT makes the CLI treat every model as 200k, so it does
22
+ * here too. An unknown model is 200k. With no turn read, or an agent that is not Claude, there is nothing: no %, no advice.
17
23
  * Advice only on the PM's turn (a session that works is never told to compact), and only from 70%. */
18
24
 
19
25
  const BASE = 200000;
20
26
  const LONG = 1000000;
21
27
  const LEVELS = [[90, 'crit'], [70, 'warn'], [50, 'quiet']];
22
28
 
29
+ const OFF_HINT = '[no-1m]'; // not a model: cliHints' word for CLAUDE_CODE_DISABLE_1M_CONTEXT, so a caller passes one list
23
30
  const isLong = (m) => /\[1m\]/i.test(String(m || ''));
24
31
 
32
+ /* The release a model id names: claude-opus-4-8 → [4, 8], claude-sonnet-5-20260101 → [5, 0], opus → null (an alias: the latest). */
33
+ function versionOf(model, family) {
34
+ const m = String(model).match(new RegExp(`${family}-(\\d+)(?:[-.](\\d{1,2}))?(?!\\d)`, 'i'));
35
+ return m ? [Number(m[1]), Number(m[2] || 0)] : null;
36
+ }
37
+ const atLeast = (v, major, minor) => v[0] > major || (v[0] === major && v[1] >= minor);
38
+ function native1M(model) {
39
+ const s = String(model || '').toLowerCase();
40
+ if (!s) return false;
41
+ if (isLong(s)) return true;
42
+ if (/claude-\d/.test(s)) return false; // the old naming (claude-3-5-sonnet): all 200k
43
+ if (/fable/.test(s)) return true;
44
+ for (const [family, major, minor] of [['opus', 4, 7], ['sonnet', 5, 0], ['haiku', 5, 5]]) {
45
+ if (!new RegExp(`(^|[^a-z])${family}([^a-z]|$)`).test(s)) continue;
46
+ const v = versionOf(s, family);
47
+ return v ? atLeast(v, major, minor) : true; // a bare alias names the latest release
48
+ }
49
+ return false;
50
+ }
51
+
25
52
  function of(seen, hints = []) {
26
53
  if (!seen || !(seen.used > 0)) return null;
27
- const window = hints.some(isLong) || (seen.peak || 0) > BASE ? LONG : BASE;
54
+ const models = hints.filter((h) => h && h !== OFF_HINT);
55
+ const long = !hints.includes(OFF_HINT) && (models.some(isLong) || native1M(seen.model || models[0]));
56
+ const window = long || (seen.peak || 0) > BASE ? LONG : BASE;
28
57
  const pct = Math.min(100, Math.floor((seen.used / window) * 100)); // floor: 69.6% is not yet 70%
29
58
  const lv = LEVELS.find(([n]) => pct >= n);
30
59
  return { used: seen.used, window, pct, level: lv ? lv[1] : null, at: seen.at || null };
@@ -37,18 +66,31 @@ function advise(ctx, shown) {
37
66
 
38
67
  /* The CLI's own model setting. A session PMOS started with no model choice runs the CLI's default, and that setting is what can make it a
39
68
  * [1m] model. Read again only when a settings file changes (sessionList runs on every broadcast). */
40
- const memo = new Map(); // file → { stamp, model }
41
- function modelIn(file) {
69
+ const memo = new Map(); // file → { stamp, model, off }
70
+ const OFF = 'CLAUDE_CODE_DISABLE_1M_CONTEXT';
71
+ const isOn = (v) => v != null && v !== '' && !/^(0|false|no)$/i.test(String(v));
72
+ function settingsIn(file) {
42
73
  let stamp = null; try { const st = fs.statSync(file); stamp = st.size + ':' + st.mtimeMs; } catch { return null; }
43
- const m = memo.get(file); if (m && m.stamp === stamp) return m.model;
44
- let model = null; try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); model = typeof j.model === 'string' ? j.model : null; } catch { model = null; }
45
- memo.set(file, { stamp, model });
46
- return model;
74
+ const m = memo.get(file); if (m && m.stamp === stamp) return m;
75
+ let model = null, off = false;
76
+ try {
77
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
78
+ model = typeof j.model === 'string' ? j.model : null;
79
+ off = !!(j.env && typeof j.env === 'object' && isOn(j.env[OFF]));
80
+ } catch { model = null; off = false; }
81
+ const out = { stamp, model, off };
82
+ memo.set(file, out);
83
+ return out;
47
84
  }
48
- function cliHints(root, home = os.homedir()) {
85
+ function settingsFiles(root, home) {
49
86
  const files = [path.join(home, '.claude', 'settings.json')];
50
87
  if (root) files.push(path.join(String(root), '.claude', 'settings.json'), path.join(String(root), '.claude', 'settings.local.json'));
51
- return files.map(modelIn).concat(process.env.ANTHROPIC_MODEL || null).filter(Boolean);
88
+ return files;
89
+ }
90
+ function cliHints(root, home = os.homedir()) {
91
+ const read = settingsFiles(root, home).map((f) => settingsIn(f) || {});
92
+ const off = isOn(process.env[OFF]) || read.some((x) => x.off);
93
+ return read.map((x) => x.model || null).concat(process.env.ANTHROPIC_MODEL || null, off ? OFF_HINT : null).filter(Boolean);
52
94
  }
53
95
 
54
- module.exports = { of, advise, cliHints, BASE, LONG };
96
+ module.exports = { of, advise, cliHints, native1M, OFF_HINT, BASE, LONG };
@@ -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 };
@@ -18,7 +18,9 @@
18
18
  * PM types the confirmation at a terminal, and an agent session passes with its own actor (D98); every other command
19
19
  * takes lib/actor.js fromCli: the PM at a terminal, the bound agent session from
20
20
  * PMOS_SESSION, `agent:unknown` otherwise. An agent's placement applies at once and its line names the agent (D98).
21
- * Work lanes only (D86 point 4): `list` and `export` leave document-lane initiatives out.
21
+ * Work lanes only (D86 point 4): `list` and `export` leave document-lane initiatives out. `list` names them in one closing
22
+ * line, because a record on a document lane is never on Home, the Board or the Roadmap: a feature record written by hand on
23
+ * the `prd` lane otherwise disappears with no word (batch 25 item 7). `new` refuses a document lane already (the writer).
22
24
  * D105 retired Now / Next / Later: `now`, `next` and `later` answer with what to use instead and change nothing.
23
25
  * Exit codes: 0 done, 1 refused, 2 usage.
24
26
  */
@@ -47,6 +49,16 @@ function startOnDefault(root, r, actor) {
47
49
  } catch { /* the initiative is made; it runs Product feature */ }
48
50
  }
49
51
 
52
+ /** The records on a document lane (D86 point 4): never on the Roadmap, Home or the Board. Stopped and accepted ones are left out. */
53
+ const documentsOf = (rows) => (rows || []).filter((r) => r && r.ok !== false && r.group && r.group !== 'work' && !r.accepted && !r.stopped)
54
+ .map((r) => ({ id: r.id, lane: r.lane || null }));
55
+ const WORK_LANES = 'new-feature, improvement, bug-fix, discovery or chore';
56
+ /** One line: what is not on the Roadmap, and what to do when it is work and not a document. */
57
+ function docNote(docs) {
58
+ const names = docs.map((d) => `${d.id} (${d.lane || 'document'})`).join(', ');
59
+ return `Not on the Roadmap · ${docs.length} on a document lane: ${names}. A document lane does not show on Home or the Board. If it is work, set lane: in its record to ${WORK_LANES}.`;
60
+ }
61
+
50
62
  const COMMANDS = { initiative: ['new', 'top', 'up', 'down', 'bottom', 'after', 'check-by', 'adopt', 'drop', 'list', 'export'] };
51
63
  /** The horizon verbs D105 retired: they answer with what to use instead. */
52
64
  const RETIRED = Object.freeze(['now', 'next', 'later']);
@@ -147,7 +159,7 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
147
159
  const stage = (id) => lifecycle.labelOf(byRow.get(id));
148
160
  if (json) {
149
161
  say(JSON.stringify({ ok: true, ranked: o.placed.ranked, byInit: o.placed.byInit, stages: Object.fromEntries(o.placed.ranked.map((id) => [id, stage(id)])),
150
- proposals: o.placed.proposals.map((p) => ({ ...p, by: actorLib.pageName(p.by) })) }));
162
+ proposals: o.placed.proposals.map((p) => ({ ...p, by: actorLib.pageName(p.by) })), documents: documentsOf(o.rows) }));
151
163
  return 0;
152
164
  }
153
165
  const ids = o.placed.ranked;
@@ -163,6 +175,8 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
163
175
  say(` ${p.rank + 1}. ${id} — ${(byRow.get(id) || {}).title || id}${bits.length ? ' · ' + bits.join(' · ') : ''}`);
164
176
  }
165
177
  for (const p of o.placed.proposals) say(`proposal ${p.eid}: ${p.init} ${p.after ? `after ${p.after}` : 'to the top'} · ${actorLib.pageName(p.by)}`);
178
+ const docs = documentsOf(o.rows);
179
+ if (docs.length) say(docNote(docs));
166
180
  return 0;
167
181
  }
168
182
 
@@ -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
  },
@@ -100,7 +100,7 @@ async function handle(req, res, url, ctx) {
100
100
  const bound = r.ok && ctx.claudeSetup.bound(r.profile);
101
101
  // A new check of another login, or a failed check, drops everything bound to the old one.
102
102
  const keep = bound && st.profile === r.profile && st.fingerprint === bound.fingerprint;
103
- write(ctx, { ...(keep ? st : { project: st.project, folder: st.folder }), profile: r.profile || b.profile, fingerprint: bound ? bound.fingerprint : null, checked: { state: r.state, at: r.checked_at || null } });
103
+ write(ctx, { ...(keep ? st : { project: st.project, folder: st.folder }), profile: r.profile || b.profile, fingerprint: bound ? bound.fingerprint : null, checked: { state: r.state, at: r.checked_at || null, message: r.message || null } });
104
104
  return send({ ...view(ctx), ...r });
105
105
  }
106
106
  const bound = verified(ctx, st);
@@ -290,7 +290,7 @@ function feedHarness(acc, raw, clean) {
290
290
  // context use (batch 13 item 8): the NEWEST real turn's size, not the sum. A subagent's turn has its own context and is left out.
291
291
  if (m.model && m.model !== SYNTHETIC && !r.isSidechain) {
292
292
  const used = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0);
293
- if (used > 0) { acc.ctx = { used, at: r.timestamp || null }; if (used > acc.peak) acc.peak = used; }
293
+ if (used > 0) { acc.ctx = { used, at: r.timestamp || null, model: clean(m.model) }; if (used > acc.peak) acc.peak = used; } // model: lib/context-use.js names the window from it
294
294
  }
295
295
  }
296
296
  for (const c of (m.content || [])) {
@@ -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.499.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.499.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.499.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);
@@ -43,21 +43,26 @@ export function mountOnboard(host, { ws = '', done = false } = {}) {
43
43
  function turns() {
44
44
  const p = P(), c = (S.data && S.data.claude) || { profiles: [] }, out = [];
45
45
  // 1. connect and test
46
- const profiles = c.profiles || [];
47
- const pick = S.profile || p.profile || c.selected || (profiles[0] && profiles[0].id);
46
+ // One row per login that really differs: a pinned copy of the login on this computer shows as that login, and PMOS's own
47
+ // profile is the "separate login" row (bug 2026-11-03: "My usual Claude login" and "PMOS" repeated the other two rows).
48
+ const profiles = c.profiles || [], ownId = c.own || '__own__';
49
+ const row = (id) => (id === '__own__' || (id && id === c.own) ? ownId : (c.alias && c.alias[id]) || id);
50
+ const pick = row(S.profile || p.profile || c.selected || (profiles[0] && profiles[0].id));
48
51
  if (!p.verified) {
49
- const failed = p.checked && p.checked.state && p.checked.state !== 'ready' && p.profile === pick;
50
- const opts = profiles.map((x) => `<label class="ob-opt${x.id === pick ? ' on' : ''}"><input type="radio" name="ob-login" value="${esc(x.id)}"${x.id === pick ? ' checked' : ''}><span><b>${esc(x.id === '__default__' ? 'The Claude login on this computer' : x.label)}</b>${x.id === '__default__' ? '<em>Whatever account Claude Code uses in your usual terminal</em>' : ''}</span></label>`).join('');
52
+ const failed = p.checked && p.checked.state && p.checked.state !== 'ready' && row(p.profile) === pick;
53
+ const opt = (id, label, note) => `<label class="ob-opt${id === pick ? ' on' : ''}"><input type="radio" name="ob-login" value="${esc(id)}"${id === pick ? ' checked' : ''}><span><b>${esc(label)}</b>${note ? `<em>${esc(note)}</em>` : ''}</span></label>`;
54
+ const opts = profiles.map((x) => opt(x.id, x.id === '__default__' ? 'The Claude login on this computer' : x.label, x.id === '__default__' ? 'Whatever account Claude Code uses in your usual terminal' : '')).join('')
55
+ + opt(ownId, 'A separate login just for PMOS', 'Keeps PMOS on its own account. You sign in once.');
51
56
  out.push({ mood: failed ? 'honest' : 'needs-you', body: `<p>${failed ? '' : 'Let’s set up a product or project. First, which Claude login should PMOS use? I’ll run one short check on exactly that login.'}</p>`
52
- + (failed ? `<div class="ob-bad" role="alert"><b>${esc(S.data.claude.status && S.data.claude.status.profile === p.profile ? S.data.claude.status.message : (p.checked.state === 'signed_out' ? 'This login needs signing in again.' : 'This login did not pass the check.'))}</b><span>I won’t switch to a different account on my own. Sign this one in, or pick another login.</span></div>` : '')
53
- + `<div class="ut-card ob-card" role="radiogroup" aria-label="Claude login">${opts}<label class="ob-opt${pick === '__own__' ? ' on' : ''}"><input type="radio" name="ob-login" value="__own__"${pick === '__own__' ? ' checked' : ''}><span><b>A separate login just for PMOS</b><em>Keeps PMOS on its own account. You sign in once.</em></span></label>`
57
+ + (failed ? `<div class="ob-bad" role="alert"><b>${esc(p.checked.message || (p.checked.state === 'signed_out' ? 'This login needs signing in again.' : 'This login did not pass the check.'))}</b><span>I won’t switch to a different account on my own. Sign this one in, or pick another login.</span></div>` : '')
58
+ + `<div class="ut-card ob-card" role="radiogroup" aria-label="Claude login">${opts}`
54
59
  + `<div class="ob-row">${btn('check', 'Test this login', 'pri')}${btn('login', 'Sign in to it', '')}</div>`
55
60
  + `<p class="ob-note">The test sends one short message to Claude with no tools. It uses a little of this account’s usage.</p>`
56
61
  + (S.session ? '<p class="ob-note">Finish signing in in the browser window Claude opened. If it asks for a code, paste it below. I test the login as soon as you are done.</p><div class="cs-term ob-term" aria-label="Claude sign-in terminal"></div>' : '')
57
62
  + `</div>` });
58
63
  return out;
59
64
  }
60
- const label = (profiles.find((x) => x.id === p.profile) || {}).label || p.profile;
65
+ const label = row(p.profile) === ownId ? 'A separate login just for PMOS' : (profiles.find((x) => x.id === row(p.profile)) || {}).label || p.profile;
61
66
  out.push({ mood: 'handled', body: `<div class="ob-good">${CHECK}<b>Login works · ${esc(label)}</b><span>Tested ${p.checked && p.checked.at ? new Date(p.checked.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'just now'} · one test message, no tools</span></div>`
62
67
  + (p.project ? '' : `<p>What product or project is this for? Its name is enough to start.</p><p class="ob-sub">If you have them, add the team, a Jira key, a link or the folder where it lives. They help me find the right sources, not a same-name one.</p><div class="ob-row"><button type="button" class="hm-chip loose" data-ob="relogin">Use another login</button></div>`) });
63
68
  if (!p.project) return out;
@@ -151,7 +156,9 @@ export function mountOnboard(host, { ws = '', done = false } = {}) {
151
156
  S.busy = a; S.note = ''; draw();
152
157
  try {
153
158
  if (a === 'check' || a === 'login') {
154
- let profile = S.profile || p.profile || (S.data.claude && S.data.claude.selected);
159
+ const cl = S.data.claude || {};
160
+ let profile = S.profile || p.profile || cl.selected;
161
+ if (cl.alias && cl.alias[profile]) profile = cl.alias[profile]; // a pinned copy of the usual login is tested as that login
155
162
  if (profile === '__own__') { const own = await post('own'); if (!own.ok) throw new Error(own.error || 'The PMOS login could not be made.'); profile = own.id; S.profile = profile; await load(); }
156
163
  const r = await post(a, { profile });
157
164
  if (a === 'login') {
@@ -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. */