create-pmos 0.496.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 (36) hide show
  1. package/package.json +51 -33
  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 +35 -19
  7. package/payload/state/discoveries.md +1 -1
  8. package/workspace/briefing-premium-test.js +2 -2
  9. package/workspace/changelog.json +50 -0
  10. package/workspace/guide/briefing-premium.md +4 -2
  11. package/workspace/guide/data-freshness.md +5 -1
  12. package/workspace/guide/home.md +3 -3
  13. package/workspace/guide/launcher.md +7 -1
  14. package/workspace/lib/briefing-premium.js +38 -12
  15. package/workspace/lib/engine/workflow-state.js +35 -19
  16. package/workspace/lib/git-identity-cli.js +67 -0
  17. package/workspace/lib/git-identity.js +93 -0
  18. package/workspace/lib/pmos-verbs.js +3 -0
  19. package/workspace/lib/routes-boards.js +8 -3
  20. package/workspace/lib/vendor/scripts/workflow-state.js +35 -19
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/bd-ini-panel.js +1 -1
  24. package/workspace/public/pm/chat-turn-end.js +19 -0
  25. package/workspace/public/pm/home-app.js +13 -3
  26. package/workspace/public/pm/home-chat.js +7 -2
  27. package/workspace/public/pm/home-ini.js +8 -1
  28. package/workspace/public/pm/home-need.js +1 -1
  29. package/workspace/public/pm/ini-edit.js +82 -19
  30. package/workspace/public/pm/ini-tasks.js +6 -1
  31. package/workspace/public/pm/ini-view-model.js +3 -1
  32. package/workspace/public/pm/ini-view.css +31 -7
  33. package/workspace/public/pm/premium-board.js +2 -2
  34. package/workspace/stage-card-tasks-test.js +4 -3
  35. package/workspace/test-lists/chat-turn-end.json +9 -0
  36. package/workspace/test-lists/git-identity.json +5 -0
@@ -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})`));
@@ -1438,11 +1466,11 @@ function documentDuplicates(root, opts) {
1438
1466
  return { ok: true, profile, archive, groups };
1439
1467
  }
1440
1468
  /**
1441
- * Jira keys the repository holds for an initiative (S4): the Jira tab's draft; the record's `jira:` keys; in the
1442
- * record's text, a Jira link (`…/browse/KEY`) or a key on a line that says Jira; and a Jira link in its found
1443
- * documents. A bare key elsewhere is not read: "UTF-8" and "ISO-8601" have the same shape.
1469
+ * Jira keys a handoff record holds for an initiative (S4): the Jira tab's draft (`- jira: KEY` lines) and the
1470
+ * record's `jira:` / `jira_key:` / `jira_keys:` / `jira_epic:` / `jira_issue_key:` frontmatter. Body text and found documents are not
1471
+ * read: a key a PM mentions in prose ("see Jira LOY-41", an epic link in a spec) is a reference, not a handoff.
1444
1472
  */
1445
- function jiraKeysOf(root, L, id, fm, found) {
1473
+ function jiraKeysOf(L, id, fm) {
1446
1474
  const keys = [];
1447
1475
  const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
1448
1476
  try {
@@ -1451,21 +1479,9 @@ function jiraKeysOf(root, L, id, fm, found) {
1451
1479
  if (m && JIRA_KEY.test(m[1].toUpperCase())) keys.push(m[1].toUpperCase());
1452
1480
  }
1453
1481
  } catch { /* no draft */ }
1454
- for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
1482
+ for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic', 'jira_issue_key']) {
1455
1483
  for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1456
1484
  }
1457
- const fromText = (text, lines) => {
1458
- for (const m of String(text).matchAll(/\/browse\/([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1459
- if (lines) for (const line of String(text).split('\n')) {
1460
- if (!/jira/i.test(line)) continue;
1461
- for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1462
- }
1463
- };
1464
- try { fromText(fs.readFileSync(path.join(L.initiatives, `${id}.md`), 'utf8'), true); } catch { /* no record text */ }
1465
- for (const rel of found ? [].concat(found.brief, found.draft, found.review, found.handoff) : []) {
1466
- if (!SCAN_TEXT.test(rel)) continue;
1467
- try { fromText(fs.readFileSync(path.join(root, rel), 'utf8').slice(0, 200000), false); } catch { /* unreadable */ }
1468
- }
1469
1485
  return [...new Set(keys)];
1470
1486
  }
1471
1487
 
@@ -1483,7 +1499,7 @@ function jiraKeysOf(root, L, id, fm, found) {
1483
1499
  * handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
1484
1500
  * When the record names no file for brief, draft or review, or names one that does not exist, a
1485
1501
  * document the repository scan found for the id completes the step (scan-documents S3); Jira keys
1486
- * in the repository complete the handoff (S4). Found evidence never blocks and never fails CI.
1502
+ * in the Jira tab's draft or the record's `jira:` frontmatter complete the handoff (S4), never a key in body text. Found evidence never blocks and never fails CI.
1487
1503
  * Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
1488
1504
  * existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
1489
1505
  * CI-blocking in either track: none can be true before the PR exists.
@@ -1564,7 +1580,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1564
1580
  if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
1565
1581
  else {
1566
1582
  const to = String(fm.handed_off || '').trim();
1567
- const keys = to ? [] : jiraKeysOf(root, L, id, fm, found);
1583
+ const keys = to ? [] : jiraKeysOf(L, id, fm);
1568
1584
  steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
1569
1585
  : keys.length ? step('handoff', 'handed off', 'done', `handed off to Jira — ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ` and ${keys.length - 5} more` : ''}`)
1570
1586
  : foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.496.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.496.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.496.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);
@@ -0,0 +1,19 @@
1
+ /* pm/chat-turn-end.js — tells when a chat turn ended, so the page beside the chat reads its records again (batch 25 item 3: "When chat
2
+ * said it has made changes to the doc or content shown on the viewer, the content on the viewer should automatically update without
3
+ * the user needing to refresh"). home-chat.js takes a snapshot on each read of the conversation; the host re-reads what the Viewer shows
4
+ * when a turn ends.
5
+ *
6
+ * snapOf(key, busy, messages) { key, busy, agents }: the conversation's place, whether Claude is still at work, and how many of its
7
+ * messages are not the PM's.
8
+ * turnEnded(prev, cur) true once per finished turn: the same conversation, not at work now, and either at work on the last read
9
+ * or with more of Claude's messages than then (a turn that started and finished between two reads).
10
+ * The first read of a conversation is never a finished turn: opening a chat does not re-read the page. */
11
+ export function snapOf(key, busy, messages) {
12
+ const agents = (Array.isArray(messages) ? messages : []).filter((m) => m && m.role !== 'user').length;
13
+ return { key: String(key || ''), busy: !!busy, agents };
14
+ }
15
+
16
+ export function turnEnded(prev, cur) {
17
+ if (!prev || !cur || !cur.key || prev.key !== cur.key || cur.busy) return false;
18
+ return prev.busy || cur.agents > prev.agents;
19
+ }
@@ -47,7 +47,7 @@ import * as YD from './home-day.js';
47
47
  import * as AD from './cozy-adler.js'; // Adler, Claude's face (batch 16 item 2)
48
48
  import * as OKL from './okr-list.js'; // the product page's Goals line (batch 16 item 6, GsProduct)
49
49
  import { canDictate, dictate } from './home-dictate.js';
50
- import { mountIni, iniList, iniOverview, iniPage, iniAddTask } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
50
+ import { mountIni, iniList, iniOverview, iniPage, iniAddTask, iniFresh } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
51
51
  import * as MEN from './home-mentions.js';
52
52
  import * as LS from './load-shapes.js'; // the last view, the grey shapes and the held Viewer column while Home reads (page-open-speed) // @Mentions, the permanent row at the top of Home and its Viewer (Viewer kit, XvANew)
53
53
  import { mountRecRow, clockOf as recClock } from './home-rec-row.js';
@@ -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] || []) });
@@ -756,7 +756,15 @@ const chat = mountChat($('#hmChat'), {
756
756
  desk: () => !isPhone(),
757
757
  reopenViewer: () => reopenViewer(),
758
758
  head: (t) => (isPhone() ? '' : TD.chatHeadHtml(chatHead(t))), // the phone names it in its top bar (#hmTop)
759
+ // a chat turn finished: read the records again, so the list and the Viewer show what Claude changed with no refresh (batch 25 item 3).
760
+ // load() keeps the open folds (ini-view.js FOLD) and the Viewer's scroll (home-ini.js redrawView); the initiative's layout is read again too
761
+ turnEnded: () => turnEnded(),
759
762
  });
763
+ let turnT = null;
764
+ function turnEnded() {
765
+ clearTimeout(turnT);
766
+ turnT = setTimeout(async () => { await load(); iniFresh(); }, 400); // two chats that finish together read once
767
+ }
760
768
  /* claude-onboarding-build: set up a project as a conversation in this chat's place (cozy-onboard.js). It opens on ?onboard=1, shows the
761
769
  * finished project's next steps on ?onboarded=1, and opens by itself on a first run: setup not finished and no registered folder holds an
762
770
  * initiative. A named workspace does not stop it: the installer opens its own empty ~/PMOS folder by name (bug 2026-11-02, Windows). */
@@ -859,7 +867,9 @@ function alsoOf(ini) {
859
867
  // in an initiative's chat only that initiative's own next step shows; another initiative's is not "also" this conversation's
860
868
  const other = H.needsOf(S.D).filter((r) => !(S.sel && S.sel === r.id));
861
869
  const n = ini ? other.find((r) => r.ini === ini) : other[0];
862
- 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;
863
873
  }
864
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
865
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';
@@ -33,6 +33,7 @@ import { splitFiles, filesHtml, sizeText, MAX_BYTES, MAX_FILES, setAttachSink }
33
33
  import * as VWC from './cozy-viewer.js';
34
34
  import { canDictate, dictate } from './home-dictate.js';
35
35
  import { adler } from './cozy-adler.js';
36
+ import { snapOf, turnEnded } from './chat-turn-end.js'; // a finished turn re-reads what the Viewer shows (batch 25 item 3)
36
37
  /* The views a chat opens go in the Viewer: on a page with no dialog the old viewer's views draw in the cozy card (premium-viewer.js). */
37
38
  const views = {
38
39
  ctx: () => import('./premium-vw-ctx.js'), file: () => import('./premium-vw-file.js'), attach: () => import('./premium-attach.js'),
@@ -92,7 +93,11 @@ export function mountChat(el, host) {
92
93
  C.th = th && th.ok ? th : null; C.conv = conv && conv.ok ? conv : null;
93
94
  if (host.touch && sid() && C.touched !== sid()) { C.touched = sid(); host.touch(C.touched); } // the Live pill: the PM opened it now
94
95
  C.sess = host.session ? host.session(t.sid || (C.th && C.th.session)) : null;
96
+ // a turn that finished: Claude may have changed what the Viewer shows (tasks, documents), so the host reads it again (batch 25 item 3)
97
+ const snap = snapOf(t.key || t.sid, running() || !!(C.th && C.th.working), C.conv && C.conv.messages);
98
+ const ended = turnEnded(C.turn, snap); C.turn = snap;
95
99
  draw();
100
+ if (ended && host.turnEnded) host.turnEnded(t);
96
101
  clearTimeout(C.timer);
97
102
  const live = (C.th && (C.th.session || C.th.working)) || (t.sid && C.conv && C.conv.state !== 'ended');
98
103
  C.timer = setTimeout(() => { if (!document.hidden) read(); else C.timer = setTimeout(read, IDLE_POLL); }, live ? POLL : IDLE_POLL);
@@ -395,7 +400,7 @@ export function mountChat(el, host) {
395
400
  const chipsOf = (items) => (chipsHtmlFn ? chipsHtmlFn(items) : '');
396
401
  function drawFoot(R) {
397
402
  const t = C.t, goal = C.th && C.th.goal;
398
- 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>` : '';
399
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)
400
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';
401
406
  const sc = t.scope || { text: 'All work', icon: 'M5 21V4M5 4h12l-2 4 2 4H5' };
@@ -201,6 +201,13 @@ function ask(anchor, label, value, go, o = {}) { // one line of words, inline
201
201
  i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const v = i.value.trim(); if (v) { closePop(); go(v); } } });
202
202
  }
203
203
 
204
+ /** The open initiative's records changed elsewhere (a chat turn finished, batch 25 item 3): read its layout again, as a press does.
205
+ * The page redraws in place; the folds and the scroll stay. */
206
+ export function iniFresh() {
207
+ if (!X.ctx) return;
208
+ const id = X.ctx.S.ini, M = curM();
209
+ if (M) readL(id, M, true);
210
+ }
204
211
  export function iniList(sc) {
205
212
  if (!X.ctx || !sc) return;
206
213
  sc.querySelectorAll('[data-hi-slot]').forEach((n) => n.remove());
@@ -287,7 +294,7 @@ function wirePage(b, id) {
287
294
  const M = curM(); if (!M) return;
288
295
  inp.disabled = true;
289
296
  const D = opt ? opt : ADD_DATES || {};
290
- 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 } : {}) });
291
298
  inp.disabled = false;
292
299
  if (!r || r.ok === false) { say((r && r.error) || 'The task was not added.'); return; }
293
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'