create-pmos 0.37.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 +4 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/frontmatter-format.md +27 -61
  6. package/payload/okf/core/templates/decision-record-template.md +34 -0
  7. package/payload/okf/core/templates/index.md +7 -2
  8. package/payload/okf/core/templates/pm-brief-template.md +34 -0
  9. package/payload/okf/core/templates/project-plan-template.md +43 -0
  10. package/payload/okf/core/templates/report-weekly-template.md +35 -0
  11. package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
  12. package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
  13. package/payload/planning/evals/eval-calibration.md +8 -0
  14. package/payload/planning/okrs/health-budgets.md +8 -0
  15. package/payload/scripts/anchor-resolve.js +56 -6
  16. package/payload/scripts/gate-initiative.sh +32 -0
  17. package/payload/scripts/okf-upgrade.js +232 -0
  18. package/payload/scripts/pmos-memory.js +375 -0
  19. package/payload/scripts/pmos-model.js +239 -0
  20. package/payload/scripts/pmos-skills.js +274 -0
  21. package/payload/scripts/workflow-state.js +178 -10
  22. package/payload/scripts/workflow-sweep.js +2 -1
  23. package/payload/state/discoveries.md +8 -0
  24. package/payload/state/index.md +7 -0
  25. package/payload/state/log.md +7 -0
  26. package/workspace/README.md +61 -204
  27. package/workspace/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 +1 -1
  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
@@ -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));
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* pmos-skills — the directory of discovered and generated skills (D76).
4
+ *
5
+ * node pmos-skills.js add --id my-skill --file ./downloaded.skill --url https://… --license MIT --author someone --why "…" [--origin discovered|generated]
6
+ * node pmos-skills.js list | show <id> [--sha <12>] | reject <id> --sha <12> --reason "…" | retire <id> --sha <12> [--root <repo>] [--json]
7
+ *
8
+ * THE POINT OF THIS MODULE IS WHAT IT WILL NOT DO. A skill here is a prompt that runs with the PM's tools and
9
+ * accounts, and the loader re-reads skill files on every call, so a downloaded file is runnable the moment it lands.
10
+ * Therefore:
11
+ * · `add` puts a text file in QUARANTINE and records provenance. It cannot approve anything. There is no approve
12
+ * command in this file at all: approval is a PM press in the workspace, recorded in the workspace's own runtime
13
+ * state on that machine (outside the repository, so a commit or a hand-edited registry cannot approve for the PM).
14
+ * · the registry in the repository is SUBTRACTIVE-SAFE: anyone may append `add`, `reject` and `retire`, and none of
15
+ * them can make a skill run.
16
+ * · a file is accepted only as ONE text file of at most 40 KB; archives, binaries, NUL bytes and non-UTF-8 are refused.
17
+ * · a critical finding refuses the file outright (nothing is stored); soft findings are stored and shown.
18
+ * · the scanner is evadable (the repo's own SECURITY note says so). It is a filter, not a verdict: the PM reading
19
+ * the full text, and the loader re-hashing it, are the controls.
20
+ */
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const crypto = require('crypto');
24
+ const memory = require('./pmos-memory.js');
25
+
26
+ const MAX_BYTES = 40 * 1024;
27
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
28
+ const ORIGINS = ['discovered', 'generated'];
29
+ const PERMISSIVE = new Set(['mit', 'apache-2.0', 'bsd-2-clause', 'bsd-3-clause', 'isc', 'cc0-1.0', 'unlicense', '0bsd']);
30
+ const FORCED_KEYS = ['agent', 'run', 'surface']; // a discovered skill's own values for these are never honoured
31
+
32
+ // ── paths ────────────────────────────────────────────────────────────────────────────────────
33
+ function stateDir(root, create) {
34
+ const kit = path.join(root, 'pmos', 'state'); const self = path.join(root, 'planning');
35
+ if (fs.existsSync(kit)) return kit;
36
+ if (fs.existsSync(self)) return self;
37
+ if (create) { fs.mkdirSync(kit, { recursive: true }); return kit; }
38
+ return null;
39
+ }
40
+ function skillsDir(root, create) {
41
+ const s = stateDir(root, create); if (!s) return null;
42
+ const d = path.join(s, 'skills'); if (create) fs.mkdirSync(d, { recursive: true });
43
+ return d;
44
+ }
45
+ const sha256 = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex');
46
+
47
+ // ── the scan ─────────────────────────────────────────────────────────────────────────────────
48
+ // Critical = refuse. These are the memory scanner's rules (injection phrasing, credentials, exfiltration, a
49
+ // planted key, invisible and bidirectional characters) — in a skill body they have no legitimate use.
50
+ const SOFT = [
51
+ ['shell command', /\b(rm\s+-rf|sudo\s|chmod\s+[0-7]{3,4}|mkfs|dd\s+if=|:\(\)\s*\{|\beval\s*\(|bash\s+-c|sh\s+-c)\b/],
52
+ ['downloads and runs', /\b(curl|wget)\b[^\n]{0,120}\b(sh|bash|zsh|python|node)\b/i],
53
+ ['network fetch', /\b(curl|wget|fetch|http\.get|requests\.(get|post)|urllib)\b/i],
54
+ ['hidden comment', /<!--[\s\S]{0,2000}?-->/],
55
+ ['long encoded blob', /[A-Za-z0-9+\/=]{120,}/],
56
+ ['mentions credential files', /(~\/\.ssh|\bid_rsa\b|\.aws\/credentials|\.env\b|\.netrc|keychain|ANTHROPIC_API_KEY|OPENAI_API_KEY|GITHUB_TOKEN)/i],
57
+ ['asks to hide or skip the user', /\b(do not tell the user|don't tell the user|without (asking|telling|confirm)|silently|secretly|no need to inform)\b/i],
58
+ ['sends data out', /\b(send|email|post|upload|forward)\b[^\n]{0,60}\b(to|at)\b[^\n]{0,40}(@|https?:\/\/)/i],
59
+ ];
60
+ function lineOf(text, index) { return text.slice(0, index).split('\n').length; }
61
+ function scanSkill(text) {
62
+ const critical = []; const soft = [];
63
+ const lines = text.split('\n');
64
+ for (const [name, re] of memory.SCAN) {
65
+ const g = new RegExp(re.source, re.flags.replace('g', '') + 'g'); let m;
66
+ while ((m = g.exec(text)) && critical.length < 20) { critical.push({ rule: name, line: lineOf(text, m.index) }); if (m.index === g.lastIndex) g.lastIndex++; }
67
+ }
68
+ for (const [name, re] of SOFT) {
69
+ const g = new RegExp(re.source, re.flags.replace('g', '') + 'g'); let m; let n = 0;
70
+ while ((m = g.exec(text)) && n < 5) { soft.push({ rule: name, line: lineOf(text, m.index) }); n++; if (m.index === g.lastIndex) g.lastIndex++; }
71
+ }
72
+ const external = [...new Set((text.match(/https?:\/\/[^\s)>"']+/g) || []))];
73
+ if (external.length) soft.push({ rule: `external links (${external.length})`, line: lineOf(text, text.indexOf(external[0])) });
74
+ void lines;
75
+ return { critical, soft };
76
+ }
77
+ // The frontmatter keys a discovered skill sets that this directory will IGNORE (the harness is forced).
78
+ function ignoredKeys(text) {
79
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); if (!m) return [];
80
+ const out = [];
81
+ for (const k of FORCED_KEYS) { const kv = new RegExp(`^${k}:\\s*(.+)$`, 'm').exec(m[1]); if (kv && !(k === 'agent' && /^["']?claude["']?$/.test(kv[1].trim())) && !(k === 'run' && /^["']?agent["']?$/.test(kv[1].trim()))) out.push(`${k}: ${kv[1].trim().slice(0, 40)}`); }
82
+ return out;
83
+ }
84
+
85
+ // ── validation of an incoming file ───────────────────────────────────────────────────────────
86
+ function validateIncoming(buf) {
87
+ if (!Buffer.isBuffer(buf)) return 'no file content';
88
+ if (buf.length === 0) return 'the file is empty';
89
+ if (buf.length > MAX_BYTES) return `the file is ${buf.length} bytes; the limit is ${MAX_BYTES} — a skill is one short text file`;
90
+ if (buf.includes(0)) return 'the file contains NUL bytes — it is binary, not a text skill';
91
+ if (buf[0] === 0x50 && buf[1] === 0x4b) return 'the file looks like an archive — only a single text file is accepted';
92
+ let text; try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch { return 'the file is not valid UTF-8 text'; }
93
+ return { text };
94
+ }
95
+
96
+ // ── the registry (append-only, in the repository) ────────────────────────────────────────────
97
+ function readEvents(dir) {
98
+ let text = ''; try { text = fs.readFileSync(path.join(dir, 'registry.jsonl'), 'utf8'); } catch { return { events: [], torn: 0 }; }
99
+ const events = []; let torn = 0;
100
+ for (const line of text.split('\n')) {
101
+ if (!line.trim()) continue;
102
+ try { const e = JSON.parse(line); if (e && e.eid && e.op) events.push(e); else torn++; } catch { torn++; }
103
+ }
104
+ return { events, torn };
105
+ }
106
+ function appendEvent(dir, ev) {
107
+ const file = path.join(dir, 'registry.jsonl');
108
+ const dst = fs.lstatSync(dir);
109
+ if (!dst.isDirectory() || dst.isSymbolicLink()) throw new Error('the skills directory is not a plain directory — nothing was written');
110
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o644);
111
+ try {
112
+ const st = fs.fstatSync(fd);
113
+ if (!st.isFile() || st.nlink !== 1) throw new Error('registry.jsonl is not a plain single-link file — nothing was written');
114
+ const before = st.size; const buf = Buffer.from(JSON.stringify(ev) + '\n');
115
+ if (fs.writeSync(fd, buf) !== buf.length) { try { fs.ftruncateSync(fd, before); } catch { /* best effort */ } throw new Error('short write — rolled back'); }
116
+ } finally { fs.closeSync(fd); }
117
+ }
118
+ let _seq = 0;
119
+ const newEid = () => 's-' + Date.now().toString(36).padStart(9, '0') + '-' + String(_seq++ % 1000).padStart(3, '0') + '-' + crypto.randomBytes(3).toString('hex');
120
+ const byTime = (a, b) => String(a.at).localeCompare(String(b.at)) || String(a.eid).localeCompare(String(b.eid));
121
+
122
+ /* Fold to one row per (id, sha). State here is only quarantined | rejected | retired: whether a candidate is APPROVED
123
+ * is not in this file — it is the PM's local approval, joined at load time by `resolve`. Adds first, in any order. */
124
+ function fold(events) {
125
+ const seen = new Map(); for (const e of events) if (!seen.has(e.eid)) seen.set(e.eid, e);
126
+ const all = [...seen.values()].sort(byTime);
127
+ const rows = new Map();
128
+ for (const e of all) if (e.op === 'add' && ID_RE.test(String(e.id || '')) && /^[0-9a-f]{64}$/.test(String(e.sha || ''))) {
129
+ const key = `${e.id}@${e.sha}`;
130
+ if (!rows.has(key)) rows.set(key, { id: e.id, sha: e.sha, sha12: e.sha.slice(0, 12), origin: ORIGINS.includes(e.origin) ? e.origin : 'discovered',
131
+ source_url: e.source_url || null, author: e.author || null, license: e.license || 'unknown', why: e.why || '', found_by: e.by || 'unknown',
132
+ found_at: e.at, bytes: e.bytes || null, scan: e.scan || { critical: [], soft: [] }, ignored_keys: e.ignored_keys || [], file: e.file || null, state: 'quarantined' });
133
+ }
134
+ for (const e of all) {
135
+ if (e.op !== 'reject' && e.op !== 'retire') continue;
136
+ const targets = e.sha ? [rows.get(`${e.id}@${e.sha}`)] : [...rows.values()].filter((r) => r.id === e.id);
137
+ for (const r of targets) if (r) { r.state = e.op === 'reject' ? 'rejected' : 'retired'; r.decided_by = e.by; r.decided_at = e.at; r.reason = e.reason || ''; }
138
+ }
139
+ return [...rows.values()];
140
+ }
141
+
142
+ const relFile = (root, id, sha) => path.posix.join(path.relative(root, skillsDir(root, false) || '').split(path.sep).join('/'), 'candidates', id, `${sha.slice(0, 12)}.skill`);
143
+
144
+ /* add — agent CLI and the workspace both use it. Returns { ok, id, sha, scan } or { ok:false, error }. */
145
+ function add(root, req) {
146
+ const id = String(req.id || '').trim();
147
+ if (!ID_RE.test(id)) return { ok: false, error: `id must be a plain slug (a-z, 0-9, hyphen; at most 49 characters), got '${id.slice(0, 40)}'` };
148
+ const origin = req.origin || 'discovered';
149
+ if (!ORIGINS.includes(origin)) return { ok: false, error: `origin must be one of ${ORIGINS.join(', ')}` };
150
+ const url = String(req.source_url || '').trim();
151
+ if (origin === 'discovered' && !/^https?:\/\/\S+$/.test(url)) return { ok: false, error: 'a discovered skill needs the http(s) URL it came from' };
152
+ if (!String(req.why || '').trim()) return { ok: false, error: 'say why this skill is worth the PM reading (one sentence)' };
153
+ const v = validateIncoming(req.content);
154
+ if (typeof v === 'string') return { ok: false, error: v };
155
+ const text = v.text; const sha = sha256(text);
156
+ const scan = scanSkill(text);
157
+ if (scan.critical.length) return { ok: false, error: `refused — ${scan.critical.slice(0, 3).map((c) => `${c.rule} (line ${c.line})`).join('; ')}. Nothing was stored.`, scan };
158
+ const dir = skillsDir(root, true);
159
+ const existing = fold(readEvents(dir).events).find((r) => r.id === id && r.sha === sha);
160
+ if (existing) return { ok: true, id, sha, sha12: sha.slice(0, 12), duplicate: true, state: existing.state, scan };
161
+ const cdir = path.join(dir, 'candidates', id); fs.mkdirSync(cdir, { recursive: true });
162
+ const file = path.join(cdir, `${sha.slice(0, 12)}.skill`);
163
+ try { const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o644); fs.writeSync(fd, text); fs.closeSync(fd); }
164
+ catch (e) { return { ok: false, error: `could not store the candidate — ${String((e && e.message) || e).slice(0, 100)}` }; }
165
+ const ev = { eid: newEid(), at: new Date().toISOString(), by: req.by || 'agent:cli', op: 'add', id, sha, origin, source_url: url || null,
166
+ author: req.author ? String(req.author).slice(0, 80) : null, license: String(req.license || 'unknown').slice(0, 40), why: String(req.why).trim().slice(0, 400),
167
+ bytes: Buffer.byteLength(text), scan, ignored_keys: ignoredKeys(text), file: relFile(root, id, sha) };
168
+ try { appendEvent(dir, ev); } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
169
+ render(root);
170
+ return { ok: true, id, sha, sha12: sha.slice(0, 12), state: 'quarantined', scan, file: ev.file };
171
+ }
172
+
173
+ function decide(root, op, req) {
174
+ const dir = skillsDir(root, false); if (!dir) return { ok: false, error: 'no skills directory yet' };
175
+ const rows = fold(readEvents(dir).events);
176
+ const target = rows.find((r) => r.id === req.id && (!req.sha || r.sha.startsWith(req.sha)));
177
+ if (!target) return { ok: false, error: `no candidate '${req.id}'${req.sha ? ` at ${req.sha}` : ''}` };
178
+ try { appendEvent(dir, { eid: newEid(), at: new Date().toISOString(), by: req.by || 'agent:cli', op, id: target.id, sha: target.sha, reason: String(req.reason || '').slice(0, 300) }); }
179
+ catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
180
+ render(root);
181
+ return { ok: true, id: target.id, sha12: target.sha12, state: op === 'reject' ? 'rejected' : 'retired' };
182
+ }
183
+
184
+ function candidates(root) {
185
+ const dir = skillsDir(root, false); if (!dir) return { rows: [], torn: 0 };
186
+ const { events, torn } = readEvents(dir);
187
+ return { rows: fold(events), torn };
188
+ }
189
+ function textOf(root, row) {
190
+ const dir = skillsDir(root, false); if (!dir) return { ok: false, error: 'no skills directory' };
191
+ const file = path.join(dir, 'candidates', row.id, `${row.sha12}.skill`);
192
+ let st; try { st = fs.lstatSync(file); } catch { return { ok: false, error: 'the candidate file is missing' }; }
193
+ if (!st.isFile() || st.isSymbolicLink()) return { ok: false, error: 'the candidate file is not a plain file' };
194
+ const text = fs.readFileSync(file, 'utf8');
195
+ if (sha256(text) !== row.sha) return { ok: false, error: 'the candidate file no longer matches its recorded hash — it was changed after it was filed', changed: true };
196
+ return { ok: true, text, file };
197
+ }
198
+
199
+ /* resolve — what may RUN. `approvals` is the PM's LOCAL list: [{id, sha, ack_license}]. A candidate runs only if it
200
+ * is approved there, not rejected or retired in the registry, and its file still hashes to the approved value. */
201
+ function resolve(root, approvals) {
202
+ const { rows } = candidates(root);
203
+ const approved = new Map(); for (const a of approvals || []) if (a && a.id && a.sha) approved.set(`${a.id}@${a.sha}`, a);
204
+ const skills = []; const blocked = [];
205
+ for (const r of rows) {
206
+ const key = `${r.id}@${r.sha}`; const a = approved.get(key);
207
+ if (!a) continue; // quarantined: simply not offered
208
+ if (r.state !== 'quarantined') { blocked.push({ id: r.id, sha12: r.sha12, why: `${r.state} in the registry` }); continue; }
209
+ const t = textOf(root, r);
210
+ if (!t.ok) { blocked.push({ id: r.id, sha12: r.sha12, why: t.error }); continue; }
211
+ skills.push({ id: r.id, sha: r.sha, sha12: r.sha12, text: t.text, file: t.file, row: r });
212
+ }
213
+ // an approved id with two approved hashes: the newest approval wins
214
+ const best = new Map();
215
+ for (const s of skills) { const prev = best.get(s.id); const ts = (approved.get(`${s.id}@${s.sha}`) || {}).at || ''; if (!prev || ts > prev.ts) best.set(s.id, { s, ts }); }
216
+ return { skills: [...best.values()].map((x) => x.s), blocked };
217
+ }
218
+
219
+ const licenseNeedsAck = (license) => !PERMISSIVE.has(String(license || '').toLowerCase().trim());
220
+
221
+ // ── the generated directory page (OKF v0.2) ─────────────────────────────────────────────────
222
+ function render(root) {
223
+ const dir = skillsDir(root, false); if (!dir) return;
224
+ const { rows } = candidates(root);
225
+ const lines = ['---', 'type: Reference', 'title: "Discovered and generated skills"',
226
+ 'description: "Every candidate skill found or generated for this repository, with where it came from. Nothing here runs until the PM approves its exact text in the workspace."',
227
+ `generated: {by: process:pmos-skills, at: "${new Date().toISOString()}"}`, 'status: stable', '---', '',
228
+ '# Discovered and generated skills', '',
229
+ 'A candidate is quarantined until the PM opens its text and approves it in the workspace. Approval is local to the machine that gave it. The registry is `registry.jsonl`; the text of each candidate is under `candidates/`.', '',
230
+ '| id | origin | state | licence | source | found | hash | findings |', '|---|---|---|---|---|---|---|---|'];
231
+ for (const r of rows.sort((a, b) => a.id.localeCompare(b.id) || String(b.found_at).localeCompare(String(a.found_at)))) {
232
+ const src = r.source_url ? `[${(r.author || 'source')}](${r.source_url})` : (r.origin === 'generated' ? 'generated here' : '—');
233
+ lines.push(`| ${r.id} | ${r.origin} | ${r.state} | ${r.license} | ${src} | ${String(r.found_at).slice(0, 10)} | \`${r.sha12}\` | ${(r.scan.soft || []).length ? (r.scan.soft || []).map((f) => f.rule).join(', ') : 'none'} |`);
234
+ }
235
+ if (!rows.length) lines.push('| _none yet_ | | | | | | | |');
236
+ lines.push('');
237
+ try { fs.writeFileSync(path.join(dir, 'index.md'), lines.join('\n')); } catch { /* the index is a convenience, never a reason to fail */ }
238
+ }
239
+
240
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────
241
+ function main(argv) {
242
+ const a = argv.slice(2); const cmd = a[0]; const opt = {}; const rest = [];
243
+ for (let i = 1; i < a.length; i++) { if (a[i].startsWith('--')) { const k = a[i].slice(2); if (k === 'json') opt.json = true; else opt[k] = a[++i]; } else rest.push(a[i]); }
244
+ const root = path.resolve(opt.root || process.cwd());
245
+ const out = (o, human) => process.stdout.write(opt.json ? JSON.stringify(o, null, 2) + '\n' : human + '\n');
246
+ if (!cmd || cmd === 'help' || cmd === '--help') { process.stdout.write('usage: pmos-skills.js add --id <slug> --file <path> --url <https url> --license <spdx> --author <name> --why "<reason>" [--origin discovered|generated] | list | show <id> | reject <id> --sha <12> --reason "…" | retire <id> --sha <12> [--root <repo>] [--json]\n(there is no approve command: the PM approves in the workspace)\n'); return cmd ? 0 : 1; }
247
+ if (cmd === 'add') {
248
+ let content; try { content = fs.readFileSync(path.resolve(opt.file || '')); } catch { process.stderr.write('--file must name a readable file\n'); return 2; }
249
+ const r = add(root, { id: opt.id, content, source_url: opt.url, license: opt.license, author: opt.author, why: opt.why, origin: opt.origin, by: opt.by });
250
+ if (!r.ok) { process.stderr.write(`not filed: ${r.error}\n`); return 2; }
251
+ out(r, `${r.duplicate ? 'already filed' : 'filed in quarantine'}: ${r.id} @ ${r.sha12} — ${(r.scan.soft || []).length} soft finding(s). It does not run until the PM approves it in the workspace.`);
252
+ return 0;
253
+ }
254
+ if (cmd === 'list') {
255
+ const { rows } = candidates(root);
256
+ out(rows, rows.length ? rows.map((r) => `${r.id}@${r.sha12} ${r.origin} ${r.state} ${r.license} ${(r.scan.soft || []).length} soft finding(s)`).join('\n') : 'no candidates');
257
+ return 0;
258
+ }
259
+ if (cmd === 'show') {
260
+ const { rows } = candidates(root); const r = rows.find((x) => x.id === rest[0] && (!opt.sha || x.sha.startsWith(opt.sha)));
261
+ if (!r) { process.stderr.write('no such candidate\n'); return 2; }
262
+ const t = textOf(root, r); out({ ...r, text: t.ok ? t.text : null, text_error: t.ok ? null : t.error }, `${r.id}@${r.sha12} ${r.state}\nsource: ${r.source_url}\nlicense: ${r.license}\nwhy: ${r.why}\n${t.ok ? '\n' + t.text : '\n(' + t.error + ')'}`);
263
+ return 0;
264
+ }
265
+ if (cmd === 'reject' || cmd === 'retire') {
266
+ const r = decide(root, cmd, { id: rest[0], sha: opt.sha, reason: opt.reason, by: opt.by });
267
+ if (!r.ok) { process.stderr.write(r.error + '\n'); return 2; }
268
+ out(r, `${cmd}ed ${r.id}@${r.sha12}`); return 0;
269
+ }
270
+ process.stderr.write(`unknown command '${cmd}'\n`); return 2;
271
+ }
272
+
273
+ module.exports = { MAX_BYTES, ID_RE, PERMISSIVE, FORCED_KEYS, scanSkill, ignoredKeys, validateIncoming, fold, add, decide, candidates, textOf, resolve, licenseNeedsAck, render, skillsDir, stateDir, readEvents, appendEvent, sha256 };
274
+ if (require.main === module) process.exit(main(process.argv));