create-pmos 0.34.0 → 0.36.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 (82) hide show
  1. package/bin/create-pmos.js +15 -3
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/okf/core/concepts/agent-harness.md +2 -2
  5. package/payload/okf/core/concepts/design-loop.md +5 -5
  6. package/payload/okf/core/concepts/eval-driven-pm.md +1 -1
  7. package/payload/okf/core/concepts/okr-anti-optimism.md +1 -1
  8. package/payload/okf/core/concepts/okr-tree.md +2 -2
  9. package/payload/okf/core/concepts/output-eval.md +2 -2
  10. package/payload/okf/core/concepts/sdlc-loop.md +1 -1
  11. package/payload/okf/core/concepts/self-refining-harness.md +3 -3
  12. package/payload/okf/core/templates/content-brief-template.md +1 -1
  13. package/payload/okf/core/templates/design-brief-template.md +1 -1
  14. package/payload/okf/core/templates/eval-rubric-template.md +1 -1
  15. package/payload/okf/core/templates/log.md +1 -1
  16. package/payload/okf/core/templates/prd-template.md +1 -1
  17. package/payload/okf/core/templates/spike-brief-template.md +1 -1
  18. package/payload/scripts/frontier.sh +6 -2
  19. package/payload/scripts/gate-initiative.sh +24 -3
  20. package/payload/scripts/metrics.py +20 -4
  21. package/payload/scripts/read-jsonl.js +53 -0
  22. package/payload/scripts/workflow-state.js +144 -35
  23. package/payload/scripts/workflow-sweep.js +39 -22
  24. package/payload/skills/SKILL-FORMAT.md +1 -1
  25. package/payload/skills/copywriting.skill +1 -1
  26. package/payload/skills/correction-signal-derive.skill +1 -1
  27. package/payload/skills/design-brief.skill +4 -4
  28. package/payload/skills/design-reconcile.skill +1 -1
  29. package/payload/skills/discovery.skill +3 -3
  30. package/payload/skills/eval-rubric.skill +4 -4
  31. package/payload/skills/evaluator.skill +1 -1
  32. package/payload/skills/explain-run.skill +1 -1
  33. package/payload/skills/explain-surface.skill +6 -6
  34. package/payload/skills/harness-refine.skill +1 -1
  35. package/payload/skills/ingest-repo.skill +6 -6
  36. package/payload/skills/okr.skill +2 -2
  37. package/payload/skills/pmos-router.skill +8 -8
  38. package/payload/skills/prd.skill +5 -5
  39. package/payload/skills/prototype-grill.skill +8 -8
  40. package/payload/skills/prototype.skill +7 -7
  41. package/payload/skills/skill-creator.skill +1 -1
  42. package/payload/skills/source-to-concept.skill +1 -1
  43. package/payload/skills/ste-term-map.md +69 -0
  44. package/payload/skills/strategic-brief.skill +4 -4
  45. package/payload/skills/technical-writing-ste.skill +176 -0
  46. package/payload/skills-native/copywriting/SKILL.md +1 -1
  47. package/payload/skills-native/correction-signal-derive/SKILL.md +1 -1
  48. package/payload/skills-native/design-brief/SKILL.md +4 -4
  49. package/payload/skills-native/design-reconcile/SKILL.md +1 -1
  50. package/payload/skills-native/discovery/SKILL.md +3 -3
  51. package/payload/skills-native/eval-rubric/SKILL.md +4 -4
  52. package/payload/skills-native/evaluator/SKILL.md +1 -1
  53. package/payload/skills-native/explain-run/SKILL.md +1 -1
  54. package/payload/skills-native/explain-surface/SKILL.md +6 -6
  55. package/payload/skills-native/harness-refine/SKILL.md +1 -1
  56. package/payload/skills-native/ingest-repo/SKILL.md +6 -6
  57. package/payload/skills-native/okr/SKILL.md +2 -2
  58. package/payload/skills-native/pmos-router/SKILL.md +8 -8
  59. package/payload/skills-native/prd/SKILL.md +5 -5
  60. package/payload/skills-native/prototype/SKILL.md +7 -7
  61. package/payload/skills-native/prototype-grill/SKILL.md +8 -8
  62. package/payload/skills-native/skill-creator/SKILL.md +1 -1
  63. package/payload/skills-native/source-to-concept/SKILL.md +1 -1
  64. package/payload/skills-native/strategic-brief/SKILL.md +4 -4
  65. package/payload/skills-native/technical-writing-ste/SKILL.md +178 -0
  66. package/workspace/bin/pmos.js +9 -3
  67. package/workspace/lib/engine/read-jsonl.js +53 -0
  68. package/workspace/lib/engine/workflow-state.js +144 -35
  69. package/workspace/lib/file-mode-parse.js +19 -4
  70. package/workspace/lib/tree-share.js +69 -1
  71. package/workspace/package-lock.json +2 -2
  72. package/workspace/package.json +1 -1
  73. package/workspace/public/pm/board-read.js +38 -0
  74. package/workspace/public/pm/data.js +102 -1
  75. package/workspace/public/pm/modes.js +50 -28
  76. package/workspace/public/pm/panel-detail.js +2 -17
  77. package/workspace/public/pm/panel-initiatives.js +3 -20
  78. package/workspace/public/pm/panel-newsess.js +14 -1
  79. package/workspace/public/pm/panel-tabs.js +53 -14
  80. package/workspace/public/pm/tab-term.js +30 -2
  81. package/workspace/server.js +95 -70
  82. package/workspace/test-run.js +51 -9
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ /*
3
+ * scripts/read-jsonl.js — THE JSONL reader for the scripts/ checkers and the workflow engine.
4
+ *
5
+ * Six near-identical readers grew here, with three different torn-line contracts, and the repo's
6
+ * own comments record the cost twice: a silently-dropped torn line re-opened a verified entry
7
+ * (check-verify-queue.js) and made an accepted initiative read as never accepted (workflow-state.js).
8
+ * Each new checker re-decided the torn-line question from scratch and got it wrong at least twice.
9
+ * One reader, one contract — the strictest one found:
10
+ *
11
+ * - a torn line is DROPPED, never guessed at — a truncated row parsed "as far as it goes" would
12
+ * fabricate a record nobody wrote;
13
+ * - and it is REPORTED, never swallowed: every defect comes back with its file, 1-based line,
14
+ * parse reason and a bounded slice of the text, so the caller can say out loud what it could
15
+ * not read. "Assuming someone else counts is how nobody counts."
16
+ * - a MISSING (or unreadable) file is its own state, distinguishable from an empty one — the
17
+ * cadence checker needs the difference and a gate must not confuse "no ledger" with "no rows".
18
+ *
19
+ * Returns { rows, defects, torn, missing }: rows are the parsed objects in file order; defects is
20
+ * [{ file, line, reason, text }]; torn === defects.length, kept as a count because most callers
21
+ * only need the number; missing is true when the file could not be read at all (rows/defects empty).
22
+ *
23
+ * DELIBERATELY FLAT in scripts/, not scripts/lib/: the kit and the workspace bundle both vendor the
24
+ * engine's require closure by BASENAME into a flat directory (kit/build-kit.sh, scripts/
25
+ * vendor-workspace-engine.js), so a `./lib/` require would resolve in the checkout and break in
26
+ * every vendored copy — the exact incident class the scripts/lib/sink.js re-export note records.
27
+ * workspace/lib/file-mode-parse.js does NOT delegate here for the mirrored reason: the workspace
28
+ * bundle ships alone, cannot require across the bundle boundary, and its silent-drop reader is the
29
+ * locked mirror of kit/scripts/metrics.py (kit/fixtures/test_parsers.py holds that agreement).
30
+ *
31
+ * Zero dependencies, stock Node.
32
+ */
33
+ const fs = require('fs');
34
+
35
+ function readJsonl(file) {
36
+ let text;
37
+ try { text = fs.readFileSync(file, 'utf8'); }
38
+ catch { return { rows: [], defects: [], torn: 0, missing: true }; }
39
+ const rows = [];
40
+ const defects = [];
41
+ const lines = text.split('\n');
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const t = lines[i].trim();
44
+ if (!t) continue;
45
+ try { rows.push(JSON.parse(t)); }
46
+ catch (e) {
47
+ defects.push({ file, line: i + 1, reason: String((e && e.message) || 'not valid JSON').slice(0, 120), text: t.slice(0, 120) });
48
+ }
49
+ }
50
+ return { rows, defects, torn: defects.length, missing: false };
51
+ }
52
+
53
+ module.exports = { readJsonl };
@@ -58,18 +58,43 @@ const delivery = require('./delivery-index');
58
58
  // The OKR documents and the budget registry are read once per root, not once per initiative: the
59
59
  // board derives ~100 ladders in a single pass and re-reading the same three files each time is the
60
60
  // same waste the shared commit-range exists to avoid.
61
- const _okrCache = new Map();
62
- const _budCache = new Map();
61
+ //
62
+ // KEYED BY WHAT THE FILES ARE, never by where they live alone. The workspace server requires this
63
+ // module in-process and holds it for days, so a root-only key kept serving the ladder yesterday's
64
+ // anchor index after the PM fixed the OKR document — the OKR panel (which reads fresh) and the
65
+ // anchor rung disagreed about one field until a restart, the exact two-readers defect
66
+ // anchor-resolve was extracted to end. The stamp is mtime+size over the documents and their
67
+ // directories (the directory catches a file added or removed); the stats cost microseconds against
68
+ // the parse they guard. delivery-index keys its cache on the trunk head sha for the same reason.
69
+ // One stated limit: an `okrs/` directory born at a location no prior read has seen is picked up on
70
+ // the next change to a watched path or the next process, not instantly.
71
+ const _okrCache = new Map(); // root -> { stamp, files, index }
72
+ const _budCache = new Map(); // root -> { stamp, value }
73
+ function statStamp(p) {
74
+ try { const s = fs.statSync(p); return `${s.mtimeMs}:${s.size}`; } catch { return 'absent'; }
75
+ }
76
+ function okrStamp(files) {
77
+ const dirs = [...new Set(files.map((f) => path.dirname(f)))];
78
+ return files.concat(dirs).map((p) => `${p}=${statStamp(p)}`).join('|');
79
+ }
63
80
  function okrIndexFor(root) {
64
- if (!_okrCache.has(root)) _okrCache.set(root, anchors.parseOkrIndex(root));
65
- return _okrCache.get(root);
81
+ const hit = _okrCache.get(root);
82
+ if (hit && hit.files.length && okrStamp(hit.files) === hit.stamp) return hit.index;
83
+ const files = anchors.listOkrFiles(root);
84
+ const entry = { stamp: okrStamp(files), files, index: anchors.parseOkrIndex(root) };
85
+ _okrCache.set(root, entry);
86
+ return entry.index;
66
87
  }
67
88
  function budgetsFor(root, L) {
68
- if (!_budCache.has(root)) {
69
- try { _budCache.set(root, { ok: true, text: fs.readFileSync(L.budgets, 'utf8'), path: path.relative(root, L.budgets) }); }
70
- catch { _budCache.set(root, { ok: false, text: '', path: path.relative(root, L.budgets) }); }
89
+ const stamp = statStamp(L.budgets);
90
+ const hit = _budCache.get(root);
91
+ if (!hit || hit.stamp !== stamp) {
92
+ let value;
93
+ try { value = { ok: true, text: fs.readFileSync(L.budgets, 'utf8'), path: path.relative(root, L.budgets) }; }
94
+ catch { value = { ok: false, text: '', path: path.relative(root, L.budgets) }; }
95
+ _budCache.set(root, { stamp, value });
71
96
  }
72
- return _budCache.get(root);
97
+ return _budCache.get(root).value;
73
98
  }
74
99
 
75
100
  /*
@@ -105,18 +130,31 @@ function isBuildPath(p, lane) {
105
130
  }
106
131
 
107
132
  // ── Frontmatter ────────────────────────────────────────────────────────────────────────────────
108
- // The LEADING block only — body text must never be able to fake a declaration. Mirrors
109
- // workspace/lib/file-mode-parse.js and kit/scripts/metrics.py, plus the surrounding-quote strip the
110
- // bash gate does, so all four readers agree on what a record declared.
133
+ // The LEADING block only — body text must never be able to fake a declaration. The closing ---
134
+ // fence is TERMINAL, never skipped: the fence used to be stepped over, so a key-shaped FIRST body
135
+ // line was absorbed into the block and could fake a declaration at the fence boundary (the one
136
+ // shape the gamma prose-then-keys fixture never exercised). Fenceless files keep parsing from line
137
+ // 1 and stop at the first non-key line — the kit's smoke fixtures carry no fences and must keep
138
+ // parsing. Mirrors workspace/lib/file-mode-parse.js, kit/scripts/metrics.py and the two bash gates'
139
+ // readers — first value wins, one pair of surrounding double quotes stripped — so all the readers
140
+ // agree on what a record declared (kit/fixtures/test_parsers.py + workspace/fixture-parity.test.mjs
141
+ // hold the agreement).
111
142
  function parseLeadingFrontmatter(text) {
112
143
  const fm = {};
144
+ let fenceOpen = false;
113
145
  for (const line of String(text == null ? '' : text).split('\n')) {
146
+ if (line.startsWith('---')) {
147
+ // Only the FIRST ---, before any key, opens the block; every later --- ends it.
148
+ if (fenceOpen || Object.keys(fm).length) break;
149
+ fenceOpen = true;
150
+ continue;
151
+ }
114
152
  const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
115
153
  if (m) {
116
154
  if (!(m[1] in fm)) fm[m[1]] = m[2].replace(/^"(.*)"$/s, '$1');
117
155
  continue;
118
156
  }
119
- if (line.trim() && !line.startsWith('---')) break;
157
+ if (line.trim()) break;
120
158
  }
121
159
  return fm;
122
160
  }
@@ -136,23 +174,32 @@ function readFrontmatter(file) {
136
174
  * changed return type, because every existing call site wants the rows and only the sweep wants the
137
175
  * damage. Changing the shape would have made ~six callers handle a case none of them can act on.
138
176
  */
177
+ // The implementation lives in scripts/read-jsonl.js now — this contract became the shared one after
178
+ // six hand-rolled readers each re-decided the torn-line question (and two got it wrong). The thin
179
+ // wrapper keeps this file's call sites and the out-of-band defects channel unchanged.
180
+ const jsonl = require('./read-jsonl');
139
181
  function readJsonl(file, defects) {
140
- let text;
141
- try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
142
- const out = [];
143
- const lines = text.split('\n');
144
- for (let i = 0; i < lines.length; i++) {
145
- const t = lines[i].trim();
146
- if (!t) continue;
147
- try { out.push(JSON.parse(t)); }
148
- catch (e) {
149
- // Dropped, never guessed — and now said out loud to anyone who asked to hear it.
150
- if (Array.isArray(defects)) {
151
- defects.push({ file, line: i + 1, reason: String((e && e.message) || 'not valid JSON').slice(0, 120), text: t.slice(0, 120) });
152
- }
153
- }
154
- }
155
- return out;
182
+ const r = jsonl.readJsonl(file);
183
+ // Dropped, never guessed — and said out loud to anyone who asked to hear it.
184
+ if (Array.isArray(defects)) defects.push(...r.defects);
185
+ return r.rows;
186
+ }
187
+
188
+ // The ledgers are read once per VERSION OF THE FILE, not once per initiative — the same per-root
189
+ // rule the OKR cache above states, applied to the other four per-root inputs it was skipping. The
190
+ // board filters ~665 KB of parsed rows once per ladder; re-reading and re-parsing the files each
191
+ // time was ~145 MB of work per board. The stamp is mtime+size for the same reason as the OKR
192
+ // cache's: the workspace server holds this module in-process, and a run or acceptance appended
193
+ // between requests must be visible on the next derivation, not after a restart. Callers filter the
194
+ // shared rows and never mutate them.
195
+ const _ledgerCache = new Map(); // absolute path -> { stamp, rows }
196
+ function cachedJsonl(file) {
197
+ const stamp = statStamp(file);
198
+ const hit = _ledgerCache.get(file);
199
+ if (hit && hit.stamp === stamp) return hit.rows;
200
+ const rows = readJsonl(file);
201
+ _ledgerCache.set(file, { stamp, rows });
202
+ return rows;
156
203
  }
157
204
 
158
205
  // ── Repo layout — pmos-self and the kit disagree on where state lives, so detect rather than assume
@@ -308,6 +355,39 @@ function firstAppearance(root, range, rel) {
308
355
  return range.index.has(sha) ? range.index.get(sha) : -1;
309
356
  }
310
357
 
358
+ /*
359
+ * When was each path last touched, answered from the range's OWN single pass. A `git log -1
360
+ * --format=%ct -- <paths>` per initiative was one subprocess per board row — ~85% of the board's
361
+ * runtime spent re-reading history buildRange had already captured (see the single-pass note
362
+ * above). Built lazily, memoised on the range object, so every ladder on the board shares one map.
363
+ */
364
+ function touchIndex(range) {
365
+ if (!range._touch) {
366
+ const m = new Map();
367
+ for (let i = 0; i < range.shas.length; i++) {
368
+ const t = range.dates[i];
369
+ if (!Number.isFinite(t)) continue;
370
+ for (const f of (range.changed[i] || [])) {
371
+ const prev = m.get(f);
372
+ if (prev === undefined || t > prev) m.set(f, t);
373
+ }
374
+ }
375
+ range._touch = m;
376
+ }
377
+ return range._touch;
378
+ }
379
+
380
+ // The set of paths git tracks, one `ls-files` shared across the board via the range object. Used
381
+ // only where a range is EMPTY — see the note at the chgIdx computation. `null` = git could not
382
+ // answer, which callers must treat as "fall back", never as "nothing is tracked".
383
+ function trackedIn(root, range) {
384
+ if (range._tracked === undefined) {
385
+ const out = git(root, ['ls-files', '-z']);
386
+ range._tracked = out == null ? null : new Set(out.split('\0').filter(Boolean));
387
+ }
388
+ return range._tracked;
389
+ }
390
+
311
391
  /*
312
392
  * The earliest position in the range at which this initiative's implementation was touched, plus a
313
393
  * count. A docs-only PR has no build commits, which makes every "before the build" rule vacuous —
@@ -510,10 +590,38 @@ function computeWorkflow(id, opts) {
510
590
  // that is true of everything, which makes the marker noise — so the board passes the GATE's range
511
591
  // separately and the marker is answered from that, or dropped when there is no such range.
512
592
  const changeRange = (opts && opts.changeRange) || (range.wholeHistory ? null : range);
513
- const chgIdx = changeRange ? firstAppearance(root, changeRange, recRel) : null;
593
+ // A ZERO-COMMIT range contains nothing, so firstAppearance's per-path fallback — a full-history
594
+ // `git log --diff-filter=A`, once per initiative on any full clone whose base..HEAD is empty
595
+ // (HEAD level with main) — can only conclude what one shared `ls-files` already says: a tracked
596
+ // record predates the range (-1), an on-disk-untracked one is uncommitted (Infinity). The gate's
597
+ // range — non-empty — keeps the fallback, which is what tells "added before the range" from
598
+ // "never committed" for O1/O2.
599
+ const emptyChange = !!changeRange && changeRange.ok && changeRange.shas.length === 0;
600
+ const tracked = emptyChange ? trackedIn(root, changeRange) : null;
601
+ const chgIdx = !changeRange ? null
602
+ : tracked ? (tracked.has(recRel) ? -1 : fs.existsSync(recAbs) ? Infinity : null)
603
+ : firstAppearance(root, changeRange, recRel);
514
604
  const inChange = !!changeRange && chgIdx !== -1 && chgIdx !== null && chgIdx !== undefined;
515
605
  const lastActivity = (() => {
516
606
  const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
607
+ // The board supplies the trunk range, whose single pass already holds every commit's paths and
608
+ // dates — so the answer is a map lookup, not a subprocess. The trunk alone would demote
609
+ // branch-fresh work (`git log -1` reads from HEAD, which sees branch-local commits the trunk
610
+ // range excludes), so the gate's base..HEAD range is overlaid, max date per path. The
611
+ // subprocess survives only where no whole-history range was supplied — the single-ladder call.
612
+ // One deliberate difference from the per-path subprocess: a true merge lists no files in the
613
+ // single pass, so a merge that merely reconciles histories no longer counts as activity on the
614
+ // paths it carried — the date it stamped was the sync's, not the work's.
615
+ if (opts && opts.range && range.ok && range.wholeHistory) {
616
+ const trunk = touchIndex(range);
617
+ const branch = changeRange && changeRange.ok ? touchIndex(changeRange) : null;
618
+ let best = null;
619
+ for (const p of paths) {
620
+ const t = Math.max(trunk.get(p) || 0, (branch && branch.get(p)) || 0);
621
+ if (t > 0 && (best === null || t > best)) best = t;
622
+ }
623
+ return best;
624
+ }
517
625
  const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
518
626
  const t = out && parseInt(out.trim(), 10);
519
627
  return Number.isFinite(t) ? t : null;
@@ -589,8 +697,9 @@ function computeWorkflow(id, opts) {
589
697
  const build = buildCommits(root, range, effLane, {
590
698
  id,
591
699
  // Where this initiative's own record first appears in the range — the earliest commit that could
592
- // possibly be its build. See the note in buildCommits' attribution loop.
593
- recordIdx: firstAppearance(root, range, recRel),
700
+ // possibly be its build. Already computed above as `recIdx`, from identical arguments.
701
+ // See the note in buildCommits' attribution loop.
702
+ recordIdx: recIdx,
594
703
  deliveryIndex: opts && opts.deliveryIndex,
595
704
  declaredDelivery: fm.delivered_by,
596
705
  });
@@ -748,7 +857,7 @@ function computeWorkflow(id, opts) {
748
857
  }
749
858
  }
750
859
 
751
- const runs = readJsonl(path.join(L.runsDir, L.runsFile)).filter((r) => r && r.initiative_id === id);
860
+ const runs = cachedJsonl(path.join(L.runsDir, L.runsFile)).filter((r) => r && r.initiative_id === id);
752
861
  const runsRel = path.relative(root, path.join(L.runsDir, L.runsFile));
753
862
 
754
863
  // ── priors ─ O7: the episodic read is CITED, and the citation is checked ─────────────────────
@@ -874,7 +983,7 @@ function computeWorkflow(id, opts) {
874
983
  steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
875
984
 
876
985
  // ── review ─ the Review Gate's verdict, where the lane has a rubric ──────────────────────────
877
- const evals = readJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
986
+ const evals = cachedJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
878
987
  {
879
988
  /* The rung reads the RUBRIC, not the lane.
880
989
  *
@@ -946,7 +1055,7 @@ function computeWorkflow(id, opts) {
946
1055
  }
947
1056
 
948
1057
  // ── accept ─ the human PM, final and non-delegable (D12) ─────────────────────────────────────
949
- const accepted = readJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
1058
+ const accepted = cachedJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
950
1059
  .filter((a) => a && a.initiative_id === id && a.accepted === true);
951
1060
  {
952
1061
  const blockers = steps.filter((s) => s.status === 'blocked').map((s) => s.key);
@@ -974,7 +1083,7 @@ function computeWorkflow(id, opts) {
974
1083
  {
975
1084
  const metric = String(fm.outcome_metric || '').trim();
976
1085
  const horizon = String(fm.outcome_horizon || '').trim();
977
- const checks = readJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
1086
+ const checks = cachedJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
978
1087
  .filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
979
1088
  const latest = checks.length ? checks[checks.length - 1] : null;
980
1089
  const today = new Date().toISOString().slice(0, 10);
@@ -12,8 +12,9 @@ const fs = require('fs');
12
12
  const reviewGate = require('./review-gate');
13
13
 
14
14
  // The LEADING YAML-ish frontmatter block ONLY — body text must never be able to fake a declaration.
15
- // Mirrors metrics.py exactly: `key: value` lines (first value wins), `---` fences and blank lines are
16
- // skipped, and the FIRST non-frontmatter, non-fence, non-blank line ends the block.
15
+ // Mirrors metrics.py and scripts/workflow-state.js exactly: `key: value` lines (first value wins,
16
+ // ONE pair of surrounding double quotes stripped), blank lines are skipped, the closing `---` fence
17
+ // is TERMINAL, and in a fenceless file the first non-key line ends the block.
17
18
  //
18
19
  // The value group is `(.*?)`, NOT `(.+?)`. A key written bare — `prd_path:` with nothing after it —
19
20
  // is legal YAML for a null value, and 30 of the 136 records in planning/initiatives/ are written
@@ -25,12 +26,26 @@ const reviewGate = require('./review-gate');
25
26
  // existed to prove the two agreed — which they did, on being wrong, for as long as the fixture only
26
27
  // ever compared them to each other. scripts/workflow-state.js:105 was the one correct parser and was
27
28
  // compared against nothing. The fixture now asserts VALUES; see epsilon-empty-value.md.
29
+ //
30
+ // Two later divergences closed the same way, with fixtures pinning each:
31
+ // * the closing fence used to be SKIPPED, not terminal, so a key-shaped first body line was
32
+ // absorbed into the block — a fake the gamma fixture (fakes below a prose line) never caught.
33
+ // See zeta-fence-fake.md.
34
+ // * this parser kept surrounding quotes while workflow-state.js stripped them, so the same record
35
+ // spelled its title two ways in one UI. See eta-quoted-value.md.
28
36
  function parseLeadingFrontmatter(text) {
29
37
  const fm = {};
38
+ let fenceOpen = false;
30
39
  for (const line of String(text == null ? '' : text).split('\n')) {
40
+ if (line.startsWith('---')) {
41
+ // Only the FIRST ---, before any key, opens the block; every later --- ends it.
42
+ if (fenceOpen || Object.keys(fm).length) break;
43
+ fenceOpen = true;
44
+ continue;
45
+ }
31
46
  const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
32
- if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2]; continue; }
33
- if (line.trim() && !line.startsWith('---')) break;
47
+ if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2].replace(/^"(.*)"$/s, '$1'); continue; }
48
+ if (line.trim()) break;
34
49
  }
35
50
  return fm;
36
51
  }
@@ -193,6 +193,73 @@ function describeTenant(row, writerId) {
193
193
  };
194
194
  }
195
195
 
196
+ /* ── `end it`: what a click may and may not claim ──────────────────────────────────────────────
197
+ *
198
+ * The chooser's one destructive action used to be a discarded return value:
199
+ *
200
+ * if (termMod && typeof termMod.killSession === 'function') termMod.killSession(p.id);
201
+ * setState({ endConfirm: null });
202
+ *
203
+ * Three paths reach that `setState` having sent nothing — `termMod` still null from its dynamic
204
+ * import, `killSession`'s own `FIXTURE || !id` guard, and a socket that is not open. The third is
205
+ * the one an ordinary PM meets: in a fresh window with no terminal tab, the chooser is populated
206
+ * over HTTP while the terminal WebSocket has never been opened. The list is correct, the button is
207
+ * live, the confirm arms, the second click disarms — and the session keeps running.
208
+ *
209
+ * WHY THE DECISION LIVES HERE AND NOT IN THE CLICK HANDLER. The handler is browser UI, and this
210
+ * repo's browser suites CANNOT FAIL A BUILD: workspace-ci.yml runs them, but under
211
+ * `continue-on-error: true`, staged non-blocking since the day they were first wired up. So a test
212
+ * written beside the button would run, would report, and would be swallowed — which is worse than
213
+ * not running, because the summary shows a suite executing. Measured while this was being written:
214
+ * pm-tabs-test.js is 118/120 ON MAIN and has been, with nothing blocked and nobody told.
215
+ *
216
+ * This module is a UMD that `npm test` — the step that DOES gate — already requires, so the
217
+ * decision is asserted where a regression fails the build rather than merely prints.
218
+ *
219
+ * IT TAKES FACTS, NOT OBJECTS. `hasModule` and `socketOpen` are booleans the caller reads off its
220
+ * own world. A function that reached for `termMod` itself would be untestable for exactly the
221
+ * reason the button was.
222
+ */
223
+
224
+ /** Every reason an end cannot be sent, with the sentence the PM sees. One place, so a new reason is
225
+ * a row rather than a new `else` in a click handler. */
226
+ const END_REFUSALS = Object.freeze({
227
+ 'no-id': 'this session has no id, so there is nothing to end',
228
+ 'no-module': 'the terminal is still loading — nothing can be sent yet, so nothing was',
229
+ 'no-socket': 'the terminal connection is not open, so the request was not sent. Open a terminal tab first.',
230
+ fixture: 'fixture mode — no session is really running, so nothing was ended',
231
+ });
232
+
233
+ /**
234
+ * Decide what a click on `end it` may do, and what it may claim afterwards.
235
+ *
236
+ * @param {object} env
237
+ * @param {string|null} env.id the session to end
238
+ * @param {boolean} env.hasModule the terminal module resolved and exposes `killSession`
239
+ * @param {boolean} env.socketOpen the terminal WebSocket is open (`readyState === 1`)
240
+ * @param {boolean} [env.fixture] fixture mode — nothing is really running
241
+ * @returns {{send: boolean, code: string|null, why: string|null, claim: string}}
242
+ *
243
+ * `claim` is the load-bearing field and the reason this returns an object rather than a boolean.
244
+ * When `send` is true the honest claim is **`asked`**, never `ended`: all the page can know is that
245
+ * a frame was written to a socket. Whether the session died is the server's to report, and the row
246
+ * leaving the list is what says so. A destructive control may not report an outcome it has not
247
+ * observed — the disarm was being read as that outcome, which is the second half of this defect.
248
+ *
249
+ * `disarm` is deliberately NOT a field. A refused end must leave the confirm armed, because
250
+ * re-arming is the only signal the PM gets that nothing happened; the caller derives that from
251
+ * `send` and there is no separate flag to set inconsistently.
252
+ */
253
+ function endAttempt(env) {
254
+ const e = env || {};
255
+ const refuse = (code) => ({ send: false, code, why: END_REFUSALS[code], claim: 'nothing' });
256
+ if (e.fixture) return refuse('fixture');
257
+ if (typeof e.id !== 'string' || !e.id) return refuse('no-id');
258
+ if (!e.hasModule) return refuse('no-module');
259
+ if (!e.socketOpen) return refuse('no-socket');
260
+ return { send: true, code: null, why: null, claim: 'asked' };
261
+ }
262
+
196
263
  /**
197
264
  * The whole answer to "you are about to start a session in a tree that is already busy".
198
265
  *
@@ -297,5 +364,6 @@ function joinNotices(...parts) {
297
364
  }
298
365
 
299
366
  return { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices,
300
- nameList, statedList, ago, endRisk, describeTenant, coTenancyReport, ADVISORY_ROWS };
367
+ nameList, statedList, ago, endRisk, describeTenant, coTenancyReport, ADVISORY_ROWS,
368
+ endAttempt, END_REFUSALS };
301
369
  });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.33.0",
3
+ "version": "0.36.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.33.0",
9
+ "version": "0.36.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.34.0",
3
+ "version": "0.36.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",
@@ -0,0 +1,38 @@
1
+ /* pm/board-read.js — the board, read ONE way.
2
+ *
3
+ * Four panels (panel-initiatives, panel-detail, panel-tabs, modes) render from the same three
4
+ * facts — which board is published, which records back it, which repository is showing — and each
5
+ * carried its own copy of the derivation: four chances to disagree about one fact, the exact class
6
+ * of bug the panels' own comments guard against. The byte-identical trio lives here.
7
+ *
8
+ * What deliberately does NOT live here: `repoOf` (panel-detail) falls back to `currentProduct()`
9
+ * while `slugProductOf` (panel-tabs) leaves an unknown slug undefined — `visibleTabs` reads
10
+ * undefined as "visible everywhere" and `openDoc` stamps the current repository precisely because
11
+ * of that difference. Two answers on purpose, not one answer drifted.
12
+ *
13
+ * The seed board is reachable ONLY in fixture mode. In normal mode an unpublished board is an
14
+ * EMPTY board — never the seed data: fixture rows rendering in the real app is "the beautiful
15
+ * shell" the PRD names as a failure mode.
16
+ */
17
+
18
+ import { state, FIXTURE } from './state.js';
19
+ import * as FX from './fixture.js';
20
+
21
+ /** The published board, else the fixture seed — else an EMPTY board, never the seed. */
22
+ export const boardOf = () =>
23
+ (state.board && typeof state.board === 'object' ? state.board : (FIXTURE ? FX.ALLGROUPS : {}));
24
+
25
+ /** Every initiative record: the board's own records, with user-filed ones layered on. */
26
+ export function records() {
27
+ const base = state.records && typeof state.records === 'object'
28
+ ? state.records
29
+ : (FIXTURE ? FX.DATA : {});
30
+ return { ...base, ...state.userInits };
31
+ }
32
+
33
+ /** The repository whose board is showing (prototype line 1395). */
34
+ export function currentProduct() {
35
+ if (state.product) return state.product;
36
+ const keys = Object.keys(boardOf());
37
+ return keys.length ? keys[0] : 'workspace';
38
+ }
@@ -706,6 +706,101 @@ function currentProductName() {
706
706
  return productOrder[0] || null;
707
707
  }
708
708
 
709
+ /* ── the Focus queue ─────────────────────────────────────────────────────────────────────────
710
+ * `/api/console?name=focus` is cross-product (the server accepts and ignores `ws`) and it runs the
711
+ * board and ladder derivations for every registered workspace — a cold derive the boot path
712
+ * deliberately avoids (see `lightRecord`). So it is fetched LAZILY, on entry to focus mode, never
713
+ * at load.
714
+ *
715
+ * The server's view (lib/focus.js buildFocus) is `{queue, handoffs, delegated, upkeep, badge, …}`;
716
+ * modes.js renders the fixture rows' shape `{first, waiting, syn, saw}`. The reshape happens here
717
+ * — one adapter, not a second renderer — and a failed read is published as `{readError}`, which
718
+ * the mode renders as a failed read, never as "a successful read of nothing".
719
+ */
720
+
721
+ /** One focus row in the shape the mode's queue renderer reads. The `read:` label is the caller's
722
+ * to add (lib/focus.js R4) — the server's string is the content after it. */
723
+ function focusRow(r) {
724
+ return {
725
+ n: r.rank == null ? '' : String(r.rank),
726
+ slug: r.subject || '',
727
+ // `productName` is the board's own key (the repo name data.js registered), which is what
728
+ // `jump` needs; `product` is the server's repo-key path and matches no board.
729
+ prod2: r.productName || '',
730
+ why: r.why || '',
731
+ read: r.read ? 'read: ' + r.read : '',
732
+ };
733
+ }
734
+
735
+ /** The server's focus view, reshaped to what focusList renders. Every row is the payload's — no
736
+ * fixture constant is reachable from here. */
737
+ function focusFrom(view) {
738
+ const h = view.handoffs || {};
739
+ const d = view.delegated || {};
740
+ const u = view.upkeep || {};
741
+
742
+ const syn = [];
743
+ for (const s of d.running || []) {
744
+ syn.push({
745
+ t: (s.name || s.id) + ' · ' + (s.word || s.shown) + (s.productName ? ' — ' + s.productName : ''),
746
+ hasBtn: false,
747
+ });
748
+ }
749
+ /* `starts` are the engine's own delegate OFFERS. They render as rows, not buttons: this surface
750
+ * has no runner that can feed `initialInput` into a spawn, and a ▶ button PMOS cannot honour is
751
+ * worse than no button (the server's own starts_note rule). */
752
+ for (const s of d.starts || []) {
753
+ syn.push({ t: (s.initiative || '') + ' — ' + (s.action || s.skill || ''), hasBtn: false });
754
+ }
755
+
756
+ const saw = [];
757
+ if (u.memory && u.memory.heading) {
758
+ saw.push({ t: u.memory.heading + ' — ' + (u.memory.body || u.memory.state || '') });
759
+ }
760
+ const bud = u.budgets || {};
761
+ if (bud.state === 'error') {
762
+ saw.push({ t: 'health budgets — read failed: ' + (bud.error || 'no reason given') });
763
+ }
764
+ for (const r of bud.rows || []) {
765
+ saw.push({
766
+ t: r.id + ' — cap ' + (r.cap || 'not declared') + ' · ' + (r.total || 0)
767
+ + ' anchored initiative' + (r.total === 1 ? '' : 's'),
768
+ });
769
+ }
770
+ // The server's caveat travels with the counts it qualifies: without it the totals above read as
771
+ // the cap test, which is exactly the claim the view refuses to make.
772
+ if ((bud.rows || []).length && bud.caveat) saw.push({ t: bud.caveat });
773
+
774
+ return {
775
+ first: (view.queue || []).map(focusRow),
776
+ waiting: (h.items || []).map(focusRow),
777
+ // The handoffs body for a tracker that is NOT CONNECTED — a different sentence from an empty
778
+ // list, rendered by the mode in place of the calm empty line.
779
+ waitingNote: (h.items || []).length ? null : (h.body || null),
780
+ syn,
781
+ saw,
782
+ };
783
+ }
784
+
785
+ let focusInFlight = false;
786
+
787
+ async function loadFocus() {
788
+ if (FIXTURE || focusInFlight) return;
789
+ focusInFlight = true;
790
+ // The in-flight read is STATED. With nothing published yet, the mode would otherwise render its
791
+ // calm empty line — a claim of a successful read of nothing while the read is still out. A
792
+ // usable earlier payload stays on screen for the refresh instead.
793
+ if (!state.focus || state.focus.readError) setState({ focus: { reading: true } });
794
+ try {
795
+ const view = await getJson('/api/console', { name: 'focus' });
796
+ setState({
797
+ focus: view && view.ok !== false
798
+ ? focusFrom(view)
799
+ : { readError: (view && view.error) || 'the focus queue could not be read' },
800
+ });
801
+ } finally { focusInFlight = false; }
802
+ }
803
+
709
804
  /* ── sessions ───────────────────────────────────────────────────────────────────────────────
710
805
  * Polled rather than pushed. The WebSocket that carries `sessions` frames belongs to panel 4's
711
806
  * terminal module, which owns the attach/input protocol; opening a SECOND socket here to read a
@@ -1214,7 +1309,7 @@ export function runningCount(s) {
1214
1309
  * newly selected initiative, and a repaint when it lands.
1215
1310
  */
1216
1311
  if (!FIXTURE) {
1217
- let lastSel = null, lastProduct = null;
1312
+ let lastSel = null, lastProduct = null, lastMode = null;
1218
1313
  subscribe(() => {
1219
1314
  if (state.product !== lastProduct) {
1220
1315
  lastProduct = state.product;
@@ -1222,6 +1317,12 @@ if (!FIXTURE) {
1222
1317
  if (p && p.ws && state.wsId !== p.ws) setState({ wsId: p.ws });
1223
1318
  if (p && !p.board) loadInitiatives(state.product);
1224
1319
  }
1320
+ // The focus view is fetched on ENTRY to focus mode, and re-fetched on every re-entry — the
1321
+ // queue is a statement about now, and the mode switch is the moment the PM asks for it.
1322
+ if (state.mode !== lastMode) {
1323
+ lastMode = state.mode;
1324
+ if (state.mode === 'focus') loadFocus();
1325
+ }
1225
1326
  const sel = state.sel;
1226
1327
  if (sel && sel !== lastSel) {
1227
1328
  lastSel = sel;