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.
Files changed (39) hide show
  1. package/README.md +57 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/console-state.js +48 -1
  7. package/workspace/lib/engine/workflow-state.js +13 -3
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/focus.js +967 -0
  10. package/workspace/lib/four-panel.js +345 -0
  11. package/workspace/lib/git-info.js +28 -1
  12. package/workspace/lib/handoff.js +280 -0
  13. package/workspace/lib/layout.js +68 -46
  14. package/workspace/lib/mobile-nav.js +157 -0
  15. package/workspace/lib/profiles.js +285 -0
  16. package/workspace/lib/repo-scan.js +469 -0
  17. package/workspace/package-lock.json +29 -2
  18. package/workspace/package.json +14 -4
  19. package/workspace/public/index.html +79 -279
  20. package/workspace/public/pm/app.js +242 -0
  21. package/workspace/public/pm/data.js +877 -0
  22. package/workspace/public/pm/dom.js +116 -0
  23. package/workspace/public/pm/fixture.js +979 -0
  24. package/workspace/public/pm/mobile.css +200 -0
  25. package/workspace/public/pm/mobile.js +403 -0
  26. package/workspace/public/pm/modes.js +1076 -0
  27. package/workspace/public/pm/panel-detail.js +648 -0
  28. package/workspace/public/pm/panel-initiatives.js +426 -0
  29. package/workspace/public/pm/panel-repos.js +460 -0
  30. package/workspace/public/pm/panel-tabs.js +487 -0
  31. package/workspace/public/pm/state.js +164 -0
  32. package/workspace/public/pm/tab-doc.js +279 -0
  33. package/workspace/public/pm/tab-task.js +83 -0
  34. package/workspace/public/pm/tab-term.js +560 -0
  35. package/workspace/public/pm/tokens.css +73 -0
  36. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  37. package/workspace/server.js +1072 -31
  38. package/workspace/public/app.js +0 -3450
  39. package/workspace/public/styles.css +0 -1444
@@ -0,0 +1,469 @@
1
+ 'use strict';
2
+ /*
3
+ * Repo scanner for the product-ingest pipeline (design bundle 2, area 3 — "multiple products").
4
+ *
5
+ * WHAT THIS IS. A PURE reader. `scanRepo(root)` opens a repository the PM has pointed at, reads the
6
+ * initiative records under `planning/initiatives/` and the ADRs under `okf/**\/adr/`, and returns a
7
+ * PROPOSAL describing what an import WOULD contain. It writes nothing — no files, no state rows, no
8
+ * board. The caller decides; this module only reports.
9
+ *
10
+ * THE ONE RULE THE DESIGN STATES AND THIS MODULE ENFORCES: **the scan proposes, it never decides.**
11
+ * Concretely that means the two anchor fields are separate and can never be confused:
12
+ *
13
+ * declaredAnchor what the repository itself wrote in `parent_kr:`. A statement by the repo.
14
+ * guessedAnchor what this scanner INFERRED from prose because no `parent_kr:` was written.
15
+ *
16
+ * They are mutually exclusive by construction — a record with a declaration is never guessed at, and
17
+ * a guess is never written into the declared slot. That separation is the whole point of the module.
18
+ * D59 rejects unanchored work; if a guess could reach a consumer wearing a declaration's clothes,
19
+ * the ingest pipeline would become a laundering path for exactly the work the anchor gate exists to
20
+ * stop. `why` states, per record, which of the two it is and on what evidence, in words a PM can
21
+ * check without reading this file.
22
+ *
23
+ * HONEST STATES (the same three the rest of the workspace uses):
24
+ * - A read that SUCCEEDS and finds nothing is CALM: an empty `initiatives` array plus a log line
25
+ * saying the directory is absent or holds no records. Never an error.
26
+ * - A read that FAILS is RED and carries its reason: the file lands in `unreadable` with the
27
+ * actual error, and a mirrored `error`-level entry appears in the log. It is never dropped.
28
+ * - ABSENCE IS STATED, never implied. Every file the scan chose not to propose gets a log line
29
+ * saying so and why. "Not in the output" is not a way to communicate anything.
30
+ *
31
+ * `ok` is narrow on purpose: it answers ONLY "could the scan run at all" — i.e. is `root` a readable
32
+ * directory. A repository whose `planning/` is missing, or one file of thirty that will not parse,
33
+ * still returns `ok: true`, because the scan DID run and its findings are trustworthy as far as they
34
+ * go. Collapsing "this folder has no initiatives" into the same flag as "I could not look" would
35
+ * make a partial-but-honest result indistinguishable from a broken one.
36
+ *
37
+ * Return shape (identical in every outcome, including failure — a consumer never branches on shape):
38
+ * {
39
+ * ok: boolean,
40
+ * error: string|null, // set only when ok === false
41
+ * root: string,
42
+ * initiatives: [{ id, path, declaredAnchor|null, guessedAnchor|null, why }],
43
+ * adrs: [{ id, path, title|null }],
44
+ * log: [{ level: 'info'|'warn'|'error', path|null, message }],
45
+ * unreadable: [{ path, error }],
46
+ * }
47
+ *
48
+ * CommonJS, zero dependencies, stock Node. Nothing here shells out or touches the network.
49
+ */
50
+
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+
54
+ /*
55
+ * Caps. A scan reads a repository this machine did not write, so it has to be willing to meet a
56
+ * hostile or merely broken one and still terminate with an honest answer.
57
+ *
58
+ * MAX_BYTES — an initiative record is prose with frontmatter; a megabyte is already absurd for one.
59
+ * Past the cap the file becomes `unreadable` WITH ITS SIZE rather than being read into memory or
60
+ * quietly skipped, so the PM learns the real reason a record is missing from the proposal.
61
+ *
62
+ * MAX_DEPTH — the ADR search walks `okf/` looking for directories named `adr`. A cycle made of
63
+ * directory symlinks would otherwise spin forever; we also refuse to descend into symlinked
64
+ * directories at all (see `walkForAdrDirs`), so the cap is a belt to that braces.
65
+ */
66
+ const MAX_BYTES = 1024 * 1024;
67
+ const MAX_DEPTH = 8;
68
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'coverage']);
69
+
70
+ /** Repo-relative, forward-slash path — what the log and the proposal quote back to the PM. */
71
+ function relPath(root, p) {
72
+ const r = path.relative(root, p);
73
+ return r.split(path.sep).join('/');
74
+ }
75
+
76
+ function shortErr(e) {
77
+ const m = (e && (e.code || e.message)) || String(e);
78
+ return String(m).slice(0, 200);
79
+ }
80
+
81
+ /*
82
+ * ── reading one file ──────────────────────────────────────────────────────────────────────────
83
+ *
84
+ * SYMLINKS ARE REFUSED, NOT FOLLOWED. This scanner is pointed at a repository the PM cloned rather
85
+ * than wrote, and `planning/initiatives/x.md` could be a link to anything on the machine. Following
86
+ * it would put a file from outside the repo into a proposal labelled as belonging to the repo —
87
+ * which is a false statement about provenance even when the contents are harmless. Refusing states
88
+ * the fact ("it is a symlink") instead of silently importing something else.
89
+ */
90
+ function readTextFile(file) {
91
+ let st;
92
+ try { st = fs.lstatSync(file); }
93
+ catch (e) { return { ok: false, error: `could not stat the file (${shortErr(e)})` }; }
94
+ if (st.isSymbolicLink()) {
95
+ return { ok: false, error: 'is a symbolic link — not followed, because a scan must not import a file from outside the repository it claims to describe' };
96
+ }
97
+ if (!st.isFile()) return { ok: false, error: 'is not a regular file' };
98
+ if (st.size > MAX_BYTES) {
99
+ return { ok: false, error: `is ${st.size} bytes, past the ${MAX_BYTES}-byte scan cap — not read, so nothing is claimed about it` };
100
+ }
101
+ try { return { ok: true, text: fs.readFileSync(file, 'utf8') }; }
102
+ catch (e) { return { ok: false, error: `could not be read (${shortErr(e)})` }; }
103
+ }
104
+
105
+ /*
106
+ * ── frontmatter ───────────────────────────────────────────────────────────────────────────────
107
+ *
108
+ * Deliberately close to `lib/file-mode-parse.js#parseLeadingFrontmatter` — the LEADING block only,
109
+ * so body prose can never fake a declaration — with one addition that module does not need: an
110
+ * UNTERMINATED fence is reported as malformed rather than parsed leniently.
111
+ *
112
+ * Why that difference matters here and not there: file-mode-parse reads records this repo wrote and
113
+ * CI already lints. This reads a stranger's repo, where `---` with no closing `---` means the whole
114
+ * document is being read as frontmatter and the "record" we would propose is an artefact of a typo.
115
+ * Better to hand that file to a human as unreadable-with-a-reason than to propose a guess built on
116
+ * a misparse.
117
+ *
118
+ * Returns { ok:true, fm, fenced, bodyStartLine } | { ok:false, error }.
119
+ */
120
+ function parseFrontmatter(text) {
121
+ const lines = String(text == null ? '' : text).split('\n');
122
+ let i = 0;
123
+ while (i < lines.length && !lines[i].trim()) i++;
124
+ const fenced = i < lines.length && lines[i].trim() === '---';
125
+
126
+ let block, bodyStartLine;
127
+ if (fenced) {
128
+ const open = i;
129
+ let close = -1;
130
+ for (let j = open + 1; j < lines.length; j++) {
131
+ if (lines[j].trim() === '---') { close = j; break; }
132
+ }
133
+ if (close === -1) {
134
+ return { ok: false, error: 'the frontmatter block opens with `---` and never closes — the whole file would be read as frontmatter, so nothing here can be trusted as a record' };
135
+ }
136
+ block = lines.slice(open + 1, close);
137
+ bodyStartLine = close + 2; // 1-indexed line number of the first body line
138
+ } else {
139
+ // No fence: accept a leading run of `key: value` lines (the lenient house form), stopping at the
140
+ // first line that is neither blank nor a key — same boundary rule metrics.py and the CLI use.
141
+ const start = i;
142
+ let j = i;
143
+ for (; j < lines.length; j++) {
144
+ const line = lines[j];
145
+ if (!line.trim()) continue;
146
+ if (!/^[A-Za-z_][A-Za-z0-9_]*:\s*/.test(line)) break;
147
+ }
148
+ block = lines.slice(start, j);
149
+ bodyStartLine = j + 1;
150
+ }
151
+
152
+ const fm = {};
153
+ for (const line of block) {
154
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/);
155
+ if (!m) continue; // list items, comments, folded continuations — not keys
156
+ if (m[1] in fm) continue; // first value wins, as everywhere else in PMOS
157
+ fm[m[1]] = stripQuotes(m[2]);
158
+ }
159
+ return { ok: true, fm, fenced, bodyStartLine };
160
+ }
161
+
162
+ function stripQuotes(v) {
163
+ const s = String(v == null ? '' : v).trim();
164
+ if (s.length >= 2 && ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'"))) {
165
+ return s.slice(1, -1);
166
+ }
167
+ return s;
168
+ }
169
+
170
+ /*
171
+ * ── the guess ─────────────────────────────────────────────────────────────────────────────────
172
+ *
173
+ * Only ever consulted when `parent_kr:` is absent or empty. It looks for anchor-SHAPED tokens in the
174
+ * record's BODY and, if exactly one distinct token appears, offers it as `guessedAnchor`.
175
+ *
176
+ * NARROWER THAN THE REAL RESOLVER, ON PURPOSE. `scripts/anchor-resolve.js` accepts a dozen legacy
177
+ * forms (`O1.KR3`, `**O1**`, `okrs-q3-2026 → O1.KR1`, …) because it must never reject a record a
178
+ * PMOS repo already wrote. This recogniser accepts only the two unambiguous modern forms — the colon
179
+ * triple/pair and `health:<id>`. The asymmetry is the point: a permissive resolver reading a
180
+ * DECLARATION is being generous about something a human wrote on purpose, while a permissive guesser
181
+ * reading PROSE would start proposing anchors out of sentences like "**O1** is the objective this
182
+ * quarter" — a bare `O1` in a paragraph is a mention, not an intention. A missing guess costs a PM
183
+ * one dropdown; a confident wrong guess costs them a mis-filed initiative they may never re-check.
184
+ *
185
+ * AMBIGUITY IS REPORTED, NOT BROKEN. Two or more distinct candidates yields NO guess and a `why`
186
+ * naming them all. Picking the first, or the most frequent, would be this module deciding.
187
+ *
188
+ * It reads the body and never another frontmatter key. A key that is not `parent_kr` is not an
189
+ * anchor field, and mining one would invent a second declaration site that nothing else in PMOS
190
+ * honours.
191
+ */
192
+ const RE_ANCHOR_TOKEN = /(?:health:[a-z0-9_-]+|(?:okrs-)?[qQ][1-4]-\d{4}:[a-z0-9][a-z0-9-]*(?::[kK][rR]-?[a-z0-9-]+)?)/g;
193
+
194
+ function guessAnchorFromBody(text, bodyStartLine) {
195
+ const lines = String(text == null ? '' : text).split('\n');
196
+ const found = new Map(); // token → first 1-indexed line it appeared on
197
+ for (let n = Math.max(1, bodyStartLine || 1); n <= lines.length; n++) {
198
+ const line = lines[n - 1];
199
+ RE_ANCHOR_TOKEN.lastIndex = 0;
200
+ let m;
201
+ while ((m = RE_ANCHOR_TOKEN.exec(line)) !== null) {
202
+ const tok = m[0];
203
+ if (!found.has(tok)) found.set(tok, n);
204
+ }
205
+ }
206
+ return [...found.entries()].map(([token, line]) => ({ token, line }));
207
+ }
208
+
209
+ /*
210
+ * The anchor half of one proposed initiative row. Returns { declaredAnchor, guessedAnchor, why }.
211
+ *
212
+ * Every branch below sets exactly one of the two fields (or neither). Nothing sets both — a caller
213
+ * that renders `declaredAnchor || guessedAnchor` gets a wrong-but-plausible board, so the invariant
214
+ * is asserted in the test suite rather than left to reviewer memory.
215
+ */
216
+ function anchorFacts(fm, text, bodyStartLine) {
217
+ const raw = fm && typeof fm.parent_kr === 'string' ? fm.parent_kr.trim() : '';
218
+ if (raw) {
219
+ return {
220
+ declaredAnchor: raw,
221
+ guessedAnchor: null,
222
+ why: `declared by the repository in frontmatter as \`parent_kr: ${raw}\` — the repo's own statement, not an inference by this scan. Whether it RESOLVES to a KR or a declared health budget is a separate question this scanner does not answer.`,
223
+ };
224
+ }
225
+ const declaredButEmpty = fm && 'parent_kr' in fm;
226
+ const lead = declaredButEmpty
227
+ ? 'the `parent_kr:` key is present but empty'
228
+ : 'no `parent_kr:` key in frontmatter';
229
+ const cands = guessAnchorFromBody(text, bodyStartLine);
230
+ if (cands.length === 1) {
231
+ return {
232
+ declaredAnchor: null,
233
+ guessedAnchor: cands[0].token,
234
+ why: `${lead}. \`${cands[0].token}\` appears in the body (line ${cands[0].line}) and nothing else anchor-shaped does, so it is offered as a GUESS. A mention in prose is not a declaration: D59 rejects unanchored work, and this record stays unanchored until a human confirms or replaces this value.`,
235
+ };
236
+ }
237
+ if (cands.length > 1) {
238
+ const listed = cands.map((c) => `\`${c.token}\` (line ${c.line})`).join(', ');
239
+ return {
240
+ declaredAnchor: null,
241
+ guessedAnchor: null,
242
+ why: `${lead}. The body mentions ${cands.length} different anchor-shaped tokens — ${listed} — which is too many to guess from; choosing one would be this scan deciding. A human picks.`,
243
+ };
244
+ }
245
+ return {
246
+ declaredAnchor: null,
247
+ guessedAnchor: null,
248
+ why: `${lead}, and nothing anchor-shaped appears in the body. This record is unanchored, and D59 rejects unanchored work — only the PM can anchor it.`,
249
+ };
250
+ }
251
+
252
+ /*
253
+ * ── the ADR sweep ─────────────────────────────────────────────────────────────────────────────
254
+ *
255
+ * `okf/**\/adr/*.md`: walk the OKF bundle for directories literally named `adr` and take the
256
+ * markdown directly inside them. Symlinked directories are not entered, for the same provenance
257
+ * reason single files are not followed, and the walk is depth-capped.
258
+ *
259
+ * A directory it could not read is recorded in `unreadable` like any other failed read. A walk that
260
+ * silently returns fewer directories than exist is the failure mode this whole module is written
261
+ * against: the PM would see a short ADR list and have no way to know it was short.
262
+ */
263
+ function walkForAdrDirs(dir, depth, acc, onError) {
264
+ if (depth > MAX_DEPTH) { onError(dir, `directory nesting past the ${MAX_DEPTH}-level scan cap — not descended`); return; }
265
+ let entries;
266
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
267
+ catch (e) { onError(dir, `directory could not be listed (${shortErr(e)})`); return; }
268
+ for (const ent of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
269
+ if (ent.isSymbolicLink()) { onError(path.join(dir, ent.name), 'is a symbolic link — not followed'); continue; }
270
+ if (!ent.isDirectory()) continue;
271
+ if (SKIP_DIRS.has(ent.name)) continue;
272
+ const full = path.join(dir, ent.name);
273
+ if (ent.name === 'adr') { acc.push(full); continue; } // an `adr` dir is a leaf for this purpose
274
+ walkForAdrDirs(full, depth + 1, acc, onError);
275
+ }
276
+ }
277
+
278
+ /*
279
+ * ── the scan ──────────────────────────────────────────────────────────────────────────────────
280
+ */
281
+ function scanRepo(root) {
282
+ const out = {
283
+ ok: true,
284
+ error: null,
285
+ root: typeof root === 'string' ? root : null,
286
+ initiatives: [],
287
+ adrs: [],
288
+ log: [],
289
+ unreadable: [],
290
+ };
291
+ const note = (level, p, message) => { out.log.push({ level, path: p, message }); };
292
+ // Every failed read goes to BOTH lists: `unreadable` is the machine-checkable account, the log is
293
+ // the human one shown in the ingest panel. A file that appears in only one of them is invisible to
294
+ // whoever happens to be reading the other.
295
+ const fail = (p, error) => { out.unreadable.push({ path: p, error }); note('error', p, error); };
296
+
297
+ if (typeof root !== 'string' || !root.trim()) {
298
+ out.ok = false;
299
+ out.error = 'no repository path was given to the scan';
300
+ note('error', null, out.error);
301
+ return out;
302
+ }
303
+ let rootStat;
304
+ try { rootStat = fs.statSync(root); }
305
+ catch (e) {
306
+ out.ok = false;
307
+ out.error = `the repository path could not be read (${shortErr(e)}) — nothing was scanned, and nothing about its contents is claimed`;
308
+ note('error', root, out.error);
309
+ return out;
310
+ }
311
+ if (!rootStat.isDirectory()) {
312
+ out.ok = false;
313
+ out.error = 'the repository path is not a directory — nothing was scanned';
314
+ note('error', root, out.error);
315
+ return out;
316
+ }
317
+ note('info', null, `scanning ${root} — reading only; nothing is written, imported or decided`);
318
+
319
+ // ── initiative records: planning/initiatives/*.md, flat ─────────────────────────────────────
320
+ // Flat on purpose: `planning/initiatives/` is a flat directory in every PMOS repo and in the kit
321
+ // scaffold. Recursing would start proposing records out of an `archive/` subfolder someone made
322
+ // precisely to get them off the board.
323
+ const initDirRel = 'planning/initiatives';
324
+ const initDir = path.join(root, 'planning', 'initiatives');
325
+ let initEntries = null;
326
+ try { initEntries = fs.readdirSync(initDir, { withFileTypes: true }); }
327
+ catch (e) {
328
+ if (e && e.code === 'ENOENT') {
329
+ // ABSENT, and said out loud. This is a successful read of nothing, not a failure: plenty of
330
+ // repositories legitimately have no initiative records, and a bare folder importing nothing is
331
+ // the documented behaviour of the ingest pipeline.
332
+ note('info', initDirRel, 'does not exist — this repository declares no initiative records, so the proposal has none. Stated, not assumed.');
333
+ } else {
334
+ fail(initDirRel, `directory could not be listed (${shortErr(e)}) — initiative records may exist and are NOT accounted for in this proposal`);
335
+ }
336
+ }
337
+
338
+ if (initEntries) {
339
+ const files = initEntries
340
+ .filter((ent) => !ent.isDirectory() && /\.md$/i.test(ent.name))
341
+ .map((ent) => ent.name)
342
+ .sort();
343
+ if (!files.length) {
344
+ note('info', initDirRel, 'exists but holds no markdown files — an empty read, not a failed one');
345
+ }
346
+ for (const name of files) {
347
+ const rp = `${initDirRel}/${name}`;
348
+ const full = path.join(initDir, name);
349
+ // `index.md` / `log.md` are OKF-reserved and carry no frontmatter by design (D63 exempts
350
+ // them). They are not records; saying so keeps their absence from the proposal explicit.
351
+ if (name === 'index.md' || name === 'log.md') {
352
+ note('info', rp, 'is an OKF-reserved filename (index/log) — not an initiative record, not proposed');
353
+ continue;
354
+ }
355
+ const read = readTextFile(full);
356
+ if (!read.ok) { fail(rp, read.error); continue; }
357
+ const parsed = parseFrontmatter(read.text);
358
+ if (!parsed.ok) { fail(rp, parsed.error); continue; }
359
+ const fm = parsed.fm;
360
+ if (!fm.type) {
361
+ fail(rp, 'has no `type:` in its frontmatter — D63 requires one, and a file whose kind is unstated is not assumed to be an initiative record');
362
+ continue;
363
+ }
364
+ if (String(fm.type).toLowerCase() !== 'initiative') {
365
+ note('info', rp, `declares \`type: ${fm.type}\`, not Initiative — read and not proposed`);
366
+ continue;
367
+ }
368
+ const stem = name.replace(/\.md$/i, '');
369
+ const id = (fm.id && String(fm.id).trim()) || stem;
370
+ if (!fm.id || !String(fm.id).trim()) {
371
+ note('warn', rp, `has no \`id:\` in its frontmatter — using the filename stem \`${stem}\` as the proposed id. A filename is a guess at an identity, and the import should confirm it.`);
372
+ }
373
+ const a = anchorFacts(fm, read.text, parsed.bodyStartLine);
374
+ out.initiatives.push({
375
+ id,
376
+ path: rp,
377
+ declaredAnchor: a.declaredAnchor,
378
+ guessedAnchor: a.guessedAnchor,
379
+ why: a.why,
380
+ });
381
+ }
382
+
383
+ // Duplicate ids: both rows are kept. Dropping one would make the proposal quietly disagree with
384
+ // the directory, and picking a winner is a decision this module does not get to make.
385
+ const byId = new Map();
386
+ for (const r of out.initiatives) {
387
+ if (!byId.has(r.id)) byId.set(r.id, []);
388
+ byId.get(r.id).push(r.path);
389
+ }
390
+ for (const [id, paths] of byId) {
391
+ if (paths.length > 1) {
392
+ note('warn', null, `${paths.length} records propose the same id \`${id}\` (${paths.join(', ')}) — all are listed; the import must resolve the collision, this scan does not pick one`);
393
+ }
394
+ }
395
+ const guessed = out.initiatives.filter((r) => r.guessedAnchor).length;
396
+ const unanchored = out.initiatives.filter((r) => !r.declaredAnchor).length;
397
+ note(unanchored ? 'warn' : 'info', null,
398
+ `${out.initiatives.length} initiative record(s) proposed — ${out.initiatives.length - unanchored} carry a declared anchor, ${guessed} carry a GUESS that must be confirmed, ${unanchored - guessed} have neither. A guess is not a declaration and does not satisfy D59.`);
399
+ }
400
+
401
+ // ── ADRs: okf/**/adr/*.md ───────────────────────────────────────────────────────────────────
402
+ const okfDir = path.join(root, 'okf');
403
+ let okfStat = null;
404
+ try { okfStat = fs.statSync(okfDir); }
405
+ catch (e) {
406
+ if (e && e.code === 'ENOENT') note('info', 'okf', 'does not exist — this repository carries no OKF bundle, so no ADRs are proposed. Stated, not assumed.');
407
+ else fail('okf', `could not be read (${shortErr(e)}) — ADRs may exist and are NOT accounted for in this proposal`);
408
+ }
409
+ if (okfStat && !okfStat.isDirectory()) {
410
+ fail('okf', 'exists but is not a directory — no ADRs were read');
411
+ } else if (okfStat) {
412
+ const adrDirs = [];
413
+ walkForAdrDirs(okfDir, 1, adrDirs, (p, err) => fail(relPath(root, p), err));
414
+ if (!adrDirs.length) note('info', 'okf', 'contains no `adr/` directory — no ADRs proposed');
415
+ for (const dir of adrDirs) {
416
+ const dirRel = relPath(root, dir);
417
+ let entries;
418
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
419
+ catch (e) { fail(dirRel, `directory could not be listed (${shortErr(e)}) — ADRs in it are NOT accounted for`); continue; }
420
+ const files = entries.filter((ent) => !ent.isDirectory() && /\.md$/i.test(ent.name)).map((e2) => e2.name).sort();
421
+ if (!files.length) note('info', dirRel, 'holds no markdown files — an empty read, not a failed one');
422
+ for (const name of files) {
423
+ const rp = `${dirRel}/${name}`;
424
+ if (name === 'index.md' || name === 'log.md') {
425
+ note('info', rp, 'is an OKF-reserved filename (index/log) — not an ADR, not proposed');
426
+ continue;
427
+ }
428
+ const read = readTextFile(path.join(dir, name));
429
+ if (!read.ok) { fail(rp, read.error); continue; }
430
+ const parsed = parseFrontmatter(read.text);
431
+ if (!parsed.ok) { fail(rp, parsed.error); continue; }
432
+ const fm = parsed.fm;
433
+ if (!fm.type) {
434
+ fail(rp, 'has no `type:` in its frontmatter — D63 requires one, and a file in an adr/ directory is not assumed to be an ADR on the strength of its location');
435
+ continue;
436
+ }
437
+ if (String(fm.type).toLowerCase() !== 'adr') {
438
+ note('info', rp, `declares \`type: ${fm.type}\`, not ADR — read and not proposed`);
439
+ continue;
440
+ }
441
+ const title = fm.title && String(fm.title).trim() ? String(fm.title).trim() : null;
442
+ if (!title) {
443
+ // null, not the filename. A board that prints a filename in the title column is asserting
444
+ // a title the document does not have; null lets the UI say "untitled" honestly.
445
+ note('warn', rp, 'has no `title:` — proposed with a null title rather than a filename standing in for one');
446
+ }
447
+ out.adrs.push({ id: name.replace(/\.md$/i, ''), path: rp, title });
448
+ }
449
+ }
450
+ if (adrDirs.length) {
451
+ note('info', null, `${out.adrs.length} ADR(s) proposed from ${adrDirs.length} adr/ director${adrDirs.length === 1 ? 'y' : 'ies'} — each still has to be accepted or superseded by a human before it joins knowledge`);
452
+ }
453
+ }
454
+
455
+ if (out.unreadable.length) {
456
+ note('warn', null, `${out.unreadable.length} file(s) or director${out.unreadable.length === 1 ? 'y' : 'ies'} could not be read — they are listed with their reasons and are NOT represented in the proposal above`);
457
+ }
458
+ // Deterministic ordering — two scans of the same tree must produce the same proposal, or a diff of
459
+ // one import against the next is noise.
460
+ out.initiatives.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
461
+ out.adrs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
462
+ out.unreadable.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
463
+ return out;
464
+ }
465
+
466
+ // parseFrontmatter / guessAnchorFromBody / anchorFacts are exported for the unit suite: the
467
+ // declared-vs-guessed separation is the invariant this module exists to hold, and an invariant that
468
+ // can only be checked through a full filesystem scan tends to be checked less often than it should.
469
+ module.exports = { scanRepo, parseFrontmatter, guessAnchorFromBody, anchorFacts, MAX_BYTES, MAX_DEPTH };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.12.0",
3
+ "version": "0.16.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.12.0",
9
+ "version": "0.16.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -19,6 +19,10 @@
19
19
  "bin": {
20
20
  "pmos": "bin/pmos.js"
21
21
  },
22
+ "devDependencies": {
23
+ "pixelmatch": "^7.1.0",
24
+ "pngjs": "^7.0.0"
25
+ },
22
26
  "engines": {
23
27
  "node": ">=18"
24
28
  }
@@ -57,6 +61,19 @@
57
61
  "node-addon-api": "^7.1.0"
58
62
  }
59
63
  },
64
+ "node_modules/pixelmatch": {
65
+ "version": "7.2.0",
66
+ "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
67
+ "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
68
+ "dev": true,
69
+ "license": "ISC",
70
+ "dependencies": {
71
+ "pngjs": "^7.0.0"
72
+ },
73
+ "bin": {
74
+ "pixelmatch": "bin/pixelmatch"
75
+ }
76
+ },
60
77
  "node_modules/playwright-core": {
61
78
  "version": "1.61.1",
62
79
  "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
@@ -69,6 +86,16 @@
69
86
  "node": ">=18"
70
87
  }
71
88
  },
89
+ "node_modules/pngjs": {
90
+ "version": "7.0.0",
91
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
92
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
93
+ "dev": true,
94
+ "license": "MIT",
95
+ "engines": {
96
+ "node": ">=14.19.0"
97
+ }
98
+ },
72
99
  "node_modules/ws": {
73
100
  "version": "8.21.1",
74
101
  "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
5
+ "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "pmos": "bin/pmos.js"
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
- "test": "node test.js",
12
+ "test": "node test.js && npm run test:modules",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs",
13
14
  "test:ui": "node ui-test.js",
14
- "doctor": "node doctor.js"
15
+ "test:parity": "node design-parity.js --states",
16
+ "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
+ "doctor": "node doctor.js",
18
+ "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js",
19
+ "test:offline": "node offline-tarball.test.js"
15
20
  },
16
21
  "engines": {
17
22
  "node": ">=18"
@@ -23,5 +28,10 @@
23
28
  "node-pty": "^1.0.0",
24
29
  "playwright-core": "^1.61.1",
25
30
  "ws": "^8.18.0"
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 \u2014 it just stops measuring pixels, and says so.",
33
+ "devDependencies": {
34
+ "pixelmatch": "^7.1.0",
35
+ "pngjs": "^7.0.0"
26
36
  }
27
37
  }