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.
Files changed (72) hide show
  1. package/bin/create-pmos.js +49 -9
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +4 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/frontmatter-format.md +27 -61
  6. package/payload/okf/core/templates/decision-record-template.md +34 -0
  7. package/payload/okf/core/templates/index.md +7 -2
  8. package/payload/okf/core/templates/pm-brief-template.md +34 -0
  9. package/payload/okf/core/templates/project-plan-template.md +43 -0
  10. package/payload/okf/core/templates/report-weekly-template.md +35 -0
  11. package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
  12. package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
  13. package/payload/planning/evals/eval-calibration.md +8 -0
  14. package/payload/planning/okrs/health-budgets.md +8 -0
  15. package/payload/scripts/anchor-resolve.js +56 -6
  16. package/payload/scripts/gate-initiative.sh +32 -0
  17. package/payload/scripts/okf-upgrade.js +232 -0
  18. package/payload/scripts/pmos-memory.js +375 -0
  19. package/payload/scripts/pmos-model.js +239 -0
  20. package/payload/scripts/pmos-skills.js +274 -0
  21. package/payload/scripts/workflow-state.js +178 -10
  22. package/payload/scripts/workflow-sweep.js +2 -1
  23. package/payload/state/discoveries.md +8 -0
  24. package/payload/state/index.md +7 -0
  25. package/payload/state/log.md +7 -0
  26. package/workspace/README.md +61 -204
  27. package/workspace/bin/pmos.js +23 -4
  28. package/workspace/lib/board-cache.js +18 -8
  29. package/workspace/lib/console-state.js +2 -0
  30. package/workspace/lib/engine/anchor-resolve.js +56 -6
  31. package/workspace/lib/engine/pmos-memory.js +375 -0
  32. package/workspace/lib/engine/pmos-model.js +239 -0
  33. package/workspace/lib/engine/pmos-skills.js +274 -0
  34. package/workspace/lib/engine/workflow-state.js +178 -10
  35. package/workspace/lib/focus.js +5 -6
  36. package/workspace/lib/memory.js +54 -0
  37. package/workspace/lib/models.js +50 -0
  38. package/workspace/lib/preview-kind.js +12 -1
  39. package/workspace/lib/schedule-logic.js +31 -2
  40. package/workspace/lib/skill-store.js +5 -1
  41. package/workspace/lib/skills-registry.js +74 -0
  42. package/workspace/lib/sync.js +132 -0
  43. package/workspace/lib/tree-share.js +2 -0
  44. package/workspace/package-lock.json +1 -1
  45. package/workspace/package.json +3 -3
  46. package/workspace/public/pm/app.js +32 -2
  47. package/workspace/public/pm/data.js +160 -10
  48. package/workspace/public/pm/lifecycle.js +105 -0
  49. package/workspace/public/pm/markdown.js +10 -1
  50. package/workspace/public/pm/modes-live.js +437 -0
  51. package/workspace/public/pm/modes.js +8 -5
  52. package/workspace/public/pm/panel-detail.js +22 -2
  53. package/workspace/public/pm/panel-initiatives.js +57 -12
  54. package/workspace/public/pm/panel-newsess.js +25 -0
  55. package/workspace/public/pm/panel-repos.js +130 -4
  56. package/workspace/public/pm/panel-tabs.js +49 -2
  57. package/workspace/public/pm/state.js +11 -1
  58. package/workspace/public/pm/tab-doc.js +160 -26
  59. package/workspace/public/pm/tab-term.js +2 -0
  60. package/workspace/public/pm/tab-web.js +89 -0
  61. package/workspace/public/pm/tokens.css +4 -0
  62. package/workspace/public/pm/xlsx-read.js +117 -0
  63. package/workspace/server.js +736 -42
  64. package/workspace/skills/gather-requirements.skill +53 -0
  65. package/workspace/skills/memory-groom.skill +46 -0
  66. package/workspace/skills/report-biweekly.skill +53 -0
  67. package/workspace/skills/report-daily.skill +53 -0
  68. package/workspace/skills/report-monthly.skill +53 -0
  69. package/workspace/skills/report-special.skill +49 -0
  70. package/workspace/skills/report-weekly.skill +53 -0
  71. package/workspace/skills/skill-discover.skill +49 -0
  72. package/workspace/skills/skill-generate.skill +33 -0
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* pmos-skills — the directory of discovered and generated skills (D76).
4
+ *
5
+ * node pmos-skills.js add --id my-skill --file ./downloaded.skill --url https://… --license MIT --author someone --why "…" [--origin discovered|generated]
6
+ * node pmos-skills.js list | show <id> [--sha <12>] | reject <id> --sha <12> --reason "…" | retire <id> --sha <12> [--root <repo>] [--json]
7
+ *
8
+ * THE POINT OF THIS MODULE IS WHAT IT WILL NOT DO. A skill here is a prompt that runs with the PM's tools and
9
+ * accounts, and the loader re-reads skill files on every call, so a downloaded file is runnable the moment it lands.
10
+ * Therefore:
11
+ * · `add` puts a text file in QUARANTINE and records provenance. It cannot approve anything. There is no approve
12
+ * command in this file at all: approval is a PM press in the workspace, recorded in the workspace's own runtime
13
+ * state on that machine (outside the repository, so a commit or a hand-edited registry cannot approve for the PM).
14
+ * · the registry in the repository is SUBTRACTIVE-SAFE: anyone may append `add`, `reject` and `retire`, and none of
15
+ * them can make a skill run.
16
+ * · a file is accepted only as ONE text file of at most 40 KB; archives, binaries, NUL bytes and non-UTF-8 are refused.
17
+ * · a critical finding refuses the file outright (nothing is stored); soft findings are stored and shown.
18
+ * · the scanner is evadable (the repo's own SECURITY note says so). It is a filter, not a verdict: the PM reading
19
+ * the full text, and the loader re-hashing it, are the controls.
20
+ */
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const crypto = require('crypto');
24
+ const memory = require('./pmos-memory.js');
25
+
26
+ const MAX_BYTES = 40 * 1024;
27
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
28
+ const ORIGINS = ['discovered', 'generated'];
29
+ const PERMISSIVE = new Set(['mit', 'apache-2.0', 'bsd-2-clause', 'bsd-3-clause', 'isc', 'cc0-1.0', 'unlicense', '0bsd']);
30
+ const FORCED_KEYS = ['agent', 'run', 'surface']; // a discovered skill's own values for these are never honoured
31
+
32
+ // ── paths ────────────────────────────────────────────────────────────────────────────────────
33
+ function stateDir(root, create) {
34
+ const kit = path.join(root, 'pmos', 'state'); const self = path.join(root, 'planning');
35
+ if (fs.existsSync(kit)) return kit;
36
+ if (fs.existsSync(self)) return self;
37
+ if (create) { fs.mkdirSync(kit, { recursive: true }); return kit; }
38
+ return null;
39
+ }
40
+ function skillsDir(root, create) {
41
+ const s = stateDir(root, create); if (!s) return null;
42
+ const d = path.join(s, 'skills'); if (create) fs.mkdirSync(d, { recursive: true });
43
+ return d;
44
+ }
45
+ const sha256 = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex');
46
+
47
+ // ── the scan ─────────────────────────────────────────────────────────────────────────────────
48
+ // Critical = refuse. These are the memory scanner's rules (injection phrasing, credentials, exfiltration, a
49
+ // planted key, invisible and bidirectional characters) — in a skill body they have no legitimate use.
50
+ const SOFT = [
51
+ ['shell command', /\b(rm\s+-rf|sudo\s|chmod\s+[0-7]{3,4}|mkfs|dd\s+if=|:\(\)\s*\{|\beval\s*\(|bash\s+-c|sh\s+-c)\b/],
52
+ ['downloads and runs', /\b(curl|wget)\b[^\n]{0,120}\b(sh|bash|zsh|python|node)\b/i],
53
+ ['network fetch', /\b(curl|wget|fetch|http\.get|requests\.(get|post)|urllib)\b/i],
54
+ ['hidden comment', /<!--[\s\S]{0,2000}?-->/],
55
+ ['long encoded blob', /[A-Za-z0-9+\/=]{120,}/],
56
+ ['mentions credential files', /(~\/\.ssh|\bid_rsa\b|\.aws\/credentials|\.env\b|\.netrc|keychain|ANTHROPIC_API_KEY|OPENAI_API_KEY|GITHUB_TOKEN)/i],
57
+ ['asks to hide or skip the user', /\b(do not tell the user|don't tell the user|without (asking|telling|confirm)|silently|secretly|no need to inform)\b/i],
58
+ ['sends data out', /\b(send|email|post|upload|forward)\b[^\n]{0,60}\b(to|at)\b[^\n]{0,40}(@|https?:\/\/)/i],
59
+ ];
60
+ function lineOf(text, index) { return text.slice(0, index).split('\n').length; }
61
+ function scanSkill(text) {
62
+ const critical = []; const soft = [];
63
+ const lines = text.split('\n');
64
+ for (const [name, re] of memory.SCAN) {
65
+ const g = new RegExp(re.source, re.flags.replace('g', '') + 'g'); let m;
66
+ while ((m = g.exec(text)) && critical.length < 20) { critical.push({ rule: name, line: lineOf(text, m.index) }); if (m.index === g.lastIndex) g.lastIndex++; }
67
+ }
68
+ for (const [name, re] of SOFT) {
69
+ const g = new RegExp(re.source, re.flags.replace('g', '') + 'g'); let m; let n = 0;
70
+ while ((m = g.exec(text)) && n < 5) { soft.push({ rule: name, line: lineOf(text, m.index) }); n++; if (m.index === g.lastIndex) g.lastIndex++; }
71
+ }
72
+ const external = [...new Set((text.match(/https?:\/\/[^\s)>"']+/g) || []))];
73
+ if (external.length) soft.push({ rule: `external links (${external.length})`, line: lineOf(text, text.indexOf(external[0])) });
74
+ void lines;
75
+ return { critical, soft };
76
+ }
77
+ // The frontmatter keys a discovered skill sets that this directory will IGNORE (the harness is forced).
78
+ function ignoredKeys(text) {
79
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); if (!m) return [];
80
+ const out = [];
81
+ for (const k of FORCED_KEYS) { const kv = new RegExp(`^${k}:\\s*(.+)$`, 'm').exec(m[1]); if (kv && !(k === 'agent' && /^["']?claude["']?$/.test(kv[1].trim())) && !(k === 'run' && /^["']?agent["']?$/.test(kv[1].trim()))) out.push(`${k}: ${kv[1].trim().slice(0, 40)}`); }
82
+ return out;
83
+ }
84
+
85
+ // ── validation of an incoming file ───────────────────────────────────────────────────────────
86
+ function validateIncoming(buf) {
87
+ if (!Buffer.isBuffer(buf)) return 'no file content';
88
+ if (buf.length === 0) return 'the file is empty';
89
+ if (buf.length > MAX_BYTES) return `the file is ${buf.length} bytes; the limit is ${MAX_BYTES} — a skill is one short text file`;
90
+ if (buf.includes(0)) return 'the file contains NUL bytes — it is binary, not a text skill';
91
+ if (buf[0] === 0x50 && buf[1] === 0x4b) return 'the file looks like an archive — only a single text file is accepted';
92
+ let text; try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch { return 'the file is not valid UTF-8 text'; }
93
+ return { text };
94
+ }
95
+
96
+ // ── the registry (append-only, in the repository) ────────────────────────────────────────────
97
+ function readEvents(dir) {
98
+ let text = ''; try { text = fs.readFileSync(path.join(dir, 'registry.jsonl'), 'utf8'); } catch { return { events: [], torn: 0 }; }
99
+ const events = []; let torn = 0;
100
+ for (const line of text.split('\n')) {
101
+ if (!line.trim()) continue;
102
+ try { const e = JSON.parse(line); if (e && e.eid && e.op) events.push(e); else torn++; } catch { torn++; }
103
+ }
104
+ return { events, torn };
105
+ }
106
+ function appendEvent(dir, ev) {
107
+ const file = path.join(dir, 'registry.jsonl');
108
+ const dst = fs.lstatSync(dir);
109
+ if (!dst.isDirectory() || dst.isSymbolicLink()) throw new Error('the skills directory is not a plain directory — nothing was written');
110
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o644);
111
+ try {
112
+ const st = fs.fstatSync(fd);
113
+ if (!st.isFile() || st.nlink !== 1) throw new Error('registry.jsonl is not a plain single-link file — nothing was written');
114
+ const before = st.size; const buf = Buffer.from(JSON.stringify(ev) + '\n');
115
+ if (fs.writeSync(fd, buf) !== buf.length) { try { fs.ftruncateSync(fd, before); } catch { /* best effort */ } throw new Error('short write — rolled back'); }
116
+ } finally { fs.closeSync(fd); }
117
+ }
118
+ let _seq = 0;
119
+ const newEid = () => 's-' + Date.now().toString(36).padStart(9, '0') + '-' + String(_seq++ % 1000).padStart(3, '0') + '-' + crypto.randomBytes(3).toString('hex');
120
+ const byTime = (a, b) => String(a.at).localeCompare(String(b.at)) || String(a.eid).localeCompare(String(b.eid));
121
+
122
+ /* Fold to one row per (id, sha). State here is only quarantined | rejected | retired: whether a candidate is APPROVED
123
+ * is not in this file — it is the PM's local approval, joined at load time by `resolve`. Adds first, in any order. */
124
+ function fold(events) {
125
+ const seen = new Map(); for (const e of events) if (!seen.has(e.eid)) seen.set(e.eid, e);
126
+ const all = [...seen.values()].sort(byTime);
127
+ const rows = new Map();
128
+ for (const e of all) if (e.op === 'add' && ID_RE.test(String(e.id || '')) && /^[0-9a-f]{64}$/.test(String(e.sha || ''))) {
129
+ const key = `${e.id}@${e.sha}`;
130
+ if (!rows.has(key)) rows.set(key, { id: e.id, sha: e.sha, sha12: e.sha.slice(0, 12), origin: ORIGINS.includes(e.origin) ? e.origin : 'discovered',
131
+ source_url: e.source_url || null, author: e.author || null, license: e.license || 'unknown', why: e.why || '', found_by: e.by || 'unknown',
132
+ found_at: e.at, bytes: e.bytes || null, scan: e.scan || { critical: [], soft: [] }, ignored_keys: e.ignored_keys || [], file: e.file || null, state: 'quarantined' });
133
+ }
134
+ for (const e of all) {
135
+ if (e.op !== 'reject' && e.op !== 'retire') continue;
136
+ const targets = e.sha ? [rows.get(`${e.id}@${e.sha}`)] : [...rows.values()].filter((r) => r.id === e.id);
137
+ for (const r of targets) if (r) { r.state = e.op === 'reject' ? 'rejected' : 'retired'; r.decided_by = e.by; r.decided_at = e.at; r.reason = e.reason || ''; }
138
+ }
139
+ return [...rows.values()];
140
+ }
141
+
142
+ const relFile = (root, id, sha) => path.posix.join(path.relative(root, skillsDir(root, false) || '').split(path.sep).join('/'), 'candidates', id, `${sha.slice(0, 12)}.skill`);
143
+
144
+ /* add — agent CLI and the workspace both use it. Returns { ok, id, sha, scan } or { ok:false, error }. */
145
+ function add(root, req) {
146
+ const id = String(req.id || '').trim();
147
+ if (!ID_RE.test(id)) return { ok: false, error: `id must be a plain slug (a-z, 0-9, hyphen; at most 49 characters), got '${id.slice(0, 40)}'` };
148
+ const origin = req.origin || 'discovered';
149
+ if (!ORIGINS.includes(origin)) return { ok: false, error: `origin must be one of ${ORIGINS.join(', ')}` };
150
+ const url = String(req.source_url || '').trim();
151
+ if (origin === 'discovered' && !/^https?:\/\/\S+$/.test(url)) return { ok: false, error: 'a discovered skill needs the http(s) URL it came from' };
152
+ if (!String(req.why || '').trim()) return { ok: false, error: 'say why this skill is worth the PM reading (one sentence)' };
153
+ const v = validateIncoming(req.content);
154
+ if (typeof v === 'string') return { ok: false, error: v };
155
+ const text = v.text; const sha = sha256(text);
156
+ const scan = scanSkill(text);
157
+ if (scan.critical.length) return { ok: false, error: `refused — ${scan.critical.slice(0, 3).map((c) => `${c.rule} (line ${c.line})`).join('; ')}. Nothing was stored.`, scan };
158
+ const dir = skillsDir(root, true);
159
+ const existing = fold(readEvents(dir).events).find((r) => r.id === id && r.sha === sha);
160
+ if (existing) return { ok: true, id, sha, sha12: sha.slice(0, 12), duplicate: true, state: existing.state, scan };
161
+ const cdir = path.join(dir, 'candidates', id); fs.mkdirSync(cdir, { recursive: true });
162
+ const file = path.join(cdir, `${sha.slice(0, 12)}.skill`);
163
+ try { const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o644); fs.writeSync(fd, text); fs.closeSync(fd); }
164
+ catch (e) { return { ok: false, error: `could not store the candidate — ${String((e && e.message) || e).slice(0, 100)}` }; }
165
+ const ev = { eid: newEid(), at: new Date().toISOString(), by: req.by || 'agent:cli', op: 'add', id, sha, origin, source_url: url || null,
166
+ author: req.author ? String(req.author).slice(0, 80) : null, license: String(req.license || 'unknown').slice(0, 40), why: String(req.why).trim().slice(0, 400),
167
+ bytes: Buffer.byteLength(text), scan, ignored_keys: ignoredKeys(text), file: relFile(root, id, sha) };
168
+ try { appendEvent(dir, ev); } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
169
+ render(root);
170
+ return { ok: true, id, sha, sha12: sha.slice(0, 12), state: 'quarantined', scan, file: ev.file };
171
+ }
172
+
173
+ function decide(root, op, req) {
174
+ const dir = skillsDir(root, false); if (!dir) return { ok: false, error: 'no skills directory yet' };
175
+ const rows = fold(readEvents(dir).events);
176
+ const target = rows.find((r) => r.id === req.id && (!req.sha || r.sha.startsWith(req.sha)));
177
+ if (!target) return { ok: false, error: `no candidate '${req.id}'${req.sha ? ` at ${req.sha}` : ''}` };
178
+ try { appendEvent(dir, { eid: newEid(), at: new Date().toISOString(), by: req.by || 'agent:cli', op, id: target.id, sha: target.sha, reason: String(req.reason || '').slice(0, 300) }); }
179
+ catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
180
+ render(root);
181
+ return { ok: true, id: target.id, sha12: target.sha12, state: op === 'reject' ? 'rejected' : 'retired' };
182
+ }
183
+
184
+ function candidates(root) {
185
+ const dir = skillsDir(root, false); if (!dir) return { rows: [], torn: 0 };
186
+ const { events, torn } = readEvents(dir);
187
+ return { rows: fold(events), torn };
188
+ }
189
+ function textOf(root, row) {
190
+ const dir = skillsDir(root, false); if (!dir) return { ok: false, error: 'no skills directory' };
191
+ const file = path.join(dir, 'candidates', row.id, `${row.sha12}.skill`);
192
+ let st; try { st = fs.lstatSync(file); } catch { return { ok: false, error: 'the candidate file is missing' }; }
193
+ if (!st.isFile() || st.isSymbolicLink()) return { ok: false, error: 'the candidate file is not a plain file' };
194
+ const text = fs.readFileSync(file, 'utf8');
195
+ if (sha256(text) !== row.sha) return { ok: false, error: 'the candidate file no longer matches its recorded hash — it was changed after it was filed', changed: true };
196
+ return { ok: true, text, file };
197
+ }
198
+
199
+ /* resolve — what may RUN. `approvals` is the PM's LOCAL list: [{id, sha, ack_license}]. A candidate runs only if it
200
+ * is approved there, not rejected or retired in the registry, and its file still hashes to the approved value. */
201
+ function resolve(root, approvals) {
202
+ const { rows } = candidates(root);
203
+ const approved = new Map(); for (const a of approvals || []) if (a && a.id && a.sha) approved.set(`${a.id}@${a.sha}`, a);
204
+ const skills = []; const blocked = [];
205
+ for (const r of rows) {
206
+ const key = `${r.id}@${r.sha}`; const a = approved.get(key);
207
+ if (!a) continue; // quarantined: simply not offered
208
+ if (r.state !== 'quarantined') { blocked.push({ id: r.id, sha12: r.sha12, why: `${r.state} in the registry` }); continue; }
209
+ const t = textOf(root, r);
210
+ if (!t.ok) { blocked.push({ id: r.id, sha12: r.sha12, why: t.error }); continue; }
211
+ skills.push({ id: r.id, sha: r.sha, sha12: r.sha12, text: t.text, file: t.file, row: r });
212
+ }
213
+ // an approved id with two approved hashes: the newest approval wins
214
+ const best = new Map();
215
+ for (const s of skills) { const prev = best.get(s.id); const ts = (approved.get(`${s.id}@${s.sha}`) || {}).at || ''; if (!prev || ts > prev.ts) best.set(s.id, { s, ts }); }
216
+ return { skills: [...best.values()].map((x) => x.s), blocked };
217
+ }
218
+
219
+ const licenseNeedsAck = (license) => !PERMISSIVE.has(String(license || '').toLowerCase().trim());
220
+
221
+ // ── the generated directory page (OKF v0.2) ─────────────────────────────────────────────────
222
+ function render(root) {
223
+ const dir = skillsDir(root, false); if (!dir) return;
224
+ const { rows } = candidates(root);
225
+ const lines = ['---', 'type: Reference', 'title: "Discovered and generated skills"',
226
+ 'description: "Every candidate skill found or generated for this repository, with where it came from. Nothing here runs until the PM approves its exact text in the workspace."',
227
+ `generated: {by: process:pmos-skills, at: "${new Date().toISOString()}"}`, 'status: stable', '---', '',
228
+ '# Discovered and generated skills', '',
229
+ 'A candidate is quarantined until the PM opens its text and approves it in the workspace. Approval is local to the machine that gave it. The registry is `registry.jsonl`; the text of each candidate is under `candidates/`.', '',
230
+ '| id | origin | state | licence | source | found | hash | findings |', '|---|---|---|---|---|---|---|---|'];
231
+ for (const r of rows.sort((a, b) => a.id.localeCompare(b.id) || String(b.found_at).localeCompare(String(a.found_at)))) {
232
+ const src = r.source_url ? `[${(r.author || 'source')}](${r.source_url})` : (r.origin === 'generated' ? 'generated here' : '—');
233
+ lines.push(`| ${r.id} | ${r.origin} | ${r.state} | ${r.license} | ${src} | ${String(r.found_at).slice(0, 10)} | \`${r.sha12}\` | ${(r.scan.soft || []).length ? (r.scan.soft || []).map((f) => f.rule).join(', ') : 'none'} |`);
234
+ }
235
+ if (!rows.length) lines.push('| _none yet_ | | | | | | | |');
236
+ lines.push('');
237
+ try { fs.writeFileSync(path.join(dir, 'index.md'), lines.join('\n')); } catch { /* the index is a convenience, never a reason to fail */ }
238
+ }
239
+
240
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────
241
+ function main(argv) {
242
+ const a = argv.slice(2); const cmd = a[0]; const opt = {}; const rest = [];
243
+ for (let i = 1; i < a.length; i++) { if (a[i].startsWith('--')) { const k = a[i].slice(2); if (k === 'json') opt.json = true; else opt[k] = a[++i]; } else rest.push(a[i]); }
244
+ const root = path.resolve(opt.root || process.cwd());
245
+ const out = (o, human) => process.stdout.write(opt.json ? JSON.stringify(o, null, 2) + '\n' : human + '\n');
246
+ if (!cmd || cmd === 'help' || cmd === '--help') { process.stdout.write('usage: pmos-skills.js add --id <slug> --file <path> --url <https url> --license <spdx> --author <name> --why "<reason>" [--origin discovered|generated] | list | show <id> | reject <id> --sha <12> --reason "…" | retire <id> --sha <12> [--root <repo>] [--json]\n(there is no approve command: the PM approves in the workspace)\n'); return cmd ? 0 : 1; }
247
+ if (cmd === 'add') {
248
+ let content; try { content = fs.readFileSync(path.resolve(opt.file || '')); } catch { process.stderr.write('--file must name a readable file\n'); return 2; }
249
+ const r = add(root, { id: opt.id, content, source_url: opt.url, license: opt.license, author: opt.author, why: opt.why, origin: opt.origin, by: opt.by });
250
+ if (!r.ok) { process.stderr.write(`not filed: ${r.error}\n`); return 2; }
251
+ out(r, `${r.duplicate ? 'already filed' : 'filed in quarantine'}: ${r.id} @ ${r.sha12} — ${(r.scan.soft || []).length} soft finding(s). It does not run until the PM approves it in the workspace.`);
252
+ return 0;
253
+ }
254
+ if (cmd === 'list') {
255
+ const { rows } = candidates(root);
256
+ out(rows, rows.length ? rows.map((r) => `${r.id}@${r.sha12} ${r.origin} ${r.state} ${r.license} ${(r.scan.soft || []).length} soft finding(s)`).join('\n') : 'no candidates');
257
+ return 0;
258
+ }
259
+ if (cmd === 'show') {
260
+ const { rows } = candidates(root); const r = rows.find((x) => x.id === rest[0] && (!opt.sha || x.sha.startsWith(opt.sha)));
261
+ if (!r) { process.stderr.write('no such candidate\n'); return 2; }
262
+ const t = textOf(root, r); out({ ...r, text: t.ok ? t.text : null, text_error: t.ok ? null : t.error }, `${r.id}@${r.sha12} ${r.state}\nsource: ${r.source_url}\nlicense: ${r.license}\nwhy: ${r.why}\n${t.ok ? '\n' + t.text : '\n(' + t.error + ')'}`);
263
+ return 0;
264
+ }
265
+ if (cmd === 'reject' || cmd === 'retire') {
266
+ const r = decide(root, cmd, { id: rest[0], sha: opt.sha, reason: opt.reason, by: opt.by });
267
+ if (!r.ok) { process.stderr.write(r.error + '\n'); return 2; }
268
+ out(r, `${cmd}ed ${r.id}@${r.sha12}`); return 0;
269
+ }
270
+ process.stderr.write(`unknown command '${cmd}'\n`); return 2;
271
+ }
272
+
273
+ module.exports = { MAX_BYTES, ID_RE, PERMISSIVE, FORCED_KEYS, scanSkill, ignoredKeys, validateIncoming, fold, add, decide, candidates, textOf, resolve, licenseNeedsAck, render, skillsDir, stateDir, readEvents, appendEvent, sha256 };
274
+ if (require.main === module) process.exit(main(process.argv));
@@ -94,7 +94,8 @@ function budgetsFor(root, L) {
94
94
  catch { value = { ok: false, text: '', path: path.relative(root, L.budgets) }; }
95
95
  _budCache.set(root, { stamp, value });
96
96
  }
97
- return _budCache.get(root).value;
97
+ // D67: the domain registry rides on the budgets object, read fresh (a small, PM-edited file)
98
+ return anchors.withDomains(_budCache.get(root).value, path.dirname(L.budgets), root);
98
99
  }
99
100
 
100
101
  /*
@@ -527,6 +528,21 @@ function surfaceRoute(v) {
527
528
  return 'malformed';
528
529
  }
529
530
 
531
+ // ── Repository profile (D68) ──────────────────────────────────────────────────────────────────
532
+ // A repository declares `pm-artifacts` (a PM's documents, strategy and specifications — no code) or
533
+ // `product-code` (PMOS-self and code repositories) in a one-line `.pmos-profile` file at its root.
534
+ // No file, or anything else in it, is `product-code`: every existing repository behaves as before.
535
+ const PM_LANES = ['strategy', 'prd', 'project-plan', 'discovery', 'decision-record', 'report', 'chore'];
536
+ const PM_LIGHT_BY_DEFAULT = ['decision-record', 'report', 'chore'];
537
+ const PM_NO_HANDOFF = ['decision-record', 'report', 'chore'];
538
+ function profileOf(root) {
539
+ try {
540
+ const line = fs.readFileSync(path.join(root, '.pmos-profile'), 'utf8')
541
+ .split('\n').map((l) => l.replace(/#.*$/, '').trim()).find(Boolean);
542
+ return line === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
543
+ } catch { return 'product-code'; }
544
+ }
545
+
530
546
  // ── Step model ─────────────────────────────────────────────────────────────────────────────────
531
547
  // status ∈ done | current | blocked | pending | na | unknown
532
548
  // `blocking` marks a step whose failure fails CI. O5/O6/O7 are deliberately NOT blocking: none can
@@ -539,7 +555,12 @@ function step(key, label, status, evidence, next, opts) {
539
555
  };
540
556
  }
541
557
 
542
- function computeWorkflow(id, opts) {
558
+ // D74 — `generated: {by: x, at: "2026-10-02T01:00:00Z"}` on one line (the only form PMOS writes). Returns the `at`, or ''.
559
+ function generatedAt(v) {
560
+ const m = /\bat:\s*["']?([^"',}\s]+)/.exec(String(v || ''));
561
+ return m ? m[1] : '';
562
+ }
563
+ function computeWorkflowDerived(id, opts) {
543
564
  const root = path.resolve((opts && opts.root) || process.cwd());
544
565
  const L = layout(root);
545
566
  const recRel = path.relative(root, path.join(L.initiatives, `${id}.md`));
@@ -548,9 +569,13 @@ function computeWorkflow(id, opts) {
548
569
  return { ok: false, id, error: `no initiative record at ${recRel} — an initiative must exist before its work` };
549
570
  }
550
571
  const fm = readFrontmatter(recAbs) || {};
551
- const lane = (fm.lane || 'feature').trim() || 'feature';
552
- const knownLane = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'].includes(lane);
553
- const effLane = knownLane ? lane : 'feature'; // unknown lane = strictest, as the bash gate does
572
+ const profile = profileOf(root);
573
+ const pmProfile = profile === 'pm-artifacts';
574
+ const lane = (fm.lane || (pmProfile ? 'prd' : 'feature')).trim() || (pmProfile ? 'prd' : 'feature');
575
+ const knownLane = pmProfile ? PM_LANES.includes(lane)
576
+ : ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'].includes(lane);
577
+ // unknown lane = the profile's strictest (feature in product-code, a governed prd in pm-artifacts)
578
+ const effLane = knownLane ? lane : (pmProfile ? 'prd' : 'feature');
554
579
  const range = (opts && opts.range) || commitRange(root);
555
580
  // See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
556
581
  //
@@ -575,7 +600,7 @@ function computeWorkflow(id, opts) {
575
600
  const legacy = recIdx === -1 ? true
576
601
  : /^\d{4}-\d{2}-\d{2}$/.test(recon) ? recon < ENFORCED_FROM
577
602
  : recAt ? new Date(recAt * 1000).toISOString().slice(0, 10) < ENFORCED_FROM
578
- : String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM
603
+ : String(fm.timestamp || generatedAt(fm.generated) || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM; D74: OKF v0.2 `generated.at` stands in for `timestamp`
579
604
  /*
580
605
  * `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
581
606
  * question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
@@ -673,6 +698,18 @@ function computeWorkflow(id, opts) {
673
698
  `only the PM adds a budget — use an existing id from ${budRel} or anchor to a KR`,
674
699
  { blocking: !legacy, rule: 'R1' }));
675
700
  }
701
+ } else if (r.kind === 'domain') {
702
+ // D67 — the domain's standing goal: resolved like a health budget, rejected like one when the
703
+ // id is not in the PM-edited registry.
704
+ const domRel = (budgetsFor(root, L).domains || {}).path || 'planning/okrs/domains.md';
705
+ if (r.status === 'resolved') {
706
+ steps.push(step('anchor', 'anchor', 'done', `domain:${r.domain} — ${r.domain_goal || 'standing goal not written'} (no OKR, D67)`));
707
+ } else {
708
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
709
+ `domain:${r.domain} is not a declared domain`,
710
+ `only the PM adds a domain — use an id from ${domRel}, or anchor to a KR or a health budget`,
711
+ { blocking: !legacy, rule: 'R1' }));
712
+ }
676
713
  } else if (r.status === 'resolved') {
677
714
  steps.push(step('anchor', 'anchor', 'done',
678
715
  `${anchors.canonical(r, (okrIndexFor(root) || {}).period) || anchor} — ${r.reason}`));
@@ -691,6 +728,11 @@ function computeWorkflow(id, opts) {
691
728
  }
692
729
  }
693
730
 
731
+ // ── D68: the pm-artifacts ladder replaces everything after the anchor ─────────────────────────
732
+ if (pmProfile) {
733
+ return pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, recRel, range, inChange, lastActivity });
734
+ }
735
+
694
736
  const prd = String(fm.prd_path || '').trim();
695
737
  const rubric = String(fm.rubric_path || '').trim();
696
738
  const prdIdx = prd ? firstAppearance(root, range, prd) : null;
@@ -1133,7 +1175,7 @@ function computeWorkflow(id, opts) {
1133
1175
  return {
1134
1176
  ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
1135
1177
  last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
1136
- title: fm.title || id, anchor: fm.parent_kr || null,
1178
+ title: fm.title || id, anchor: fm.parent_kr || null, profile: 'product-code',
1137
1179
  steps, blockers: blockers.map((s) => s.key), gate_failures: gateFailures.map((s) => s.rule || s.key),
1138
1180
  next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
1139
1181
  range: range.ok ? { base: range.base, commits: range.shas.length, whole_history: !!range.wholeHistory } : { error: range.reason },
@@ -1141,6 +1183,122 @@ function computeWorkflow(id, opts) {
1141
1183
  };
1142
1184
  }
1143
1185
 
1186
+ /**
1187
+ * D68 — the pm-artifacts ladder: route → anchor → brief → draft → review → approved → handed off.
1188
+ *
1189
+ * Route and anchor are computed by the shared code above and keep their rules (anchoring holds in
1190
+ * every profile — D59/D67); route is re-stated here because its product-code form asks the
1191
+ * specimen/surface questions, which a PM document does not need. What follows is evidence-derived
1192
+ * like every other rung — nothing is declared done:
1193
+ * brief — the brief file (`brief_path`, or `prd_path`) exists
1194
+ * draft — the deliverable (`doc_path`) exists, and — governed only — did not appear before the brief
1195
+ * review — an evals.jsonl verdict for this id, or a named `reviewed_by:` (a rubric, an agent, a human)
1196
+ * approved — the PM's acceptance in acceptances.jsonl (D12, never automatic)
1197
+ * handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
1198
+ * Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
1199
+ * existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
1200
+ * CI-blocking in either track: none can be true before the PR exists.
1201
+ */
1202
+ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, recRel, range, inChange, lastActivity }) {
1203
+ const declared = String(fm.track || '').trim().toLowerCase();
1204
+ const track = declared === 'light' || declared === 'governed' ? declared
1205
+ : (PM_LIGHT_BY_DEFAULT.includes(effLane) ? 'light' : 'governed');
1206
+ const enforce = track === 'governed' && !legacy;
1207
+ const exists = (p) => !!p && fs.existsSync(path.join(root, p));
1208
+
1209
+ // route — the lane is a pm-artifacts lane
1210
+ steps[0] = knownLane
1211
+ ? step('route', 'route', 'done', `${lane} lane · ${track} track (pm-artifacts profile, D68)`)
1212
+ : step('route', 'route', 'done', `unknown lane '${lane}' — treated as a governed prd, the profile's strictest (D68)`,
1213
+ `set lane: to one of ${PM_LANES.join(', ')}`);
1214
+
1215
+ const briefPath = String(fm.brief_path || fm.prd_path || '').trim();
1216
+ const docPath = String(fm.doc_path || '').trim();
1217
+ const briefIdx = briefPath ? firstAppearance(root, range, briefPath) : null;
1218
+ const docIdx = docPath ? firstAppearance(root, range, docPath) : null;
1219
+
1220
+ if (exists(briefPath)) steps.push(step('brief', 'brief', 'done', `${briefPath} present`));
1221
+ else steps.push(step('brief', 'brief', enforce ? 'blocked' : 'pending',
1222
+ briefPath ? `brief_path '${briefPath}' does not exist` : 'no brief yet',
1223
+ `write a short brief (the question, the reader, the decision it informs) and set brief_path in ${recRel}`,
1224
+ { blocking: enforce, rule: 'P1' }));
1225
+
1226
+ if (exists(docPath)) {
1227
+ const inOrder = briefIdx == null || docIdx == null ? null : docIdx >= briefIdx;
1228
+ if (enforce && inOrder === false) {
1229
+ steps.push(step('draft', 'draft', 'blocked', `${docPath} appeared before its brief ${briefPath}`,
1230
+ 'a governed document is briefed before it is drafted — the brief must land first', { blocking: true, rule: 'P2' }));
1231
+ } else steps.push(step('draft', 'draft', 'done', `${docPath} present`));
1232
+ } else steps.push(step('draft', 'draft', enforce && docPath ? 'blocked' : 'pending',
1233
+ docPath ? `doc_path '${docPath}' does not exist` : 'no draft yet',
1234
+ `write the document and set doc_path in ${recRel}`, { blocking: enforce && !!docPath, rule: 'P2' }));
1235
+
1236
+ const evals = cachedJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
1237
+ const reviewer = String(fm.reviewed_by || '').trim();
1238
+ if (evals.length) steps.push(step('review', 'review', 'done', `${evals[evals.length - 1].verdict || 'verdict'} recorded in evals.jsonl`));
1239
+ else if (reviewer) steps.push(step('review', 'review', 'done', `reviewed by ${reviewer}`));
1240
+ else steps.push(step('review', 'review', 'pending', 'not reviewed yet',
1241
+ 'name the reviewer in reviewed_by: (a person, an agent, or a rubric verdict in evals.jsonl)'));
1242
+
1243
+ const accepted = cachedJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
1244
+ .filter((a) => a && a.initiative_id === id && a.accepted === true);
1245
+ steps.push(accepted.length
1246
+ ? step('approved', 'approved', 'done', `accepted by the PM${accepted[accepted.length - 1].accepted_at ? ' on ' + String(accepted[accepted.length - 1].accepted_at).slice(0, 10) : ''}`)
1247
+ : step('approved', 'approved', 'pending', 'awaiting the PM\'s acceptance (D12)', 'the PM accepts the document — never automatic'));
1248
+
1249
+ if (PM_NO_HANDOFF.includes(effLane)) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
1250
+ else {
1251
+ const to = String(fm.handed_off || '').trim();
1252
+ steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
1253
+ : step('handoff', 'handed off', 'pending', 'not handed off yet',
1254
+ 'record where it went in handed_off: (a tracker link, the team, or "accepted as the deliverable")'));
1255
+ }
1256
+
1257
+ const by = Object.fromEntries(steps.map((x) => [x.key, x]));
1258
+ let stage = 'intake';
1259
+ if (by.brief.status === 'done') stage = 'brief';
1260
+ if (by.draft.status === 'done') stage = 'draft';
1261
+ if (by.review.status === 'done') stage = 'review';
1262
+ if (accepted.length) stage = 'accepted';
1263
+ const blockers = steps.filter((x) => x.status === 'blocked');
1264
+ const gateFailures = blockers.filter((x) => x.blocking);
1265
+ const next = steps.find((x) => x.status === 'blocked') || steps.find((x) => x.status === 'pending' && x.next) || null;
1266
+ return {
1267
+ ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
1268
+ last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
1269
+ title: fm.title || id, anchor: fm.parent_kr || null, profile: 'pm-artifacts', track,
1270
+ steps, blockers: blockers.map((x) => x.key), gate_failures: gateFailures.map((x) => x.rule || x.key),
1271
+ next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
1272
+ range: range.ok ? { base: range.base, commits: range.shas.length, whole_history: !!range.wholeHistory } : { error: range.reason },
1273
+ enforced_from: ENFORCED_FROM,
1274
+ };
1275
+ }
1276
+
1277
+ // ── Lifecycle (D70) — a stopped initiative is a ledger line, never a record field ───────────────
1278
+ // `lifecycle.jsonl` beside the other ledgers: {initiative_id, state, reason, pm, at}. The latest line
1279
+ // for an id wins. `active` reopens. The same reader exists in workspace/lib/lifecycle.js; a parity
1280
+ // fixture keeps the two in step. A lifecycle line is never an acceptance and never writes one.
1281
+ const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
1282
+ function readLifecycle(root) {
1283
+ const L = layout(root);
1284
+ const latest = new Map();
1285
+ for (const r of cachedJsonl(path.join(L.runsDir, 'lifecycle.jsonl'))) {
1286
+ if (!r || typeof r.initiative_id !== 'string') continue;
1287
+ if (r.state !== 'active' && !STOPPED_STATES.includes(r.state)) continue;
1288
+ latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' });
1289
+ }
1290
+ return latest;
1291
+ }
1292
+ function computeWorkflow(id, opts) {
1293
+ const w = computeWorkflowDerived(id, opts);
1294
+ if (!w.ok) return w;
1295
+ const lc = readLifecycle(path.resolve((opts && opts.root) || process.cwd())).get(id);
1296
+ if (!lc || lc.state === 'active') return w;
1297
+ // Stopped: leaves the board and stops gating. The ladder is kept so a reopen shows where it was.
1298
+ return { ...w, stopped: lc, blockers: [], gate_failures: [], next: null,
1299
+ steps: w.steps.map((x) => (x.status === 'blocked' ? { ...x, status: 'pending', blocking: false } : x)) };
1300
+ }
1301
+
1144
1302
  // ── Rendering ──────────────────────────────────────────────────────────────────────────────────
1145
1303
  const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
1146
1304
  const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
@@ -1192,7 +1350,15 @@ function renderBoard(rows, opts) {
1192
1350
  out.push(` ${c(1, 'PMOS workflow board')} · ${total} without a recorded acceptance · ${blocked ? c(31, `${blocked} blocked`) : c(32, 'none blocked')}${acceptedN ? c(90, ` · ${acceptedN} accepted`) : ''}`);
1193
1351
  out.push(` ${c(90, 'most recently worked on first · ✗ blocked · ● in this change')}`);
1194
1352
  out.push(` ${c(90, '─'.repeat(78))}`);
1195
- if (!shown.length) { out.push(` ${c(32, 'every initiative here has a recorded acceptance.')}`); out.push(''); return out.join('\n'); }
1353
+ const stoppedList = () => {
1354
+ const stopped = rows.filter((r) => r.ok && r.stopped);
1355
+ if (stopped.length) {
1356
+ out.push(` ${c(90, `stopped (${stopped.length}) — off the board, not gating:`)}`);
1357
+ for (const r of stopped) out.push(` ${c(90, `─ ${r.id} ${r.stopped.state}${r.stopped.at ? ' ' + String(r.stopped.at).slice(0, 10) : ''}${r.stopped.reason ? ' — ' + brief(r.stopped.reason, 60) : ''}`)}`);
1358
+ out.push('');
1359
+ }
1360
+ };
1361
+ if (!shown.length) { out.push(` ${c(32, 'every initiative here has a recorded acceptance.')}`); out.push(''); stoppedList(); return out.join('\n'); }
1196
1362
  const w1 = Math.min(34, Math.max(...shown.map((r) => r.id.length), 4));
1197
1363
  for (const r of shown) {
1198
1364
  if (!r.ok) { out.push(` ${c(31, '✗')} ${r.id.padEnd(w1)} ${c(31, r.error)}`); continue; }
@@ -1210,6 +1376,7 @@ function renderBoard(rows, opts) {
1210
1376
  // The two ranges, said out loud. The board reads the whole trunk so a delivered initiative does
1211
1377
  // not render as unstarted; the gate reads the PR so "spec before build" stays decidable. A rule
1212
1378
  // can therefore read done here and still be evaluated on the PR — intended, and not a discrepancy.
1379
+ stoppedList();
1213
1380
  out.push(` ${c(90, 'derived from the whole trunk history; the CI gate evaluates order within the PR only')}`);
1214
1381
  out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
1215
1382
  out.push('');
@@ -1232,7 +1399,7 @@ function renderBoard(rows, opts) {
1232
1399
  const BOARD_CAP = 12;
1233
1400
  function inFlight(rows, opts) {
1234
1401
  const cap = (opts && opts.cap) || BOARD_CAP;
1235
- const live = rows.filter((r) => !r.ok || !r.accepted);
1402
+ const live = rows.filter((r) => !r.ok || (!r.accepted && !r.stopped));
1236
1403
  const rank = (r) => (!r.ok ? 0 : r.blockers.length ? 1 : r.in_change ? 2 : 3);
1237
1404
  const sorted = [...live].sort((a, b) =>
1238
1405
  rank(a) - rank(b)
@@ -1285,6 +1452,7 @@ function main(argv) {
1285
1452
 
1286
1453
  // ── gate mode ──────────────────────────────────────────────────────────────────────────────
1287
1454
  if (!w.ok) { process.stdout.write(`::error::workflow-gate: ${w.error}\n`); return 1; }
1455
+ if (w.stopped) { process.stdout.write(`ℹ️ '${w.id}' is ${w.stopped.state} — stopped initiatives do not gate (D70).\n✅ workflow gate PASSED — '${w.id}' is ${w.stopped.state}.\n`); return 0; }
1288
1456
  const fails = w.steps.filter((s) => s.status === 'blocked' && s.blocking);
1289
1457
  if (fails.length) {
1290
1458
  for (const f of fails) {
@@ -1325,7 +1493,7 @@ function main(argv) {
1325
1493
  }
1326
1494
 
1327
1495
  module.exports = {
1328
- computeWorkflow, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
1496
+ computeWorkflow, readLifecycle, STOPPED_STATES, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
1329
1497
  parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
1330
1498
  // the board's trunk range, exported so the suite can assert the split: the same record is legacy
1331
1499
  // under BOTH ranges, and `in this change` is false under the trunk one.
@@ -746,9 +746,8 @@ function delegatedSection(products, skills) {
746
746
  /*
747
747
  * R10 — upkeep says only what it can read.
748
748
  *
749
- * MEMORY is a stated absence, not a count. `memory` mode is `data: 'absent'` by construction; there
750
- * is no store, so there are no unpinned entries at compaction risk. The row says the mechanism is
751
- * not built.
749
+ * MEMORY is a stated non-reading, not a count (R9, D72). Memory exists now, per repository, but this pure
750
+ * function is given no memory input, so the row points at the drawer instead of inventing a number.
752
751
  *
753
752
  * HEALTH BUDGETS carry a caveat instead of a verdict. The caps are shares of A QUARTER's
754
753
  * initiatives; no initiative record carries a period and `surfaceSpine` reads none. So the counts
@@ -761,10 +760,10 @@ const BUDGET_CAVEAT = 'The caps are shares of a QUARTER\'s initiatives. No initi
761
760
  function upkeepSection(products, registry) {
762
761
  const memory = {
763
762
  id: 'memory',
764
- state: 'absent',
763
+ state: 'unread',
765
764
  heading: 'memory grooming',
766
- body: 'not built. There is no memory store in this workspace, so there are no entries to groom and none at compaction risk. '
767
- + 'This row states the absence of the mechanism; it is not a count of zero.',
765
+ body: 'Memory is curated per repository. This row does not read it, so it carries no count and no verdict: '
766
+ + 'open the Memory drawer to see how full it is, what is unconfirmed or stale, and what waits for your approval.',
768
767
  count: null,
769
768
  };
770
769