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
|
@@ -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));
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* pmos-model — model routing by tier (D75).
|
|
4
|
+
*
|
|
5
|
+
* node pmos-model.js show the policy in force (defaults, or the PM's file)
|
|
6
|
+
* node pmos-model.js init write the defaults to <state>/models/policy.json for the PM to edit
|
|
7
|
+
* node pmos-model.js recommend "the work" [--skill x] [--agent claude]
|
|
8
|
+
* node pmos-model.js resolve [--skill x] [--tier fast|balanced|deep] [--agent claude]
|
|
9
|
+
* node pmos-model.js buckets evaluator models seen in evals.jsonl (calibration is per model, D40)
|
|
10
|
+
*
|
|
11
|
+
* PMOS routes on TIERS, never on a model name: the PM maps a tier to a model and an effort in
|
|
12
|
+
* <state>/models/policy.json, and the defaults use the harness's own aliases (haiku, sonnet, opus) so they
|
|
13
|
+
* follow the latest model of each size instead of pinning an id that will age. Nothing here fetches a price,
|
|
14
|
+
* a catalogue or a benchmark: a cost is only ever the PM's own relative `cost_rank`.
|
|
15
|
+
*
|
|
16
|
+
* A launch form is known only for a binary someone has CHECKED (today: claude, against its own --help). For any
|
|
17
|
+
* other binary, and for a shell, no flag is guessed: the answer is "no model control", and callers must say so.
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const TIERS = ['fast', 'balanced', 'deep'];
|
|
23
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']; // from `claude --help`: --effort <level>
|
|
24
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,80}$/; // a plain token: nothing a shell would interpret
|
|
25
|
+
|
|
26
|
+
const DEFAULT_POLICY = {
|
|
27
|
+
version: 1,
|
|
28
|
+
tiers: {
|
|
29
|
+
fast: { model: 'haiku', effort: 'low', cost_rank: 1 },
|
|
30
|
+
balanced: { model: 'sonnet', effort: 'medium', cost_rank: 2 },
|
|
31
|
+
deep: { model: 'opus', effort: 'high', cost_rank: 3 },
|
|
32
|
+
},
|
|
33
|
+
routes: [
|
|
34
|
+
{ match: { skill: 'evaluator' }, tier: 'deep', pinned: true, why: 'review work stays on one model so its calibration means something (D40)' },
|
|
35
|
+
{ match: { skill: 'memory-groom' }, tier: 'fast', why: 'routine review of a small store' },
|
|
36
|
+
{ match: { skill: 'report-daily' }, tier: 'fast', why: 'a short end-of-day summary' },
|
|
37
|
+
{ match: { skill: 'backlog-triage' }, tier: 'fast', why: 'sorting, not deciding' },
|
|
38
|
+
{ match: { skill: 'skill-discover' }, tier: 'balanced', why: 'judging relevance and safety of text found on the web' },
|
|
39
|
+
{ match: { skill: 'skill-generate' }, tier: 'balanced', why: 'drafting a skill from evidence in the repository' },
|
|
40
|
+
{ match: { skill: 'gather-requirements' }, tier: 'balanced', why: 'reading many sources and judging what is a requirement' },
|
|
41
|
+
{ match: { skill: 'report-weekly' }, tier: 'balanced', why: 'a weekly synthesis' },
|
|
42
|
+
{ match: { skill: 'report-biweekly' }, tier: 'balanced', why: 'a two-week synthesis' },
|
|
43
|
+
{ match: { skill: 'report-monthly' }, tier: 'balanced', why: 'a monthly review' },
|
|
44
|
+
{ match: { skill: 'report-special' }, tier: 'balanced', why: 'a one-off report' },
|
|
45
|
+
{ match: { words: ['spec', 'prd', 'architecture', 'architect', 'review', 'evaluate', 'decide', 'decision', 'strategy', 'adr', 'security', 'threat', 'contract', 'rubric', 'migration'] }, tier: 'deep', why: 'the work where a mistake is costly to find later' },
|
|
46
|
+
{ match: { words: ['summarize', 'summarise', 'format', 'list', 'triage', 'groom', 'dedupe', 'rename', 'extract', 'tag', 'classify', 'sort', 'copy', 'reformat'] }, tier: 'fast', why: 'routine, mechanical work' },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── paths ────────────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
function stateDir(root, create) {
|
|
52
|
+
const kit = path.join(root, 'pmos', 'state'); const self = path.join(root, 'planning');
|
|
53
|
+
if (fs.existsSync(kit)) return kit;
|
|
54
|
+
if (fs.existsSync(self)) return self;
|
|
55
|
+
if (create) { fs.mkdirSync(kit, { recursive: true }); return kit; }
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function modelsDir(root, create) {
|
|
59
|
+
const s = stateDir(root, create); if (!s) return null;
|
|
60
|
+
const d = path.join(s, 'models'); if (create) fs.mkdirSync(d, { recursive: true });
|
|
61
|
+
return d;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── the policy ───────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
function validate(p) {
|
|
66
|
+
const errs = [];
|
|
67
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return ['the policy must be a JSON object'];
|
|
68
|
+
if (!p.tiers || typeof p.tiers !== 'object') errs.push('`tiers` is required');
|
|
69
|
+
for (const t of TIERS) {
|
|
70
|
+
const x = p.tiers && p.tiers[t];
|
|
71
|
+
if (!x) { errs.push(`tier '${t}' is missing`); continue; }
|
|
72
|
+
if (typeof x.model !== 'string' || !MODEL_RE.test(x.model)) errs.push(`tier '${t}': model must be a plain token like "sonnet" or "claude-sonnet-5-5"`);
|
|
73
|
+
if (!EFFORTS.includes(x.effort)) errs.push(`tier '${t}': effort must be one of ${EFFORTS.join(', ')}`);
|
|
74
|
+
if (x.cost_rank !== undefined && !(Number.isFinite(x.cost_rank))) errs.push(`tier '${t}': cost_rank must be a number`);
|
|
75
|
+
}
|
|
76
|
+
if (p.routes !== undefined && !Array.isArray(p.routes)) errs.push('`routes` must be a list');
|
|
77
|
+
(p.routes || []).forEach((r, i) => {
|
|
78
|
+
if (!r || typeof r !== 'object') { errs.push(`route ${i}: must be an object`); return; }
|
|
79
|
+
if (!TIERS.includes(r.tier)) errs.push(`route ${i}: tier must be one of ${TIERS.join(', ')}`);
|
|
80
|
+
if (!r.match || (typeof r.match.skill !== 'string' && !Array.isArray(r.match.words))) errs.push(`route ${i}: match needs a skill or a list of words`);
|
|
81
|
+
if (r.pinned !== undefined && typeof r.pinned !== 'boolean') errs.push(`route ${i}: pinned must be true or false`);
|
|
82
|
+
});
|
|
83
|
+
return errs;
|
|
84
|
+
}
|
|
85
|
+
function loadPolicy(root) {
|
|
86
|
+
const dir = root ? modelsDir(root, false) : null;
|
|
87
|
+
const file = dir ? path.join(dir, 'policy.json') : null;
|
|
88
|
+
if (!file || !fs.existsSync(file)) return { ok: true, source: 'defaults', policy: DEFAULT_POLICY };
|
|
89
|
+
let parsed;
|
|
90
|
+
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
91
|
+
catch (e) { return { ok: false, source: file, error: `policy.json is not valid JSON — ${String((e && e.message) || e).slice(0, 100)}` }; }
|
|
92
|
+
const errs = validate(parsed);
|
|
93
|
+
if (errs.length) return { ok: false, source: file, error: `policy.json is not usable — ${errs.join('; ')}` };
|
|
94
|
+
return { ok: true, source: file, policy: parsed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rank = (policy, tier) => (policy.tiers[tier] && policy.tiers[tier].cost_rank) ?? TIERS.indexOf(tier);
|
|
98
|
+
const words = (t) => new Set(String(t || '').toLowerCase().match(/[a-z][a-z-]+/g) || []);
|
|
99
|
+
|
|
100
|
+
function routeFor(policy, { skill, text }) {
|
|
101
|
+
const w = words(text);
|
|
102
|
+
for (const r of policy.routes || []) {
|
|
103
|
+
if (r.match.skill && skill && r.match.skill === skill) return r;
|
|
104
|
+
if (Array.isArray(r.match.words) && r.match.words.some((x) => w.has(String(x).toLowerCase()))) return r;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── launch forms: ONLY for a binary someone has checked ──────────────────────────────────────
|
|
110
|
+
const LAUNCH = {
|
|
111
|
+
claude: (model, effort) => (effort ? ['--model', model, '--effort', effort] : ['--model', model]),
|
|
112
|
+
};
|
|
113
|
+
function launchFor(agent, resolution) {
|
|
114
|
+
if (!resolution || !resolution.tier) return { state: 'not-asked', args: [] };
|
|
115
|
+
const f = LAUNCH[agent];
|
|
116
|
+
if (!f) return { state: 'no-model-control', args: [] };
|
|
117
|
+
const { model, effort } = resolution;
|
|
118
|
+
if (!MODEL_RE.test(model) || (effort && !EFFORTS.includes(effort))) return { state: 'refused', args: [], error: `'${model}' / '${effort}' is not a plain model token and a known effort — nothing was started` };
|
|
119
|
+
return { state: 'applied', args: f(model, effort) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ctx: { skill, text, explicit: {tier?|model?, effort?}, scheduleTier, skillTier, agent }.
|
|
123
|
+
* Order: explicit → schedule tier → skill tier → policy route → nothing. A pinned route may be raised, never lowered. */
|
|
124
|
+
function resolve(root, ctx = {}) {
|
|
125
|
+
const loaded = loadPolicy(root);
|
|
126
|
+
if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
127
|
+
const policy = loaded.policy;
|
|
128
|
+
const route = routeFor(policy, ctx);
|
|
129
|
+
const ex = ctx.explicit || {};
|
|
130
|
+
let tier = null; let model = null; let effort = null; let decidedBy = null; let why = '';
|
|
131
|
+
if (ex.model) {
|
|
132
|
+
if (!MODEL_RE.test(String(ex.model))) return { ok: false, error: `'${String(ex.model).slice(0, 40)}' is not a plain model token — nothing was started` };
|
|
133
|
+
if (ex.effort && !EFFORTS.includes(ex.effort)) return { ok: false, error: `effort must be one of ${EFFORTS.join(', ')}` };
|
|
134
|
+
model = String(ex.model); effort = ex.effort || null; tier = 'custom'; decidedBy = 'explicit model'; why = 'the model was named directly for this session';
|
|
135
|
+
} else if (ex.tier || ctx.scheduleTier || ctx.skillTier || route) {
|
|
136
|
+
if (ex.tier) { tier = ex.tier; decidedBy = 'explicit tier'; why = 'chosen for this session'; }
|
|
137
|
+
else if (ctx.scheduleTier) { tier = ctx.scheduleTier; decidedBy = 'schedule'; why = "the schedule's tier"; }
|
|
138
|
+
else if (ctx.skillTier) { tier = ctx.skillTier; decidedBy = 'skill'; why = "the skill's tier"; }
|
|
139
|
+
else { tier = route.tier; decidedBy = 'policy route'; why = route.why || 'a route in the policy matched'; }
|
|
140
|
+
if (!TIERS.includes(tier)) return { ok: false, error: `'${tier}' is not a tier — one of ${TIERS.join(', ')}` };
|
|
141
|
+
model = policy.tiers[tier].model; effort = ex.effort || policy.tiers[tier].effort;
|
|
142
|
+
if (ex.effort && !EFFORTS.includes(ex.effort)) return { ok: false, error: `effort must be one of ${EFFORTS.join(', ')}` };
|
|
143
|
+
}
|
|
144
|
+
if (route && route.pinned && tier) {
|
|
145
|
+
const lowered = tier === 'custom' ? model !== policy.tiers[route.tier].model : rank(policy, tier) < rank(policy, route.tier);
|
|
146
|
+
if (lowered) return { ok: false, error: `'${ctx.skill || 'this work'}' is pinned to the ${route.tier} tier (D40: review work stays on one model so its calibration means something) — ${tier === 'custom' ? model : tier} would lower it` };
|
|
147
|
+
}
|
|
148
|
+
const base = { ok: true, source: loaded.source, tier, model, effort, decidedBy, why, pinned: !!(route && route.pinned), route: route ? { tier: route.tier, why: route.why || '' } : null };
|
|
149
|
+
const launch = launchFor(ctx.agent || 'claude', tier ? base : null);
|
|
150
|
+
return { ...base, state: launch.state, args: launch.args, ...(launch.error ? { ok: false, error: launch.error } : {}) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A recommendation for work an agent is about to do. With no rule matching, the safe middle — and it says so.
|
|
154
|
+
function recommend(root, text, ctx = {}) {
|
|
155
|
+
const t = String(text || '').trim();
|
|
156
|
+
if (!t && !ctx.skill) return { ok: false, error: 'say what the work is (a sentence), or name a skill' };
|
|
157
|
+
const loaded = loadPolicy(root);
|
|
158
|
+
if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
159
|
+
const route = routeFor(loaded.policy, { skill: ctx.skill, text: t });
|
|
160
|
+
const tier = route ? route.tier : 'balanced';
|
|
161
|
+
const x = loaded.policy.tiers[tier];
|
|
162
|
+
return { ok: true, tier, model: x.model, effort: x.effort, pinned: !!(route && route.pinned),
|
|
163
|
+
why: route ? (route.why || 'a route in the policy matched') : 'no rule matched — balanced is the safe middle; say what is at stake if it should be deep',
|
|
164
|
+
matched: !!route, source: loaded.source };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the decisions ledger (append-only, guarded like the memory ledger) ───────────────────────
|
|
168
|
+
// Written only where the PM has opted in by creating <state>/models/ (`pmos-model init`, or the policy file): a
|
|
169
|
+
// repository that never asked for model routing gets no ledger, and a test run against a checkout leaves nothing behind.
|
|
170
|
+
function logDecision(root, entry) {
|
|
171
|
+
try {
|
|
172
|
+
const dir = modelsDir(root, false); if (!dir || !fs.existsSync(dir)) return false;
|
|
173
|
+
const file = path.join(dir, 'decisions.jsonl');
|
|
174
|
+
const ds = fs.lstatSync(dir); if (!ds.isDirectory() || ds.isSymbolicLink()) return false;
|
|
175
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o644);
|
|
176
|
+
try {
|
|
177
|
+
const st = fs.fstatSync(fd); if (!st.isFile() || st.nlink !== 1) return false;
|
|
178
|
+
const before = st.size; const buf = Buffer.from(JSON.stringify({ at: new Date().toISOString(), ...entry }) + '\n');
|
|
179
|
+
if (fs.writeSync(fd, buf) !== buf.length) { try { fs.ftruncateSync(fd, before); } catch { /* best effort */ } return false; }
|
|
180
|
+
} finally { fs.closeSync(fd); }
|
|
181
|
+
return true;
|
|
182
|
+
} catch { return false; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── evaluator models seen (D40: calibration is per model) ────────────────────────────────────
|
|
186
|
+
const normModel = (m) => String(m || '').toLowerCase().trim().replace(/^anthropic\//, '');
|
|
187
|
+
function evaluatorBuckets(root) {
|
|
188
|
+
const counts = {};
|
|
189
|
+
for (const f of [path.join(root, 'planning', 'runs', 'evals.jsonl'), path.join(root, 'pmos', 'state', 'evals.jsonl')]) {
|
|
190
|
+
let text = ''; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
191
|
+
for (const line of text.split('\n')) { if (!line.trim()) continue; try { const m = normModel(JSON.parse(line).evaluator_model); if (m) counts[m] = (counts[m] || 0) + 1; } catch { /* torn */ } }
|
|
192
|
+
}
|
|
193
|
+
return counts;
|
|
194
|
+
}
|
|
195
|
+
function evaluatorNote(root, model) {
|
|
196
|
+
const b = evaluatorBuckets(root); const m = normModel(model);
|
|
197
|
+
if (!m) return null;
|
|
198
|
+
return b[m] ? null : `'${m}' has no recorded evaluator verdicts here: its calibration starts at zero and the Review Gate stays advisory for it (D40).`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
function main(argv) {
|
|
203
|
+
const a = argv.slice(2); const cmd = a[0]; const opt = {}; const rest = [];
|
|
204
|
+
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]); }
|
|
205
|
+
const root = path.resolve(opt.root || process.cwd());
|
|
206
|
+
const out = (o, human) => { process.stdout.write(opt.json ? JSON.stringify(o, null, 2) + '\n' : human + '\n'); };
|
|
207
|
+
if (!cmd || cmd === 'help' || cmd === '--help') { process.stdout.write('usage: pmos-model.js show | init | recommend "the work" [--skill x] | resolve [--skill x] [--tier t] | buckets [--root <repo>] [--json]\n'); return cmd ? 0 : 1; }
|
|
208
|
+
if (cmd === 'show') {
|
|
209
|
+
const l = loadPolicy(root); if (!l.ok) { process.stderr.write(l.error + '\n'); return 2; }
|
|
210
|
+
out(l, `policy: ${l.source}\n${TIERS.map((t) => ` ${t.padEnd(9)} ${l.policy.tiers[t].model} · effort ${l.policy.tiers[t].effort}`).join('\n')}\n ${l.policy.routes.length} route(s); first match wins`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
if (cmd === 'init') {
|
|
214
|
+
const dir = modelsDir(root, true); const file = path.join(dir, 'policy.json');
|
|
215
|
+
if (fs.existsSync(file)) { process.stderr.write(`${file} already exists — edit it; nothing was overwritten\n`); return 2; }
|
|
216
|
+
fs.writeFileSync(file, JSON.stringify(DEFAULT_POLICY, null, 2) + '\n');
|
|
217
|
+
out({ ok: true, file }, `wrote ${file} — edit the tiers and routes to suit; the defaults use the harness's own aliases`);
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
if (cmd === 'recommend') {
|
|
221
|
+
const r = recommend(root, rest.join(' ') || opt.text, { skill: opt.skill });
|
|
222
|
+
if (!r.ok) { process.stderr.write(r.error + '\n'); return 2; }
|
|
223
|
+
const launch = launchFor(opt.agent || 'claude', r);
|
|
224
|
+
if (!logDecision(root, { kind: 'recommend', work: String(rest.join(' ') || opt.text || '').slice(0, 200), skill: opt.skill || null, tier: r.tier, model: r.model, effort: r.effort, matched: r.matched, by: opt.by || 'agent:cli' })) process.stderr.write('(no decisions ledger kept: run `pmos-model init` to opt in)\n');
|
|
225
|
+
out({ ...r, launch }, `${r.tier} → ${r.model} (effort ${r.effort})${r.pinned ? ' · pinned' : ''}\n why: ${r.why}${launch.state === 'no-model-control' ? `\n note: '${opt.agent}' has no verified model control in PMOS` : ''}`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
if (cmd === 'resolve') {
|
|
229
|
+
const r = resolve(root, { skill: opt.skill, text: opt.text, agent: opt.agent || 'claude', explicit: opt.tier ? { tier: opt.tier } : (opt.model ? { model: opt.model } : {}) });
|
|
230
|
+
if (!r.ok) { process.stderr.write(r.error + '\n'); return 2; }
|
|
231
|
+
out(r, r.tier ? `${r.tier} → ${r.model} (effort ${r.effort}) · decided by ${r.decidedBy} · ${r.state}` : 'not asked — no rule matched, so no flags (the harness default)');
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
if (cmd === 'buckets') { const b = evaluatorBuckets(root); out(b, Object.keys(b).length ? Object.entries(b).sort((x, y) => y[1] - x[1]).map(([m, n]) => ` ${m} ${n} verdict(s)`).join('\n') : 'no evaluator verdicts recorded'); return 0; }
|
|
235
|
+
process.stderr.write(`unknown command '${cmd}'\n`); return 2;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { DEFAULT_POLICY, TIERS, EFFORTS, MODEL_RE, validate, loadPolicy, routeFor, resolve, recommend, launchFor, logDecision, evaluatorBuckets, evaluatorNote, modelsDir, stateDir, LAUNCH };
|
|
239
|
+
if (require.main === module) process.exit(main(process.argv));
|