create-pmos 0.22.0 → 0.24.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 +28 -0
- package/package.json +5 -3
- package/payload/AGENTS.md +7 -0
- package/payload/KIT-VERSION +1 -1
- package/payload/README.md +16 -1
- package/payload/scripts/anchor-resolve.js +277 -0
- package/payload/scripts/delivery-index.js +278 -0
- package/payload/scripts/workflow-state.js +1139 -0
- package/payload/scripts/workflow-sweep.js +764 -0
- package/workspace/README.md +10 -0
- package/workspace/lib/console-state.js +39 -18
- package/workspace/lib/engine/delivery-index.js +278 -0
- package/workspace/lib/engine/workflow-state.js +165 -21
- package/workspace/lib/session-state.js +56 -2
- package/workspace/lib/working-set.js +224 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +7 -0
- package/workspace/public/pm/data.js +28 -1
- package/workspace/public/pm/panel-initiatives.js +172 -17
- package/workspace/server.js +42 -51
|
@@ -45,6 +45,15 @@ const { execFileSync } = require('child_process');
|
|
|
45
45
|
* and the OKR surface previously held separate, incompatible opinions about the same field.
|
|
46
46
|
*/
|
|
47
47
|
const anchors = require('./anchor-resolve');
|
|
48
|
+
/*
|
|
49
|
+
* ONE reader for delivery too (workflow-status-sweep R-1). `buildCommits` used to answer "is there a
|
|
50
|
+
* build commit in this range" — a question about a RANGE — while both of its callers asked a
|
|
51
|
+
* question about an INITIATIVE. Over a pull-request range those coincide, because the range IS one
|
|
52
|
+
* initiative's work; over the trunk they do not, and every initiative read `done` with the same 488
|
|
53
|
+
* commits of evidence. The attribution signal already existed, module-private, inside the workspace
|
|
54
|
+
* server. It is shared now, for the same reason `anchor-resolve` is.
|
|
55
|
+
*/
|
|
56
|
+
const delivery = require('./delivery-index');
|
|
48
57
|
|
|
49
58
|
// The OKR documents and the budget registry are read once per root, not once per initiative: the
|
|
50
59
|
// board derives ~100 ladders in a single pass and re-reading the same three files each time is the
|
|
@@ -114,14 +123,34 @@ function parseLeadingFrontmatter(text) {
|
|
|
114
123
|
function readFrontmatter(file) {
|
|
115
124
|
try { return parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
116
125
|
}
|
|
117
|
-
|
|
126
|
+
/*
|
|
127
|
+
* The ledger reader, and the one place a torn line is REPORTED rather than merely dropped (R-4).
|
|
128
|
+
*
|
|
129
|
+
* A line that does not parse is still never guessed at — that part was always right. What was wrong
|
|
130
|
+
* was the silence: a truncated acceptance row did not make the ladder say "a row here is
|
|
131
|
+
* unreadable", it made the ladder say the initiative was never accepted. The file's own honesty rule
|
|
132
|
+
* ("a gate that cannot see its inputs reports that it cannot see them") held for git ranges and not
|
|
133
|
+
* for ledger lines.
|
|
134
|
+
*
|
|
135
|
+
* The channel is out-of-band — an optional `defects` array the caller passes in — rather than a
|
|
136
|
+
* changed return type, because every existing call site wants the rows and only the sweep wants the
|
|
137
|
+
* damage. Changing the shape would have made ~six callers handle a case none of them can act on.
|
|
138
|
+
*/
|
|
139
|
+
function readJsonl(file, defects) {
|
|
118
140
|
let text;
|
|
119
141
|
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
120
142
|
const out = [];
|
|
121
|
-
|
|
122
|
-
|
|
143
|
+
const lines = text.split('\n');
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
const t = lines[i].trim();
|
|
123
146
|
if (!t) continue;
|
|
124
|
-
try { out.push(JSON.parse(t)); }
|
|
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
|
+
}
|
|
125
154
|
}
|
|
126
155
|
return out;
|
|
127
156
|
}
|
|
@@ -211,21 +240,27 @@ function buildRange(root, { spec, whole }) {
|
|
|
211
240
|
}
|
|
212
241
|
range = base ? `${base}..HEAD` : 'HEAD';
|
|
213
242
|
}
|
|
214
|
-
|
|
243
|
+
// %x1e separates commits; %x1f separates the header from the message and the message from
|
|
244
|
+
// --name-only's file list. The MESSAGE is captured in this same pass because attribution over the
|
|
245
|
+
// trunk needs it (R-2) — a second `git log` per initiative is exactly the O(commits × initiatives)
|
|
246
|
+
// cost this single-pass rewrite existed to remove.
|
|
247
|
+
const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct%x1f%s %b%x1f', '--name-only', range]);
|
|
215
248
|
if (log == null) return { ok: false, reason: 'git history unreadable' };
|
|
216
249
|
|
|
217
250
|
const shas = [];
|
|
218
251
|
const changed = []; // index -> string[] of paths touched
|
|
219
252
|
const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
|
|
253
|
+
const subjects = []; // index -> subject + body, one line, for attribution
|
|
220
254
|
for (const chunk of log.split('\x1e')) {
|
|
221
255
|
if (!chunk.trim()) continue;
|
|
222
|
-
const
|
|
223
|
-
const head = (
|
|
256
|
+
const parts = chunk.split('\x1f');
|
|
257
|
+
const head = (parts[0] || '').trim();
|
|
224
258
|
const [sha, ct] = head.split(/\s+/);
|
|
225
259
|
if (!sha) continue;
|
|
226
260
|
shas.push(sha);
|
|
227
261
|
dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
|
|
228
|
-
|
|
262
|
+
subjects.push(String(parts[1] || '').replace(/\s+/g, ' ').trim());
|
|
263
|
+
changed.push(String(parts[2] || '').split('\n').map((s) => s.trim()).filter(Boolean));
|
|
229
264
|
}
|
|
230
265
|
const index = new Map();
|
|
231
266
|
shas.forEach((sha, i) => index.set(sha, i));
|
|
@@ -245,7 +280,7 @@ function buildRange(root, { spec, whole }) {
|
|
|
245
280
|
}
|
|
246
281
|
}
|
|
247
282
|
}
|
|
248
|
-
return { ok: true, base, index, shas, changed, dates, added, wholeHistory: whole ? true : !base };
|
|
283
|
+
return { ok: true, base, index, shas, changed, dates, subjects, added, wholeHistory: whole ? true : !base };
|
|
249
284
|
}
|
|
250
285
|
|
|
251
286
|
/*
|
|
@@ -273,11 +308,58 @@ function firstAppearance(root, range, rel) {
|
|
|
273
308
|
return range.index.has(sha) ? range.index.get(sha) : -1;
|
|
274
309
|
}
|
|
275
310
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
311
|
+
/*
|
|
312
|
+
* The earliest position in the range at which this initiative's implementation was touched, plus a
|
|
313
|
+
* count. A docs-only PR has no build commits, which makes every "before the build" rule vacuous —
|
|
314
|
+
* reported as n/a rather than as a pass it did not earn.
|
|
315
|
+
*
|
|
316
|
+
* ATTRIBUTION (R-2). This function used to take no id, so it answered a question about the RANGE
|
|
317
|
+
* while both of its callers asked one about an INITIATIVE. Which of those is right depends entirely
|
|
318
|
+
* on what the range is, and the two cases are answered differently on purpose:
|
|
319
|
+
*
|
|
320
|
+
* · A PULL-REQUEST RANGE (`base..HEAD`) — the range IS this initiative's work, by the branch
|
|
321
|
+
* convention the Initiative Gate already enforces. Any build commit in it is therefore this
|
|
322
|
+
* initiative's build commit, and the O1/O2 order rules depend on exactly that reading. This path
|
|
323
|
+
* is UNCHANGED, deliberately: the gate's verdicts must not move for a reporting fix.
|
|
324
|
+
*
|
|
325
|
+
* · THE WHOLE TRUNK (`range.wholeHistory`) — the range is everyone's work. Attribution comes from
|
|
326
|
+
* `delivery-index`: a merged `initiative/<id>` branch, or a commit that names the id and touches
|
|
327
|
+
* a build path. Without it every initiative reported `done` with the identical count, which is
|
|
328
|
+
* the tell that a per-initiative fact was being read from a repository-wide one.
|
|
329
|
+
*
|
|
330
|
+
* `signal` travels back with the answer so the rung can say WHICH evidence decided it. A bare count
|
|
331
|
+
* is what let 488 stand as an explanation for 177 different initiatives.
|
|
332
|
+
*/
|
|
333
|
+
function buildCommits(root, range, lane, opts) {
|
|
280
334
|
if (!range.ok) return { unknown: true };
|
|
335
|
+
const id = opts && opts.id;
|
|
336
|
+
const whole = !!range.wholeHistory;
|
|
337
|
+
|
|
338
|
+
// Over the trunk, the id must be attributable before any commit counts. The index is built once
|
|
339
|
+
// per trunk head by the shared reader and passed in by the board; a caller that did not supply one
|
|
340
|
+
// gets it built here (single-ladder invocations, where one extra `git log` is the whole cost).
|
|
341
|
+
let signal = null;
|
|
342
|
+
let declared = null;
|
|
343
|
+
if (whole && id) {
|
|
344
|
+
// The PM's declaration is consulted FIRST, and its failure is reported before anything else
|
|
345
|
+
// (R-2b). A `delivered_by` that does not check out must not be able to hide behind a convention
|
|
346
|
+
// signal that happens to succeed: a broken declaration is a defect in the record either way, and
|
|
347
|
+
// silently ignoring it would leave a false pointer sitting in the file forever.
|
|
348
|
+
declared = delivery.declaredDelivery(root, opts && opts.declaredDelivery);
|
|
349
|
+
if (declared && declared.declared && !declared.ok) {
|
|
350
|
+
return { unknown: false, failedDeclaration: declared, first: null, count: 0, sample: null, signal: null, attributed: true };
|
|
351
|
+
}
|
|
352
|
+
const index = (opts && opts.deliveryIndex) || delivery.deliveryIndex(root, listInitiatives(root));
|
|
353
|
+
signal = delivery.attribution(index, id);
|
|
354
|
+
if (declared && declared.ok) {
|
|
355
|
+
// A verified declaration attributes the work regardless of what the conventions found. It is
|
|
356
|
+
// the PM naming the commit, checked against git — strictly better evidence than a heuristic.
|
|
357
|
+
return { first: null, count: 0, sample: null, signal: 'declared', declaredSha: declared.sha, attributed: true, mergedOnly: true };
|
|
358
|
+
}
|
|
359
|
+
if (signal === 'unknown') return { unknown: true, reason: delivery.attributionEvidence('unknown', id) };
|
|
360
|
+
if (!signal) return { first: null, count: 0, sample: null, signal: null, attributed: true };
|
|
361
|
+
}
|
|
362
|
+
|
|
281
363
|
let first = null; let count = 0; let sample = null;
|
|
282
364
|
for (let i = 0; i < range.shas.length; i++) {
|
|
283
365
|
// range.changed came from the range's own single pass; the per-commit `git show` this replaced
|
|
@@ -285,10 +367,29 @@ function buildCommits(root, range, lane) {
|
|
|
285
367
|
const files = (range.changed && range.changed[i]) || [];
|
|
286
368
|
const hit = files.find((f) => isBuildPath(f, lane));
|
|
287
369
|
if (!hit) continue;
|
|
370
|
+
// Over the trunk, only commits that carry the id themselves are this initiative's. The branch
|
|
371
|
+
// signal is a MERGE subject, so the merge commit is the one that names it — which makes the
|
|
372
|
+
// merge position the honest "when did this land", and keeps a squashed or rebased history
|
|
373
|
+
// answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
|
|
374
|
+
// initiative is counted from its merge subject below rather than from this per-file loop.
|
|
375
|
+
if (whole && id && !commitNamesId(range, i, id)) continue;
|
|
288
376
|
count++;
|
|
289
377
|
if (first === null) { first = i; sample = hit; }
|
|
290
378
|
}
|
|
291
|
-
|
|
379
|
+
if (whole && id && signal === 'branch' && first === null) {
|
|
380
|
+
// The branch merged and every commit it carried was written on the branch, so no trunk commit
|
|
381
|
+
// subject names the id. The merge itself is the evidence, and saying "0 commits" over a merged
|
|
382
|
+
// branch would be the under-claim in a new place.
|
|
383
|
+
return { first: null, count: 0, sample: null, signal, attributed: true, mergedOnly: true };
|
|
384
|
+
}
|
|
385
|
+
return { first, count, sample, signal, attributed: whole && !!id };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Whether the commit at `i` names this initiative in its own subject or body. The subjects come from
|
|
389
|
+
// the range's own single pass, so this costs nothing per initiative.
|
|
390
|
+
function commitNamesId(range, i, id) {
|
|
391
|
+
const msg = (range.subjects && range.subjects[i]) || '';
|
|
392
|
+
return msg.includes(id);
|
|
292
393
|
}
|
|
293
394
|
|
|
294
395
|
// ── Declaration readers ────────────────────────────────────────────────────────────────────────
|
|
@@ -471,7 +572,11 @@ function computeWorkflow(id, opts) {
|
|
|
471
572
|
const prd = String(fm.prd_path || '').trim();
|
|
472
573
|
const rubric = String(fm.rubric_path || '').trim();
|
|
473
574
|
const prdIdx = prd ? firstAppearance(root, range, prd) : null;
|
|
474
|
-
const build = buildCommits(root, range, effLane
|
|
575
|
+
const build = buildCommits(root, range, effLane, {
|
|
576
|
+
id,
|
|
577
|
+
deliveryIndex: opts && opts.deliveryIndex,
|
|
578
|
+
declaredDelivery: fm.delivered_by,
|
|
579
|
+
});
|
|
475
580
|
|
|
476
581
|
// Shared helper: "A must not come after B", stated as positions, never as clock time.
|
|
477
582
|
function ordered(aIdx, bIdx) {
|
|
@@ -716,11 +821,37 @@ function computeWorkflow(id, opts) {
|
|
|
716
821
|
}
|
|
717
822
|
|
|
718
823
|
// ── build ────────────────────────────────────────────────────────────────────────────────────
|
|
824
|
+
/*
|
|
825
|
+
* The evidence names WHICH signal decided, never a bare count (R-2). `488 commit(s) on
|
|
826
|
+
* implementation paths` was true of the repository and said nothing about the initiative it was
|
|
827
|
+
* printed under — the identical number on every row was the tell.
|
|
828
|
+
*
|
|
829
|
+
* The three pending sentences below are deliberately different from each other. "No commit on the
|
|
830
|
+
* trunk is attributable to this initiative" is a statement about the INITIATIVE; "no implementation
|
|
831
|
+
* commits in this range" is a statement about the RANGE, and on a branch level with `main` that
|
|
832
|
+
* range is legitimately empty. A reader who cannot tell those apart calls the second one "never
|
|
833
|
+
* built", which is the console's half of this defect.
|
|
834
|
+
*/
|
|
719
835
|
steps.push(build.unknown
|
|
720
|
-
? step('build', 'build', 'unknown', range.reason)
|
|
721
|
-
: build.
|
|
722
|
-
|
|
723
|
-
|
|
836
|
+
? step('build', 'build', 'unknown', build.reason || range.reason)
|
|
837
|
+
: build.failedDeclaration
|
|
838
|
+
// REPORTED, never CI-blocking — the same stance R1 takes on an unresolvable anchor. Making a
|
|
839
|
+
// record's own broken pointer fail every unrelated PR would turn one typo into a repo-wide
|
|
840
|
+
// outage; the RED belongs in the surface. `blocked` still closes the accept step below, which
|
|
841
|
+
// is where a false delivery claim actually needs to stop something.
|
|
842
|
+
? step('build', 'build', 'blocked', build.failedDeclaration.reason,
|
|
843
|
+
`point delivered_by at a commit that is reachable from the trunk and changes a build path in ${recRel}, or remove the key — a declaration that fails its own check reads as evidence`,
|
|
844
|
+
{ blocking: false, rule: 'R-2b' })
|
|
845
|
+
: build.mergedOnly
|
|
846
|
+
? step('build', 'build', 'done',
|
|
847
|
+
delivery.attributionEvidence(build.signal || 'branch', id, build.declaredSha))
|
|
848
|
+
: build.first === null
|
|
849
|
+
? step('build', 'build', 'pending', build.attributed
|
|
850
|
+
? delivery.attributionEvidence(null, id)
|
|
851
|
+
: 'no implementation commits in this range')
|
|
852
|
+
: step('build', 'build', 'done', build.signal
|
|
853
|
+
? `${build.count} commit(s) on implementation paths — ${delivery.attributionEvidence(build.signal, id, build.declaredSha)}`
|
|
854
|
+
: `${build.count} commit(s) on implementation paths`));
|
|
724
855
|
|
|
725
856
|
// ── quality ─ CI's verdict, never this engine's ──────────────────────────────────────────────
|
|
726
857
|
steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
|
|
@@ -976,7 +1107,13 @@ function main(argv) {
|
|
|
976
1107
|
// it is the one thing the trunk range genuinely cannot answer.
|
|
977
1108
|
const range = historyRange(root);
|
|
978
1109
|
const changeRange = commitRange(root);
|
|
979
|
-
const
|
|
1110
|
+
const ids = listInitiatives(root);
|
|
1111
|
+
// ONE delivery index for the whole board. Built here rather than inside each ladder for the same
|
|
1112
|
+
// reason the commit range is: `deliveryIndex` memoises per trunk head, so a per-initiative build
|
|
1113
|
+
// would be cheap but the intent — one read, shared — is what keeps the rows consistent with each
|
|
1114
|
+
// other even if the trunk moved mid-run.
|
|
1115
|
+
const deliveryIdx = delivery.deliveryIndex(root, ids);
|
|
1116
|
+
const rows = ids.map((i) => computeWorkflow(i, { root, range, changeRange, deliveryIndex: deliveryIdx }));
|
|
980
1117
|
if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
|
|
981
1118
|
if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
|
|
982
1119
|
process.stdout.write(renderBoard(rows, { self, all }));
|
|
@@ -992,4 +1129,11 @@ module.exports = {
|
|
|
992
1129
|
ENFORCED_FROM,
|
|
993
1130
|
};
|
|
994
1131
|
|
|
995
|
-
|
|
1132
|
+
/*
|
|
1133
|
+
* `exitCode`, not `process.exit()`. `process.exit` tears the process down without waiting for stdout
|
|
1134
|
+
* to drain, and when stdout is a PIPE — every CI invocation, every `| jq`, every test harness —
|
|
1135
|
+
* writes larger than the pipe buffer are asynchronous. `--json` on this repository is ~65 KB, so it
|
|
1136
|
+
* arrived TRUNCATED MID-STRING and parsed as invalid JSON, while an interactive run to a TTY looked
|
|
1137
|
+
* perfect. Setting the code and letting the process end naturally flushes first.
|
|
1138
|
+
*/
|
|
1139
|
+
if (require.main === module) process.exitCode = main(process.argv);
|
|
@@ -66,8 +66,62 @@
|
|
|
66
66
|
|
|
67
67
|
// Filled = corroborated by both sources, hollow = suspected from one. `needs` takes ◆, which has
|
|
68
68
|
// sat declared-but-dead in the client's GLYPH map since the harness tower; this makes it live.
|
|
69
|
+
//
|
|
70
|
+
// THE SHAPES CARRY THE SIGNAL, AND COLOUR ONLY REINFORCES IT. Every pair in this map is
|
|
71
|
+
// distinguishable in greyscale, which is the property `fixture.js`'s SDOT does not have (it gives
|
|
72
|
+
// `working` and `needs` the same ● and separates them by hue alone). That map is the design
|
|
73
|
+
// prototype's seed data, parity-locked to prototype-original.html; this one is the shipped
|
|
74
|
+
// vocabulary. When a surface has to draw a session, it draws from here.
|
|
69
75
|
const GLYPH = { working: '●', quiet: '◼', exited: '○', needs: '◆', blocked: '◇' };
|
|
70
76
|
|
|
77
|
+
// The colour that goes with the glyph, as a SEMANTIC TOKEN NAME — never a hex, never an oklch.
|
|
78
|
+
// The workspace's own token layer (pm/tokens.css, D38) resolves it, and workspace-ci's
|
|
79
|
+
// token-hygiene step fails on raw hex in a component rule.
|
|
80
|
+
//
|
|
81
|
+
// It lives HERE, beside WORD and GLYPH, for the reason the module's header gives: a second copy of
|
|
82
|
+
// one table is free to drift. The initiative board needed a colour per presented state and there
|
|
83
|
+
// was none anywhere in the repo — adding one inside the panel would have started exactly the
|
|
84
|
+
// second table this file exists to prevent.
|
|
85
|
+
//
|
|
86
|
+
// `blocked` takes --warning and not --destructive on purpose. Its own WORD names its uncertainty
|
|
87
|
+
// ("maybe blocked") because its evidence cannot separate a permission prompt from a hung tool from
|
|
88
|
+
// a corpse; a destructive colour would launder that into a confident claim of failure.
|
|
89
|
+
// `quiet` is muted: nothing is happening and nothing is wrong, and any louder colour would make an
|
|
90
|
+
// idle session read as a problem on a board of 178 rows.
|
|
91
|
+
const COLOR = {
|
|
92
|
+
working: 'var(--success)',
|
|
93
|
+
quiet: 'var(--muted-foreground)',
|
|
94
|
+
exited: 'var(--muted-foreground)',
|
|
95
|
+
needs: 'var(--brand)',
|
|
96
|
+
blocked: 'var(--warning)',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How loudly a presented state asks for the PM, highest first. Used where several sessions have to
|
|
101
|
+
* collapse into ONE mark — a board row standing for every session on an initiative.
|
|
102
|
+
*
|
|
103
|
+
* `blocked` outranks `working` deliberately. A working session is doing what it was asked to; a
|
|
104
|
+
* "maybe blocked" one may be sitting on a permission prompt, which is the PM's to clear. Ranking
|
|
105
|
+
* it below the running states would bury the only two states that can want a human.
|
|
106
|
+
*
|
|
107
|
+
* `exited` is 0: a corpse is not a claim on anyone's attention. A caller filtering live sessions
|
|
108
|
+
* drops it before it gets here; the entry exists so the map is total over PRESENTED.
|
|
109
|
+
*/
|
|
110
|
+
const ATTENTION = { needs: 4, blocked: 3, working: 2, quiet: 1, exited: 0 };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The presented state that should stand for a whole GROUP of sessions — the loudest one.
|
|
114
|
+
* Returns null for an empty list, so a caller cannot draw a mark for nothing.
|
|
115
|
+
*/
|
|
116
|
+
function loudest(states) {
|
|
117
|
+
let best = null;
|
|
118
|
+
for (const s of states || []) {
|
|
119
|
+
if (!(s in ATTENTION)) continue; // an unknown word is never promoted into a claim
|
|
120
|
+
if (best === null || ATTENTION[s] > ATTENTION[best]) best = s;
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
}
|
|
124
|
+
|
|
71
125
|
// Which presented states may carry the open call's verb + age. Unchanged for `working` (shipped
|
|
72
126
|
// behaviour); extended to `blocked`, where the age is the most informative part of a weak claim —
|
|
73
127
|
// "a call has been open 6m and nothing is moving" is the whole signal.
|
|
@@ -134,6 +188,6 @@
|
|
|
134
188
|
return w;
|
|
135
189
|
}
|
|
136
190
|
|
|
137
|
-
return { CADENCE, TURN, PRESENTED, WORD, GLYPH, CARRIES_CALL_AGE,
|
|
138
|
-
presented, presentedStates, isNeedsClaim, label };
|
|
191
|
+
return { CADENCE, TURN, PRESENTED, WORD, GLYPH, COLOR, ATTENTION, CARRIES_CALL_AGE,
|
|
192
|
+
presented, presentedStates, isNeedsClaim, loudest, label };
|
|
139
193
|
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// working-set — which initiatives is the PM actually working on, and which of those are live.
|
|
3
|
+
//
|
|
4
|
+
// Two questions the initiative board could not answer, asked by the PM about their own board of 178
|
|
5
|
+
// records: *which of these am I currently working on*, and *which one has an active session*. Both
|
|
6
|
+
// were computable from state the browser already held, and nothing put them together.
|
|
7
|
+
//
|
|
8
|
+
// ── THE JOIN, AND WHY IT IS ONLY EVER TWO HOPS ───────────────────────────────────────────────
|
|
9
|
+
// A session carries NO initiative id. It never has. The only thing tying a PTY to an initiative is
|
|
10
|
+
// the branch convention — a working tree on `initiative/<id>` — and that is exactly the join
|
|
11
|
+
// lib/four-panel.js `sessionsFor()` already makes on the server, per initiative, inside
|
|
12
|
+
// /api/initiative:
|
|
13
|
+
//
|
|
14
|
+
// session ──s.ws──▶ workspace ──w.branch──▶ "initiative/<id>" ──▶ the initiative
|
|
15
|
+
//
|
|
16
|
+
// This module is that same join, made once over ALL sessions instead of once per initiative, so the
|
|
17
|
+
// board can colour 178 rows without 178 requests. It is deliberately NOT a second opinion: it takes
|
|
18
|
+
// the branch strings the server derived (`git rev-parse --abbrev-ref`, published on /api/workspaces)
|
|
19
|
+
// and the session rows the server published, and it runs no git and guesses at nothing.
|
|
20
|
+
//
|
|
21
|
+
// ── WHAT IT REFUSES TO DO ────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Every input that does not resolve contributes NOTHING. A session whose workspace is unknown, a
|
|
23
|
+
// workspace with no branch, a branch that is not `initiative/…`, a branch that is the literal
|
|
24
|
+
// `initiative/` with no id — each is dropped in silence rather than bucketed somewhere plausible.
|
|
25
|
+
// A marker on the wrong initiative is worse than no marker: the PM would stop trusting the column,
|
|
26
|
+
// and an untrusted signal costs more than an absent one.
|
|
27
|
+
//
|
|
28
|
+
// `exited` is not live. A session that has ended is a corpse, not a claim on anyone's attention, and
|
|
29
|
+
// it is filtered before the loudest-state fold rather than ranked at the bottom of it.
|
|
30
|
+
//
|
|
31
|
+
// ── THREE SIGNALS, KEPT APART ────────────────────────────────────────────────────────────────
|
|
32
|
+
// "Working on" is not one fact. It is three, with different strengths and different lifetimes:
|
|
33
|
+
//
|
|
34
|
+
// session a live PTY is on initiative/<id> — strongest; ends when the process does
|
|
35
|
+
// tree a registered working tree is on that branch — nothing is running in it
|
|
36
|
+
// open this window has a tab open on it — intent, not machine state; persisted
|
|
37
|
+
//
|
|
38
|
+
// They are ranked, never merged. `reason` survives all the way to the row's tooltip so the surface
|
|
39
|
+
// can say which one it is: a single undifferentiated "active" badge would assert something none of
|
|
40
|
+
// the three supports on its own.
|
|
41
|
+
//
|
|
42
|
+
// Pure functions over plain rows, like lib/tree-share.js and for the same reason — the rules are the
|
|
43
|
+
// part worth walking exhaustively in node, without a browser and without a server.
|
|
44
|
+
//
|
|
45
|
+
// ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
|
|
46
|
+
// A UMD, so the SERVER can hold the same rules the BROWSER draws from. Today the drawing consumer is
|
|
47
|
+
// pm/panel-initiatives.js; test.js is the other, and it walks the whole input domain.
|
|
48
|
+
(function (root, factory) {
|
|
49
|
+
const api = factory(
|
|
50
|
+
typeof require === 'function' && typeof module !== 'undefined' && module.exports
|
|
51
|
+
? require('./session-state')
|
|
52
|
+
: (root && root.PMOSSessionState),
|
|
53
|
+
);
|
|
54
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
55
|
+
else root.PMOSWorkingSet = api;
|
|
56
|
+
})(typeof self !== 'undefined' ? self : this, function (SS) {
|
|
57
|
+
|
|
58
|
+
const PREFIX = 'initiative/';
|
|
59
|
+
|
|
60
|
+
/** The three reasons an initiative is in the working set, loudest first. */
|
|
61
|
+
const REASONS = ['session', 'tree', 'open'];
|
|
62
|
+
const REASON_RANK = { session: 2, tree: 1, open: 0 };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The initiative id a branch name names, or null.
|
|
66
|
+
*
|
|
67
|
+
* Strict on purpose. `initiative/` with nothing after it is not an initiative, and neither is
|
|
68
|
+
* `feature/initiative/x` — the convention is a PREFIX, and matching it loosely is how a marker
|
|
69
|
+
* lands on a row that has nothing to do with the session.
|
|
70
|
+
*/
|
|
71
|
+
function initiativeOfBranch(branch) {
|
|
72
|
+
const b = typeof branch === 'string' ? branch : '';
|
|
73
|
+
if (!b.startsWith(PREFIX)) return null;
|
|
74
|
+
const id = b.slice(PREFIX.length);
|
|
75
|
+
return id ? id : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is this session row one the board should draw? A corpse is not. */
|
|
79
|
+
function isLive(s) {
|
|
80
|
+
return !!s && s.shown !== 'exited';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every LIVE session, grouped by the initiative its working tree is on.
|
|
85
|
+
*
|
|
86
|
+
* sessions the rows /api/sessions publishes (id, ws, name, shown, label, …)
|
|
87
|
+
* branchOf ws id → branch string, or null. The caller supplies it because the browser holds
|
|
88
|
+
* it as a Map built by foldWorkspaces and the server holds it as a workspace list;
|
|
89
|
+
* taking a function rather than either shape keeps this module ignorant of both.
|
|
90
|
+
*
|
|
91
|
+
* Returns `{ <id>: [session, …] }` — insertion-ordered, never containing an empty array, and
|
|
92
|
+
* never containing an initiative the join could not actually reach.
|
|
93
|
+
*/
|
|
94
|
+
function sessionsByInitiative(sessions, branchOf) {
|
|
95
|
+
const out = Object.create(null);
|
|
96
|
+
if (typeof branchOf !== 'function') return out;
|
|
97
|
+
for (const s of Array.isArray(sessions) ? sessions : []) {
|
|
98
|
+
if (!isLive(s) || !s.ws) continue;
|
|
99
|
+
let id = null;
|
|
100
|
+
// A branch lookup that THROWS must not take the board down with it. The map is the data
|
|
101
|
+
// layer's and this module does not own its failure modes.
|
|
102
|
+
try { id = initiativeOfBranch(branchOf(s.ws)); } catch { id = null; }
|
|
103
|
+
if (!id) continue;
|
|
104
|
+
(out[id] || (out[id] = [])).push(s);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The one presented state that stands for a whole initiative's sessions — the loudest, via
|
|
111
|
+
* session-state's own ATTENTION order. Null when there is nothing live, so a caller cannot draw
|
|
112
|
+
* a mark for nothing.
|
|
113
|
+
*/
|
|
114
|
+
function stateOf(sessions) {
|
|
115
|
+
if (!SS || typeof SS.loudest !== 'function') return null;
|
|
116
|
+
return SS.loudest((sessions || []).map((s) => s && s.shown));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every initiative a registered working tree is checked out on.
|
|
121
|
+
*
|
|
122
|
+
* `branches` is a flat list of branch strings — the caller enumerates its own registry. A tree on
|
|
123
|
+
* initiative/<id> means the PM has a checkout dedicated to that initiative, whether or not
|
|
124
|
+
* anything is running in it; that is a weaker claim than a live session and a real one.
|
|
125
|
+
*/
|
|
126
|
+
function initiativesWithTree(branches) {
|
|
127
|
+
const out = new Set();
|
|
128
|
+
for (const b of Array.isArray(branches) ? branches : []) {
|
|
129
|
+
const id = initiativeOfBranch(b);
|
|
130
|
+
if (id) out.add(id);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Every initiative this window has a tab open on.
|
|
137
|
+
*
|
|
138
|
+
* The weakest of the three signals and the only one that reflects INTENT rather than machine
|
|
139
|
+
* state. It is also durable: pm/state.js persists `tabs`, so it survives a reload — which is the
|
|
140
|
+
* whole reason it is worth reading. It can be stale (a tab left open for a week reads like one
|
|
141
|
+
* opened a minute ago), which is why it ranks last and why its reason is carried, not hidden.
|
|
142
|
+
*/
|
|
143
|
+
function initiativesWithTab(tabs) {
|
|
144
|
+
const out = new Set();
|
|
145
|
+
for (const t of Array.isArray(tabs) ? tabs : []) {
|
|
146
|
+
if (t && typeof t.slug === 'string' && t.slug) out.add(t.slug);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* THE WORKING SET — the rows the board pins above the Objectives.
|
|
153
|
+
*
|
|
154
|
+
* live {id: [session…]} from sessionsByInitiative
|
|
155
|
+
* trees Set<id> from initiativesWithTree
|
|
156
|
+
* tabs Set<id> from initiativesWithTab
|
|
157
|
+
* onBoard (id) => boolean is this slug on the repository currently showing? REQUIRED, and
|
|
158
|
+
* the reason a tab or a tree belonging to another repository cannot
|
|
159
|
+
* leak a row onto the wrong board.
|
|
160
|
+
*
|
|
161
|
+
* Ordered by attention: needs → blocked → working → quiet → tree → open, alphabetical within a
|
|
162
|
+
* band. So the initiative waiting on the PM is the first row on the screen.
|
|
163
|
+
*
|
|
164
|
+
* Each entry is `{slug, reason, state, sessions}`. `reason` is one of REASONS and is never
|
|
165
|
+
* collapsed away — the surface says which of the three it is.
|
|
166
|
+
*/
|
|
167
|
+
function workingSet({ live, trees, tabs, onBoard } = {}) {
|
|
168
|
+
const L = live && typeof live === 'object' ? live : {};
|
|
169
|
+
const T = trees instanceof Set ? trees : new Set();
|
|
170
|
+
const B = tabs instanceof Set ? tabs : new Set();
|
|
171
|
+
const keep = typeof onBoard === 'function' ? onBoard : () => false;
|
|
172
|
+
|
|
173
|
+
const seen = new Map();
|
|
174
|
+
const add = (slug, reason, sessions) => {
|
|
175
|
+
if (!slug || seen.has(slug) || !keep(slug)) return;
|
|
176
|
+
seen.set(slug, { slug, reason, state: reason === 'session' ? stateOf(sessions) : null, sessions: sessions || [] });
|
|
177
|
+
};
|
|
178
|
+
for (const slug of Object.keys(L)) add(slug, 'session', L[slug]);
|
|
179
|
+
for (const slug of T) add(slug, 'tree', null);
|
|
180
|
+
for (const slug of B) add(slug, 'open', null);
|
|
181
|
+
|
|
182
|
+
const attention = (e) => (e.state && SS && SS.ATTENTION ? (SS.ATTENTION[e.state] || 0) : 0);
|
|
183
|
+
return [...seen.values()].sort((a, b) => {
|
|
184
|
+
if (REASON_RANK[a.reason] !== REASON_RANK[b.reason]) return REASON_RANK[b.reason] - REASON_RANK[a.reason];
|
|
185
|
+
if (attention(a) !== attention(b)) return attention(b) - attention(a);
|
|
186
|
+
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* What the PM reads when they hover a row's session mark: every live session named INDIVIDUALLY,
|
|
192
|
+
* with the SERVER's own word for its state.
|
|
193
|
+
*
|
|
194
|
+
* The glyph summarises and the tooltip does not — a count would reproduce the status bar's
|
|
195
|
+
* "3 sessions running", which is the thing that says how many and never which. `s.label` is
|
|
196
|
+
* server-rendered (session-state's `label`, one clock, one sentence), so this cannot word a
|
|
197
|
+
* session differently from the pane it belongs to. A row with no label falls back to `shown`
|
|
198
|
+
* rather than inventing a sentence.
|
|
199
|
+
*/
|
|
200
|
+
function sessionTitle(slug, sessions) {
|
|
201
|
+
const rows = (sessions || []).filter(isLive);
|
|
202
|
+
if (!rows.length) return '';
|
|
203
|
+
const lines = rows.map((s) => '· ' + (s.name || s.id || 'session') + ' — ' + (s.label || s.shown || 'unknown'));
|
|
204
|
+
const n = rows.length;
|
|
205
|
+
return [
|
|
206
|
+
n + ' live session' + (n === 1 ? '' : 's') + ' on ' + PREFIX + slug,
|
|
207
|
+
...lines,
|
|
208
|
+
].join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One line naming why an initiative is in the working set. Stated, never implied. */
|
|
212
|
+
function reasonTitle(entry) {
|
|
213
|
+
if (!entry) return '';
|
|
214
|
+
if (entry.reason === 'session') return sessionTitle(entry.slug, entry.sessions);
|
|
215
|
+
if (entry.reason === 'tree') return 'a registered working tree is on ' + PREFIX + entry.slug + ' — nothing is running in it';
|
|
216
|
+
return 'you have a tab open on this initiative in this window';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
PREFIX, REASONS, REASON_RANK,
|
|
221
|
+
initiativeOfBranch, isLive, sessionsByInitiative, stateOf,
|
|
222
|
+
initiativesWithTree, initiativesWithTab, workingSet, sessionTitle, reasonTitle,
|
|
223
|
+
};
|
|
224
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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.24.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
5
|
+
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"pmos": "bin/pmos.js"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"playwright-core": "^1.61.1",
|
|
30
30
|
"ws": "^8.18.0"
|
|
31
31
|
},
|
|
32
|
-
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs
|
|
32
|
+
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"pixelmatch": "^7.1.0",
|
|
35
35
|
"pngjs": "^7.0.0"
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
Both read this file, so the sentence in the terminal and the sentence in the chooser cannot
|
|
36
36
|
drift apart. Classic script (a UMD setting window.PMOSTreeShare), like the two above. -->
|
|
37
37
|
<script src="/lib/tree-share.js"></script>
|
|
38
|
+
<!-- The session→initiative join, made once over every session instead of once per initiative
|
|
39
|
+
(workspace-active-visibility). The SERVER makes the same two-hopper per initiative inside
|
|
40
|
+
/api/initiative (lib/four-panel.js sessionsFor); the BOARD needs it for 178 rows at once and
|
|
41
|
+
cannot spend 178 requests on it. Loaded here rather than imported so it stays walkable in node
|
|
42
|
+
— test.js requires it and walks its whole input domain. It reads window.PMOSSessionState for
|
|
43
|
+
the attention order, so it must come AFTER that script. -->
|
|
44
|
+
<script src="/lib/working-set.js"></script>
|
|
38
45
|
<!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
|
|
39
46
|
window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
|
|
40
47
|
rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
|