create-pmos 0.37.0 → 0.38.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 (71) hide show
  1. package/bin/create-pmos.js +22 -3
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +4 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/frontmatter-format.md +27 -61
  6. package/payload/okf/core/templates/decision-record-template.md +34 -0
  7. package/payload/okf/core/templates/index.md +7 -2
  8. package/payload/okf/core/templates/pm-brief-template.md +34 -0
  9. package/payload/okf/core/templates/project-plan-template.md +43 -0
  10. package/payload/okf/core/templates/report-weekly-template.md +35 -0
  11. package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
  12. package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
  13. package/payload/planning/evals/eval-calibration.md +8 -0
  14. package/payload/planning/okrs/health-budgets.md +8 -0
  15. package/payload/scripts/anchor-resolve.js +56 -6
  16. package/payload/scripts/gate-initiative.sh +32 -0
  17. package/payload/scripts/okf-upgrade.js +232 -0
  18. package/payload/scripts/pmos-memory.js +375 -0
  19. package/payload/scripts/pmos-model.js +239 -0
  20. package/payload/scripts/pmos-skills.js +274 -0
  21. package/payload/scripts/workflow-state.js +178 -10
  22. package/payload/scripts/workflow-sweep.js +2 -1
  23. package/payload/state/discoveries.md +8 -0
  24. package/payload/state/index.md +7 -0
  25. package/payload/state/log.md +7 -0
  26. package/workspace/README.md +61 -204
  27. package/workspace/lib/board-cache.js +18 -8
  28. package/workspace/lib/console-state.js +2 -0
  29. package/workspace/lib/engine/anchor-resolve.js +56 -6
  30. package/workspace/lib/engine/pmos-memory.js +375 -0
  31. package/workspace/lib/engine/pmos-model.js +239 -0
  32. package/workspace/lib/engine/pmos-skills.js +274 -0
  33. package/workspace/lib/engine/workflow-state.js +178 -10
  34. package/workspace/lib/focus.js +5 -6
  35. package/workspace/lib/memory.js +54 -0
  36. package/workspace/lib/models.js +50 -0
  37. package/workspace/lib/preview-kind.js +12 -1
  38. package/workspace/lib/schedule-logic.js +31 -2
  39. package/workspace/lib/skill-store.js +5 -1
  40. package/workspace/lib/skills-registry.js +74 -0
  41. package/workspace/lib/sync.js +132 -0
  42. package/workspace/lib/tree-share.js +2 -0
  43. package/workspace/package-lock.json +1 -1
  44. package/workspace/package.json +3 -3
  45. package/workspace/public/pm/app.js +32 -2
  46. package/workspace/public/pm/data.js +160 -10
  47. package/workspace/public/pm/lifecycle.js +105 -0
  48. package/workspace/public/pm/markdown.js +10 -1
  49. package/workspace/public/pm/modes-live.js +371 -0
  50. package/workspace/public/pm/modes.js +7 -4
  51. package/workspace/public/pm/panel-detail.js +22 -2
  52. package/workspace/public/pm/panel-initiatives.js +57 -12
  53. package/workspace/public/pm/panel-newsess.js +25 -0
  54. package/workspace/public/pm/panel-repos.js +130 -4
  55. package/workspace/public/pm/panel-tabs.js +49 -2
  56. package/workspace/public/pm/state.js +11 -1
  57. package/workspace/public/pm/tab-doc.js +160 -26
  58. package/workspace/public/pm/tab-term.js +2 -0
  59. package/workspace/public/pm/tab-web.js +89 -0
  60. package/workspace/public/pm/tokens.css +4 -0
  61. package/workspace/public/pm/xlsx-read.js +117 -0
  62. package/workspace/server.js +690 -40
  63. package/workspace/skills/gather-requirements.skill +53 -0
  64. package/workspace/skills/memory-groom.skill +46 -0
  65. package/workspace/skills/report-biweekly.skill +53 -0
  66. package/workspace/skills/report-daily.skill +53 -0
  67. package/workspace/skills/report-monthly.skill +53 -0
  68. package/workspace/skills/report-special.skill +49 -0
  69. package/workspace/skills/report-weekly.skill +53 -0
  70. package/workspace/skills/skill-discover.skill +49 -0
  71. package/workspace/skills/skill-generate.skill +33 -0
@@ -134,7 +134,20 @@ const RUNGS = {
134
134
  },
135
135
  };
136
136
 
137
- const laneOf = (lane) => (PHASES[lane] ? lane : 'feature');
137
+ /* D68 — the pm-artifacts profile's ladder: every PM lane walks brief → draft → review → approved
138
+ * (→ handed off). Keyed `pm:<lane>` because `discovery` and `chore` exist in BOTH profiles with
139
+ * different ladders; the profile comes from the engine's own ladder row, never guessed. */
140
+ const PM_LANES_CLIENT = ['strategy', 'prd', 'project-plan', 'discovery', 'decision-record', 'report', 'chore'];
141
+ const PM_NO_HANDOFF_CLIENT = ['decision-record', 'report', 'chore'];
142
+ for (const l of PM_LANES_CLIENT) {
143
+ const withHandoff = !PM_NO_HANDOFF_CLIENT.includes(l);
144
+ PHASES['pm:' + l] = ['intake', 'brief', 'draft', 'review', 'approved'].concat(withHandoff ? ['handed off'] : []);
145
+ RUNGS['pm:' + l] = Object.assign({ intake: ['route', 'anchor'], brief: ['brief'], draft: ['draft'], review: ['review'], approved: ['approved'] },
146
+ withHandoff ? { 'handed off': ['handoff'] } : {});
147
+ }
148
+ const laneOf = (lane, profile) => (profile === 'pm-artifacts'
149
+ ? 'pm:' + (PM_LANES_CLIENT.includes(lane) ? lane : 'prd')
150
+ : (PHASES[lane] ? lane : 'feature'));
138
151
 
139
152
  /**
140
153
  * The status a phase inherits from the rungs it folds.
@@ -214,8 +227,8 @@ function pathIn(text) {
214
227
  }
215
228
 
216
229
  /** The `steps[]` panel 3 reads: one entry per lane phase, carrying the folded rungs' evidence. */
217
- function phaseSteps(lane, ladderSteps) {
218
- const L = laneOf(lane);
230
+ function phaseSteps(lane, ladderSteps, profile) {
231
+ const L = laneOf(lane, profile);
219
232
  const by = {};
220
233
  for (const s of ladderSteps || []) by[s.key] = s;
221
234
  return PHASES[L].map((phase) => {
@@ -233,6 +246,28 @@ function phaseSteps(lane, ladderSteps) {
233
246
 
234
247
  /* ── record derivation ──────────────────────────────────────────────────────────────────────── */
235
248
 
249
+ /**
250
+ * overhaul-r2 B3/B4 — where the initiative belongs: Domain › Objective › KR › Project, from the
251
+ * resolver's SEPARATE fields (never parsed out of `reason`). A missing level is stated, not blank:
252
+ * a domain anchor has no OKR and says so; a health anchor is upkeep. `line` is the KR's own
253
+ * baseline → target text when the OKR document writes one — the measure the KR moves.
254
+ */
255
+ function crumbFor(anchor, fm) {
256
+ if (!anchor) return null;
257
+ const parts = [];
258
+ const dom = (fm && fm.domain) || (anchor.kind === 'domain' ? 'domain:' + anchor.domain : null);
259
+ if (dom) parts.push({ k: 'domain', v: String(dom).replace(/^domain:/, '') });
260
+ if (anchor.kind === 'domain') parts.push({ k: 'objective', v: '(no OKR)', gap: true });
261
+ else if (anchor.kind === 'health') parts.push({ k: 'budget', v: 'health:' + anchor.budget + ' — upkeep, not a bet' });
262
+ else if (anchor.kind === 'okr') {
263
+ parts.push({ k: 'objective', v: anchor.objective_name || anchor.objective || '(unresolved)', gap: !anchor.objective_name });
264
+ if (anchor.kr) parts.push({ k: 'KR', v: anchor.kr_name || anchor.kr, gap: !anchor.kr_name });
265
+ else parts.push({ k: 'KR', v: '(objective-level — no KR written)', gap: true });
266
+ } else parts.push({ k: 'anchor', v: '(none — rejected under D59)', gap: true });
267
+ if (fm && fm.project) parts.push({ k: 'project', v: String(fm.project) });
268
+ return { parts, goal: anchor.kind === 'domain' ? anchor.domain_goal || null : null, line: anchor.kr_line || null };
269
+ }
270
+
236
271
  /** The `pills` row: lane, then the resolved anchor or an honest rejection, then a declared flow. */
237
272
  function pillsFor({ lane, anchor, flow }) {
238
273
  const out = [];
@@ -353,7 +388,7 @@ function recordFrom(payload) {
353
388
  const row = (payload.ladder && payload.ladder.ok && payload.ladder.row) || null;
354
389
  const scope = (payload.ladder && payload.ladder.scope) || null;
355
390
  const lane = fm.lane || (row && row.lane) || null;
356
- const steps = row ? phaseSteps(lane, row.steps) : [];
391
+ const steps = row ? phaseSteps(lane, row.steps, row.profile) : [];
357
392
  const ctx = { record: payload.record, prd: fm.prd_path || null, rubric: fm.rubric_path || null };
358
393
  // Authored tasks win over derivation, per phase (PRD, "Tasks"). Their absence is not a defect.
359
394
  const authored = payload.tasks || {};
@@ -361,7 +396,7 @@ function recordFrom(payload) {
361
396
  const byKey = {};
362
397
  for (const s of (row && row.steps) || []) byKey[s.key] = s;
363
398
  for (const st of steps) {
364
- const rungs = (RUNGS[laneOf(lane)][st.k] || []).map((k) => byKey[k]).filter(Boolean);
399
+ const rungs = (RUNGS[laneOf(lane, row && row.profile)][st.k] || []).map((k) => byKey[k]).filter(Boolean);
365
400
  tasks[st.k] = Array.isArray(authored[st.k]) && authored[st.k].length
366
401
  ? authored[st.k]
367
402
  : derivedTasks(st.k, rungs, ctx);
@@ -371,6 +406,7 @@ function recordFrom(payload) {
371
406
  stage: row ? row.stage : null,
372
407
  flow: fm.flow || null,
373
408
  pills: pillsFor({ lane, anchor: payload.anchor, flow: fm.flow || null }),
409
+ crumb: crumbFor(payload.anchor, fm),
374
410
  // What the ladder was DERIVED ON, which is exactly what the footer sentence claims.
375
411
  branch: scope ? scope.branch : null,
376
412
  answer: answerFor(row),
@@ -457,6 +493,13 @@ function groupsFromSpine(spine) {
457
493
  gct: String(b.initiatives.length), hasKr: false, slugs: b.initiatives.map((i) => i.id),
458
494
  });
459
495
  }
496
+ // D67: a domain group — work that serves the domain's standing goal with no OKR, said plainly
497
+ for (const d of spine.domains || []) {
498
+ groups.push({
499
+ gid: d.id, idc: C.fg, gname: 'domain — no OKR' + (d.goal ? ' · ' + d.goal : ''),
500
+ gct: String(d.initiatives.length), hasKr: false, slugs: d.initiatives.map((i) => i.id),
501
+ });
502
+ }
460
503
  // D59: rejected work is FILED, never hidden. It is the last group and it says what it is.
461
504
  groups.push({
462
505
  gid: 'unanchored', dyn: true, idc: C.wrn,
@@ -526,7 +569,7 @@ function foldWorkspaces(rows) {
526
569
  const key = w.repo_key || w.root;
527
570
  if (!byRepo.has(key)) {
528
571
  byRepo.set(key, {
529
- name: w.repo_name || w.name, ws: w.id, root: w.root, trees: [],
572
+ name: w.label || w.repo_name || w.name, ws: w.id, root: w.root, trees: [],
530
573
  // The server derived this from the tree's `origin` remote (git-info.originUrl). It is read
531
574
  // here and never re-derived: a second opinion about what a repository IS would be a second
532
575
  // thing to keep true, and this one is the one that did the read.
@@ -539,6 +582,8 @@ function foldWorkspaces(rows) {
539
582
  // remote, since a linked worktree of the same repo answers with the same origin anyway.
540
583
  if (w.root === key) {
541
584
  p.ws = w.id;
585
+ // the PM's own name for the repository (overhaul-r1 R3.1) lives on the main tree's row
586
+ if (w.label) p.name = w.label;
542
587
  p.kind = w.kind === 'github' ? 'github' : 'local';
543
588
  p.remote = w.remote || null;
544
589
  }
@@ -585,6 +630,7 @@ async function loadBoard(prod) {
585
630
  for (const r of o.direct || []) note(r, { canonical: o.key });
586
631
  }
587
632
  for (const b of spine.budgets || []) for (const r of b.initiatives) note(r, { canonical: b.id });
633
+ for (const d of spine.domains || []) for (const r of d.initiatives) note(r, { canonical: d.id });
588
634
  for (const r of spine.unanchored || []) note(r, null);
589
635
 
590
636
  // The library can see initiatives the SPINE has no record for: artifacts that group into a unit
@@ -830,7 +876,7 @@ let sessionDigest = null;
830
876
  function sessionProjection(rows) {
831
877
  return (rows || []).map((s) => [
832
878
  s.id, s.ws, s.name, s.agent, s.state, s.shown, s.note || '', s.cwd || '',
833
- s.profile_id || '', s.profile_line || '',
879
+ s.profile_id || '', s.profile_line || '', s.model_line || '',
834
880
  // the label's own resolution, not the millisecond behind it
835
881
  s.shown === 'quiet' && Number.isFinite(s.quietMs) ? Math.max(1, Math.round(s.quietMs / 60000)) : '',
836
882
  // Joined on U+0000, fields and rows both. A separator that cannot occur in any of these
@@ -939,6 +985,66 @@ export async function loadRepos() {
939
985
  });
940
986
  }
941
987
 
988
+ /**
989
+ * panel-repos.js — `productTrees(name)`, synchronous. The LINES OF WORK of one repository
990
+ * (overhaul-r1 R2.1): every registered tree sharing its `repo_key`, main tree first. `initiative`
991
+ * is read from an `initiative/<id>` branch and says so (`by: 'branch'`) — it is a reading of the
992
+ * branch name, not a recorded attribution, and the row must not present it as one.
993
+ */
994
+ export function productTrees(name) {
995
+ const p = products.get(name);
996
+ if (!p || !Array.isArray(p.trees)) return [];
997
+ const key = p.root;
998
+ return p.trees.map((t) => {
999
+ const m = /^initiative\/(.+)$/.exec(t.branch || '');
1000
+ return { ws: t.id, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
1001
+ initiative: m ? m[1] : null, by: m ? 'branch' : null };
1002
+ }).sort((a, b) => (b.main - a.main));
1003
+ }
1004
+
1005
+ /** The branch of a registered tree, or null — for tab labels and the status bar (R2.2/R2.3). */
1006
+ export function branchOfWs(ws) {
1007
+ return ws ? (wsBranch.get(ws) || null) : null;
1008
+ }
1009
+
1010
+ /** True when `ws` is a linked worktree rather than its repository's main tree. */
1011
+ export function isLinkedTree(ws) {
1012
+ if (!ws) return false;
1013
+ for (const p of products.values()) {
1014
+ if (!Array.isArray(p.trees)) continue;
1015
+ const t = p.trees.find((x) => x.id === ws);
1016
+ if (t) return t.root !== p.root;
1017
+ }
1018
+ return false;
1019
+ }
1020
+
1021
+ /**
1022
+ * panel-repos.js — `removeRepository(name)`. Forgets the repository AND its worktree rows; never
1023
+ * deletes a folder (R3.2). Resolves `{ok}` or `{ok:false, reason, error}` with the server's own
1024
+ * words, which the row shows as-is (R3.3).
1025
+ */
1026
+ export async function removeRepository(name) {
1027
+ if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
1028
+ const p = products.get(name);
1029
+ if (!p) return { ok: false, reason: 'unknown', error: 'unknown repository' };
1030
+ const res = await postJson('/api/workspaces/forget', { id: p.ws, repository: true });
1031
+ if (res && res.ok) {
1032
+ products.delete(name);
1033
+ productOrder = productOrder.filter((n) => n !== name);
1034
+ publishBoards();
1035
+ if (state.product === name) setState({ product: productOrder[0] || null, sel: null, wsId: null });
1036
+ }
1037
+ return res || { ok: false, error: 'the workspace server did not answer' };
1038
+ }
1039
+
1040
+ /** panel-repos.js — `renameRepository(name, label)`. A display name only (R3.1). */
1041
+ export async function renameRepository(name, label) {
1042
+ if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
1043
+ const p = products.get(name);
1044
+ if (!p) return { ok: false, reason: 'unknown', error: 'unknown repository' };
1045
+ return (await postJson('/api/workspaces/rename', { id: p.ws, name: label })) || { ok: false, error: 'the workspace server did not answer' };
1046
+ }
1047
+
942
1048
  /**
943
1049
  * panel-repos.js — `firstSlugOf(id)`, synchronous. The initiative selected when a repository is
944
1050
  * picked. A repository whose board is loaded and genuinely empty returns null, and panel 3 says so.
@@ -1184,10 +1290,12 @@ export async function saveScratchpad(key, text) {
1184
1290
  }
1185
1291
 
1186
1292
  /** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
1187
- export async function readDoc(path) {
1293
+ export async function readDoc(path, ws) {
1188
1294
  if (FIXTURE) return '';
1189
1295
  const p = products.get(currentProductName());
1190
- const url = '/api/doc?' + q({ ws: state.wsId || (p ? p.ws : ''), path });
1296
+ // `ws` is the tree a search hit came from (overhaul-r1 R1.3) — a branch-only file must be read
1297
+ // from its own worktree, not from the product's main tree.
1298
+ const url = '/api/doc?' + q({ ws: ws || state.wsId || (p ? p.ws : ''), path });
1191
1299
  const res = await fetch(url);
1192
1300
  if (!res.ok) throw new Error(res.status === 404 ? 'not a readable text document in this workspace' : 'HTTP ' + res.status);
1193
1301
  return res.text();
@@ -1203,10 +1311,12 @@ export async function readDoc(path) {
1203
1311
  * that a fixture render is provably not a real read wearing a costume, and `no-network.test.js`
1204
1312
  * checks that claim at source level.
1205
1313
  */
1206
- export async function searchFiles(query, limit) {
1314
+ export async function searchFiles(query, limit, all) {
1207
1315
  const q = String(query == null ? '' : query).trim();
1208
1316
  if (FIXTURE || !q) return { ok: true, q, hits: [], truncated: false, scanned: 0 };
1209
1317
  const p = products.get(currentProductName());
1318
+ // overhaul-r4 B1 — `all`: every registered repository, each hit tagged with its repository
1319
+ if (all) return getJson('/api/search/files', { scope: 'all', q, limit });
1210
1320
  return getJson('/api/search/files', { ws: state.wsId || (p ? p.ws : ''), q, limit });
1211
1321
  }
1212
1322
 
@@ -1330,3 +1440,43 @@ if (!FIXTURE) {
1330
1440
  }
1331
1441
  });
1332
1442
  }
1443
+
1444
+ /* ── D70 — stopped initiatives and press-only sync ───────────────────────────────────────────
1445
+ * Each of these is reached only by a PM press. None runs on load, on a timer or as a side effect. */
1446
+ export function wsOf(name) { const p = products.get(name); return p ? p.ws : null; }
1447
+
1448
+ /** The lifecycle of every initiative in a repository, from the same surface the board uses. */
1449
+ export async function lifecycleMap(name) {
1450
+ const ws = wsOf(name);
1451
+ if (FIXTURE || !ws) return {};
1452
+ const res = await getJson('/api/surface', { name: 'initiatives', ws });
1453
+ const out = {};
1454
+ const items = (res && res.surface && res.surface.initiatives) || (res && res.initiatives) || [];
1455
+ for (const i of items) if (i && i.lifecycle) out[i.id] = i.lifecycle;
1456
+ return out;
1457
+ }
1458
+
1459
+ export async function setLifecycle(name, initiativeId, newState, reason) {
1460
+ if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is recorded' };
1461
+ const ws = wsOf(name);
1462
+ if (!ws) return { ok: false, error: 'unknown repository' };
1463
+ const res = await postJson('/api/lifecycle', { ws, initiative_id: initiativeId, state: newState, reason });
1464
+ if (res && res.ok) { const p = products.get(name); if (p) { p.board = null; await loadBoard(p); publishBoards(); } }
1465
+ return res || { ok: false, error: 'the workspace server did not answer' };
1466
+ }
1467
+
1468
+ export async function syncStatusOf(name) {
1469
+ const ws = wsOf(name);
1470
+ if (FIXTURE || !ws) return null;
1471
+ return getJson('/api/sync/status', { ws });
1472
+ }
1473
+
1474
+ export async function syncRepository(name) {
1475
+ if (FIXTURE) return { ok: false, message: 'fixture mode — nothing to sync' };
1476
+ const ws = wsOf(name);
1477
+ if (!ws) return { ok: false, message: 'unknown repository' };
1478
+ const res = await postJson('/api/sync', { ws });
1479
+ const p = products.get(name);
1480
+ if (res && res.ok && p) { p.board = null; await loadBoard(p); publishBoards(); }
1481
+ return res || { ok: false, message: 'the workspace server did not answer' };
1482
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * lifecycle.js — D70. The stopped-state control on an initiative, and the Sync control on a repository.
3
+ *
4
+ * Both are PM presses and nothing else: this module starts no timer and loads nothing on its own
5
+ * beyond the local read of the current state. The state is a ledger line (lifecycle.jsonl); closing,
6
+ * holding or cancelling an initiative takes it off the board and stops it gating, and reopening
7
+ * puts it back.
8
+ */
9
+ import { el } from './dom.js';
10
+ import { state, setState, FIXTURE } from './state.js';
11
+
12
+ let dataMod = null;
13
+ const dataReady = import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
14
+
15
+ const known = {}; // product -> { map, at } — the lifecycle of every initiative, read once per press
16
+ const loading = new Set();
17
+
18
+ const BTN = 'font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);cursor:pointer';
19
+ const STATES = [['closed', 'close'], ['on-hold', 'hold'], ['cancelled', 'cancel']];
20
+
21
+ function refresh(product) {
22
+ if (FIXTURE || !product || loading.has(product)) return;
23
+ loading.add(product);
24
+ dataReady.then(() => (dataMod ? dataMod.lifecycleMap(product) : {}))
25
+ .then((map) => { known[product] = { map: map || {} }; })
26
+ .catch(() => { known[product] = { map: {} }; })
27
+ .finally(() => { loading.delete(product); setState((s) => ({ lcTick: (s.lcTick || 0) + 1 })); });
28
+ }
29
+
30
+ async function apply(product, slug, to) {
31
+ const word = to === 'active' ? 'Reopen' : to === 'closed' ? 'Close' : to === 'on-hold' ? 'Put on hold' : 'Cancel';
32
+ const reason = to === 'active' ? '' : (typeof window !== 'undefined' && window.prompt ? window.prompt(word + ' “' + slug + '” — why? (one line, kept in the record)', '') : '');
33
+ if (reason === null) return; // the PM backed out
34
+ const out = await dataMod.setLifecycle(product, slug, to, String(reason).trim());
35
+ if (!out || out.ok === false) { setState({ lcNotice: { slug, text: (out && out.error) || 'not recorded' } }); return; }
36
+ setState({ lcNotice: null });
37
+ refresh(product);
38
+ }
39
+
40
+ /** The control row for one initiative. Live mode only; the fixture is the prototype's. */
41
+ export function lifecycleRow(product, slug) {
42
+ if (FIXTURE || !product || !slug) return null;
43
+ if (!known[product]) refresh(product);
44
+ const cur = known[product] && known[product].map[slug];
45
+ const n = state.lcNotice && state.lcNotice.slug === slug ? state.lcNotice : null;
46
+ const kids = [];
47
+ if (cur) {
48
+ kids.push(el('span', { 'data-r6': 'lifecycle-state', style: 'font-family:var(--mono);font-size:10px;padding:2px 6px;border:1px solid var(--border);color:var(--muted-foreground)',
49
+ title: 'off the board and not gating' + (cur.reason ? ' — ' + cur.reason : '') }, String(cur.state) + (cur.at ? ' · ' + String(cur.at).slice(0, 10) : '')));
50
+ kids.push(el('button', { onClick: () => apply(product, slug, 'active'), style: BTN, 'data-r6': 'reopen', title: 'put this back on the board' }, 'reopen'));
51
+ } else {
52
+ for (const [st, label] of STATES) {
53
+ kids.push(el('button', { onClick: () => apply(product, slug, st), style: BTN, 'data-r6': label,
54
+ title: label + ' this initiative — it leaves the board and stops gating; it can be reopened' }, label));
55
+ }
56
+ }
57
+ if (n) kids.push(el('span', { style: 'font-size:11px;color:var(--destructive)' }, n.text));
58
+ return el('div', { 'data-r6': 'lifecycle', style: 'display:flex;gap:5px;flex-wrap:wrap;align-items:center;padding:6px 14px;border-bottom:1px solid var(--border)' }, kids);
59
+ }
60
+
61
+ /* ── Sync ─────────────────────────────────────────────────────────────────────────────────── */
62
+ const syncInfo = {};
63
+ const syncing = new Set();
64
+ function readSync(product) {
65
+ if (syncInfo[product] && syncInfo[product].loading) return;
66
+ syncInfo[product] = { ...(syncInfo[product] || {}), loading: true };
67
+ dataReady.then(() => (dataMod ? dataMod.syncStatusOf(product) : null))
68
+ .then((s) => { syncInfo[product] = { status: s, last: (syncInfo[product] || {}).last }; })
69
+ .catch(() => { syncInfo[product] = { status: null }; })
70
+ .finally(() => setState((s) => ({ lcTick: (s.lcTick || 0) + 1 })));
71
+ }
72
+
73
+ function glance(st) {
74
+ if (!st || st.ok === false) return '';
75
+ const bits = [];
76
+ if (st.behind) bits.push('↓' + st.behind);
77
+ if (st.ahead) bits.push('↑' + st.ahead);
78
+ if (st.dirty_records) bits.push('●' + st.dirty_records);
79
+ return bits.join(' ');
80
+ }
81
+
82
+ /** The Sync button for a repository. Shows behind / ahead / uncommitted from LOCAL git, no network. */
83
+ export function syncButton(product) {
84
+ if (FIXTURE || !product) return null;
85
+ if (!syncInfo[product]) readSync(product);
86
+ const info = syncInfo[product] || {};
87
+ const busy = syncing.has(product);
88
+ const g = glance(info.status);
89
+ const tip = info.status && info.status.ok !== false
90
+ ? 'Sync — fetch, bring in the other machine’s work, commit your records, push. ' + (info.status.state || '') + (g ? ' (' + g + ')' : '')
91
+ : 'Sync — ' + ((info.status && (info.status.error || info.status.message)) || 'checking…');
92
+ return el('button', {
93
+ 'data-r6': 'sync', disabled: busy ? 'disabled' : null, title: tip,
94
+ style: 'font-family:var(--mono);font-size:11px;background:none;border:0;color:var(--muted-foreground);cursor:pointer;padding:0 4px',
95
+ onClick: async () => {
96
+ syncing.add(product); setState((s) => ({ lcTick: (s.lcTick || 0) + 1 }));
97
+ const out = await dataMod.syncRepository(product);
98
+ syncing.delete(product);
99
+ const text = out.ok ? 'synced — ' + (out.steps || []).map((x) => x.step + ': ' + x.note).join(' · ')
100
+ : 'sync stopped at ' + (out.stopped_at || 'start') + ' — ' + (out.message || out.error || 'unknown');
101
+ syncInfo[product] = { status: null, last: text }; readSync(product);
102
+ setState({ repoNotice: { name: product, tone: out.ok ? 'ok' : 'bad', text } });
103
+ },
104
+ }, busy ? '…' : '⇅' + (g ? ' ' + g : ''));
105
+ }
@@ -176,9 +176,18 @@ function mdLink(txt, url, ctx) {
176
176
  const u = String(url || '').trim();
177
177
  if (!u) return null;
178
178
  if (REMOTE.test(u) || MAILTO.test(u)) {
179
- return el('a', {
179
+ const a = el('a', {
180
180
  href: u, target: '_blank', rel: 'noopener noreferrer', style: ST.a,
181
181
  }, inline(txt, ctx));
182
+ // overhaul-r5 (D69): beside an http(s) link, ⧉ opens it as a web tab — only when the PM clicks it.
183
+ // Offered only where the caller supplies `onWeb` (the live doc tab); every other render is unchanged.
184
+ if (ctx && typeof ctx.onWeb === 'function' && REMOTE.test(u)) {
185
+ return el('span', null, a, el('button', {
186
+ onClick: (e) => { e.preventDefault(); ctx.onWeb(u); }, title: 'open this link as a web tab', 'data-r5': 'open-tab',
187
+ style: 'margin-left:3px;font-size:10px;padding:0 4px;border:1px solid var(--border);color:var(--muted-foreground);vertical-align:1px',
188
+ }, '⧉'));
189
+ }
190
+ return a;
182
191
  }
183
192
  if (u.startsWith('#')) return null; // an in-page anchor has nowhere to go here
184
193
  if (!OPENABLE.test(u)) return null;