create-pmos 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/bin/create-pmos.js +22 -3
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +6 -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/lib/board-cache.js +18 -8
  28. package/workspace/lib/console-state.js +2 -0
  29. package/workspace/lib/engine/anchor-resolve.js +56 -6
  30. package/workspace/lib/engine/pmos-memory.js +375 -0
  31. package/workspace/lib/engine/pmos-model.js +239 -0
  32. package/workspace/lib/engine/pmos-skills.js +274 -0
  33. package/workspace/lib/engine/workflow-state.js +178 -10
  34. package/workspace/lib/focus.js +5 -6
  35. package/workspace/lib/memory.js +54 -0
  36. package/workspace/lib/models.js +50 -0
  37. package/workspace/lib/preview-kind.js +12 -1
  38. package/workspace/lib/schedule-logic.js +31 -2
  39. package/workspace/lib/skill-store.js +5 -1
  40. package/workspace/lib/skills-registry.js +74 -0
  41. package/workspace/lib/sync.js +132 -0
  42. package/workspace/lib/tree-share.js +2 -0
  43. package/workspace/package-lock.json +2 -2
  44. package/workspace/package.json +3 -3
  45. package/workspace/public/pm/app.js +32 -2
  46. package/workspace/public/pm/data.js +160 -10
  47. package/workspace/public/pm/lifecycle.js +105 -0
  48. package/workspace/public/pm/markdown.js +10 -1
  49. package/workspace/public/pm/modes-live.js +371 -0
  50. package/workspace/public/pm/modes.js +7 -4
  51. package/workspace/public/pm/panel-detail.js +22 -2
  52. package/workspace/public/pm/panel-initiatives.js +57 -12
  53. package/workspace/public/pm/panel-newsess.js +25 -0
  54. package/workspace/public/pm/panel-repos.js +130 -4
  55. package/workspace/public/pm/panel-tabs.js +49 -2
  56. package/workspace/public/pm/state.js +11 -1
  57. package/workspace/public/pm/tab-doc.js +160 -26
  58. package/workspace/public/pm/tab-term.js +2 -0
  59. package/workspace/public/pm/tab-web.js +89 -0
  60. package/workspace/public/pm/tokens.css +4 -0
  61. package/workspace/public/pm/xlsx-read.js +117 -0
  62. package/workspace/server.js +690 -40
  63. package/workspace/skills/gather-requirements.skill +53 -0
  64. package/workspace/skills/memory-groom.skill +46 -0
  65. package/workspace/skills/report-biweekly.skill +53 -0
  66. package/workspace/skills/report-daily.skill +53 -0
  67. package/workspace/skills/report-monthly.skill +53 -0
  68. package/workspace/skills/report-special.skill +49 -0
  69. package/workspace/skills/report-weekly.skill +53 -0
  70. package/workspace/skills/skill-discover.skill +49 -0
  71. package/workspace/skills/skill-generate.skill +33 -0
@@ -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));
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* pmos-memory — curated repository memory (D72).
4
+ *
5
+ * node pmos-memory.js add --text "…" [--target memory|user] [--confidence unconfirmed] [--source kind:ref] [--stale-after YYYY-MM-DD]
6
+ * node pmos-memory.js replace --old "unique substring" --text "…" [--target …]
7
+ * node pmos-memory.js remove --old "unique substring" [--target …]
8
+ * node pmos-memory.js confirm --old "unique substring"
9
+ * node pmos-memory.js search "words" [--limit 8] ranks memory AND the requirement/report notes (lexical, not semantic)
10
+ * node pmos-memory.js list | pending | snapshot | approve <eid> | reject <eid> (all take --root <repo>, --json)
11
+ *
12
+ * The LEDGER is the truth: <state>/memory/memory.jsonl, append-only. The current entries are FOLDED
13
+ * from it by (event time, event id) — never by file position — so two machines appending the same file
14
+ * (merge=union) fold to the same result, and a duplicated line is harmless. The views MEMORY.md and
15
+ * USER.md are generated, git-ignored, and rebuilt at session start: a session reads them once (a frozen
16
+ * snapshot) and sees its own writes only in the next one.
17
+ *
18
+ * This is the harness pattern Hermes uses (bounded curated files, add/replace/remove by unique
19
+ * substring, a capacity error that lists the entries, a frozen snapshot, optional write approval, a
20
+ * scan for injection and credentials) with three changes: the store lives in the repository so it
21
+ * travels with Sync, entries carry provenance and staleness, and the scan runs on READ as well as on
22
+ * write — an agent can append to the file by hand, so the writer cannot be the only guard.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+
28
+ const TARGETS = ['memory', 'user'];
29
+ const DEFAULTS = { memory_chars: 2200, user_chars: 1375, write_approval: false };
30
+ const CONFIDENCE = ['confirmed', 'unconfirmed'];
31
+ const HUMAN = /^human:/;
32
+
33
+ // ── paths ────────────────────────────────────────────────────────────────────────────────────
34
+ function stateDir(root, create) {
35
+ const kit = path.join(root, 'pmos', 'state');
36
+ const self = path.join(root, 'planning');
37
+ if (fs.existsSync(kit)) return kit;
38
+ if (fs.existsSync(self)) return self;
39
+ if (create) { fs.mkdirSync(kit, { recursive: true }); return kit; }
40
+ return null;
41
+ }
42
+ function memDir(root, create) {
43
+ const s = stateDir(root, create);
44
+ if (!s) return null;
45
+ const d = path.join(s, 'memory');
46
+ if (create) fs.mkdirSync(d, { recursive: true });
47
+ return d;
48
+ }
49
+
50
+ // ── the scan ─────────────────────────────────────────────────────────────────────────────────
51
+ const SCAN = [
52
+ ['prompt injection', /\b(ignore|disregard|forget)\b[^.\n]{0,40}\b(previous|prior|above|earlier|all)\b[^.\n]{0,30}\b(instruction|prompt|rule|direction)s?\b/i],
53
+ ['prompt injection', /\b(you are now|from now on you|act as if you have no|reveal (your|the) (system )?prompt|new instructions:)/i],
54
+ ['prompt injection', /<\s*\/?\s*(system|assistant|instructions?)\s*>/i],
55
+ ['credential', /\b(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|sk-[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,})\b/],
56
+ ['credential', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
57
+ ['credential', /\b(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{6,}/i],
58
+ ['exfiltration', /\b(curl|wget|nc|ncat)\b[^\n]{0,80}(\$\w+|\$\{|`|\|\s*(sh|bash))/i],
59
+ ['backdoor', /\bauthorized_keys\b|\bssh-(rsa|ed25519)\s+AAAA/i],
60
+ ['invisible character', /[​-‏‪-‮⁠-⁤]/],
61
+ ];
62
+ function scan(text) {
63
+ const t = String(text == null ? '' : text);
64
+ for (const [name, re] of SCAN) if (re.test(t)) return name;
65
+ return null;
66
+ }
67
+
68
+ // ── the ledger ───────────────────────────────────────────────────────────────────────────────
69
+ function readEvents(dir) {
70
+ const file = path.join(dir, 'memory.jsonl');
71
+ let text = '';
72
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return { events: [], torn: 0 }; }
73
+ const events = []; let torn = 0;
74
+ for (const line of text.split('\n')) {
75
+ if (!line.trim()) continue;
76
+ try { const e = JSON.parse(line); if (e && typeof e === 'object' && e.eid && e.op) events.push(e); else torn++; }
77
+ catch { torn++; }
78
+ }
79
+ return { events, torn };
80
+ }
81
+ function readConfig(dir) {
82
+ try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')) }; }
83
+ catch { return { ...DEFAULTS }; }
84
+ }
85
+ const byTime = (a, b) => String(a.at).localeCompare(String(b.at)) || String(a.eid).localeCompare(String(b.eid));
86
+
87
+ /* Fold the events to the current state. Pure and order-independent: events are de-duplicated by eid
88
+ * and sorted by (at, eid) before they are applied. */
89
+ function fold(events, now) {
90
+ const seen = new Map();
91
+ for (const e of events) if (!seen.has(e.eid)) seen.set(e.eid, e);
92
+ const all = [...seen.values()].sort(byTime);
93
+ const decided = new Map(); // staged eid -> 'approve' | 'reject'
94
+ for (const e of all) if (e.op === 'approve' || e.op === 'reject') decided.set(e.ref, e.op);
95
+ const entries = new Map(); const pending = []; const blocked = [];
96
+ const nowMs = now ? Date.parse(now) : Date.now();
97
+ // Two passes. ADDs first, in any order, so that a replace / remove / confirm finds the entry it names even when
98
+ // its timestamp sorts before the add's (two machines whose clocks disagree by a second, or two events in the
99
+ // same millisecond). The other operations then apply in (time, id) order. An operation naming an id that no add
100
+ // created is ignored.
101
+ const pass = [e => e.op === 'add', e => e.op !== 'add'];
102
+ for (const wanted of pass) for (const e of all) {
103
+ if (!wanted(e)) continue;
104
+ if (!['add', 'replace', 'remove', 'confirm'].includes(e.op)) continue;
105
+ if (e.op !== 'confirm' && e.staged) {
106
+ const d = decided.get(e.eid);
107
+ if (d === 'reject') continue;
108
+ if (d !== 'approve') { pending.push(e); continue; }
109
+ }
110
+ if (e.op === 'add' || e.op === 'replace') {
111
+ const hit = scan(e.text);
112
+ if (hit) { blocked.push({ eid: e.eid, op: e.op, reason: hit, by: e.by, at: e.at }); continue; }
113
+ }
114
+ if (e.op === 'add') {
115
+ if (!TARGETS.includes(e.target)) continue;
116
+ entries.set(e.eid, { id: e.eid, target: e.target, text: String(e.text), confidence: e.confidence === 'confirmed' ? 'confirmed' : 'unconfirmed',
117
+ source: e.source || null, by: e.by || 'unknown', at: e.at, updated_at: e.at, stale_after: e.stale_after || null, verified: [] });
118
+ } else if (e.op === 'replace') {
119
+ const cur = entries.get(e.id); if (!cur) continue;
120
+ cur.text = String(e.text); cur.updated_at = e.at; cur.by = e.by || cur.by;
121
+ if (e.confidence) cur.confidence = e.confidence === 'confirmed' ? 'confirmed' : 'unconfirmed';
122
+ if (e.source) cur.source = e.source;
123
+ if (e.stale_after !== undefined) cur.stale_after = e.stale_after || null;
124
+ cur.verified = [];
125
+ } else if (e.op === 'remove') {
126
+ entries.delete(e.id);
127
+ } else if (e.op === 'confirm') {
128
+ const cur = entries.get(e.id); if (!cur) continue;
129
+ cur.confidence = 'confirmed'; cur.verified.push({ by: e.by, at: e.at });
130
+ }
131
+ }
132
+ const list = [...entries.values()].map((x) => ({ ...x, stale: !!(x.stale_after && Date.parse(x.stale_after) <= nowMs) }));
133
+ return { entries: list, pending, blocked };
134
+ }
135
+
136
+ const used = (entries, target) => entries.filter((e) => e.target === target).reduce((n, e) => n + e.text.length + 1, 0);
137
+ function usage(state, cfg) {
138
+ const out = {};
139
+ for (const t of TARGETS) {
140
+ const limit = cfg[`${t}_chars`]; const u = used(state.entries, t);
141
+ out[t] = { used: u, limit, percent: Math.round((u / limit) * 100) };
142
+ }
143
+ return out;
144
+ }
145
+
146
+ // ── the guarded append ───────────────────────────────────────────────────────────────────────
147
+ // Sink fixed from the caller's root; validate before open; O_NOFOLLOW; single link; torn-append rollback.
148
+ function appendEvent(dir, ev) {
149
+ const file = path.join(dir, 'memory.jsonl');
150
+ const dst = fs.lstatSync(dir);
151
+ if (!dst.isDirectory() || dst.isSymbolicLink()) throw new Error('the memory directory is not a plain directory — nothing was written');
152
+ const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
153
+ const fd = fs.openSync(file, flags, 0o644);
154
+ try {
155
+ const st = fs.fstatSync(fd);
156
+ if (!st.isFile() || st.nlink !== 1) throw new Error('memory.jsonl is not a plain single-link file — nothing was written');
157
+ const before = st.size;
158
+ const line = JSON.stringify(ev) + '\n';
159
+ const buf = Buffer.from(line, 'utf8');
160
+ const n = fs.writeSync(fd, buf);
161
+ if (n !== buf.length) { try { fs.ftruncateSync(fd, before); } catch { /* best effort */ } throw new Error('short write — rolled back'); }
162
+ } finally { fs.closeSync(fd); }
163
+ }
164
+ const nowIso = () => new Date().toISOString();
165
+ // An event id sorts in the order events were made: a zero-padded millisecond, then a per-process counter (two
166
+ // writes in the same millisecond keep their order), then random bytes to keep ids from different machines apart.
167
+ let _seq = 0;
168
+ const newEid = () => 'm-' + Date.now().toString(36).padStart(9, '0') + '-' + String(_seq++ % 1000).padStart(3, '0') + '-' + crypto.randomBytes(3).toString('hex');
169
+
170
+ function load(root) {
171
+ const dir = memDir(root, false);
172
+ const { events, torn } = dir ? readEvents(dir) : { events: [], torn: 0 };
173
+ const cfg = dir ? readConfig(dir) : { ...DEFAULTS };
174
+ const state = fold(events, nowIso());
175
+ return { dir, cfg, state, torn, usage: usage(state, cfg) };
176
+ }
177
+
178
+ function listFor(state, target) { return state.entries.filter((e) => e.target === target); }
179
+ function matchOne(state, target, sub) {
180
+ const s = String(sub || '').trim();
181
+ if (!s) return { ok: false, error: 'a substring that identifies one entry is required (--old)' };
182
+ const pool = listFor(state, target);
183
+ const exact = pool.filter((e) => e.text === s);
184
+ const hits = exact.length === 1 ? exact : pool.filter((e) => e.text.includes(s));
185
+ if (hits.length === 1) return { ok: true, entry: hits[0] };
186
+ return { ok: false, error: hits.length ? `'${s}' matches ${hits.length} entries — use a longer, unique substring` : `'${s}' matches no entry in ${target}`, entries: pool };
187
+ }
188
+
189
+ /* One write. `actor` is 'human:<id>' (the PM, via the workspace) or an agent label. With
190
+ * write_approval on, a non-human add/replace/remove is staged, not applied. */
191
+ function write(root, req) {
192
+ const op = req.op;
193
+ const target = req.target || 'memory';
194
+ if (!TARGETS.includes(target)) return { ok: false, error: `target must be one of ${TARGETS.join(', ')}` };
195
+ const cur = load(root);
196
+ const dir = memDir(root, true);
197
+ const cfg = cur.dir ? cur.cfg : readConfig(dir);
198
+ const actor = req.by || 'agent:cli';
199
+ const at = nowIso();
200
+ const staged = !HUMAN.test(actor) && !!cfg.write_approval && ['add', 'replace', 'remove'].includes(op);
201
+ let ev;
202
+ if (op === 'add' || op === 'replace') {
203
+ const text = String(req.text == null ? '' : req.text).trim();
204
+ if (!text) return { ok: false, error: 'text is required' };
205
+ if (text.length > 600) return { ok: false, error: 'an entry is a short fact: 600 characters at most' };
206
+ const hit = scan(text);
207
+ if (hit) return { ok: false, error: `blocked by the scan (${hit}) — nothing was written` };
208
+ const conf = req.confidence || (HUMAN.test(actor) ? 'confirmed' : 'unconfirmed');
209
+ if (!CONFIDENCE.includes(conf)) return { ok: false, error: `confidence must be ${CONFIDENCE.join(' or ')}` };
210
+ let id = null; let delta = text.length + 1;
211
+ if (op === 'replace') {
212
+ const m = matchOne(cur.state, target, req.old);
213
+ if (!m.ok) return m;
214
+ id = m.entry.id; delta = text.length - m.entry.text.length;
215
+ }
216
+ const limit = cfg[`${target}_chars`]; const u = used(cur.state.entries, target);
217
+ if (u + delta > limit) {
218
+ return { ok: false, error: `${target} is full (${u}/${limit} chars) and this write needs ${delta} more — merge or remove entries first`, entries: listFor(cur.state, target), usage: cur.usage };
219
+ }
220
+ ev = { eid: newEid(), at, by: actor, op, target, ...(id ? { id } : {}), text, confidence: conf,
221
+ ...(req.source ? { source: req.source } : {}), ...(req.stale_after ? { stale_after: req.stale_after } : {}), ...(staged ? { staged: true } : {}) };
222
+ } else if (op === 'remove' || op === 'confirm') {
223
+ const m = matchOne(cur.state, target, req.old || req.text);
224
+ if (!m.ok) return m;
225
+ ev = { eid: newEid(), at, by: actor, op, target, id: m.entry.id, ...(staged ? { staged: true } : {}) };
226
+ if (op === 'confirm' && !HUMAN.test(actor)) return { ok: false, error: 'only the PM confirms an entry' };
227
+ } else if (op === 'approve' || op === 'reject') {
228
+ if (!HUMAN.test(actor)) return { ok: false, error: 'only the PM approves or rejects a pending write' };
229
+ if (!cur.state.pending.some((p) => p.eid === req.ref)) return { ok: false, error: `no pending write ${req.ref}` };
230
+ ev = { eid: newEid(), at, by: actor, op, ref: req.ref };
231
+ } else return { ok: false, error: `unknown operation '${op}'` };
232
+ try { appendEvent(dir, ev); } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
233
+ const after = render(root);
234
+ return { ok: true, event: ev, staged, usage: after.usage };
235
+ }
236
+
237
+ // ── the frozen snapshot ──────────────────────────────────────────────────────────────────────
238
+ function viewFor(target, state, usageT, cfg, at) {
239
+ const title = target === 'memory' ? 'Repository memory' : 'About the PM';
240
+ const lines = [
241
+ '---', 'type: Memory', `title: "${title}"`,
242
+ `description: "Curated ${target === 'memory' ? 'facts about the product and domain' : 'preferences and working style of the PM'} — a snapshot generated from the memory ledger (D72). Edit with pmos-memory, not by hand."`,
243
+ `generated: {by: process:pmos-memory, at: "${at}"}`, 'status: stable', '---', '',
244
+ `# ${title} [${usageT.percent}% — ${usageT.used}/${usageT.limit} chars]`, '',
245
+ ];
246
+ if (usageT.percent >= 80) lines.push('> Above 80% full: merge or remove entries before adding.', '');
247
+ const es = listFor(state, target).sort(byTime);
248
+ if (!es.length) lines.push('_Nothing recorded yet._');
249
+ for (const e of es) {
250
+ const tags = [e.confidence === 'unconfirmed' ? 'UNCONFIRMED' : null, e.stale ? 'STALE' : null].filter(Boolean);
251
+ const src = e.source ? ` · source: ${e.source}` : '';
252
+ lines.push(`- ${tags.length ? `(${tags.join(', ')}) ` : ''}${e.text} <!-- ${e.id}${src} · ${e.by} · ${String(e.at).slice(0, 10)} -->`);
253
+ }
254
+ lines.push('');
255
+ return lines.join('\n');
256
+ }
257
+ function render(root) {
258
+ const cur = load(root);
259
+ const dir = cur.dir;
260
+ if (!dir) return { ok: true, rendered: false, usage: cur.usage };
261
+ const at = nowIso();
262
+ for (const t of TARGETS) {
263
+ const file = path.join(dir, t === 'memory' ? 'MEMORY.md' : 'USER.md');
264
+ try { if (fs.lstatSync(file).isSymbolicLink()) continue; } catch { /* absent */ }
265
+ fs.writeFileSync(file, viewFor(t, cur.state, cur.usage[t], cur.cfg, at));
266
+ }
267
+ const gi = path.join(dir, '.gitignore');
268
+ const want = 'MEMORY.md\nUSER.md\n';
269
+ try { if (fs.readFileSync(gi, 'utf8') !== want) fs.writeFileSync(gi, want); } catch { fs.writeFileSync(gi, want); }
270
+ return { ok: true, rendered: true, usage: cur.usage, blocked: cur.state.blocked, pending: cur.state.pending.length };
271
+ }
272
+
273
+
274
+ // ── search (R11) ─────────────────────────────────────────────────────────────────────────────
275
+ // LEXICAL recall over what a repository already knows: its memory entries and the requirement and report notes
276
+ // PMOS's agents filed. BM25 over words — it finds what shares words with the query, and does NOT understand
277
+ // meaning; the output says so. No model, no network, no dependency. Past SESSIONS are searched elsewhere
278
+ // (the workspace's /api/search), per machine.
279
+ const STOP = new Set('the and for with that this from are was were have has had not but you your our their its can will may all any one two per via into out off than then them they she him her who why how what when where which while about over under also more most some such only very just'.split(' '));
280
+ const stem = (w) => (w.length > 4 && /[^s]s$/.test(w) ? w.slice(0, -1) : w); // crude plural folding, nothing more
281
+ const tokens = (t) => (String(t || '').toLowerCase().match(/[a-z0-9][a-z0-9'-]{1,}/g)?.filter((w) => w.length > 2 && !STOP.has(w)) || []).map(stem);
282
+ function noteChunks(root) {
283
+ const st = stateDir(root, false); if (!st) return [];
284
+ const out = [];
285
+ const walk = (dir, depth) => {
286
+ let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
287
+ for (const e of ents) {
288
+ const p = path.join(dir, e.name);
289
+ if (e.isSymbolicLink()) continue;
290
+ if (e.isDirectory() && depth < 4) walk(p, depth + 1);
291
+ else if (e.isFile() && e.name.endsWith('.md') && !['index.md', 'log.md'].includes(e.name)) {
292
+ let text = ''; try { text = fs.readFileSync(p, 'utf8'); } catch { continue; }
293
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
294
+ const rel = path.relative(root, p).split(path.sep).join('/');
295
+ let buf = '';
296
+ const flush = () => { const t = buf.trim(); if (t.length > 20) out.push({ source: `note:${rel}`, text: t.slice(0, 420) }); buf = ''; };
297
+ for (const line of body.split('\n')) { if (!line.trim() || /^#{1,6}\s/.test(line)) flush(); if (line.trim()) buf += (buf ? ' ' : '') + line.replace(/^#{1,6}\s*/, '').trim(); if (buf.length > 320) flush(); }
298
+ flush();
299
+ }
300
+ }
301
+ };
302
+ for (const sub of ['requirements', 'reports']) walk(path.join(st, sub), 0);
303
+ walk(path.join(root, 'reports'), 0);
304
+ return out;
305
+ }
306
+ function search(root, query, limit) {
307
+ const q = tokens(query);
308
+ if (!q.length) return { ok: false, error: 'a query with at least one real word is required' };
309
+ const cur = load(root);
310
+ const docs = [
311
+ ...cur.state.entries.map((e) => ({ source: `memory:${e.target}`, id: e.id, text: e.text, confidence: e.confidence, stale: e.stale })),
312
+ ...noteChunks(root),
313
+ ];
314
+ if (!docs.length) return { ok: true, method: 'lexical', hits: [], searched: 0, note: 'nothing to search yet' };
315
+ const toks = docs.map((d) => tokens(d.text));
316
+ const avg = toks.reduce((n, t) => n + t.length, 0) / docs.length || 1;
317
+ const df = new Map(); for (const t of toks) for (const w of new Set(t)) df.set(w, (df.get(w) || 0) + 1);
318
+ const N = docs.length; const k1 = 1.4; const b = 0.75;
319
+ const hits = [];
320
+ docs.forEach((d, i) => {
321
+ const tf = new Map(); for (const w of toks[i]) tf.set(w, (tf.get(w) || 0) + 1);
322
+ let score = 0;
323
+ for (const w of q) { const f = tf.get(w); if (!f) continue; const idf = Math.log(1 + (N - df.get(w) + 0.5) / (df.get(w) + 0.5)); score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + b * (toks[i].length / avg)))); }
324
+ if (score > 0) hits.push({ ...d, score: Math.round(score * 1000) / 1000 });
325
+ });
326
+ hits.sort((a, c) => c.score - a.score || String(a.source).localeCompare(String(c.source)));
327
+ return { ok: true, method: 'lexical', note: 'ranked by shared words, not by meaning', searched: docs.length, hits: hits.slice(0, Math.max(1, Math.min(25, +limit || 8))) };
328
+ }
329
+
330
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────
331
+ function main(argv) {
332
+ const a = argv.slice(2);
333
+ const cmd = a[0];
334
+ const opt = {}; const rest = [];
335
+ for (let i = 1; i < a.length; i++) {
336
+ 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]);
337
+ }
338
+ const root = path.resolve(opt.root || process.cwd());
339
+ const say = (o) => process.stdout.write(opt.json ? JSON.stringify(o, null, 2) + '\n' : '');
340
+ if (!cmd || cmd === 'help' || cmd === '--help') {
341
+ process.stdout.write('usage: pmos-memory.js add|replace|remove|confirm|search|list|pending|snapshot|approve|reject [--text … --old … --target memory|user --confidence … --source … --stale-after … --root <repo> --json]\n');
342
+ return cmd ? 0 : 1;
343
+ }
344
+ if (cmd === 'search') {
345
+ const r = search(root, rest.join(' ') || opt.q, opt.limit);
346
+ if (opt.json) { say(r); return r.ok ? 0 : 2; }
347
+ if (!r.ok) { process.stderr.write(`${r.error}\n`); return 2; }
348
+ process.stdout.write(`${r.hits.length} hit(s) of ${r.searched} searched — ${r.note}\n`);
349
+ for (const h of r.hits) process.stdout.write(` [${h.source}${h.confidence === 'unconfirmed' ? ', unconfirmed' : ''}${h.stale ? ', stale' : ''}] ${h.text.slice(0, 160)}\n`);
350
+ return 0;
351
+ }
352
+ if (cmd === 'list' || cmd === 'pending' || cmd === 'snapshot') {
353
+ if (cmd === 'snapshot') { const r = render(root); say(r); if (!opt.json) process.stdout.write(r.rendered ? `snapshot rebuilt — memory ${r.usage.memory.percent}%, user ${r.usage.user.percent}%\n` : 'no memory directory yet\n'); return 0; }
354
+ const cur = load(root);
355
+ if (opt.json) { say({ entries: cur.state.entries, pending: cur.state.pending, blocked: cur.state.blocked, usage: cur.usage, torn: cur.torn }); return 0; }
356
+ if (cmd === 'pending') { for (const p of cur.state.pending) process.stdout.write(`${p.eid} ${p.op} ${p.target} ${p.text || p.id} (${p.by})\n`); return 0; }
357
+ for (const t of TARGETS) {
358
+ process.stdout.write(`${t} [${cur.usage[t].percent}% — ${cur.usage[t].used}/${cur.usage[t].limit}]\n`);
359
+ for (const e of listFor(cur.state, t)) process.stdout.write(` ${e.confidence === 'unconfirmed' ? '(unconfirmed) ' : ''}${e.stale ? '(stale) ' : ''}${e.text}\n`);
360
+ }
361
+ for (const b of cur.state.blocked) process.stdout.write(`BLOCKED ${b.eid}: ${b.reason}\n`);
362
+ return 0;
363
+ }
364
+ const req = { op: cmd, target: opt.target, text: opt.text, old: opt.old, confidence: opt.confidence, source: opt.source,
365
+ stale_after: opt['stale-after'], by: opt.by, ref: rest[0] };
366
+ const r = write(root, req);
367
+ if (opt.json) { say(r); return r.ok ? 0 : 2; }
368
+ if (r.ok) { process.stdout.write(`${r.staged ? 'held for the PM to approve' : 'saved'} — ${req.target || 'memory'} ${r.usage[req.target || 'memory'].percent}% full. It appears in the next session's snapshot.\n`); return 0; }
369
+ process.stderr.write(`not saved: ${r.error}\n`);
370
+ if (r.entries) for (const e of r.entries) process.stderr.write(` - ${e.text}\n`);
371
+ return 2;
372
+ }
373
+
374
+ module.exports = { fold, scan, load, write, render, search, memDir, stateDir, readEvents, readConfig, usage, appendEvent, TARGETS, DEFAULTS, SCAN };
375
+ if (require.main === module) process.exit(main(process.argv));