create-pmos 0.13.0 → 0.15.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 +39 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/focus.test.js +650 -0
- package/workspace/handoff.test.js +443 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/drive-guard.js +98 -0
- package/workspace/lib/drive-plan.js +97 -0
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +127 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package.json +4 -2
- package/workspace/profiles.test.js +362 -0
- package/workspace/public/app.js +2318 -208
- package/workspace/public/index.html +158 -28
- package/workspace/public/styles.css +865 -51
- package/workspace/repo-scan.test.js +327 -0
- package/workspace/server.js +914 -23
|
@@ -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 };
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,8 +9,10 @@
|
|
|
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",
|
|
13
14
|
"test:ui": "node ui-test.js",
|
|
15
|
+
"test:all": "npm test && npm run test:ui",
|
|
14
16
|
"doctor": "node doctor.js"
|
|
15
17
|
},
|
|
16
18
|
"engines": {
|