create-pmos 0.34.0 → 0.37.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/bin/create-pmos.js +15 -3
- package/package.json +1 -1
- package/payload/AGENTS.md +2 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/agent-harness.md +2 -2
- package/payload/okf/core/concepts/design-loop.md +5 -5
- package/payload/okf/core/concepts/eval-driven-pm.md +1 -1
- package/payload/okf/core/concepts/okr-anti-optimism.md +1 -1
- package/payload/okf/core/concepts/okr-tree.md +2 -2
- package/payload/okf/core/concepts/output-eval.md +2 -2
- package/payload/okf/core/concepts/sdlc-loop.md +1 -1
- package/payload/okf/core/concepts/self-refining-harness.md +3 -3
- package/payload/okf/core/templates/content-brief-template.md +1 -1
- package/payload/okf/core/templates/design-brief-template.md +1 -1
- package/payload/okf/core/templates/eval-rubric-template.md +1 -1
- package/payload/okf/core/templates/log.md +1 -1
- package/payload/okf/core/templates/prd-template.md +1 -1
- package/payload/okf/core/templates/spike-brief-template.md +1 -1
- package/payload/scripts/frontier.sh +6 -2
- package/payload/scripts/gate-initiative.sh +24 -3
- package/payload/scripts/metrics.py +20 -4
- package/payload/scripts/read-jsonl.js +53 -0
- package/payload/scripts/workflow-state.js +144 -35
- package/payload/scripts/workflow-sweep.js +39 -22
- package/payload/skills/SKILL-FORMAT.md +1 -1
- package/payload/skills/copywriting.skill +1 -1
- package/payload/skills/correction-signal-derive.skill +1 -1
- package/payload/skills/design-brief.skill +4 -4
- package/payload/skills/design-reconcile.skill +1 -1
- package/payload/skills/discovery.skill +3 -3
- package/payload/skills/eval-rubric.skill +4 -4
- package/payload/skills/evaluator.skill +1 -1
- package/payload/skills/explain-run.skill +1 -1
- package/payload/skills/explain-surface.skill +6 -6
- package/payload/skills/harness-refine.skill +1 -1
- package/payload/skills/ingest-repo.skill +6 -6
- package/payload/skills/okr.skill +2 -2
- package/payload/skills/pmos-router.skill +8 -8
- package/payload/skills/prd.skill +5 -5
- package/payload/skills/prototype-grill.skill +8 -8
- package/payload/skills/prototype.skill +7 -7
- package/payload/skills/skill-creator.skill +1 -1
- package/payload/skills/source-to-concept.skill +1 -1
- package/payload/skills/ste-term-map.md +69 -0
- package/payload/skills/strategic-brief.skill +4 -4
- package/payload/skills/technical-writing-ste.skill +176 -0
- package/payload/skills-native/copywriting/SKILL.md +1 -1
- package/payload/skills-native/correction-signal-derive/SKILL.md +1 -1
- package/payload/skills-native/design-brief/SKILL.md +4 -4
- package/payload/skills-native/design-reconcile/SKILL.md +1 -1
- package/payload/skills-native/discovery/SKILL.md +3 -3
- package/payload/skills-native/eval-rubric/SKILL.md +4 -4
- package/payload/skills-native/evaluator/SKILL.md +1 -1
- package/payload/skills-native/explain-run/SKILL.md +1 -1
- package/payload/skills-native/explain-surface/SKILL.md +6 -6
- package/payload/skills-native/harness-refine/SKILL.md +1 -1
- package/payload/skills-native/ingest-repo/SKILL.md +6 -6
- package/payload/skills-native/okr/SKILL.md +2 -2
- package/payload/skills-native/pmos-router/SKILL.md +8 -8
- package/payload/skills-native/prd/SKILL.md +5 -5
- package/payload/skills-native/prototype/SKILL.md +7 -7
- package/payload/skills-native/prototype-grill/SKILL.md +8 -8
- package/payload/skills-native/skill-creator/SKILL.md +1 -1
- package/payload/skills-native/source-to-concept/SKILL.md +1 -1
- package/payload/skills-native/strategic-brief/SKILL.md +4 -4
- package/payload/skills-native/technical-writing-ste/SKILL.md +178 -0
- package/workspace/bin/pmos.js +9 -3
- package/workspace/lib/engine/read-jsonl.js +53 -0
- package/workspace/lib/engine/workflow-state.js +144 -35
- package/workspace/lib/file-mode-parse.js +19 -4
- package/workspace/lib/tree-share.js +69 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/board-read.js +38 -0
- package/workspace/public/pm/data.js +102 -1
- package/workspace/public/pm/modes.js +50 -28
- package/workspace/public/pm/panel-detail.js +2 -17
- package/workspace/public/pm/panel-initiatives.js +3 -20
- package/workspace/public/pm/panel-newsess.js +14 -1
- package/workspace/public/pm/panel-tabs.js +53 -14
- package/workspace/public/pm/tab-term.js +30 -2
- package/workspace/server.js +95 -70
- 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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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()
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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.
|
|
593
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
16
|
-
//
|
|
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()
|
|
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.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.37.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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;
|