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