create-pmos 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -333,6 +333,38 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.21.0
337
+
338
+ **The workspace terminal's lines are no longer double-spaced.** Run
339
+ `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **A terminal row is 20px instead of 26px.** Agent output in the terminal panel was rendering with
342
+ 13.5px of empty space around a 12.5px character — and because agent CLIs print blank lines
343
+ between blocks, and those were 26px too, the whole transcript read as nearly double-spaced. It
344
+ now matches the spacing the workspace was designed with.
345
+ - **Your agent gets more terminal.** This was never only cosmetic: the workspace divides the
346
+ panel's height by the row height to decide how many lines to tell the shell it has. The same
347
+ panel that reported 21 lines now reports 28, so full-screen agent interfaces have a third more
348
+ room to draw into.
349
+ - *(If you are curious: the number `1.65` appeared twice in one file and meant two different things.
350
+ As a CSS rule it multiplies the font size — 12.5 × 1.65 = 20.6px, which is the design. Handed to
351
+ the terminal engine, the same number multiplies the font's own already-leaded box, which is 16px
352
+ — giving 26px. A comment in the file asserted the two agreed, which is how it survived.)*
353
+
354
+ ## What's new in 0.20.1
355
+
356
+ **An HTML document opens rendered, not as its own source.** Opening an `.html` file in the
357
+ workspace's document viewer showed you the markup instead of the page.
358
+
359
+ ## What's new in 0.20.0
360
+
361
+ **External links name a real record instead of inventing one.** The Jira/Confluence/Docs link
362
+ controls used to fabricate every identifier they recorded. A `+` button now opens a form and
363
+ records nothing until you supply the reference or a session resolves it — and `resolved` cannot be
364
+ asserted, only evidenced: a row claiming it without naming the profile that resolved it is refused.
365
+ PMOS still holds no credential and makes no outbound request; the lookup runs in an agent session
366
+ carrying your own MCP.
367
+
336
368
  ## What's new in 0.19.0
337
369
 
338
370
  **A long document scrolls its own panel, and starting a session asks what is already running.** Run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@1d54a27
1
+ kernel-source: pmos@38fc236
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.20.0",
9
+ "version": "0.22.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -766,6 +766,11 @@ export async function loadRepos() {
766
766
  name,
767
767
  kind: prod && prod.kind === 'github' ? 'github' : 'local',
768
768
  count: prod && prod.count !== null ? prod.count : null,
769
+ // The board's OTHER population, published beside the count rather than folded into it — see
770
+ // `prod.unrecorded` in loadBoard and the sub-line in panel-repos.js#toTuple. A row that
771
+ // reports only the spine's number is accurate about itself and silent about the rest of the
772
+ // panel; the two together are what make panel 1 and panel 2 unable to disagree.
773
+ unrecorded: prod && typeof prod.unrecorded === 'number' ? prod.unrecorded : 0,
769
774
  };
770
775
  });
771
776
  }
@@ -824,12 +829,17 @@ export async function addRepo({ source, kind }) {
824
829
  publishBoards();
825
830
  const prod = products.get(p.name);
826
831
  return {
827
- repo: { name: p.name, kind: 'local', count: prod.count },
832
+ repo: { name: p.name, kind: 'local', count: prod.count, unrecorded: prod.unrecorded || 0 },
828
833
  log: [
829
834
  { t: 'registered ' + w.root, c: 'var(--muted-foreground)' },
830
835
  prod.error
831
836
  ? { t: 'the initiative spine could not be read — ' + prod.error, c: 'var(--destructive)' }
832
- : { t: 'read planning/ … ' + (prod.count || 0) + ' initiative record(s)', c: 'var(--muted-foreground)' },
837
+ // The log says WHERE the records were read from, because "0 initiative record(s)" against
838
+ // a repo that has them is the defect this initiative exists to close — a line naming
839
+ // `planning/` on a kit repo that keeps them in `pmos/state/` would have described a
840
+ // directory the server never opened.
841
+ : { t: 'read ' + (prod.count === 1 ? '1 initiative record' : (prod.count || 0) + ' initiative records')
842
+ + (prod.unrecorded ? ' · ' + prod.unrecorded + ' artifact group(s) with no record' : ''), c: 'var(--muted-foreground)' },
833
843
  { t: 'done — ' + p.name + ' added', c: 'var(--success)' },
834
844
  ],
835
845
  };
@@ -57,7 +57,16 @@ function toTuple(r) {
57
57
  * read (a folder with no `planning/`, a tree that errored) rendered "local · " — a sub-line that
58
58
  * trails off mid-sentence and reads as a bug in the row rather than as "PMOS has not counted
59
59
  * this". The dash is the design's own vocabulary for a value that is not there. */
60
- const sub = r.sub || (n === null ? '—' : n + (n === 1 ? ' initiative' : ' initiatives'));
60
+ /* …AND THE ROW MAY NOT STATE A NUMBER THE BOARD CONTRADICTS. The headline count is the SPINE's —
61
+ * that ruling stands, it is what keeps `176 initiatives` off a repository holding 138 records and
62
+ * 38 orphans. What it did not cover is the other side of the same coin: with no records found and
63
+ * artifacts grouped anyway, the row printed a confident `0 initiatives` over a board the PM could
64
+ * see was not empty, and on this repo it silently dropped 20 groups from a headline of 177. The
65
+ * second clause names that population in the board's own vocabulary ("artifacts with no
66
+ * initiative record"), counted separately and never added in. Absent when there are none. */
67
+ const un = typeof r.unrecorded === 'number' && r.unrecorded > 0 ? r.unrecorded : 0;
68
+ const sub = r.sub || (n === null ? '—'
69
+ : n + (n === 1 ? ' initiative' : ' initiatives') + (un ? ' · ' + un + ' unrecorded' : ''));
61
70
  const kind = (r.kind || r.source) === 'local' ? 'local' : (r.kind || r.source) === 'github' ? 'github'
62
71
  : (r.remote || r.origin) ? 'github' : 'local';
63
72
  return [String(id), sub, kind];
@@ -37,7 +37,9 @@
37
37
  * so the first real metrics arrive after the woff2 lands).
38
38
  *
39
39
  * The xterm palette is the DESIGN's: `--term-bg` for the background — dark in both themes, D41 —
40
- * and IBM Plex Mono at the design's 12.5px / 1.65. xterm's colour parser does not understand
40
+ * and IBM Plex Mono at the design's 12.5px. Its LINE HEIGHT is not the design's 1.65: that number
41
+ * is a CSS line-height and xterm's option of the same name is not one, so it is converted rather
42
+ * than copied — see TERM_LINE_HEIGHT. xterm's colour parser does not understand
41
43
  * `oklch()`, so the token is resolved to a concrete sRGB value at runtime (canvas round-trip, with
42
44
  * the pre-computed hex as the fallback) rather than handed to xterm as written.
43
45
  */
@@ -446,6 +448,31 @@ if (typeof window !== 'undefined') {
446
448
 
447
449
  /* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
448
450
 
451
+ /* ── one terminal row ────────────────────────────────────────────────────────────────────────
452
+ * TWO pane kinds, TWO different meanings for the same number, and that is the whole of the defect
453
+ * this constant exists to keep fixed.
454
+ *
455
+ * FAUX SHELL (fixture) — SCROLL_STYLE below. `line-height:1.65` is CSS: a unitless multiplier on
456
+ * the FONT SIZE. One line box is 12.5 × 1.65 = 20.625px. That is the PM's prototype, ported
457
+ * character for character (planning/design/four-panel/prototype-original.html:929), and it is
458
+ * also the value design-parity.js compares. It was never wrong; leave it alone.
459
+ *
460
+ * LIVE TERMINAL (real) — the xterm options. `lineHeight` there is xterm's OWN option, and its
461
+ * renderer computes `cell.height = floor(char.height × lineHeight)` where `char.height` is the
462
+ * font's MEASURED glyph box — 16px for IBM Plex Mono at 12.5px, already carrying the leading the
463
+ * typeface ships with. So xterm's number adds leading ON TOP OF leading where CSS's replaces it.
464
+ *
465
+ * Passing the design's CSS 1.65 straight into xterm therefore rendered floor(1.65 × 16) = 26px
466
+ * rows against a 20.625px reference — 26% looser than the design, 13.5px of empty space around a
467
+ * 12.5px glyph — and the header comment at the top of this file recorded the belief that the two
468
+ * agreed. Measured in Chromium against the served woff2: 1.0 → 16px, 1.2 → 19px, 1.3 → 20px,
469
+ * 1.65 → 26px. 1.3 is the ratio that reproduces the design's line box through xterm's formula.
470
+ *
471
+ * It is not paint-only: addon-fit divides the pane height by the cell height to pick `rows`, and
472
+ * syncSize() sends that to the PTY. At 26px a 565px pane told the shell it had 21 rows; at 20px it
473
+ * tells it 28. The row height IS how much terminal the agent CLI gets to draw into.
474
+ */
475
+ const TERM_LINE_HEIGHT = 1.3;
449
476
  const SCROLL_STYLE = 'flex:1;min-height:0;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
450
477
  const LINE_STYLE = (c) => 'color:' + c + ';white-space:pre-wrap;overflow-wrap:anywhere';
451
478
  const PROMPT_ROW_STYLE = 'display:flex;gap:8px;align-items:baseline';
@@ -542,7 +569,10 @@ function buildReal(tab) {
542
569
  const term = new Terminal({
543
570
  fontFamily: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
544
571
  fontSize: 12.5,
545
- lineHeight: 1.65,
572
+ // NOT the design's 1.65 — and that is the whole point. See TERM_LINE_HEIGHT above: xterm's
573
+ // `lineHeight` multiplies the font's MEASURED box, where the design's CSS number multiplies the
574
+ // font size. Handing the CSS number to xterm is what made a row 26px instead of 20.6px.
575
+ lineHeight: TERM_LINE_HEIGHT,
546
576
  theme: xtheme(),
547
577
  cursorBlink: true,
548
578
  scrollback: 5000,
@@ -495,11 +495,97 @@ function findDirsNamed(root, name, maxDepth) {
495
495
  return found;
496
496
  }
497
497
  function planningRoots(root) { return findDirsNamed(root, 'planning', BUNDLE_MAX_DEPTH); }
498
- // A repo with a planning/ bundle (at any shallow depth) or a DECISIONS.md gets the initiative lens
499
- // + curated knowledge; anything else is a plain document tree. Detection is nested-aware so a repo
500
- // that vendors a PMOS bundle under a sub-folder still shows its initiatives (the reported defect).
498
+
499
+ /*
500
+ * ── WHERE A REPOSITORY KEEPS ITS INITIATIVE RECORDS ──────────────────────────────────────────
501
+ *
502
+ * THE ONE ANSWER. `surfaceSpine` and `surfaceOkr` both used `findDirsNamed(root,'initiatives',2)`
503
+ * directly, and that search reaches `planning/initiatives/` at the repo root and nothing deeper —
504
+ * one level above where the scaffolder THIS PROJECT PUBLISHES puts records:
505
+ *
506
+ * planning/initiatives/ depth 2 pmos-self … found before
507
+ * pmos/planning/initiatives/ depth 3 a bundle vendored under pmos/ … MISSED
508
+ * pmos/state/initiatives/ depth 3 every create-pmos kit … MISSED
509
+ *
510
+ * So a kit adopter's repository reported `ok:true, empty:true` — a SUCCESSFUL read of nothing —
511
+ * and panel 1 printed a confident `0 initiatives` over a repo full of records. `planningRoots`
512
+ * finds `pmos/planning` (that name IS at depth 2), so the library lens saw the same repo's
513
+ * documents while the spine saw no records at all: two panels, one repository, contradictory.
514
+ *
515
+ * The fact was already in this process twice. `stateDirFor` resolves — and will create —
516
+ * `pmos/state/`, and `scripts/workflow-state.js#layout`, the engine every ladder derives through,
517
+ * branches on `pmos/state/initiatives` by name. Only the READ path had never been told.
518
+ *
519
+ * DERIVED FROM BUNDLE ROOTS, NOT FROM A DEEPER WALK. Raising BUNDLE_MAX_DEPTH would have found
520
+ * these directories and also widened `prototype`, `okf`, `skills` and `build-skills` discovery,
521
+ * for which this change has no finding. Instead each bundle root — the repo root, plus the parent
522
+ * of every discovered `planning/` and `state/` directory — is asked for the two layouts PMOS
523
+ * ships. That is a fixed pair of existence checks per bundle root over walks already performed,
524
+ * and it cannot start proposing records out of a directory nobody vendored.
525
+ */
526
+ function isDirSafe(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
527
+
528
+ function bundleRoots(root) {
529
+ const out = [root];
530
+ const seen = new Set([path.resolve(root)]);
531
+ const add = (d) => { const r = path.resolve(d); if (!seen.has(r)) { seen.add(r); out.push(d); } };
532
+ for (const p of planningRoots(root)) add(path.dirname(p));
533
+ for (const d of findDirsNamed(root, 'state', BUNDLE_MAX_DEPTH)) add(path.dirname(d));
534
+ return out;
535
+ }
536
+
537
+ /** Every directory this repository keeps initiative RECORDS in. Existence-checked, de-duplicated. */
538
+ function initiativeDirs(root) {
539
+ const out = [];
540
+ const seen = new Set();
541
+ const add = (d) => {
542
+ const r = path.resolve(d);
543
+ if (seen.has(r) || !isDirSafe(d)) return;
544
+ seen.add(r); out.push(d);
545
+ };
546
+ // The original search first, so no layout that worked before can stop working.
547
+ for (const d of findDirsNamed(root, 'initiatives', BUNDLE_MAX_DEPTH)) add(d);
548
+ for (const b of bundleRoots(root)) {
549
+ add(path.join(b, 'planning', 'initiatives')); // pmos-self, at the root or in a bundle
550
+ add(path.join(b, 'state', 'initiatives')); // the create-pmos kit (kit/build-kit.sh)
551
+ }
552
+ return out;
553
+ }
554
+
555
+ /*
556
+ * The health-budget registry, read from the bundle root that HAS one.
557
+ *
558
+ * `anchors.readBudgets(root)` is a fixed `<root>/planning/okrs/health-budgets.md`. A kit vendors it
559
+ * to `pmos/planning/okrs/health-budgets.md` (build-kit.sh writes it on every fresh vendor), so in
560
+ * a kit repo it was never read — and every `parent_kr: health:<id>` resolved against an empty
561
+ * registry into `unanchored`, with a reason that blamed the record. Finding the records without
562
+ * this would have replaced an invisible initiative with a misfiled one.
563
+ *
564
+ * A repo with no registry ANYWHERE still gets the root-relative `{ok:false}` — absence is reported,
565
+ * never faked, and `budgets_readable` stays false so the surface can say so.
566
+ */
567
+ function budgetsFor(A, root) {
568
+ const first = A.readBudgets(root);
569
+ if (first && first.ok) return first;
570
+ for (const b of bundleRoots(root)) {
571
+ if (path.resolve(b) === path.resolve(root)) continue;
572
+ const r = A.readBudgets(b);
573
+ if (r && r.ok) return { ...r, path: path.relative(root, path.join(b, 'planning', 'okrs', 'health-budgets.md')) };
574
+ // The engine's kit path for the same registry (workflow-state.js#layout).
575
+ const kit = path.join(b, 'state', 'health-budgets.md');
576
+ try { return { ok: true, text: fs.readFileSync(kit, 'utf8'), path: path.relative(root, kit) }; }
577
+ catch { /* not here either — keep looking, then fall back to the root's own answer */ }
578
+ }
579
+ return first;
580
+ }
581
+
582
+ // A repo with a planning/ bundle (at any shallow depth), initiative records in a layout PMOS ships,
583
+ // or a DECISIONS.md gets the initiative lens + curated knowledge; anything else is a plain document
584
+ // tree. Detection is nested-aware so a repo that vendors a PMOS bundle under a sub-folder still
585
+ // shows its initiatives (the reported defect) — including a kit whose records are its whole bundle.
501
586
  function wsShape(root) {
502
- return planningRoots(root).length || fs.existsSync(path.join(root, 'DECISIONS.md')) ? 'pmos' : 'plain';
587
+ return planningRoots(root).length || initiativeDirs(root).length
588
+ || fs.existsSync(path.join(root, 'DECISIONS.md')) ? 'pmos' : 'plain';
503
589
  }
504
590
 
505
591
  // ── Library: the file tree the sidebar browses. ───────────────────────────────────────
@@ -739,12 +825,37 @@ function walkMd(dir, acc) {
739
825
  }
740
826
  return acc;
741
827
  }
828
+ /*
829
+ * Initiative RECORDS that live outside every planning root — `pmos/state/initiatives/` in a
830
+ * create-pmos kit. `walkMd` over the planning roots cannot reach them, so in a kit repo the one
831
+ * document that carries the lane and the anchor was absent from its own initiative's artifact list
832
+ * even once the spine could see it. They are read through the SAME `docMeta` as every other
833
+ * document (ROLE_BY_DIR maps an `initiatives/` parent to role `record`, wherever it sits), so the
834
+ * join, the role and the id come from one implementation rather than a second opinion.
835
+ */
836
+ function looseRecordMetas(root, proots) {
837
+ const under = (d) => proots.some((p) => insideRoot(path.resolve(p), path.resolve(d)));
838
+ const out = [];
839
+ for (const dir of initiativeDirs(root)) {
840
+ if (under(dir)) continue; // already covered by the planning walk
841
+ let ents = [];
842
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
843
+ for (const e of ents) {
844
+ if (!e.isFile() || !e.name.endsWith('.md') || e.name.startsWith('.')) continue;
845
+ const m = docMeta(root, path.join(dir, e.name));
846
+ if (m) out.push(m);
847
+ }
848
+ }
849
+ return out;
850
+ }
851
+
742
852
  function initiativeIndex(root) {
743
853
  const proots = planningRoots(root); // planning/ at the root OR nested (e.g. IDPF/pmos/planning)
744
854
  const specimens = specimenMetas(root); // prototype/<id>/ — outside planning/, grouped by dir
745
- if (!proots.length && !specimens.length) return { initiatives: [], ungrouped: [] };
855
+ const records = looseRecordMetas(root, proots); // pmos/state/initiatives/ — outside planning/ too
856
+ if (!proots.length && !specimens.length && !records.length) return { initiatives: [], ungrouped: [] };
746
857
  const metas = proots.flatMap((p) => walkMd(p, [])).map((f) => docMeta(root, f))
747
- .filter(Boolean).concat(specimens);
858
+ .filter(Boolean).concat(specimens).concat(records);
748
859
  // Pass 1: every id an artifact explicitly declares. Only these are real initiatives —
749
860
  // a filename stem is promoted to a group only when it matches a declared id or when
750
861
  // several artifacts share it, so a lone research note never invents an initiative.
@@ -1075,10 +1186,10 @@ function surfaceOkr(root) {
1075
1186
  // Anchor health, NOT the initiative↔KR join — the join is an explicit non-goal of this spec. What
1076
1187
  // is reported is only whether each anchor names something these documents declare, because an
1077
1188
  // anchor pointing at nothing is precisely what went unseen for two days.
1078
- const budgets = A.readBudgets(root);
1189
+ const budgets = budgetsFor(A, root);
1079
1190
  const unresolvable = [];
1080
1191
  let checked = 0, empty = 0;
1081
- for (const dir of findDirsNamed(root, 'initiatives', BUNDLE_MAX_DEPTH)) {
1192
+ for (const dir of initiativeDirs(root)) {
1082
1193
  let files = [];
1083
1194
  try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { continue; }
1084
1195
  for (const f of files) {
@@ -1124,7 +1235,7 @@ function surfaceSpine(root) {
1124
1235
  if (!index.ok) {
1125
1236
  return { objectives: [], budgets: [], unanchored: [], unreadable: index.error || 'the OKR documents could not be read' };
1126
1237
  }
1127
- const budgetsDoc = A.readBudgets(root);
1238
+ const budgetsDoc = budgetsFor(A, root);
1128
1239
 
1129
1240
  // the empty spine, keyed for O(1) attachment below
1130
1241
  const objectives = index.objectives.map((o) => ({
@@ -1138,7 +1249,7 @@ function surfaceSpine(root) {
1138
1249
  const budgets = new Map(); // health:<id> groups — peers of objectives (D59), not a footnote
1139
1250
  const unanchored = []; // R6 — never dropped to make the tree tidy
1140
1251
 
1141
- for (const dir of findDirsNamed(root, 'initiatives', BUNDLE_MAX_DEPTH)) {
1252
+ for (const dir of initiativeDirs(root)) {
1142
1253
  let files = [];
1143
1254
  try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort(); } catch { continue; }
1144
1255
  for (const f of files) {