create-pmos 0.37.0 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pmos.js +49 -9
- package/package.json +1 -1
- package/payload/AGENTS.md +4 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/frontmatter-format.md +27 -61
- package/payload/okf/core/templates/decision-record-template.md +34 -0
- package/payload/okf/core/templates/index.md +7 -2
- package/payload/okf/core/templates/pm-brief-template.md +34 -0
- package/payload/okf/core/templates/project-plan-template.md +43 -0
- package/payload/okf/core/templates/report-weekly-template.md +35 -0
- package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
- package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
- package/payload/planning/evals/eval-calibration.md +8 -0
- package/payload/planning/okrs/health-budgets.md +8 -0
- package/payload/scripts/anchor-resolve.js +56 -6
- package/payload/scripts/gate-initiative.sh +32 -0
- package/payload/scripts/okf-upgrade.js +232 -0
- package/payload/scripts/pmos-memory.js +375 -0
- package/payload/scripts/pmos-model.js +239 -0
- package/payload/scripts/pmos-skills.js +274 -0
- package/payload/scripts/workflow-state.js +178 -10
- package/payload/scripts/workflow-sweep.js +2 -1
- package/payload/state/discoveries.md +8 -0
- package/payload/state/index.md +7 -0
- package/payload/state/log.md +7 -0
- package/workspace/README.md +61 -204
- package/workspace/bin/pmos.js +23 -4
- package/workspace/lib/board-cache.js +18 -8
- package/workspace/lib/console-state.js +2 -0
- package/workspace/lib/engine/anchor-resolve.js +56 -6
- package/workspace/lib/engine/pmos-memory.js +375 -0
- package/workspace/lib/engine/pmos-model.js +239 -0
- package/workspace/lib/engine/pmos-skills.js +274 -0
- package/workspace/lib/engine/workflow-state.js +178 -10
- package/workspace/lib/focus.js +5 -6
- package/workspace/lib/memory.js +54 -0
- package/workspace/lib/models.js +50 -0
- package/workspace/lib/preview-kind.js +12 -1
- package/workspace/lib/schedule-logic.js +31 -2
- package/workspace/lib/skill-store.js +5 -1
- package/workspace/lib/skills-registry.js +74 -0
- package/workspace/lib/sync.js +132 -0
- package/workspace/lib/tree-share.js +2 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +32 -2
- package/workspace/public/pm/data.js +160 -10
- package/workspace/public/pm/lifecycle.js +105 -0
- package/workspace/public/pm/markdown.js +10 -1
- package/workspace/public/pm/modes-live.js +437 -0
- package/workspace/public/pm/modes.js +8 -5
- package/workspace/public/pm/panel-detail.js +22 -2
- package/workspace/public/pm/panel-initiatives.js +57 -12
- package/workspace/public/pm/panel-newsess.js +25 -0
- package/workspace/public/pm/panel-repos.js +130 -4
- package/workspace/public/pm/panel-tabs.js +49 -2
- package/workspace/public/pm/state.js +11 -1
- package/workspace/public/pm/tab-doc.js +160 -26
- package/workspace/public/pm/tab-term.js +2 -0
- package/workspace/public/pm/tab-web.js +89 -0
- package/workspace/public/pm/tokens.css +4 -0
- package/workspace/public/pm/xlsx-read.js +117 -0
- package/workspace/server.js +736 -42
- package/workspace/skills/gather-requirements.skill +53 -0
- package/workspace/skills/memory-groom.skill +46 -0
- package/workspace/skills/report-biweekly.skill +53 -0
- package/workspace/skills/report-daily.skill +53 -0
- package/workspace/skills/report-monthly.skill +53 -0
- package/workspace/skills/report-special.skill +49 -0
- package/workspace/skills/report-weekly.skill +53 -0
- package/workspace/skills/skill-discover.skill +49 -0
- package/workspace/skills/skill-generate.skill +33 -0
|
@@ -82,6 +82,7 @@ const RE_KR_BULLET = new RegExp('^\\s*[-*]\\s*\\*\\*\\s*' + KR_TOKEN + '\\s*[—
|
|
|
82
82
|
|
|
83
83
|
// Anchor forms. Order matters only in that the colon forms are tried before the looser ones.
|
|
84
84
|
const RE_HEALTH = /^health:([a-z0-9_-]+)/i; // START-anchored — rule 2
|
|
85
|
+
const RE_DOMAIN = /^domain:([a-z0-9][a-z0-9_-]*)/i; // D67 — the third anchor class
|
|
85
86
|
const RE_COLON_KR = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*):(kr-?[a-z0-9-]+)$', 'i');
|
|
86
87
|
const RE_COLON_OBJ = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*)$', 'i');
|
|
87
88
|
const RE_ARROW = new RegExp('^(?:okrs-)?' + PERIOD + '\\s*(?:→|->)\\s*' + OBJ_TOKEN + '(?:\\.' + KR_TOKEN + ')?$', 'i');
|
|
@@ -176,7 +177,11 @@ function parseOkrIndex(root) {
|
|
|
176
177
|
// A KR with no open objective stays UNATTACHED (rule 3) — never inherited from a previous one.
|
|
177
178
|
if (k && cur && !cur.krs.some((x) => x.id === krId(k[1]))) {
|
|
178
179
|
cur.krs.push({ id: krId(k[1]), token: k[1], name: (k[2] || '').trim() });
|
|
180
|
+
continue;
|
|
179
181
|
}
|
|
182
|
+
// the KR's own measure — its `baseline: … → target: …` sub-bullet (overhaul-r2 B4)
|
|
183
|
+
const bl = /^\s+[-*]\s*baseline\s*:\s*(.+)$/i.exec(line);
|
|
184
|
+
if (bl && cur && cur.krs.length && !cur.krs[cur.krs.length - 1].line) cur.krs[cur.krs.length - 1].line = bl[1].trim();
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
return { ok: true, objectives, files: files.map((f) => path.relative(root, f)), period: period || null, error: null };
|
|
@@ -184,8 +189,36 @@ function parseOkrIndex(root) {
|
|
|
184
189
|
|
|
185
190
|
function readBudgets(root) {
|
|
186
191
|
const p = path.join(root, 'planning', 'okrs', 'health-budgets.md');
|
|
187
|
-
|
|
188
|
-
|
|
192
|
+
let out;
|
|
193
|
+
try { out = { ok: true, text: fs.readFileSync(p, 'utf8'), path: path.relative(root, p) }; }
|
|
194
|
+
catch { out = { ok: false, text: '', path: path.relative(root, p) }; }
|
|
195
|
+
return withDomains(out, path.dirname(p), root);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* D67 — the domain registry, `domains.md` beside `health-budgets.md`. Closed and PM-edited, like the
|
|
200
|
+
* budgets: a domain exists only as a table row `| domain:<id> | standing goal | repositories |`.
|
|
201
|
+
* It rides on the budgets object every caller already passes, so the gate, the ladder, the sweep and
|
|
202
|
+
* the workspace all learn the class without a signature change. Absent file = no domains declared
|
|
203
|
+
* (`ok:false`), which makes any `domain:` anchor `unknown`, never silently resolved.
|
|
204
|
+
*/
|
|
205
|
+
function readDomains(dir, root) {
|
|
206
|
+
const p = path.join(dir, 'domains.md');
|
|
207
|
+
const rel = root ? path.relative(root, p) : p;
|
|
208
|
+
let text;
|
|
209
|
+
try { text = fs.readFileSync(p, 'utf8'); } catch { return { ok: false, path: rel, rows: [] }; }
|
|
210
|
+
const rows = [];
|
|
211
|
+
for (const line of text.split('\n')) {
|
|
212
|
+
const m = /^\|\s*`?domain:([a-z0-9][a-z0-9_-]*)`?\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|/i.exec(line);
|
|
213
|
+
if (m) rows.push({ id: m[1].toLowerCase(), goal: m[2], repos: m[3] });
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, path: rel, rows };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Attach the domain registry to a budgets object (callers that build their own budgets use this). */
|
|
219
|
+
function withDomains(budgets, dir, root) {
|
|
220
|
+
if (!budgets) return budgets;
|
|
221
|
+
return Object.assign(budgets, { domains: readDomains(dir, root) });
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
/**
|
|
@@ -221,9 +254,24 @@ function unquote(s) {
|
|
|
221
254
|
|
|
222
255
|
function resolveAnchor(raw, index, budgets) {
|
|
223
256
|
const original = unquote(String(raw == null ? '' : raw).trim());
|
|
224
|
-
const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null
|
|
257
|
+
const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null,
|
|
258
|
+
// separate fields so no client has to parse `reason` (overhaul-r2 B1)
|
|
259
|
+
objective_name: null, kr_name: null, kr_line: null, domain: null, domain_goal: null };
|
|
225
260
|
if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
|
|
226
261
|
|
|
262
|
+
const d = RE_DOMAIN.exec(original);
|
|
263
|
+
if (d) {
|
|
264
|
+
const id = d[1].toLowerCase();
|
|
265
|
+
const out = Object.assign({}, base, { kind: 'domain', domain: id, form: 'domain:<id>' });
|
|
266
|
+
const reg = budgets && budgets.domains;
|
|
267
|
+
if (!reg) return Object.assign(out, { status: 'unknown', reason: 'domain registry not supplied to the resolver' });
|
|
268
|
+
if (!reg.ok) return Object.assign(out, { status: 'unknown', reason: `no domain registry at ${reg.path} — declare the domain there (D67)` });
|
|
269
|
+
const row = reg.rows.find((r) => r.id === id);
|
|
270
|
+
return row
|
|
271
|
+
? Object.assign(out, { status: 'resolved', domain_goal: row.goal || null, reason: `domain:${id} — ${row.goal || 'no standing goal written'}`.slice(0, 160) })
|
|
272
|
+
: Object.assign(out, { status: 'unresolvable', reason: `domain:${id} is not a declared domain in ${reg.path} (D67)` });
|
|
273
|
+
}
|
|
274
|
+
|
|
227
275
|
// Rule 2: the FORM decides, not a substring. Commentary is stripped only for the OKR branch,
|
|
228
276
|
// because a health anchor's id must sit at the very start of the raw value.
|
|
229
277
|
const h = RE_HEALTH.exec(original);
|
|
@@ -269,13 +317,14 @@ function resolveAnchor(raw, index, budgets) {
|
|
|
269
317
|
}
|
|
270
318
|
if (!out.kr) {
|
|
271
319
|
// Assumption A2: objective-only anchoring is a legitimate resolvable kind, not debt.
|
|
272
|
-
return Object.assign(out, { status: 'resolved', reason: `objective '${obj.id}' — no KR written (objective-level anchor)` });
|
|
320
|
+
return Object.assign(out, { status: 'resolved', objective_name: obj.name || null, reason: `objective '${obj.id}' — no KR written (objective-level anchor)` });
|
|
273
321
|
}
|
|
274
322
|
const kr = obj.krs.find((k) => k.id === out.kr);
|
|
275
323
|
if (!kr) {
|
|
276
324
|
return Object.assign(out, { status: 'unresolvable', reason: `objective '${obj.id}' declares no KR '${out.kr}' (has: ${obj.krs.map((k) => k.id).join(', ') || 'none'})` });
|
|
277
325
|
}
|
|
278
|
-
return Object.assign(out, { status: 'resolved',
|
|
326
|
+
return Object.assign(out, { status: 'resolved', objective_name: obj.name || null, kr_name: kr.name || null, kr_line: kr.line || null,
|
|
327
|
+
reason: `${obj.id}/${kr.id} — ${kr.name}`.slice(0, 160) });
|
|
279
328
|
}
|
|
280
329
|
|
|
281
330
|
/**
|
|
@@ -285,6 +334,7 @@ function resolveAnchor(raw, index, budgets) {
|
|
|
285
334
|
function canonical(resolved, fallbackPeriod) {
|
|
286
335
|
if (!resolved) return null;
|
|
287
336
|
if (resolved.kind === 'health') return resolved.budget ? `health:${resolved.budget}` : null;
|
|
337
|
+
if (resolved.kind === 'domain') return resolved.domain ? `domain:${resolved.domain}` : null;
|
|
288
338
|
if (resolved.kind !== 'okr' || !resolved.objective) return null;
|
|
289
339
|
const period = resolved.period || fallbackPeriod || null;
|
|
290
340
|
if (!period) return null;
|
|
@@ -293,7 +343,7 @@ function canonical(resolved, fallbackPeriod) {
|
|
|
293
343
|
|
|
294
344
|
module.exports = {
|
|
295
345
|
unquote,
|
|
296
|
-
parseOkrIndex, readBudgets, resolveAnchor, canonical,
|
|
346
|
+
parseOkrIndex, readBudgets, readDomains, withDomains, resolveAnchor, canonical,
|
|
297
347
|
objectiveId, krId, stripCommentary, listOkrFiles,
|
|
298
348
|
ACCEPTED_FORMS,
|
|
299
349
|
// exported for the tests that assert the widening happened together (R-4)
|
|
@@ -86,6 +86,38 @@ anchor="$(frontval parent_kr)"
|
|
|
86
86
|
# file IS the row, so the gate is where the same rule lands. A KR or health:<id> — never blank.
|
|
87
87
|
[ -n "$anchor" ] || fail "initiative '$id' has no parent_kr in $init_file — anchor it to a KR, or to a declared health budget (parent_kr: health:<id> from $KIT_ROOT/planning/okrs/health-budgets.md, D59). Unanchored work is rejected (D21)."
|
|
88
88
|
|
|
89
|
+
# ---- D68: the pm-artifacts profile ------------------------------------------
|
|
90
|
+
# A repository whose root holds `.pmos-profile` reading `pm-artifacts` is a PM's documents repo: its
|
|
91
|
+
# lanes are strategy | prd | project-plan | discovery | decision-record | report | chore, and its
|
|
92
|
+
# ladder is brief -> draft -> review -> approved -> handed off. A `light` track (the default for
|
|
93
|
+
# decision-record, report, chore) needs only the anchored record above; a `governed` track (the
|
|
94
|
+
# default for the rest) needs its brief to exist, and a draft it names to exist. Order is the
|
|
95
|
+
# engine's to check (workflow-state.js --gate); this gate checks presence. No rubric, contract or
|
|
96
|
+
# build is asked for in this profile — that is the point of it.
|
|
97
|
+
profile=""
|
|
98
|
+
if [ -f .pmos-profile ]; then profile="$(sed -e 's/#.*$//' -e 's/[[:space:]]//g' .pmos-profile | grep -m1 . || true)"; fi
|
|
99
|
+
if [ "$profile" = "pm-artifacts" ]; then
|
|
100
|
+
pmlane="$(frontval lane)"; [ -n "$pmlane" ] || pmlane="$lane"
|
|
101
|
+
case "$pmlane" in
|
|
102
|
+
strategy|prd|project-plan|discovery|decision-record|report|chore) : ;;
|
|
103
|
+
*) note "unknown pm-artifacts lane '$pmlane' on '$id' — enforcing a governed prd (the profile's strictest, D68)."; pmlane="prd" ;;
|
|
104
|
+
esac
|
|
105
|
+
track="$(frontval track)"
|
|
106
|
+
case "$track" in
|
|
107
|
+
light|governed) : ;;
|
|
108
|
+
*) case "$pmlane" in decision-record|report|chore) track="light" ;; *) track="governed" ;; esac ;;
|
|
109
|
+
esac
|
|
110
|
+
if [ "$track" = "governed" ]; then
|
|
111
|
+
brief="$(frontval brief_path)"; [ -n "$brief" ] || brief="$prd"
|
|
112
|
+
docp="$(frontval doc_path)"
|
|
113
|
+
[ -n "$brief" ] || fail "initiative '$id' ($pmlane, governed track) has no brief — set brief_path in $init_file to a short brief (the question, the reader, the decision it informs). A light track needs none: set track: light (D68)."
|
|
114
|
+
[ -f "$brief" ] || fail "brief_path '$brief' (from $init_file) does not exist in the repo."
|
|
115
|
+
[ -z "$docp" ] || [ -f "$docp" ] || fail "doc_path '$docp' (from $init_file) does not exist in the repo."
|
|
116
|
+
fi
|
|
117
|
+
echo "✅ initiative gate PASSED — '$id' ($pmlane lane, $track track, pm-artifacts profile): $init_file exists and is anchored."
|
|
118
|
+
exit 0
|
|
119
|
+
fi
|
|
120
|
+
|
|
89
121
|
# ---- lane-parameterized artifact checks (D56) --------------------------------
|
|
90
122
|
# The kernel invariants already held above (anchored record; the human merge stays the Acceptance
|
|
91
123
|
# Gate); what varies per lane is the spec/eval artifact. In EVERY lane a rubric_path that is set
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* okf-upgrade — bring the documents PMOS writes into a TENANT repository to OKF v0.2 (D73).
|
|
4
|
+
*
|
|
5
|
+
* node scripts/okf-upgrade.js <repo> report what would change (nothing is written)
|
|
6
|
+
* node scripts/okf-upgrade.js <repo> --write apply it
|
|
7
|
+
* node scripts/okf-upgrade.js <repo> --check exit 1 if a document in scope is not conformant
|
|
8
|
+
*
|
|
9
|
+
* SCOPE is the documents PMOS or its agents write: requirement notes, report drafts and definitions,
|
|
10
|
+
* the memory views, the kit's seed documents, and <repo>/reports/. It never touches an initiative,
|
|
11
|
+
* a ticket, a *.jsonl ledger, needs.json, or any file outside that list, and it refuses PMOS's own
|
|
12
|
+
* repository (which follows OKF v0.2 itself since D74, with its own gate). Idempotent: a second --write changes nothing.
|
|
13
|
+
*
|
|
14
|
+
* HONESTY. `generated.by` is recorded nowhere in an old file, so the tool does not invent it: an
|
|
15
|
+
* existing `timestamp` is KEPT (OKF tolerates unknown keys) and reported as "author unknown".
|
|
16
|
+
*
|
|
17
|
+
* SINGLE-LINE FORM. Five of PMOS's frontmatter readers stop at the first indented line, so a block-style
|
|
18
|
+
* `generated:` / `sources:` / `verified:` is rewritten to one line (`generated: {by: x, at: "…"}`).
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const OKF_VERSION = '0.2';
|
|
24
|
+
const FLOW_KEYS = ['generated', 'sources', 'verified'];
|
|
25
|
+
|
|
26
|
+
// ── scope ────────────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
function isPmosSelf(root) { return fs.existsSync(path.join(root, 'okf', 'core', 'concepts')); }
|
|
28
|
+
function stateDir(root) {
|
|
29
|
+
const kit = path.join(root, 'pmos', 'state');
|
|
30
|
+
if (fs.existsSync(kit)) return kit;
|
|
31
|
+
const self = path.join(root, 'planning');
|
|
32
|
+
return fs.existsSync(self) ? self : null;
|
|
33
|
+
}
|
|
34
|
+
function walkMd(dir, out) {
|
|
35
|
+
let ents = [];
|
|
36
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
37
|
+
for (const e of ents) {
|
|
38
|
+
const p = path.join(dir, e.name);
|
|
39
|
+
if (e.isSymbolicLink()) continue;
|
|
40
|
+
if (e.isDirectory()) walkMd(p, out);
|
|
41
|
+
else if (e.isFile() && e.name.endsWith('.md')) out.push(p);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function scopeFiles(root) {
|
|
45
|
+
const s = stateDir(root);
|
|
46
|
+
const files = [];
|
|
47
|
+
if (s) {
|
|
48
|
+
for (const sub of ['requirements', 'reports', 'memory']) walkMd(path.join(s, sub), files);
|
|
49
|
+
try { for (const n of fs.readdirSync(s)) if (n.endsWith('.md') && !['index.md', 'log.md'].includes(n)) files.push(path.join(s, n)); } catch { /* none */ }
|
|
50
|
+
}
|
|
51
|
+
walkMd(path.join(root, 'reports'), files);
|
|
52
|
+
for (const p of [path.join(root, 'pmos', 'planning', 'evals', 'eval-calibration.md'), path.join(root, 'pmos', 'planning', 'okrs', 'health-budgets.md')]) {
|
|
53
|
+
if (fs.existsSync(p)) files.push(p);
|
|
54
|
+
}
|
|
55
|
+
return [...new Set(files)].filter((f) => !['index.md', 'log.md'].includes(path.basename(f)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── a deliberately small frontmatter reader/writer ───────────────────────────────────────────
|
|
59
|
+
function split(text) {
|
|
60
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
61
|
+
if (!m) return { fm: null, body: text };
|
|
62
|
+
return { fm: m[1], body: m[2] };
|
|
63
|
+
}
|
|
64
|
+
// Top-level entries as { key, lines[] }: an entry owns its indented continuation lines.
|
|
65
|
+
function entries(fm) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const line of fm.split(/\r?\n/)) {
|
|
68
|
+
if (/^\s+\S/.test(line) && out.length) { out[out.length - 1].lines.push(line); continue; }
|
|
69
|
+
const m = /^([A-Za-z_][\w-]*)\s*:/.exec(line);
|
|
70
|
+
out.push({ key: m ? m[1] : null, lines: [line] });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const q = (s) => (/^[A-Za-z0-9_.\/@+-]+$/.test(s) && !/^(true|false|null|yes|no|\d+)$/i.test(s) ? s : JSON.stringify(s));
|
|
75
|
+
function scalar(v) { return String(v).trim().replace(/^["']|["']$/g, ''); }
|
|
76
|
+
function parseBlock(entry) {
|
|
77
|
+
const head = entry.lines[0].replace(/^[^:]*:/, '').trim();
|
|
78
|
+
const rest = entry.lines.slice(1);
|
|
79
|
+
if (head && !rest.length) return { flow: true }; // already one line
|
|
80
|
+
if (head) return null; // something unexpected
|
|
81
|
+
const items = []; let cur = null;
|
|
82
|
+
for (const l of rest) {
|
|
83
|
+
const li = /^\s+-\s*(.*)$/.exec(l);
|
|
84
|
+
const kv = /^\s+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(l);
|
|
85
|
+
if (li) {
|
|
86
|
+
const inner = li[1]; const ikv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(inner);
|
|
87
|
+
cur = ikv ? { [ikv[1]]: scalar(ikv[2]) } : { resource: scalar(inner) };
|
|
88
|
+
items.push(cur);
|
|
89
|
+
} else if (kv) {
|
|
90
|
+
if (cur && items.length) cur[kv[1]] = scalar(kv[2]);
|
|
91
|
+
else { cur = {}; items.push(cur); cur[kv[1]] = scalar(kv[2]); items.single = true; }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { items, single: !!items.single };
|
|
95
|
+
}
|
|
96
|
+
function flowOf(key, parsed) {
|
|
97
|
+
const obj = (o) => `{${Object.entries(o).map(([k, v]) => `${k}: ${q(String(v))}`).join(', ')}}`;
|
|
98
|
+
if (key === 'generated') return `generated: ${obj(parsed.items[0] || {})}`;
|
|
99
|
+
return `${key}: [${parsed.items.map(obj).join(', ')}]`;
|
|
100
|
+
}
|
|
101
|
+
// Parse the items of a one-line flow list `[{k: v, k: "v"}, {…}]` back into objects. Values are bare or JSON-quoted.
|
|
102
|
+
function flowItems(line) {
|
|
103
|
+
const body = line.replace(/^[^:]*:\s*/, '');
|
|
104
|
+
const out = [];
|
|
105
|
+
const objRe = /\{((?:[^{}"]|"(?:[^"\\]|\\.)*")*)\}/g; let m;
|
|
106
|
+
while ((m = objRe.exec(body))) {
|
|
107
|
+
const o = {}; const kvRe = /([A-Za-z_][\w-]*)\s*:\s*("(?:[^"\\]|\\.)*"|[^,]*)/g; let kv;
|
|
108
|
+
while ((kv = kvRe.exec(m[1]))) { let v = kv[2].trim(); if (v.startsWith('"')) { try { v = JSON.parse(v); } catch { /* keep raw */ } } o[kv[1]] = v; }
|
|
109
|
+
out.push(o);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function firstH1(body) { const m = /^#\s+(.+?)\s*$/m.exec(body); return m ? m[1].trim() : null; }
|
|
114
|
+
function titleFromName(file) { return path.basename(file, '.md').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); }
|
|
115
|
+
|
|
116
|
+
// `# Citations` (or ##) list at the end of the body → sources items. Returns null when nothing converts.
|
|
117
|
+
function takeCitations(body) {
|
|
118
|
+
const m = /(^|\n)#{1,3}\s+Citations\s*\n([\s\S]*?)(?=\n#{1,3}\s|\s*$)/.exec(body);
|
|
119
|
+
if (!m) return null;
|
|
120
|
+
const items = []; let unconverted = 0;
|
|
121
|
+
for (const raw of m[2].split('\n')) {
|
|
122
|
+
const l = raw.trim(); if (!l) continue;
|
|
123
|
+
const mm = /^[-*]\s+(?:\[([^\]]*)\]\(([^)]+)\)|(https?:\/\/\S+))(?:\s*[—-]\s*(.*))?$/.exec(l);
|
|
124
|
+
if (!mm) { unconverted++; continue; }
|
|
125
|
+
const resource = mm[2] || mm[3]; const title = mm[1] || mm[4] || null;
|
|
126
|
+
items.push(title ? { resource, title } : { resource });
|
|
127
|
+
}
|
|
128
|
+
return { whole: m[0], lead: m[1], items, unconverted };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function upgradeDoc(file, text) {
|
|
132
|
+
const notes = []; const issues = [];
|
|
133
|
+
let { fm, body } = split(text);
|
|
134
|
+
let ents;
|
|
135
|
+
if (fm === null) {
|
|
136
|
+
issues.push('no frontmatter');
|
|
137
|
+
ents = [{ key: 'type', lines: ['type: Reference'] }, { key: 'title', lines: [`title: ${q(firstH1(body) || titleFromName(file))}`] }];
|
|
138
|
+
notes.push('added type and title (type is a guess: Reference)');
|
|
139
|
+
body = text;
|
|
140
|
+
} else {
|
|
141
|
+
ents = entries(fm);
|
|
142
|
+
const has = (k) => ents.some((e) => e.key === k);
|
|
143
|
+
if (!has('type')) { issues.push('no type'); ents.unshift({ key: 'type', lines: ['type: Reference'] }); notes.push('added type: Reference (a guess)'); }
|
|
144
|
+
if (!has('title')) { issues.push('no title'); ents.splice(1, 0, { key: 'title', lines: [`title: ${q(firstH1(body) || titleFromName(file))}`] }); notes.push('added title from the first heading'); }
|
|
145
|
+
for (const e of ents) {
|
|
146
|
+
if (!FLOW_KEYS.includes(e.key) || e.lines.length < 2 && /:\s*\S/.test(e.lines[0])) continue;
|
|
147
|
+
const parsed = parseBlock(e);
|
|
148
|
+
if (parsed && parsed.items && parsed.items.length) {
|
|
149
|
+
issues.push(`block-style ${e.key} (readers stop at the first indented line)`);
|
|
150
|
+
e.lines = [flowOf(e.key, parsed)]; notes.push(`${e.key} rewritten to one line`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const cit = takeCitations(body);
|
|
155
|
+
if (cit && cit.items.length) {
|
|
156
|
+
issues.push('citations in the body (v0.2 keeps them in `sources`)');
|
|
157
|
+
const src = ents.find((e) => e.key === 'sources');
|
|
158
|
+
const have = src ? flowItems(src.lines[0]) : [];
|
|
159
|
+
const merged = [...have];
|
|
160
|
+
for (const it of cit.items) if (!merged.some((h) => h.resource === it.resource)) merged.push(it);
|
|
161
|
+
const line = `sources: [${merged.map((o) => `{${Object.entries(o).map(([k, v]) => `${k}: ${q(String(v))}`).join(', ')}}`).join(', ')}]`;
|
|
162
|
+
if (src) src.lines = [line]; else ents.push({ key: 'sources', lines: [line] });
|
|
163
|
+
if (!cit.unconverted) body = body.replace(cit.whole, cit.lead ? '\n' : '');
|
|
164
|
+
else notes.push(`${cit.unconverted} citation line(s) are not links and stay in the body`);
|
|
165
|
+
notes.push(`${cit.items.length} citation(s) moved to sources`);
|
|
166
|
+
}
|
|
167
|
+
const hasTs = ents.some((e) => e.key === 'timestamp'); const hasGen = ents.some((e) => e.key === 'generated');
|
|
168
|
+
if (hasTs && !hasGen) notes.push('legacy timestamp kept — who generated it is not recorded, so generated.by is not invented');
|
|
169
|
+
const out = `---\n${ents.map((e) => e.lines.join('\n')).join('\n')}\n---\n${body}`;
|
|
170
|
+
const final = out.endsWith('\n') ? out : out + '\n';
|
|
171
|
+
return { text: final, changed: final !== text, issues, notes };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── the bundle root ──────────────────────────────────────────────────────────────────────────
|
|
175
|
+
function rootIndex(dir) {
|
|
176
|
+
const names = []; try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) if (!e.name.startsWith('.') && (e.isDirectory() || e.name.endsWith('.md'))) names.push(e); } catch { /* none */ }
|
|
177
|
+
const lines = ['---', `okf_version: "${OKF_VERSION}"`, '---', '', '# Knowledge bundle', '', 'Documents PMOS and its agents write in this repository (OKF v0.2, D73).', ''];
|
|
178
|
+
for (const e of names.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
179
|
+
if (['index.md', 'log.md'].includes(e.name)) continue;
|
|
180
|
+
lines.push(e.isDirectory() ? `- [${e.name}/](/${e.name}/)` : `- [${e.name}](/${e.name})`);
|
|
181
|
+
}
|
|
182
|
+
return lines.join('\n') + '\n';
|
|
183
|
+
}
|
|
184
|
+
function rootFiles(root) {
|
|
185
|
+
const dir = stateDir(root); const todo = []; const issues = [];
|
|
186
|
+
if (!dir) return { todo, issues, dir };
|
|
187
|
+
const idx = path.join(dir, 'index.md'); const log = path.join(dir, 'log.md');
|
|
188
|
+
let idxText = null; try { idxText = fs.readFileSync(idx, 'utf8'); } catch { /* absent */ }
|
|
189
|
+
if (idxText === null) { issues.push('no bundle-root index.md'); todo.push({ file: idx, text: rootIndex(dir), note: 'created index.md with okf_version "0.2"' }); }
|
|
190
|
+
else if (!new RegExp(`^okf_version:\\s*["']?${OKF_VERSION.replace('.', '\\.')}["']?\\s*$`, 'm').test(split(idxText).fm || '')) {
|
|
191
|
+
issues.push(`bundle-root index.md does not declare okf_version "${OKF_VERSION}"`);
|
|
192
|
+
const { fm, body } = split(idxText);
|
|
193
|
+
todo.push({ file: idx, text: fm === null ? `---\nokf_version: "${OKF_VERSION}"\n---\n${idxText}` : `---\n${fm.replace(/^okf_version:.*$/m, '').replace(/\n+$/, '')}\nokf_version: "${OKF_VERSION}"\n---\n${body}`.replace(/^---\n\n/, '---\n'), note: 'set okf_version "0.2" on index.md' });
|
|
194
|
+
}
|
|
195
|
+
if (!fs.existsSync(log)) {
|
|
196
|
+
issues.push('no log.md');
|
|
197
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
198
|
+
todo.push({ file: log, text: `# Log\n\nNewest first, ISO dates.\n\n## ${day}\n\n- Adopted OKF v0.2 (D73).\n`, note: 'created log.md' });
|
|
199
|
+
}
|
|
200
|
+
return { todo, issues, dir };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function run(root, { write = false } = {}) {
|
|
204
|
+
if (isPmosSelf(root)) return { ok: false, error: 'this is PMOS\'s own repository, which has its own Frontmatter Gate (D74) — okf-upgrade is for tenant repositories' };
|
|
205
|
+
if (!stateDir(root)) return { ok: false, error: 'no pmos/state/ or planning/ directory here — nothing to upgrade' };
|
|
206
|
+
const files = scopeFiles(root); const results = [];
|
|
207
|
+
for (const f of files) {
|
|
208
|
+
const text = fs.readFileSync(f, 'utf8');
|
|
209
|
+
const r = upgradeDoc(f, text);
|
|
210
|
+
results.push({ file: path.relative(root, f), ...r });
|
|
211
|
+
if (write && r.changed) fs.writeFileSync(f, r.text);
|
|
212
|
+
}
|
|
213
|
+
const rf = rootFiles(root);
|
|
214
|
+
for (const t of rf.todo) { results.push({ file: path.relative(root, t.file), changed: true, issues: [], notes: [t.note], rootFile: true }); if (write) fs.writeFileSync(t.file, t.text); }
|
|
215
|
+
const issues = results.flatMap((r) => r.issues.map((i) => ({ file: r.file, issue: i }))).concat(rf.issues.map((i) => ({ file: path.relative(root, path.join(rf.dir, 'index.md')), issue: i })));
|
|
216
|
+
return { ok: true, write, scanned: files.length, results, issues, changed: results.filter((r) => r.changed).length };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function main(argv) {
|
|
220
|
+
const a = argv.slice(2); const flags = a.filter((x) => x.startsWith('--')); const pos = a.filter((x) => !x.startsWith('--'));
|
|
221
|
+
const root = path.resolve(pos[0] || process.cwd());
|
|
222
|
+
const write = flags.includes('--write'); const check = flags.includes('--check');
|
|
223
|
+
const r = run(root, { write: write && !check });
|
|
224
|
+
if (!r.ok) { process.stderr.write(`okf-upgrade: ${r.error}\n`); return 2; }
|
|
225
|
+
if (flags.includes('--json')) { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); return check && r.issues.length ? 1 : 0; }
|
|
226
|
+
for (const x of r.results) if (x.changed || x.issues.length) process.stdout.write(`${x.changed ? (write && !check ? 'upgraded' : 'would change') : 'ok'} ${x.file}\n${x.notes.map((n) => ` · ${n}`).join('\n')}${x.notes.length ? '\n' : ''}`);
|
|
227
|
+
process.stdout.write(`\n${r.scanned} document(s) in scope · ${r.issues.length} not conformant · ${check ? 'check only' : write ? `${r.changed} written` : `${r.changed} would change — nothing written; add --write to apply`}\n`);
|
|
228
|
+
return check && r.issues.length ? 1 : 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { run, upgradeDoc, scopeFiles, isPmosSelf, OKF_VERSION };
|
|
232
|
+
if (require.main === module) process.exit(main(process.argv));
|