create-pmos 0.14.0 → 0.16.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 +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +157 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +14 -4
- package/workspace/public/index.html +79 -279
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +1072 -31
- package/workspace/public/app.js +0 -3450
- package/workspace/public/styles.css +0 -1444
package/README.md
CHANGED
|
@@ -333,6 +333,63 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.16.0
|
|
337
|
+
|
|
338
|
+
**The Terminal Workspace is now a four-panel surface.** The three-column layout is retired and
|
|
339
|
+
replaced by one built to a PM-authored design: repositories, initiatives, initiative detail, and a
|
|
340
|
+
tab strip. Run `npx create-pmos@latest workspace`; the version bump means an existing install
|
|
341
|
+
auto-upgrades.
|
|
342
|
+
|
|
343
|
+
- **Panel 1 — repositories.** Every repo you've added, with its kind (`⎇` github / `▤` local) and
|
|
344
|
+
its initiative count, plus a settings & tools drawer holding the secondary modes — `agents`,
|
|
345
|
+
`memory`, `knowledge` for the current repository, and `focus`, `skills`, `connections`,
|
|
346
|
+
`scheduled` across all of them.
|
|
347
|
+
- **Panel 2 — initiatives, as a channel list.** Collapsible objective/KR groups over `#slug` rows,
|
|
348
|
+
one search box that forces the groups open and reports matches elsewhere, and an add form.
|
|
349
|
+
Initiatives with no anchor are filed and shown as rejected (D59) rather than quietly hidden.
|
|
350
|
+
- **Panel 3 — initiative detail.** The record's pills and derived footer, **task phases** whose
|
|
351
|
+
status is derived from the workflow ladder rather than declared, a **links** section for
|
|
352
|
+
references that are not repo files, and the artifact list. A record that fails to parse renders
|
|
353
|
+
red with the parse error — never a blank panel.
|
|
354
|
+
- **Panel 4 — tabs, scoped per repository.** Three kinds side by side: **term** (a real PTY running
|
|
355
|
+
your agent CLI untouched), **doc** (view/raw/edit/diff with save, revert and a dirty flag), and
|
|
356
|
+
**task** (the task spec view). New session, close, and split.
|
|
357
|
+
- **A phone surface.** The same four panels collapse into a mobile navigation model, so triage from
|
|
358
|
+
a phone is a first-class path and not a scaled-down desktop.
|
|
359
|
+
- **Artifact links are declarations, not integrations.** Linking an artifact to an external
|
|
360
|
+
destination appends `{artifact_path, kind, target}` to `pmos/state/artifact-links.jsonl`. The sync
|
|
361
|
+
panel is rendered, but with no connector configured its summary line says exactly that — *no
|
|
362
|
+
connector configured — nothing was read, and nothing will be written* — and the approval buttons
|
|
363
|
+
are disabled. It will not show you a difference it never read.
|
|
364
|
+
- **`?fixture=proto`** renders the whole surface from a bundled fixture with no repository at all,
|
|
365
|
+
which is also how the design comparison is run. It is never the default path.
|
|
366
|
+
|
|
367
|
+
Unchanged where it matters: **no database, no network at runtime, no telemetry.** The front end
|
|
368
|
+
loads only same-origin assets — fonts and the terminal emulator are served from the install, never
|
|
369
|
+
from a CDN — the server binds `127.0.0.1`, and the only outbound paths remain the ones you invoke
|
|
370
|
+
yourself (`git clone` on repo ingest, `pmos upgrade`, and the one-time `npm install` that builds
|
|
371
|
+
node-pty). No new runtime dependency was added. The file-mode kit itself is unchanged and still
|
|
372
|
+
dependency-free.
|
|
373
|
+
|
|
374
|
+
## What's new in 0.15.0
|
|
375
|
+
|
|
376
|
+
**A major Terminal Workspace rebuild** (#242) — the PM spine, a Claude Design refresh, and a proper
|
|
377
|
+
viewer column, plus five new capabilities. Run `npx create-pmos@latest workspace`; the version bump
|
|
378
|
+
means an existing install auto-upgrades.
|
|
379
|
+
|
|
380
|
+
- **A Focus queue** — the workspace surfaces what actually needs you, as a queue to work down rather
|
|
381
|
+
than a grid to scan.
|
|
382
|
+
- **A phone surface** — the workspace now has a real mobile navigation model, so you can triage from
|
|
383
|
+
a phone, not only a desktop.
|
|
384
|
+
- **Claude profiles** — per-session profile descriptors, so different agent setups are legible at a
|
|
385
|
+
glance.
|
|
386
|
+
- **Multi-product repo-scan** — the ingest pipeline can scan across more than one product repo.
|
|
387
|
+
- **A Jira handoff model** — a links model for handing an initiative off to an external tracker from
|
|
388
|
+
the workspace (opt-in; the file-mode kit still needs no network).
|
|
389
|
+
|
|
390
|
+
The file-mode kit itself is unchanged and still dependency-free — all of the above lives in the
|
|
391
|
+
optional Terminal Workspace GUI.
|
|
392
|
+
|
|
336
393
|
## What's new in 0.14.0
|
|
337
394
|
|
|
338
395
|
**The foundation of an autonomous workflow driver lands in the workspace.** Two new libraries
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-07-
|
|
1
|
+
kernel-source: pmos@198c57c
|
|
2
|
+
assembled: 2026-07-30
|
|
3
3
|
mode: vendor
|
package/workspace/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "PMOS Terminal Workspace"
|
|
4
|
-
timestamp: 2026-07-
|
|
4
|
+
timestamp: 2026-07-30
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# PMOS Terminal Workspace
|
|
@@ -49,14 +49,34 @@ npm run test:ui # browser checks: terminal geometry, reader scroll, markdow
|
|
|
49
49
|
```
|
|
50
50
|
</details>
|
|
51
51
|
|
|
52
|
-
## Layout (PM-directed,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
## Layout — four panels (PM-directed design, built by `workspace-four-panel`)
|
|
53
|
+
The three-column layout is retired. The surface is now **four collapsible panels** under a 44px top
|
|
54
|
+
bar and a 26px status bar, on the oklch token layer, in both themes:
|
|
55
|
+
|
|
56
|
+
1. **Repositories** — every repo you've added, with its kind (`⎇` github / `▤` local) and a
|
|
57
|
+
`github|local · N initiatives` sub-line. `+` adds one by picking a folder (typing a path still
|
|
58
|
+
works; where no chooser exists, an in-app folder browser stands in). The **settings & tools**
|
|
59
|
+
drawer holds the secondary modes in two groups: *this repository* (`agents` · `memory` ·
|
|
60
|
+
`knowledge`) and *all repositories* (`focus` · `skills` · `connections` · `scheduled`). There is
|
|
61
|
+
deliberately no `workflow` entry — workflows are decided by the agent at runtime, not configured.
|
|
62
|
+
2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
|
|
63
|
+
box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
|
|
64
|
+
initiatives are filed honestly and shown **rejected** under D59, never hidden.
|
|
65
|
+
3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the derived
|
|
66
|
+
footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
|
|
67
|
+
are derived from the workflow ladder unless the record authors them), a **links** section for
|
|
68
|
+
references that are not repo files, and **artifacts** with an external destination and the sync
|
|
69
|
+
panel. A record that fails to read renders **red with the parse error** — never a blank panel.
|
|
70
|
+
4. **Tabs** — scoped per repository, two-line labels naming the initiative, three kinds: **term** (a
|
|
71
|
+
real PTY), **doc** (view/raw/edit/diff, save/revert, dirty flag), and **task** (the task spec).
|
|
72
|
+
`+ session`, close, split.
|
|
73
|
+
|
|
74
|
+
`⌘K`/`Ctrl-K` focuses search; `Esc` closes the active tab. The panel state you choose survives a
|
|
75
|
+
reload.
|
|
76
|
+
|
|
77
|
+
- **A phone surface.** The same four panel modules render on a phone — one shell, one renderer, no
|
|
78
|
+
phone-shaped copy of a list. `lib/mobile-nav.js` owns the destinations and the stack; the live
|
|
79
|
+
terminal host is never re-parented, so a session you navigate away from keeps running.
|
|
60
80
|
- **Rename a session any time** — the ✎ on its row, a double-click on its tab, or `F2`. Live or
|
|
61
81
|
exited, and it survives a server restart.
|
|
62
82
|
- **Documents read like documents** — the identity block becomes a card, tables are tables,
|
|
@@ -66,6 +86,18 @@ the split and the rail width are draggable. The layout you choose survives a rel
|
|
|
66
86
|
denied); markdown renders **repo-relative images inline**. A remote `` image is
|
|
67
87
|
never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).
|
|
68
88
|
|
|
89
|
+
**Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the
|
|
90
|
+
server unable to read any repository — that is how the design comparison is run
|
|
91
|
+
(`npm run test:parity`), and it is never the default.
|
|
92
|
+
|
|
93
|
+
## Artifact links are declarations, not integrations
|
|
94
|
+
PMOS makes no outbound request and holds no credential. Linking an artifact to an external
|
|
95
|
+
destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to
|
|
96
|
+
`pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector
|
|
97
|
+
configured its summary line says exactly that: *no connector configured — nothing was read, and
|
|
98
|
+
nothing will be written*, with the approval buttons disabled. A difference summary it never read
|
|
99
|
+
would be the watermelon this repo has a word for.
|
|
100
|
+
|
|
69
101
|
## The gates are real, never automatic (R5)
|
|
70
102
|
`approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and
|
|
71
103
|
clear the matching needs entry — only ever on your click.
|
|
@@ -83,9 +115,9 @@ count them. `pm_label` is **never** derived from a click — κ labels are the P
|
|
|
83
115
|
|
|
84
116
|
## Your own runs are searchable (the history lens)
|
|
85
117
|
Every session's scrollback is already mirrored to disk, which makes it a record of what earlier
|
|
86
|
-
runs actually **did** — the thing structured run rows don't carry.
|
|
87
|
-
|
|
88
|
-
the
|
|
118
|
+
runs actually **did** — the thing structured run rows don't carry. `GET /api/search?q=…&ws=…`
|
|
119
|
+
searches it, and an agent working in a pane can ask the question itself. (The four-panel rebuild
|
|
120
|
+
has not yet re-surfaced this as a UI lens — the endpoint is live, the front-end entry point is not.)
|
|
89
121
|
|
|
90
122
|
Two guarantees. It is scoped to **one repo** — a workspace never searches another's history
|
|
91
123
|
(R1/R3), and an unrecognised workspace id resolves to home, so it can only ever see less.
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* design-parity — decides whether the workspace matches the design the PM handed over.
|
|
3
|
+
*
|
|
4
|
+
* The PM's bar for this surface was one word: IDENTICAL. That is not a thing anyone can hold in
|
|
5
|
+
* their head across four panels, two themes and ten interaction states, so it is decided here
|
|
6
|
+
* mechanically instead. This renders the PM's own prototype and the built app side by side at the
|
|
7
|
+
* same viewport with the same font files, and diffs them on four axes:
|
|
8
|
+
*
|
|
9
|
+
* text the ordered list of rendered text nodes — a reworded label is a failure
|
|
10
|
+
* style computed styles on structurally-matched elements
|
|
11
|
+
* geometry bounding boxes, to a 2px tolerance
|
|
12
|
+
* pixels a screenshot diff, failing above 0.5% of the viewport
|
|
13
|
+
*
|
|
14
|
+
* WHY THE APP IS RENDERED IN FIXTURE MODE. A diff needs like-for-like content, and the app's real
|
|
15
|
+
* content is this repository. `?fixture=proto` loads the prototype's own seed data through the app's
|
|
16
|
+
* real renderer — so what is being compared is the rendering, which is the thing under test, and not
|
|
17
|
+
* the data, which is not. The same fixture doubles as the offline demo path: it proves the UI draws
|
|
18
|
+
* with no repository readable at all. What it deliberately does NOT prove is that the app is wired to
|
|
19
|
+
* anything; that is dimension 2 of the rubric and it is asserted in ui-test.js against real state.
|
|
20
|
+
*
|
|
21
|
+
* OFFLINE BY CONSTRUCTION. The prototype's runtime fetches React from unpkg. Both files are vendored
|
|
22
|
+
* under planning/design/four-panel/vendor/ and injected through the runtime's own `window.__resources`
|
|
23
|
+
* override, and the Google Fonts link is rewritten to the workspace's own vendored woff2 — the same
|
|
24
|
+
* byte-identical faces the app serves, so a font-fallback difference can never be mistaken for a
|
|
25
|
+
* design difference. Babel is not vendored because the prototype does not need it (verified: the
|
|
26
|
+
* render is node-for-node identical without it).
|
|
27
|
+
*
|
|
28
|
+
* node design-parity.js both themes, default state
|
|
29
|
+
* node design-parity.js --theme=light one theme
|
|
30
|
+
* node design-parity.js --states also walk the interaction states (see STATES below)
|
|
31
|
+
* node design-parity.js --state=<id> one named state
|
|
32
|
+
* node design-parity.js --keep keep the rendered PNGs for inspection
|
|
33
|
+
*
|
|
34
|
+
* THE STATE WALK. `--states` was advertised in this header for two review rounds while doing
|
|
35
|
+
* nothing — the flag was never read, so "identical across the captured interaction states" (PRD
|
|
36
|
+
* success criterion 1, and the rubric's test_method) was decided on the opening screen alone. It is
|
|
37
|
+
* implemented now: each state is a list of LABELS to click, applied to the prototype and to the app
|
|
38
|
+
* from a fresh load, and the same four axes are diffed at the end of the sequence. The steps are
|
|
39
|
+
* text, not selectors, deliberately — a selector would encode one page's DOM and quietly stop
|
|
40
|
+
* matching on the other, which is the shape a check takes when it goes decorative. A step that
|
|
41
|
+
* cannot find its label on EITHER page is a hard failure: "could not drive the app there" must
|
|
42
|
+
* never be recorded as "the app matched there".
|
|
43
|
+
*
|
|
44
|
+
* Exit 0 only when every axis is clean. Exit 3 means it could not run (no browser, no server) —
|
|
45
|
+
* which is a skip, and is reported as one rather than as a pass.
|
|
46
|
+
*/
|
|
47
|
+
'use strict';
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const http = require('http');
|
|
51
|
+
const os = require('os');
|
|
52
|
+
|
|
53
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
54
|
+
const DESIGN = path.join(ROOT, 'planning', 'design', 'four-panel');
|
|
55
|
+
const FONTS = path.join(__dirname, 'public', 'vendor');
|
|
56
|
+
const APP_URL = process.env.PMOS_PARITY_APP || 'http://127.0.0.1:7433';
|
|
57
|
+
const REF_PORT = +(process.env.PMOS_PARITY_PORT || 8899);
|
|
58
|
+
const OUT = process.env.PMOS_PARITY_OUT || path.join(os.tmpdir(), 'pmos-design-parity');
|
|
59
|
+
|
|
60
|
+
const argv = process.argv.slice(2);
|
|
61
|
+
const themes = argv.some((a) => a.startsWith('--theme=')) ? [argv.find((a) => a.startsWith('--theme=')).split('=')[1]] : ['dark', 'light'];
|
|
62
|
+
const KEEP = argv.includes('--keep');
|
|
63
|
+
const W = 1600, H = 1000;
|
|
64
|
+
|
|
65
|
+
/* THE CAPTURED INTERACTION STATES.
|
|
66
|
+
*
|
|
67
|
+
* Each entry is a state the PM can put the surface into, expressed as the labels a person would
|
|
68
|
+
* click to get there — read as the element's OWN text, so "add repository" means the span holding
|
|
69
|
+
* exactly that, in either page's markup. `default` is the opening screen and always runs.
|
|
70
|
+
*
|
|
71
|
+
* Chosen because each one changes a DIFFERENT part of the layout, so the set covers the interaction
|
|
72
|
+
* model the handoff brief calls final rather than repeating one kind of change: a drawer over panel
|
|
73
|
+
* 1, a collapsed group in panel 2, an expanded task tree and an opened tab in panels 3 and 4, a
|
|
74
|
+
* collapsed column, and the panel-3 inline expansion. A state whose divergence is a DECISION rather
|
|
75
|
+
* than a defect does not belong here — it belongs in the PRD — so if one is ever excluded, the
|
|
76
|
+
* reason goes in `why` and gets printed, not left as an absence nobody can see.
|
|
77
|
+
*/
|
|
78
|
+
const STATES = [
|
|
79
|
+
{ id: 'default', steps: [] },
|
|
80
|
+
{ id: 'settings-drawer', steps: ['settings & tools'] },
|
|
81
|
+
{ id: 'group-collapsed', steps: ['health:tech'] },
|
|
82
|
+
{ id: 'phase-collapsed', steps: ['intake'] },
|
|
83
|
+
{ id: 'task-tab-open', steps: ['produce the build output'] },
|
|
84
|
+
{ id: 'column-collapsed', steps: ['⟨'] },
|
|
85
|
+
{ id: 'add-repository', steps: ['add repository'] },
|
|
86
|
+
];
|
|
87
|
+
const stateArg = argv.find((a) => a.startsWith('--state='));
|
|
88
|
+
const states = stateArg
|
|
89
|
+
? STATES.filter((s) => s.id === stateArg.split('=')[1])
|
|
90
|
+
: (argv.includes('--states') ? STATES : STATES.filter((s) => s.id === 'default'));
|
|
91
|
+
if (!states.length) {
|
|
92
|
+
console.error(`no such state: ${stateArg.split('=')[1]} — known: ${STATES.map((s) => s.id).join(', ')}`);
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PROPS = [
|
|
97
|
+
'display', 'gridTemplateColumns', 'gridTemplateRows', 'flexDirection', 'alignItems',
|
|
98
|
+
'justifyContent', 'gap', 'minHeight', 'padding', 'backgroundColor', 'color',
|
|
99
|
+
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
|
100
|
+
'borderLeftColor', 'borderBottomColor', 'borderRadius', 'fontFamily', 'fontSize', 'fontWeight',
|
|
101
|
+
'lineHeight', 'letterSpacing', 'textTransform', 'writingMode', 'overflowX', 'overflowY',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
function skip(why) { console.log(`⏭ design-parity SKIPPED — ${why}`); process.exit(3); }
|
|
105
|
+
|
|
106
|
+
function requireOptional(name) { try { return require(name); } catch { return null; } }
|
|
107
|
+
|
|
108
|
+
// ── the reference page, patched offline in memory ───────────────────────────────────────────────
|
|
109
|
+
function referenceHtml() {
|
|
110
|
+
const src = fs.readFileSync(path.join(DESIGN, 'prototype-original.html'), 'utf8');
|
|
111
|
+
const map = JSON.stringify({
|
|
112
|
+
'https://unpkg.com/react@18.3.1/umd/react.production.min.js': './vendor/react.production.min.js',
|
|
113
|
+
'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js': './vendor/react-dom.production.min.js',
|
|
114
|
+
});
|
|
115
|
+
let h = src.replace('<script src="./support.js"></script>',
|
|
116
|
+
`<script>window.__resources=${map};</script>\n<script src="./support.js"></script>`);
|
|
117
|
+
h = h.replace(/<link rel="preconnect"[^>]*>\s*/g, '');
|
|
118
|
+
h = h.replace(/<link href="https:\/\/fonts\.googleapis\.com[^>]*>/, '<link rel="stylesheet" href="./fonts.css">');
|
|
119
|
+
if (/fonts\.googleapis|unpkg\.com/.test(h.replace(map, ''))) {
|
|
120
|
+
throw new Error('reference page still carries a remote asset after patching — refusing to compare against a page that would need the network');
|
|
121
|
+
}
|
|
122
|
+
return h;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const FONTS_CSS = `
|
|
126
|
+
@font-face{font-family:"Space Grotesk";src:url("./fonts/space-grotesk-var.woff2") format("woff2-variations");font-weight:300 700;font-style:normal;font-display:block}
|
|
127
|
+
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-400.woff2") format("woff2");font-weight:400;font-style:normal;font-display:block}
|
|
128
|
+
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-500.woff2") format("woff2");font-weight:500;font-style:normal;font-display:block}
|
|
129
|
+
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-700.woff2") format("woff2");font-weight:700;font-style:normal;font-display:block}
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
function serveReference() {
|
|
133
|
+
const html = referenceHtml();
|
|
134
|
+
const types = { '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.html': 'text/html' };
|
|
135
|
+
const server = http.createServer((req, res) => {
|
|
136
|
+
const p = decodeURIComponent(req.url.split('?')[0]);
|
|
137
|
+
if (p === '/' || p === '/index.html') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(html); }
|
|
138
|
+
if (p === '/fonts.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(FONTS_CSS); }
|
|
139
|
+
let file = null;
|
|
140
|
+
if (p.startsWith('/fonts/')) file = path.join(FONTS, path.basename(p));
|
|
141
|
+
else if (p === '/support.js' || p.startsWith('/vendor/')) file = path.join(DESIGN, p.replace(/^\//, ''));
|
|
142
|
+
if (file && fs.existsSync(file)) {
|
|
143
|
+
res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' });
|
|
144
|
+
return res.end(fs.readFileSync(file));
|
|
145
|
+
}
|
|
146
|
+
res.writeHead(404).end('not found');
|
|
147
|
+
});
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
server.on('error', reject);
|
|
150
|
+
server.listen(REF_PORT, '127.0.0.1', () => resolve(server));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Click the element whose OWN text is `label`, on whichever page it is given. Returns false when
|
|
155
|
+
* nothing visible carries that text — the caller turns that into a failure rather than skipping the
|
|
156
|
+
* state, because a state that could not be reached is not a state that matched. `closest('button')`
|
|
157
|
+
* is what actually gets clicked: the prototype's runtime wraps each interpolated label in a span, so
|
|
158
|
+
* the text node and the control are usually different elements. */
|
|
159
|
+
async function drive(page, steps) {
|
|
160
|
+
const applied = [];
|
|
161
|
+
for (const label of steps) {
|
|
162
|
+
const hit = await page.evaluate((lab) => {
|
|
163
|
+
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3)
|
|
164
|
+
.map((n) => n.textContent.replace(/\s+/g, ' ').trim()).join(' ').trim();
|
|
165
|
+
const visible = (e) => {
|
|
166
|
+
const cs = getComputedStyle(e);
|
|
167
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
168
|
+
const r = e.getBoundingClientRect();
|
|
169
|
+
return r.width > 0 && r.height > 0;
|
|
170
|
+
};
|
|
171
|
+
const target = [...document.querySelectorAll('*')].find((e) => own(e) === lab && visible(e));
|
|
172
|
+
if (!target) return false;
|
|
173
|
+
(target.closest('button,[role="button"]') || target).click();
|
|
174
|
+
return true;
|
|
175
|
+
}, label);
|
|
176
|
+
applied.push({ label, hit });
|
|
177
|
+
await page.waitForTimeout(600);
|
|
178
|
+
}
|
|
179
|
+
return applied;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function snap(browser, url, theme, tag, steps = []) {
|
|
183
|
+
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
|
|
184
|
+
const errors = [];
|
|
185
|
+
page.on('pageerror', (e) => errors.push(String(e.message)));
|
|
186
|
+
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
|
187
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
|
188
|
+
await page.waitForTimeout(1200);
|
|
189
|
+
if (theme === 'light') {
|
|
190
|
+
await page.evaluate(() => {
|
|
191
|
+
const b = [...document.querySelectorAll('button')].find((x) => /theme/i.test(x.textContent || ''));
|
|
192
|
+
if (b) b.click();
|
|
193
|
+
});
|
|
194
|
+
await page.waitForTimeout(500);
|
|
195
|
+
}
|
|
196
|
+
// The interaction steps are applied AFTER the theme is settled, so a state is diffed in the theme
|
|
197
|
+
// it was reached in rather than in whatever theme the toggle left behind.
|
|
198
|
+
const applied = await drive(page, steps);
|
|
199
|
+
await page.waitForFunction(() => document.fonts.status === 'loaded', null, { timeout: 15000 }).catch(() => {});
|
|
200
|
+
const png = path.join(OUT, `${tag}.png`);
|
|
201
|
+
await page.screenshot({ path: png });
|
|
202
|
+
const data = await page.evaluate((props) => {
|
|
203
|
+
const root = document.querySelector('[data-pm-theme]') || document.body;
|
|
204
|
+
const nodes = [], texts = [];
|
|
205
|
+
const walk = (e) => {
|
|
206
|
+
const cs = getComputedStyle(e);
|
|
207
|
+
if (cs.display === 'none') return;
|
|
208
|
+
const r = e.getBoundingClientRect();
|
|
209
|
+
const own = [...e.childNodes].filter((n) => n.nodeType === 3)
|
|
210
|
+
.map((n) => n.textContent.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
|
|
211
|
+
const st = {}; for (const p of props) st[p] = cs[p];
|
|
212
|
+
// `full` is the element's WHOLE visible text, used to anchor style comparison; `own` is only
|
|
213
|
+
// its own text nodes. They differ because the prototype's runtime wraps each interpolation in
|
|
214
|
+
// a span, so its label sits one element deeper than the app's.
|
|
215
|
+
nodes.push({ text: own.slice(0, 60), full: (e.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
216
|
+
box: [r.x, r.y, r.width, r.height].map(Math.round), st });
|
|
217
|
+
// Walk childNodes IN ORDER, emitting text where it actually sits rather than hoisting all of an
|
|
218
|
+
// element's own text ahead of its children. The status bar is the case that forced this: the
|
|
219
|
+
// design writes "local · persistent server · ", then a <span> holding the count, then
|
|
220
|
+
// " sessions running", so hoisting produced "…server · sessions running 3" and reported a text
|
|
221
|
+
// difference on a bar that is pixel-identical. Reading order is what the PM sees; it is what
|
|
222
|
+
// the stream has to be.
|
|
223
|
+
for (const c of e.childNodes) {
|
|
224
|
+
if (c.nodeType === 3) {
|
|
225
|
+
const s = c.textContent.replace(/\s+/g, ' ').trim();
|
|
226
|
+
if (s) texts.push(s);
|
|
227
|
+
} else if (c.nodeType === 1) walk(c);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
walk(root);
|
|
231
|
+
return { nodes, texts };
|
|
232
|
+
}, PROPS);
|
|
233
|
+
await page.close();
|
|
234
|
+
return { ...data, errors, png, applied };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function diff(A, B, theme) {
|
|
238
|
+
const problems = [];
|
|
239
|
+
if (B.errors.length) problems.push(`console errors in the app (${B.errors.length}): ${B.errors.slice(0, 5).join(' | ')}`);
|
|
240
|
+
// Compare the CONCATENATED stream, not the node list. Node boundaries are an artefact of the
|
|
241
|
+
// prototype's interpolation wrappers: its template runtime wraps every {{ }} in a <span>, so the
|
|
242
|
+
// status bar's session count is three text nodes there and one in the app. The handoff brief calls
|
|
243
|
+
// that runtime an implementation detail, so reproducing its wrapper elements would be copying the
|
|
244
|
+
// scaffolding rather than the design. What the PM reads is the sequence of words on screen, and
|
|
245
|
+
// that is what has to match — in content and in order.
|
|
246
|
+
const streamA = A.texts.join(' ').replace(/\s+/g, ' ').trim();
|
|
247
|
+
const streamB = B.texts.join(' ').replace(/\s+/g, ' ').trim();
|
|
248
|
+
let missing = [], extra = [];
|
|
249
|
+
if (streamA !== streamB) {
|
|
250
|
+
const wa = streamA.split(' '), wb = streamB.split(' ');
|
|
251
|
+
let i = 0;
|
|
252
|
+
while (i < wa.length && i < wb.length && wa[i] === wb[i]) i++;
|
|
253
|
+
missing = wa.slice(i, i + 20); extra = wb.slice(i, i + 20);
|
|
254
|
+
problems.push('the rendered text differs — first divergence at word ' + i + ':\n design: …' +
|
|
255
|
+
wa.slice(Math.max(0, i - 6), i + 14).join(' ') + '\n app: …' +
|
|
256
|
+
wb.slice(Math.max(0, i - 6), i + 14).join(' '));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Anchor on FULL text, keeping the OUTERMOST match. The walk is pre-order, so a parent is seen
|
|
260
|
+
// before its children and first-wins yields the outermost element carrying that text — pairing the
|
|
261
|
+
// design's <button> with the app's <button>, rather than pairing the design's interpolation <span>
|
|
262
|
+
// against the app's button and reporting the button's own padding and border as differences.
|
|
263
|
+
const index = (ns) => { const m = new Map(); for (const n of ns) if (n.full && !m.has(n.full)) m.set(n.full, n); return m; };
|
|
264
|
+
const ia = index(A.nodes), ib = index(B.nodes);
|
|
265
|
+
const sd = [];
|
|
266
|
+
for (const [t, na] of ia) {
|
|
267
|
+
const nb = ib.get(t); if (!nb) continue;
|
|
268
|
+
for (const p of PROPS) if (na.st[p] !== nb.st[p]) sd.push(` [${JSON.stringify(t.slice(0, 30))}] ${p}: design=${na.st[p]} app=${nb.st[p]}`);
|
|
269
|
+
const off = na.box.map((v, i) => Math.abs(v - nb.box[i]));
|
|
270
|
+
if (Math.max(...off) > 2) sd.push(` [${JSON.stringify(t.slice(0, 30))}] box: design=${na.box} app=${nb.box}`);
|
|
271
|
+
}
|
|
272
|
+
if (sd.length) problems.push(`${sd.length} computed-style / geometry difference(s):\n` + sd.slice(0, 40).join('\n'));
|
|
273
|
+
return { problems, missing, extra, styleDiffs: sd, theme };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
(async () => {
|
|
277
|
+
const pw = requireOptional('playwright-core');
|
|
278
|
+
if (!pw) skip('playwright-core is not installed (npm install in workspace/)');
|
|
279
|
+
// Discovery is lib/find-browser.js's, not a third private candidate list. This file used to hunt
|
|
280
|
+
// only under /opt/pw-browsers, so on a runner whose browser is /usr/bin/chromium-browser it found
|
|
281
|
+
// nothing and reported a SKIP — from a machine that had a browser the whole time.
|
|
282
|
+
const exe = require('./lib/find-browser').findBrowser();
|
|
283
|
+
if (!exe) skip('no Chrome/Chromium found (set PMOS_CHROME, or install one)');
|
|
284
|
+
if (!fs.existsSync(path.join(DESIGN, 'prototype-original.html'))) skip(`the design reference is missing at ${DESIGN}`);
|
|
285
|
+
|
|
286
|
+
const alive = await new Promise((r) => {
|
|
287
|
+
const req = http.get(APP_URL, (res) => { res.resume(); r(true); });
|
|
288
|
+
req.on('error', () => r(false)); req.setTimeout(3000, () => { req.destroy(); r(false); });
|
|
289
|
+
});
|
|
290
|
+
if (!alive) skip(`the workspace is not running at ${APP_URL} (node server.js)`);
|
|
291
|
+
|
|
292
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
293
|
+
const server = await serveReference();
|
|
294
|
+
const browser = await pw.chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--font-render-hinting=none'] });
|
|
295
|
+
|
|
296
|
+
let failed = 0, compared = 0;
|
|
297
|
+
const report = {};
|
|
298
|
+
for (const theme of themes) for (const st of states) {
|
|
299
|
+
const key = `${theme}/${st.id}`;
|
|
300
|
+
const A = await snap(browser, `http://127.0.0.1:${REF_PORT}/index.html`, theme, `design-${theme}-${st.id}`, st.steps);
|
|
301
|
+
const B = await snap(browser, `${APP_URL}/?fixture=proto`, theme, `app-${theme}-${st.id}`, st.steps);
|
|
302
|
+
const d = diff(A, B, theme);
|
|
303
|
+
|
|
304
|
+
// COULD THE STATE BE REACHED AT ALL? Asserted before anything is compared, and on BOTH pages: a
|
|
305
|
+
// step that found no label leaves the page in the previous state, where a clean diff would say
|
|
306
|
+
// only that two unchanged screens still match. Reported as a failure with the step named.
|
|
307
|
+
const missedD = A.applied.filter((s) => !s.hit).map((s) => s.label);
|
|
308
|
+
const missedA = B.applied.filter((s) => !s.hit).map((s) => s.label);
|
|
309
|
+
if (missedD.length || missedA.length) {
|
|
310
|
+
d.problems.push(`the state could not be reached — ` +
|
|
311
|
+
(missedD.length ? `no visible "${missedD.join('", "')}" in the DESIGN; ` : '') +
|
|
312
|
+
(missedA.length ? `no visible "${missedA.join('", "')}" in the APP; ` : '') +
|
|
313
|
+
`nothing below this line is a comparison of ${st.id}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let pixels = null;
|
|
317
|
+
const pixelmatch = requireOptional('pixelmatch'); const pngjs = requireOptional('pngjs');
|
|
318
|
+
if (pixelmatch && pngjs) {
|
|
319
|
+
const pm = pixelmatch.default || pixelmatch;
|
|
320
|
+
const a = pngjs.PNG.sync.read(fs.readFileSync(A.png)), b = pngjs.PNG.sync.read(fs.readFileSync(B.png));
|
|
321
|
+
if (a.width === b.width && a.height === b.height) {
|
|
322
|
+
const out = new pngjs.PNG({ width: a.width, height: a.height });
|
|
323
|
+
const n = pm(a.data, b.data, out.data, a.width, a.height, { threshold: 0.12 });
|
|
324
|
+
fs.writeFileSync(path.join(OUT, `diff-${theme}-${st.id}.png`), pngjs.PNG.sync.write(out));
|
|
325
|
+
pixels = n / (a.width * a.height);
|
|
326
|
+
if (pixels > 0.005) d.problems.push(`pixel diff ${(pixels * 100).toFixed(2)}% of the viewport — ${path.join(OUT, `diff-${theme}-${st.id}.png`)}`);
|
|
327
|
+
} else d.problems.push(`screenshot sizes differ: design ${a.width}x${a.height}, app ${b.width}x${b.height}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
report[key] = {
|
|
331
|
+
steps: st.steps,
|
|
332
|
+
designNodes: A.nodes.length, appNodes: B.nodes.length,
|
|
333
|
+
textMissing: d.missing.length, textExtra: d.extra.length,
|
|
334
|
+
styleDiffs: d.styleDiffs.length,
|
|
335
|
+
pixelPct: pixels === null ? 'not measured (pixelmatch/pngjs absent)' : +(pixels * 100).toFixed(3),
|
|
336
|
+
clean: d.problems.length === 0,
|
|
337
|
+
};
|
|
338
|
+
compared++;
|
|
339
|
+
console.log(`\n── ${key} ──`);
|
|
340
|
+
console.log(JSON.stringify(report[key], null, 1));
|
|
341
|
+
if (d.problems.length) { failed++; console.log('\n' + d.problems.join('\n\n')); }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await browser.close();
|
|
345
|
+
server.close();
|
|
346
|
+
fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 1));
|
|
347
|
+
if (!KEEP && !failed) for (const f of fs.readdirSync(OUT)) if (f.endsWith('.png')) fs.unlinkSync(path.join(OUT, f));
|
|
348
|
+
|
|
349
|
+
const scope = `${themes.join(' and ')} × ${states.length} state(s) [${states.map((s) => s.id).join(', ')}]`;
|
|
350
|
+
if (failed) {
|
|
351
|
+
console.log(`\n❌ design parity FAILED in ${failed}/${compared} comparison(s). Renders kept in ${OUT}.`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
console.log(`\n✅ design parity — the app is identical to the PM's prototype across ${scope} — ${compared} comparison(s).`);
|
|
355
|
+
if (states.length === 1 && states[0].id === 'default') {
|
|
356
|
+
console.log(' (default state only. `--states` walks all ' + STATES.length + ', which is what the PRD asks for.)');
|
|
357
|
+
}
|
|
358
|
+
})().catch((e) => { console.error('design-parity ERROR:', e.stack); process.exit(2); });
|
|
@@ -222,4 +222,51 @@ function workflow({ root }) {
|
|
|
222
222
|
|
|
223
223
|
// ENGINE_PATHS/loadEngine are exported for the packaging guard in workspace/test.js: the incident
|
|
224
224
|
// that made them a list was invisible to every unit test precisely because resolution was inline.
|
|
225
|
-
|
|
225
|
+
/* ONE initiative's ladder, derived on demand.
|
|
226
|
+
*
|
|
227
|
+
* WHY THIS IS SEPARATE FROM `workflow`. The board caps what it returns (`inFlight`) and reports the
|
|
228
|
+
* remainder as `hidden` — 12 shown against 93 hidden in this repository. That cap is right for a
|
|
229
|
+
* board, which is a list of what is in flight. It is wrong for a PANEL, which is asked about one
|
|
230
|
+
* initiative the PM picked by name: reading the capped list and finding nothing there means "not in
|
|
231
|
+
* the top twelve", and a panel that renders that as "no initiative record" states something false
|
|
232
|
+
* about 93 of 105 records. This derives the one that was asked for, so the answer is about the
|
|
233
|
+
* initiative rather than about its rank.
|
|
234
|
+
*
|
|
235
|
+
* It is safe on the request path for the same reason the board is not: this shells git once for the
|
|
236
|
+
* commit range and runs the engine ONCE, rather than 105 times. `workflow` still owns the board.
|
|
237
|
+
*/
|
|
238
|
+
function ladder({ root, id }) {
|
|
239
|
+
if (!root) return { ok: false, error: 'no workspace root resolved for the ladder' };
|
|
240
|
+
if (!id) return { ok: false, error: 'no initiative id given' };
|
|
241
|
+
const loaded = loadEngine();
|
|
242
|
+
if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
243
|
+
const engine = loaded.engine;
|
|
244
|
+
// ABSENT and BROKEN are different facts and must not share an answer. The engine returns
|
|
245
|
+
// `ok:false` for BOTH "there is no record with this id" and "the record is there and unreadable",
|
|
246
|
+
// and collapsing them makes the panel paint a red "read failed" over an initiative that simply
|
|
247
|
+
// does not exist — overstating a missing file as a fault. The initiative list is the arbiter:
|
|
248
|
+
// an id the repository does not know is an empty read, and only an id it DOES know can fail one.
|
|
249
|
+
let known = null;
|
|
250
|
+
try { known = engine.listInitiatives(root); } catch { known = null; }
|
|
251
|
+
if (Array.isArray(known) && !known.includes(id)) return { ok: true, empty: true, row: null };
|
|
252
|
+
let range, row;
|
|
253
|
+
try { range = engine.commitRange(root); }
|
|
254
|
+
catch (e) { return { ok: false, error: 'could not resolve the commit range: ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
255
|
+
try { row = engine.computeWorkflow(id, { root, range }); }
|
|
256
|
+
catch (e) { return { ok: false, error: `could not derive the ladder for ${id}: ` + String((e && e.message) || 'error').slice(0, 160) }; }
|
|
257
|
+
if (row && row.ok === false) return { ok: false, error: row.error || `the record for ${id} could not be read` };
|
|
258
|
+
const bi = gitInfo.branchInfo(root);
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
empty: !row, // no such record — distinct from a read that failed
|
|
262
|
+
row: row || null,
|
|
263
|
+
scope: {
|
|
264
|
+
branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
|
|
265
|
+
branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
|
|
266
|
+
commits: range.ok ? range.shas.length : null,
|
|
267
|
+
empty_range: range.ok ? range.shas.length === 0 : null,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { metrics, radar, workflow, ladder, computeMetricsFromRuns, loadJson, loadJsonl, ENGINE_PATHS, loadEngine };
|
|
@@ -391,10 +391,20 @@ function computeWorkflow(id, opts) {
|
|
|
391
391
|
// ── prototype ─ O3: a "needs-instance" answer obliges a prototype BEFORE the PRD (D60) ───────
|
|
392
392
|
{
|
|
393
393
|
const pred = String(fm.predecessor || '').trim();
|
|
394
|
-
|
|
394
|
+
// The lane is part of the question, not just the answer. O3 asks "does a lookable instance have
|
|
395
|
+
// to precede this spec?" — and for a PROTOTYPE-lane record the instance IS what this record
|
|
396
|
+
// produces (D60). Demanding a predecessor of the leg that answers the specimen test is not
|
|
397
|
+
// merely wrong, it is unsatisfiable: a prototype leg has no leg before it. The file already
|
|
398
|
+
// holds this same fact twice — `isBuildPath` (:93) and the `review` step both read
|
|
399
|
+
// `lane === 'prototype'` — so this is the third place, not a new idea.
|
|
400
|
+
// The other three O3 branches are untouched: a FEATURE record still owes its prototype, named,
|
|
401
|
+
// of the right lane, and briefed before its PRD.
|
|
402
|
+
if (sRoute !== 'instance' || effLane === 'prototype') {
|
|
395
403
|
steps.push(step('prototype', 'prototype', 'na',
|
|
396
|
-
|
|
397
|
-
|
|
404
|
+
effLane === 'prototype'
|
|
405
|
+
? 'prototype lane — this record IS the leg the specimen test asks for (D60), so nothing precedes it'
|
|
406
|
+
: sRoute === 'description' ? 'specimen test says description-judgeable — routed straight to spec, untaxed'
|
|
407
|
+
: 'specimen test not declared'));
|
|
398
408
|
} else if (!pred) {
|
|
399
409
|
steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
|
|
400
410
|
'specimen_test says needs-instance but no predecessor is named',
|