create-pmos 0.43.0 → 0.45.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/cockpit-install.js +130 -0
- package/bin/create-pmos.js +47 -4
- package/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +10 -25
- package/payload/AGENTS.md +4 -0
- package/payload/KIT-VERSION +1 -1
- package/payload/mods/.claude-plugin/marketplace.json +12 -0
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +9 -0
- package/payload/mods/pmos-cockpit/README.md +71 -0
- package/payload/mods/pmos-cockpit/hooks/hooks.json +1 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +53 -0
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +88 -0
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +561 -0
- package/payload/mods/pmos-cockpit/hooks/lib/initiative.mjs +54 -0
- package/payload/mods/pmos-cockpit/hooks/lib/layout.mjs +31 -0
- package/payload/mods/pmos-cockpit/hooks/lib/probes.mjs +34 -0
- package/payload/mods/pmos-cockpit/hooks/lib/profile.mjs +17 -0
- package/payload/mods/pmos-cockpit/hooks/lib/prompt-section.mjs +49 -0
- package/payload/mods/pmos-cockpit/hooks/lib/session-facts.mjs +110 -0
- package/payload/mods/pmos-cockpit/hooks/lib/skills.mjs +69 -0
- package/payload/mods/pmos-cockpit/hooks/lib/status-line.mjs +64 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +634 -0
- package/payload/mods/pmos-cockpit/types/index.d.ts +60 -0
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +119 -7
- package/payload/skills/pmos-router.skill +1 -1
- package/payload/skills/prototype.skill +1 -1
- package/payload/skills/strategic-brief.skill +1 -1
- package/payload/skills-native/harness-refine/SKILL.md +1 -1
- package/payload/skills-native/pmos-router/SKILL.md +1 -1
- package/payload/skills-native/prototype/SKILL.md +1 -1
- package/payload/skills-native/strategic-brief/SKILL.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// The guards' matcher (D81, M3, M6, M8, M9, M10): what a tool call is, as far as a pure function can
|
|
2
|
+
// tell. It returns findings; the module that wires it decides what to do with each.
|
|
3
|
+
//
|
|
4
|
+
// ledger a write to a *.jsonl ledger in the state root -> deny, name the writer
|
|
5
|
+
// accept-grants a model write to acceptances or grants, or running
|
|
6
|
+
// record-acceptance.js, pmos-log.sh accept|grant -> deny, a person's act
|
|
7
|
+
// governing the installed mod, .claude/pmos-cockpit.json, the
|
|
8
|
+
// plugin entries in .claude/settings.json -> deny, a person edits these
|
|
9
|
+
// merge gh pr merge, the GitHub merge tools, a push to the
|
|
10
|
+
// default branch, a forced push, npm publish -> needs a live grant
|
|
11
|
+
// cloud a cloud-mutating command from AGENTS.md -> deny, the PM runs it
|
|
12
|
+
// outward a write tool for Confluence, Jira, Slack, Gmail … -> ask for each item
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS IS NOT. A shell can do what no matcher names: a path built from pieces, a script written
|
|
15
|
+
// first and run second, a tool this file has never seen. The mod is defence in depth. The writers
|
|
16
|
+
// validate, branch protection and CI stay the real gate where a remote exists. The comments below say
|
|
17
|
+
// which routes are covered and which are not.
|
|
18
|
+
//
|
|
19
|
+
// Never in the way (M17): reading, editing and testing pass. A segment that only reads a ledger is
|
|
20
|
+
// fine; `git add` and `git commit` of a ledger are fine; a script on the read allow-list is fine.
|
|
21
|
+
//
|
|
22
|
+
// Pure: no I/O. The caller gives it the layout, the branch facts and, for settings.json, the text.
|
|
23
|
+
|
|
24
|
+
// ── paths ───────────────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
export const LEDGER_NAMES = new Set([
|
|
26
|
+
'runs.jsonl', 'evals.jsonl', 'acceptances.jsonl', 'outcome_checks.jsonl', 'grants.jsonl',
|
|
27
|
+
'agent-runs.jsonl', 'lifecycle.jsonl', 'verify-queue.jsonl',
|
|
28
|
+
]);
|
|
29
|
+
const ACCEPT_GRANTS = new Set(['acceptances.jsonl', 'grants.jsonl']);
|
|
30
|
+
|
|
31
|
+
export function resolvePath(p, cwd) {
|
|
32
|
+
let s = String(p);
|
|
33
|
+
if (!s.startsWith('/')) s = `${String(cwd).replace(/\/+$/, '')}/${s}`;
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const part of s.split('/')) {
|
|
36
|
+
if (part === '' || part === '.') continue;
|
|
37
|
+
if (part === '..') out.pop();
|
|
38
|
+
else out.push(part);
|
|
39
|
+
}
|
|
40
|
+
return `/${out.join('/')}`;
|
|
41
|
+
}
|
|
42
|
+
const under = (p, dir) => p === dir || p.startsWith(dir.replace(/\/+$/, '') + '/');
|
|
43
|
+
const base = (p) => p.slice(p.lastIndexOf('/') + 1);
|
|
44
|
+
|
|
45
|
+
/** What a file path is, for a write. ctx: { root, layout: {kind,stateRoot}|null } */
|
|
46
|
+
export function classifyPath(path, ctx) {
|
|
47
|
+
const root = ctx.root.replace(/\/+$/, '');
|
|
48
|
+
const abs = resolvePath(path, root);
|
|
49
|
+
const name = base(abs);
|
|
50
|
+
const state = ctx.layout?.stateRoot ?? null;
|
|
51
|
+
const inState = state !== null && under(abs, state);
|
|
52
|
+
if (name.endsWith('.jsonl') && (inState || LEDGER_NAMES.has(name))) {
|
|
53
|
+
return { kind: ACCEPT_GRANTS.has(name) ? 'accept-grants' : 'ledger', path: abs };
|
|
54
|
+
}
|
|
55
|
+
if (abs === `${root}/.claude/pmos-cockpit.json`) return { kind: 'governing', path: abs, what: '.claude/pmos-cockpit.json' };
|
|
56
|
+
if (ctx.layout?.kind === 'kit' && under(abs, `${root}/pmos/mods`)) return { kind: 'governing', path: abs, what: 'the installed mod (pmos/mods)' };
|
|
57
|
+
if (abs === `${root}/.claude/settings.json`) return { kind: 'settings', path: abs, what: '.claude/settings.json' };
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PLUGIN_KEYS = /extraKnownMarketplaces|enabledPlugins|pmos-cockpit|pmos-mods/;
|
|
62
|
+
/** Does an edit to settings.json touch the plugin entries? `current` is the file's text now, or null. */
|
|
63
|
+
export function touchesPluginEntries(e, current) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
for (const k of ['old_string', 'new_string', 'content', 'new_source']) if (typeof e[k] === 'string') parts.push(e[k]);
|
|
66
|
+
if (Array.isArray(e.edits)) for (const x of e.edits) { if (typeof x?.old_string === 'string') parts.push(x.old_string); if (typeof x?.new_string === 'string') parts.push(x.new_string); }
|
|
67
|
+
if (parts.some((t) => PLUGIN_KEYS.test(t))) return true;
|
|
68
|
+
// A whole-file write can drop the entries without naming them.
|
|
69
|
+
if (typeof e.content === 'string' && current !== null && PLUGIN_KEYS.test(current)) return !PLUGIN_KEYS.test(e.content);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const PATH_KEYS = ['file_path', 'notebook_path', 'path', 'filepath', 'filename', 'destination', 'dest', 'target_file'];
|
|
74
|
+
const WRITE_BUILTIN = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
75
|
+
// A tool from a server that writes files: its name says so (mcp__filesystem__write_file, …).
|
|
76
|
+
const WRITE_MCP = /(^|_)(write|edit|append|move|rename|replace|delete|remove|copy|create_or_update|push_files|apply_patch)(_|$)/i;
|
|
77
|
+
|
|
78
|
+
export function isFileWriteTool(tool) {
|
|
79
|
+
if (WRITE_BUILTIN.has(tool)) return true;
|
|
80
|
+
if (tool.startsWith('mcp__')) return WRITE_MCP.test(tool.split('__').slice(2).join('_'));
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const pathsOf = (e) => PATH_KEYS.map((k) => e[k]).filter((v) => typeof v === 'string' && v !== '');
|
|
84
|
+
|
|
85
|
+
// ── the shell ───────────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// A small quote-aware reader. It is not a shell: it knows quotes, escapes, the separators, the
|
|
87
|
+
// redirect operators and here-documents, which is what the guards need.
|
|
88
|
+
|
|
89
|
+
function readSubstitutions(cmd) {
|
|
90
|
+
// $( … ) and `…` outside single quotes, returned as their own commands.
|
|
91
|
+
const subs = [];
|
|
92
|
+
let i = 0;
|
|
93
|
+
let q = null;
|
|
94
|
+
while (i < cmd.length) {
|
|
95
|
+
const c = cmd[i];
|
|
96
|
+
if (q === "'") { if (c === "'") q = null; i += 1; continue; }
|
|
97
|
+
if (c === '\\') { i += 2; continue; }
|
|
98
|
+
if (c === "'" && q === null) { q = "'"; i += 1; continue; }
|
|
99
|
+
if (c === '"') { q = q === '"' ? null : '"'; i += 1; continue; }
|
|
100
|
+
if (c === '$' && cmd[i + 1] === '(') {
|
|
101
|
+
let depth = 1; let j = i + 2;
|
|
102
|
+
while (j < cmd.length && depth > 0) { if (cmd[j] === '(') depth += 1; else if (cmd[j] === ')') depth -= 1; j += 1; }
|
|
103
|
+
subs.push(cmd.slice(i + 2, j - 1)); i = j; continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === '`') {
|
|
106
|
+
const j = cmd.indexOf('`', i + 1);
|
|
107
|
+
if (j === -1) { subs.push(cmd.slice(i + 1)); break; }
|
|
108
|
+
subs.push(cmd.slice(i + 1, j)); i = j + 1; continue;
|
|
109
|
+
}
|
|
110
|
+
i += 1;
|
|
111
|
+
}
|
|
112
|
+
return subs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Split a command into segments of words. A word is { t: text, dyn: has an unresolved $ or glob,
|
|
117
|
+
* op: a redirect operator }. A segment also carries the bodies of its here-documents.
|
|
118
|
+
*/
|
|
119
|
+
export function parseShell(cmd) {
|
|
120
|
+
const segs = [];
|
|
121
|
+
let seg = { words: [], heredocs: [] };
|
|
122
|
+
let cur = null;
|
|
123
|
+
let pending = null; // { delim, strip, seg }
|
|
124
|
+
let i = 0;
|
|
125
|
+
const flushWord = () => { if (cur !== null) { seg.words.push(cur); cur = null; } };
|
|
126
|
+
const flushSeg = () => { flushWord(); if (seg.words.length || seg.heredocs.length || pending?.seg === seg) segs.push(seg); seg = { words: [], heredocs: [] }; };
|
|
127
|
+
const add = (ch, dyn) => { if (cur === null) cur = { t: '', dyn: false }; cur.t += ch; if (dyn) cur.dyn = true; };
|
|
128
|
+
const standalone = (k) => cur === null && (k + 1 >= cmd.length || /[\s]/.test(cmd[k + 1]));
|
|
129
|
+
while (i < cmd.length) {
|
|
130
|
+
const c = cmd[i];
|
|
131
|
+
if (c === '\n') {
|
|
132
|
+
flushSeg(); i += 1;
|
|
133
|
+
if (pending) {
|
|
134
|
+
const lines = []; let j = i;
|
|
135
|
+
while (j < cmd.length) {
|
|
136
|
+
let k = cmd.indexOf('\n', j); if (k === -1) k = cmd.length;
|
|
137
|
+
const line = cmd.slice(j, k); j = k + 1;
|
|
138
|
+
if ((pending.strip ? line.trim() : line) === pending.delim) break;
|
|
139
|
+
lines.push(line);
|
|
140
|
+
}
|
|
141
|
+
pending.seg.heredocs.push(lines.join('\n')); pending = null; i = j;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (c === ' ' || c === '\t') { flushWord(); i += 1; continue; }
|
|
146
|
+
if (c === '\\') { if (cmd[i + 1] === '\n') { i += 2; continue; } add(cmd[i + 1] ?? '', false); i += 2; continue; }
|
|
147
|
+
if (c === "'") {
|
|
148
|
+
const j = cmd.indexOf("'", i + 1); const end = j === -1 ? cmd.length : j;
|
|
149
|
+
if (cur === null) cur = { t: '', dyn: false }; cur.t += cmd.slice(i + 1, end); i = end + 1; continue;
|
|
150
|
+
}
|
|
151
|
+
if (c === '"') {
|
|
152
|
+
let j = i + 1; if (cur === null) cur = { t: '', dyn: false };
|
|
153
|
+
while (j < cmd.length && cmd[j] !== '"') {
|
|
154
|
+
if (cmd[j] === '\\' && j + 1 < cmd.length) { cur.t += cmd[j + 1]; j += 2; continue; }
|
|
155
|
+
if (cmd[j] === '$' || cmd[j] === '`') cur.dyn = true;
|
|
156
|
+
cur.t += cmd[j]; j += 1;
|
|
157
|
+
}
|
|
158
|
+
i = j + 1; continue;
|
|
159
|
+
}
|
|
160
|
+
if (c === '&' && cmd[i + 1] === '>') {
|
|
161
|
+
flushWord(); i += 2; if (cmd[i] === '>') i += 1;
|
|
162
|
+
seg.words.push({ t: '&>', dyn: false, op: true }); continue;
|
|
163
|
+
}
|
|
164
|
+
if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || ((c === '{' || c === '}') && standalone(i))) {
|
|
165
|
+
flushSeg(); i += 1; if ((c === '&' || c === '|') && cmd[i] === c) i += 1; continue;
|
|
166
|
+
}
|
|
167
|
+
if (c === '>' || c === '<') {
|
|
168
|
+
if (cur !== null && /^\d+$/.test(cur.t)) cur = null; else flushWord();
|
|
169
|
+
let op = c; i += 1;
|
|
170
|
+
if (cmd[i] === c) { op += c; i += 1; if (op === '<<' && cmd[i] === '-') { op += '-'; i += 1; } else if (op === '<<' && cmd[i] === '<') { op += '<'; i += 1; } }
|
|
171
|
+
else if (c === '>' && (cmd[i] === '|' || cmd[i] === '&')) { op += cmd[i]; i += 1; }
|
|
172
|
+
seg.words.push({ t: op, dyn: false, op: true });
|
|
173
|
+
if (op === '<<' || op === '<<-') {
|
|
174
|
+
while (cmd[i] === ' ' || cmd[i] === '\t') i += 1;
|
|
175
|
+
let d = ''; let j = i;
|
|
176
|
+
while (j < cmd.length && !/[\s;|&()<>]/.test(cmd[j])) { if (cmd[j] !== "'" && cmd[j] !== '"' && cmd[j] !== '\\') d += cmd[j]; j += 1; }
|
|
177
|
+
pending = { delim: d, strip: op === '<<-', seg }; i = j;
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
add(c, c === '*' || c === '?' || c === '[' || c === '$' || c === '`' || c === '{' || c === '}'); i += 1;
|
|
182
|
+
}
|
|
183
|
+
flushSeg();
|
|
184
|
+
return segs;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── findings ────────────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
const KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', 'time', 'coproc']);
|
|
189
|
+
const WRAPPERS = new Set(['env', 'sudo', 'command', 'builtin', 'exec', 'nohup', 'time', 'nice', 'ionice', 'xargs', 'stdbuf', 'setsid']);
|
|
190
|
+
const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
|
|
191
|
+
const INTERPRETERS = new Set(['python', 'python3', 'node', 'nodejs', 'perl', 'ruby', 'php', 'deno', 'bun', 'osascript']);
|
|
192
|
+
// Verbs that only read what they are given. `sed` and `find` have a read form, checked separately.
|
|
193
|
+
const READ_VERBS = new Set([
|
|
194
|
+
'cat', 'head', 'tail', 'less', 'more', 'grep', 'egrep', 'fgrep', 'rg', 'wc', 'ls', 'stat', 'file', 'jq', 'sort', 'uniq', 'cut',
|
|
195
|
+
'column', 'diff', 'cmp', 'md5sum', 'sha1sum', 'sha256sum', 'shasum', 'nl', 'tac', 'od', 'xxd', 'hexdump', 'echo', 'printf', 'test', '[',
|
|
196
|
+
'readlink', 'realpath', 'basename', 'dirname', 'cd', 'pwd', 'true', 'false', 'wait', 'type', 'which', 'tr', 'fold', 'strings', 'comm', 'join', 'paste', 'du',
|
|
197
|
+
]);
|
|
198
|
+
const GIT_READ = new Set(['add', 'commit', 'diff', 'log', 'show', 'status', 'blame', 'ls-files', 'grep', 'rev-parse', 'check-ignore', 'cat-file', 'rev-list', 'branch', 'remote', 'fetch', 'config', 'describe', 'shortlog', 'ls-tree', 'whatchanged']);
|
|
199
|
+
// Scripts that read the ledgers and write nothing, by file name. A script written first and then given
|
|
200
|
+
// one of these names is the "shell can always" route; it is not defended here.
|
|
201
|
+
const READ_SCRIPTS = new Set(['metrics.py', 'workflow-state.js', 'workflow-sweep.js', 'check_rubrics.py', 'check_okf_drift.py', 'frontmatter_lint.py', 'check-verify-queue.js', 'pmos-model.js', 'pmos-skills.js', 'md-newlines.js']);
|
|
202
|
+
// Code that writes a file, as inline interpreter text gives it away. A read of settings.json by an
|
|
203
|
+
// interpreter passes; one that writes does not.
|
|
204
|
+
const INLINE_WRITE = /\b(write|writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|rename|renameSync|unlink|unlinkSync|rmSync|copyFile|copyFileSync|truncate|dump|dumps)\b|open\([^)]*,\s*['"][wax]/;
|
|
205
|
+
const WRITERS = new Set(['record-acceptance.js', 'record-eval.js', 'record-outcome.js', 'log-run.js', 'pmos-log.sh']);
|
|
206
|
+
const base0 = (w) => base(w.t);
|
|
207
|
+
|
|
208
|
+
// A verb that reads can still be told where to write: sort -o FILE, uniq IN OUT, xxd -r IN OUT, less -o.
|
|
209
|
+
function writesByFlag(verb, args) {
|
|
210
|
+
const flags = args.filter((a) => a.startsWith('-'));
|
|
211
|
+
const files = args.filter((a) => !a.startsWith('-'));
|
|
212
|
+
if (verb === 'sort') return flags.some((a) => /^-[A-Za-z]*o|^--output/.test(a));
|
|
213
|
+
if (verb === 'uniq') return files.length >= 2;
|
|
214
|
+
if (verb === 'xxd') return flags.some((a) => /^-[A-Za-z]*r/.test(a)) && files.length >= 2;
|
|
215
|
+
if (verb === 'less' || verb === 'more') return flags.some((a) => /^-[oO]/.test(a));
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stripWrappers(words) {
|
|
220
|
+
let i = 0;
|
|
221
|
+
while (i < words.length) {
|
|
222
|
+
const w = words[i];
|
|
223
|
+
if (w.op) { i += 2; continue; }
|
|
224
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.t)) { i += 1; continue; }
|
|
225
|
+
const b = base0(w);
|
|
226
|
+
if (b === 'timeout') { i += 1; while (i < words.length && (words[i].t.startsWith('-') || /^[\d.]+[smhd]?$/.test(words[i].t))) i += 1; continue; }
|
|
227
|
+
if (KEYWORDS.has(w.t)) { i += 1; continue; }
|
|
228
|
+
if (WRAPPERS.has(b)) { i += 1; while (i < words.length && (words[i].t.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i].t)) && b !== 'xargs') i += 1; if (b === 'xargs') while (i < words.length && words[i].t.startsWith('-')) i += 1; continue; }
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
return words.slice(i);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// `git -C dir -c k=v --no-pager <sub> …`: the subcommand and what follows it.
|
|
235
|
+
function gitSub(args) {
|
|
236
|
+
let i = 0;
|
|
237
|
+
while (i < args.length) {
|
|
238
|
+
const a = args[i];
|
|
239
|
+
if (['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'].includes(a)) { i += 2; continue; }
|
|
240
|
+
if (a.startsWith('-')) { i += 1; continue; }
|
|
241
|
+
return { sub: a, rest: args.slice(i + 1) };
|
|
242
|
+
}
|
|
243
|
+
return { sub: '', rest: [] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const nonFlags = (words) => words.filter((w) => !w.op && !w.t.startsWith('-')).map((w) => w.t);
|
|
247
|
+
|
|
248
|
+
function pushTargets(args) {
|
|
249
|
+
// the refspec targets of a `git push`, as branch names
|
|
250
|
+
const out = [];
|
|
251
|
+
for (const a of args) {
|
|
252
|
+
if (a === '') continue;
|
|
253
|
+
const dst = a.includes(':') ? a.slice(a.lastIndexOf(':') + 1) : a;
|
|
254
|
+
out.push(dst.replace(/^\+/, '').replace(/^refs\/heads\//, ''));
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function gitPush(args, ctx, findings, raw) {
|
|
260
|
+
const flags = args.filter((a) => a.startsWith('-')).join(' ');
|
|
261
|
+
const rest = args.filter((a) => !a.startsWith('-'));
|
|
262
|
+
const refspecs = rest.slice(1); // the first is the remote
|
|
263
|
+
const forced = /(^|\s)(-f|--force|--force-with-lease(=\S*)?|--force-if-includes)(\s|$)/.test(flags) || refspecs.some((r) => r.startsWith('+'));
|
|
264
|
+
const deleting = /(^|\s)(-d|--delete|--mirror|--prune)(\s|$)/.test(flags) || refspecs.some((r) => r.startsWith(':'));
|
|
265
|
+
const defaults = new Set(['main', 'master', ...(ctx.defaultBranch ? [ctx.defaultBranch] : [])]);
|
|
266
|
+
let targets = pushTargets(refspecs).map((t) => (t === 'HEAD' ? (ctx.branch ?? 'HEAD') : t));
|
|
267
|
+
if (targets.length === 0) targets = [ctx.branch ?? ''];
|
|
268
|
+
if (/(^|\s)(--all|--mirror)(\s|$)/.test(flags)) targets.push('main'); // every ref, the default branch included
|
|
269
|
+
for (const t of targets) {
|
|
270
|
+
if (forced) findings.push({ kind: 'merge', action: 'forced push', target: t || ctx.branch || 'unknown', raw });
|
|
271
|
+
else if (deleting && defaults.has(t)) findings.push({ kind: 'merge', action: 'deleting the default branch', target: t, raw });
|
|
272
|
+
else if (t === '' || defaults.has(t)) findings.push({ kind: 'merge', action: 'push to the default branch', target: t || 'unknown', raw });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function ghFindings(args, findings, raw) {
|
|
277
|
+
// `gh -R owner/repo pr merge 3`: global flags take a value, so look for the words, not the position
|
|
278
|
+
const sub = nonFlags(args.map((t) => ({ t })));
|
|
279
|
+
const at = sub.findIndex((x, i) => (x === 'pr' && sub[i + 1] === 'merge') || x === 'api');
|
|
280
|
+
const pr = at !== -1 && sub[at] === 'pr';
|
|
281
|
+
if (pr) {
|
|
282
|
+
const rest = sub.slice(at + 2);
|
|
283
|
+
const n = rest.find((x) => /^\d+$/.test(x)) ?? rest.map((x) => /\/pull\/(\d+)/.exec(x)?.[1]).find(Boolean) ?? null;
|
|
284
|
+
findings.push({ kind: 'merge', action: 'gh pr merge', target: n, raw });
|
|
285
|
+
} else if (at !== -1) {
|
|
286
|
+
const text = args.join(' ');
|
|
287
|
+
const m = /\/pulls\/(\d+)\/merge\b|\/pulls\/(\d+)\b.*auto_merge/.exec(text);
|
|
288
|
+
const writes = /(^|\s)(-X|--method)[= ]?(PUT|POST|PATCH|DELETE)\b/i.test(text) || /(^|\s)(-f|-F|--field|--raw-field|--input)\b/.test(text);
|
|
289
|
+
if (m && writes) findings.push({ kind: 'merge', action: 'gh api merge', target: m[1] ?? m[2], raw });
|
|
290
|
+
else if (/\bmergePullRequest\b|\benablePullRequestAutoMerge\b/.test(text)) findings.push({ kind: 'merge', action: 'GraphQL merge', target: null, raw });
|
|
291
|
+
else if (/\/git\/refs\/heads\/(main|master)\b/.test(text) && writes) findings.push({ kind: 'merge', action: 'gh api update default branch', target: 'main', raw });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const CLOUD = /^(db\s+(push|reset)|functions\s+deploy|projects\s+create|secrets\s+set|branches\s+create|link)\b/;
|
|
296
|
+
|
|
297
|
+
function segmentFindings(seg, ctx, findings, depth) {
|
|
298
|
+
const raw = seg.words.map((w) => w.t).join(' ');
|
|
299
|
+
const words = stripWrappers(seg.words);
|
|
300
|
+
if (seg.words.length === 0 && seg.heredocs.length === 0) return;
|
|
301
|
+
const verb = words.length ? base0(words[0]) : '';
|
|
302
|
+
const args = words.slice(1).map((w) => w.t);
|
|
303
|
+
const stateDir = ctx.layout?.stateRoot ?? null;
|
|
304
|
+
|
|
305
|
+
// nested commands: bash -c "…", eval "…", and here-documents fed to a shell
|
|
306
|
+
if (depth < 4) {
|
|
307
|
+
if (SHELLS.has(verb)) {
|
|
308
|
+
const ci = args.findIndex((a) => /^-[A-Za-z]*c[A-Za-z]*$/.test(a));
|
|
309
|
+
if (ci !== -1 && args[ci + 1] !== undefined) analyzeInto(args[ci + 1], ctx, findings, depth + 1);
|
|
310
|
+
for (const h of seg.heredocs) analyzeInto(h, ctx, findings, depth + 1);
|
|
311
|
+
}
|
|
312
|
+
if (verb === 'eval') analyzeInto(args.join(' '), ctx, findings, depth + 1);
|
|
313
|
+
if (verb === 'source' || verb === '.') { /* a file: not read here */ }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── merge-class ────────────────────────────────────────────────────────────────────────
|
|
317
|
+
if (verb === 'gh') ghFindings(args, findings, raw);
|
|
318
|
+
if (verb === 'git') {
|
|
319
|
+
const g = gitSub(args);
|
|
320
|
+
if (g.sub === 'push') gitPush(g.rest, ctx, findings, raw);
|
|
321
|
+
}
|
|
322
|
+
if (['npm', 'pnpm', 'yarn', 'npx', 'bun'].includes(verb)) {
|
|
323
|
+
const a = nonFlags(words.slice(1).map((w) => w));
|
|
324
|
+
const i = a.findIndex((x) => ['publish', 'unpublish', 'deprecate'].includes(x));
|
|
325
|
+
if (i !== -1) {
|
|
326
|
+
const rest = a.slice(i + 1);
|
|
327
|
+
findings.push({ kind: 'merge', action: `${verb} ${a[i]}`, publish: true, target: rest[0] ?? null, raw });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (verb === 'supabase' && CLOUD.test(nonFlags(words.slice(1)).join(' '))) findings.push({ kind: 'cloud', action: `supabase ${nonFlags(words.slice(1)).slice(0, 2).join(' ')}`, raw });
|
|
331
|
+
|
|
332
|
+
// ── the ledgers ────────────────────────────────────────────────────────────────────────
|
|
333
|
+
const scriptName = [words[0], ...words.slice(1, 3)].map((w) => (w ? base(w.t) : '')).find((n) => WRITERS.has(n) || READ_SCRIPTS.has(n)) ?? '';
|
|
334
|
+
if (scriptName === 'record-acceptance.js') findings.push({ kind: 'accept-grants', why: 'record-acceptance.js writes the acceptance ledger', raw });
|
|
335
|
+
if (scriptName === 'pmos-log.sh') {
|
|
336
|
+
const sub = args.slice(args.findIndex((a) => base(a) === 'pmos-log.sh') + 1).find((a) => !a.startsWith('-'));
|
|
337
|
+
if (sub === 'accept' || sub === 'grant' || sub === 'grant-use') findings.push({ kind: 'accept-grants', why: `pmos-log.sh ${sub} is a person's command`, raw });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// does this segment write a protected path? Redirect targets, then the verb.
|
|
341
|
+
const redirectTargets = [];
|
|
342
|
+
for (let k = 0; k < seg.words.length; k += 1) {
|
|
343
|
+
const w = seg.words[k];
|
|
344
|
+
if (w.op && (w.t.startsWith('>') || w.t === '&>')) { const t = seg.words[k + 1]; if (t && !t.op) redirectTargets.push(t); }
|
|
345
|
+
}
|
|
346
|
+
const mentions = [];
|
|
347
|
+
const consider = (text, dyn) => {
|
|
348
|
+
// every path-ish piece that names a ledger, or that could (dynamic .jsonl)
|
|
349
|
+
const re = /[^\s'"`<>|;&()=,\[\]{}]*\.jsonl\b/g;
|
|
350
|
+
let m;
|
|
351
|
+
while ((m = re.exec(text)) !== null) {
|
|
352
|
+
const p = m[0]; const name = base(p);
|
|
353
|
+
const bare = name === '.jsonl' || /[*?$]/.test(name);
|
|
354
|
+
let hit = null;
|
|
355
|
+
if (LEDGER_NAMES.has(name)) hit = name; else if (stateDir !== null && !/[$`~]/.test(p) && under(resolvePath(p, ctx.root), stateDir)) hit = name; else if (bare || /[$`]/.test(p)) hit = name;
|
|
356
|
+
if (hit) mentions.push({ name: hit, path: p });
|
|
357
|
+
}
|
|
358
|
+
if (dyn && stateDir !== null && /\.jsonl/.test(text)) mentions.push({ name: 'dynamic.jsonl', path: text });
|
|
359
|
+
};
|
|
360
|
+
for (const w of seg.words) if (!w.op) consider(w.t, w.dyn);
|
|
361
|
+
if (ctx.tainted?.size) {
|
|
362
|
+
for (const w of seg.words) {
|
|
363
|
+
if (w.op || !w.dyn) continue;
|
|
364
|
+
for (const n of ctx.tainted) if (new RegExp(`\\$\\{?${n}\\b`).test(w.t)) mentions.push({ name: 'variable', path: w.t });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (INTERPRETERS.has(verb) || SHELLS.has(verb)) for (const h of seg.heredocs) consider(h, false);
|
|
368
|
+
// a redirect to a glob or a variable, in a command that also names the state directory or a ledger
|
|
369
|
+
for (const t of redirectTargets) {
|
|
370
|
+
if (t.dyn && stateDir !== null && words.some((w) => LEDGER_NAMES.has(base(w.t)) || w.t.includes(base(stateDir)))) mentions.push({ name: 'glob', path: t.t });
|
|
371
|
+
}
|
|
372
|
+
if (mentions.length > 0) {
|
|
373
|
+
const redirectsToLedger = redirectTargets.some((t) => mentions.some((m) => m.path === t.t || base(t.t) === m.name || (t.dyn && m.name !== undefined)));
|
|
374
|
+
let readOnly = false;
|
|
375
|
+
if (READ_VERBS.has(verb) || (verb === '' && !redirectTargets.length)) readOnly = !writesByFlag(verb, args);
|
|
376
|
+
else if (verb === 'git') readOnly = GIT_READ.has(gitSub(args).sub);
|
|
377
|
+
else if (verb === 'sed') readOnly = !args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a)) && !args.some((a) => /(^|[;{}\s])w\s|\/w\s/.test(a));
|
|
378
|
+
else if (verb === 'find') readOnly = !args.some((a) => ['-delete', '-exec', '-execdir', '-fprint', '-fprintf', '-ok'].includes(a));
|
|
379
|
+
else if (verb === 'awk' || verb === 'gawk') readOnly = !args.some((a) => /system\s*\(|print[^;]*>|getline\s*<.*\||\|&/.test(a)) && !/-i\s*inplace|--include.*inplace/.test(args.join(' '));
|
|
380
|
+
else if (INTERPRETERS.has(verb) || verb === 'bash' || verb === 'sh') readOnly = scriptName !== '' && READ_SCRIPTS.has(scriptName) && !args.some((a) => a === '-c' || a === '-e' || a === '--eval');
|
|
381
|
+
else if (WRITERS.has(scriptName) || READ_SCRIPTS.has(scriptName)) readOnly = READ_SCRIPTS.has(scriptName);
|
|
382
|
+
if (!readOnly || redirectsToLedger) {
|
|
383
|
+
const acc = mentions.some((m) => ACCEPT_GRANTS.has(m.name));
|
|
384
|
+
findings.push({ kind: acc ? 'accept-grants' : 'ledger', why: `${verb || 'a redirect'} writes ${mentions[0].path}`, raw });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── the governing files ────────────────────────────────────────────────────────────────
|
|
389
|
+
const touched = [];
|
|
390
|
+
const gov = /[^\s'"`<>|;&()=,]*(?:pmos-cockpit\.json|settings\.json|pmos\/mods(?:\/[^\s'"`<>|;&()=,]*)?)/g;
|
|
391
|
+
const texts = seg.words.filter((w) => !w.op).map((w) => w.t);
|
|
392
|
+
if (INTERPRETERS.has(verb) || SHELLS.has(verb)) texts.push(...seg.heredocs);
|
|
393
|
+
for (const text of texts) {
|
|
394
|
+
let m;
|
|
395
|
+
while ((m = gov.exec(text)) !== null) {
|
|
396
|
+
const c = classifyPath(m[0].replace(/^\.\//, './'), ctx);
|
|
397
|
+
if (c && (c.kind === 'governing' || c.kind === 'settings')) touched.push(c);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (touched.length) {
|
|
401
|
+
const wholeFile = redirectTargets.some((t) => /settings\.json$|pmos-cockpit\.json$/.test(t.t)) || ['tee', 'cp', 'mv', 'install', 'dd', 'ln', 'rm'].includes(verb);
|
|
402
|
+
const writes = !READ_VERBS.has(verb) && !(verb === 'git' && GIT_READ.has(gitSub(args).sub)) && !(verb === 'sed' && !args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a)));
|
|
403
|
+
const redirect = redirectTargets.length > 0;
|
|
404
|
+
const interp = INTERPRETERS.has(verb) || SHELLS.has(verb);
|
|
405
|
+
for (const c of touched) {
|
|
406
|
+
if (!(writes || redirect)) continue;
|
|
407
|
+
if (c.kind === 'governing') findings.push({ kind: 'governing', what: c.what, raw });
|
|
408
|
+
else if (PLUGIN_KEYS.test(raw) || wholeFile || redirect || (interp && INLINE_WRITE.test(texts.join('\n')))) findings.push({ kind: 'governing', what: c.what, raw });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A variable set to a ledger or to the state directory taints the later words that use it, so
|
|
414
|
+
// `F=pmos/state/runs.jsonl; echo x >> $F` is found. The set lives for one command string.
|
|
415
|
+
function taintedNames(segs, ctx) {
|
|
416
|
+
const names = new Set();
|
|
417
|
+
const state = ctx.layout?.stateRoot ?? null;
|
|
418
|
+
for (const seg of segs) {
|
|
419
|
+
for (const w of seg.words) {
|
|
420
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/.exec(w.t);
|
|
421
|
+
if (!m) continue;
|
|
422
|
+
const v = m[2];
|
|
423
|
+
const hitsLedger = /\.jsonl\b/.test(v) || [...LEDGER_NAMES].some((n) => v.includes(n));
|
|
424
|
+
const hitsState = state !== null && (under(resolvePath(v, ctx.root), state) || v.includes('pmos/state') || v.includes('planning/runs'));
|
|
425
|
+
if (hitsLedger || hitsState) names.add(m[1]);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return names;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function analyzeInto(cmd, ctx, findings, depth) {
|
|
432
|
+
for (const sub of readSubstitutions(cmd)) if (depth < 4) analyzeInto(sub, ctx, findings, depth + 1);
|
|
433
|
+
const segs = parseShell(cmd);
|
|
434
|
+
const tainted = taintedNames(segs, ctx);
|
|
435
|
+
for (const seg of segs) segmentFindings(seg, { ...ctx, tainted }, findings, depth);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Findings for one Bash command.
|
|
440
|
+
* @param {string} command
|
|
441
|
+
* @param {{ root: string, layout: {kind: string, stateRoot: string|null}|null, branch?: string|null, defaultBranch?: string|null }} ctx
|
|
442
|
+
*/
|
|
443
|
+
export function classifyBash(command, ctx) {
|
|
444
|
+
const findings = [];
|
|
445
|
+
analyzeInto(String(command), ctx, findings, 0);
|
|
446
|
+
return findings;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── MCP tools ───────────────────────────────────────────────────────────────────────────────────
|
|
450
|
+
const tail = (tool) => tool.split('__').slice(2).join('__');
|
|
451
|
+
const server = (tool) => (tool.split('__')[1] ?? '').toLowerCase();
|
|
452
|
+
|
|
453
|
+
const OUTWARD = [
|
|
454
|
+
{ server: /atlassian|confluence|jira/, verb: /^(create|edit|add|update|transition|delete|link)/i },
|
|
455
|
+
{ server: /slack/, verb: /^slack_(send_message|schedule_message|create_canvas|update_canvas|add_reaction)$/ },
|
|
456
|
+
{ server: /gmail/, verb: /^(send_message|reply|forward)$/ },
|
|
457
|
+
{ server: /google_?drive/, verb: /^(share_file|create_file|update_file|copy_file|trash_file)$/ },
|
|
458
|
+
{ server: /google_?calendar/, verb: /^(create_event|update_event|delete_event|respond_to_event)$/ },
|
|
459
|
+
];
|
|
460
|
+
|
|
461
|
+
/** The kind of a tool call that is not Bash and not a file write, or null. */
|
|
462
|
+
export function classifyTool(e, ctx) {
|
|
463
|
+
const tool = String(e.tool);
|
|
464
|
+
if (!tool.startsWith('mcp__')) return null;
|
|
465
|
+
const t = tail(tool);
|
|
466
|
+
if (/(^|__)merge_pull_request$/.test(t) || /(^|_)enable_pr_auto_merge$/.test(t)) {
|
|
467
|
+
const n = e.pullNumber ?? e.pull_number ?? e.number ?? null;
|
|
468
|
+
return { kind: 'merge', action: t, target: n === null ? null : String(n) };
|
|
469
|
+
}
|
|
470
|
+
if (/^github$/.test(server(tool)) && /^(push_files|create_or_update_file|delete_file)$/.test(t)) {
|
|
471
|
+
const defaults = new Set(['main', 'master', ...(ctx.defaultBranch ? [ctx.defaultBranch] : [])]);
|
|
472
|
+
const br = typeof e.branch === 'string' && e.branch !== '' ? e.branch : null;
|
|
473
|
+
if (br === null || defaults.has(br)) return { kind: 'merge', action: `${t} to the default branch`, target: br ?? ctx.defaultBranch ?? 'main' };
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
for (const o of OUTWARD) if (o.server.test(server(tool)) && o.verb.test(t)) return { kind: 'outward', action: tool };
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Everything the guards need to decide about one tool call, in one place.
|
|
482
|
+
* @returns {Array<{kind: string, [k: string]: any}>}
|
|
483
|
+
*/
|
|
484
|
+
export function findingsFor(e, ctx, settingsText = null) {
|
|
485
|
+
const tool = String(e.tool);
|
|
486
|
+
if (tool === 'Bash') return typeof e.command === 'string' ? classifyBash(e.command, ctx) : [];
|
|
487
|
+
const out = [];
|
|
488
|
+
if (isFileWriteTool(tool)) {
|
|
489
|
+
for (const p of pathsOf(e)) {
|
|
490
|
+
const c = classifyPath(p, ctx);
|
|
491
|
+
if (c === null) continue;
|
|
492
|
+
if (c.kind === 'settings') { if (touchesPluginEntries(e, settingsText)) out.push({ kind: 'governing', what: c.what, path: c.path }); continue; }
|
|
493
|
+
out.push({ ...c });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const other = classifyTool(e, ctx);
|
|
497
|
+
if (other) out.push(other);
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The writer to name in a block message. */
|
|
502
|
+
export function writerHint(ctx) {
|
|
503
|
+
return ctx.layout?.kind === 'self'
|
|
504
|
+
? 'scripts/log-run.js, scripts/record-eval.js or scripts/record-outcome.js'
|
|
505
|
+
: 'pmos/scripts/pmos-log.sh (run, eval, outcome or friction)';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── what the model reads when a call is denied ─────────────────────────────────────────────────
|
|
509
|
+
const SCOPE_HINT = (f) => (f.publish ? 'publish:<artifact>' : f.target ? `merge:${f.target}` : 'merge:<PR number>');
|
|
510
|
+
|
|
511
|
+
/** One sentence the model reads on a deny. It always names what to do next. */
|
|
512
|
+
export function denyMessage(f, ctx) {
|
|
513
|
+
const head = 'Blocked by pmos-cockpit:';
|
|
514
|
+
switch (f.kind) {
|
|
515
|
+
case 'ledger':
|
|
516
|
+
return `${head} a hand-written change to a ledger (${f.why ?? f.path ?? 'a *.jsonl file in the state root'}). Write ledger rows with ${writerHint(ctx)}, which validates the row. Reading a ledger is not blocked.`;
|
|
517
|
+
case 'accept-grants':
|
|
518
|
+
return `${head} acceptances and grants are the PM's (D12: Acceptance is non-delegable). ${f.why ? `${f.why}. ` : ''}The PM records one by typing /pmos-accept <run_id> pass|fail <note> or /pmos-grant <scope>, or by pressing the button. Ask the PM to do it.`;
|
|
519
|
+
case 'governing':
|
|
520
|
+
return `${head} ${f.what} is governing configuration. A person edits it${ctx.layout?.kind === 'kit' ? '; npx create-pmos --update refreshes the installed mod' : ''}. Ask the PM.`;
|
|
521
|
+
case 'cloud':
|
|
522
|
+
return `${head} ${f.action} changes a cloud service. AGENTS.md makes it confirm-first, and no grant covers it in this release. Ask the PM to run it.`;
|
|
523
|
+
default:
|
|
524
|
+
return `${head} ${f.action ?? 'this action'} is not allowed here.`;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** A merge-class denial: why no grant allows it, and the command that mints one. */
|
|
529
|
+
export function denyMerge(f, reason) {
|
|
530
|
+
const what = `${f.action}${f.target ? ` ${f.target}` : ''}`;
|
|
531
|
+
return `Blocked by pmos-cockpit: ${what} needs the PM's grant, and ${reason} Ask the PM to type /pmos-grant ${SCOPE_HINT(f)} (single use, 120 minutes). A grant for one target never covers another.`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The guard's own failure path. When the matcher or its inputs throw, the mod cannot classify the
|
|
536
|
+
* call, so it uses names alone: an ordinary action is allowed, and a merge, a publish or a ledger,
|
|
537
|
+
* acceptance, grants or governing-file write is denied (M17). Returns a reason, or null.
|
|
538
|
+
*/
|
|
539
|
+
export function looseDeny(e) {
|
|
540
|
+
const tool = String(e.tool);
|
|
541
|
+
const say = (what) => `Blocked by pmos-cockpit: the guard failed while checking this call, and ${what} is denied when it cannot be checked. An ordinary action would have been allowed. Ask the PM, or retry.`;
|
|
542
|
+
if (tool === 'Bash' && typeof e.command === 'string') {
|
|
543
|
+
const c = e.command;
|
|
544
|
+
if (/\bgh\b[^\n;|&]*\b(pr\s+merge|api)\b|\bnpm\b[^\n;|&]*\bpublish\b|\b(pnpm|yarn)\b[^\n;|&]*\bpublish\b|\bgit\b[^\n;|&]*\bpush\b|\bsupabase\b[^\n;|&]*\b(db push|functions deploy|projects create|secrets set)\b/.test(c)) return say('a merge, push or publish');
|
|
545
|
+
if (/\.jsonl\b|record-acceptance|pmos-log\.sh\s+(accept|grant)|pmos\/mods|pmos-cockpit\.json|settings\.json/.test(c)) return say('a write that names a ledger or governing file');
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
if (isFileWriteTool(tool)) {
|
|
549
|
+
const hit = PATH_KEYS.map((k) => e[k]).some((v) => typeof v === 'string' && /\.jsonl$|pmos\/mods\/|pmos-cockpit\.json$|\.claude\/settings\.json$/.test(v));
|
|
550
|
+
return hit ? say('a write to a ledger or governing file') : null;
|
|
551
|
+
}
|
|
552
|
+
if (tool.startsWith('mcp__') && /merge_pull_request|enable_pr_auto_merge/.test(tool)) return say('a merge');
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Does a user message read as an approval? Used only to OFFER a press; nothing is written from it. */
|
|
557
|
+
export function readsAsApproval(text) {
|
|
558
|
+
const t = String(text ?? '').trim();
|
|
559
|
+
if (t === '' || t.startsWith('/') || t.length > 600) return false;
|
|
560
|
+
return /\b(accepted?|i accept|approved?|lgtm|ship it|looks good|go ahead and merge|merge (it|this|that|pr\s*#?\d+))\b/i.test(t) && !/\?\s*$/.test(t);
|
|
561
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Which initiative is this session in? One rule, and it is the workspace's: the branch is
|
|
2
|
+
// `initiative/<id>` with an id after it (workspace/lib/working-set.js, initiativeOfBranch). A test
|
|
3
|
+
// pins this function to that one on the same cases, so the mod and the workspace cannot disagree.
|
|
4
|
+
//
|
|
5
|
+
// It never guesses. A branch with another name, a detached head, an id that is not a safe file name
|
|
6
|
+
// and an id with no record all answer "no initiative" and say why.
|
|
7
|
+
|
|
8
|
+
const PREFIX = 'initiative/';
|
|
9
|
+
// The id becomes part of a path, so it must be a plain slug. The workspace accepts any text after
|
|
10
|
+
// the prefix because it only compares strings; the mod reads files, so it is stricter, and says so.
|
|
11
|
+
const SAFE_ID = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
12
|
+
|
|
13
|
+
/** The id a branch names, or null. Same cases as the workspace's `initiativeOfBranch`. */
|
|
14
|
+
export function initiativeOfBranch(branch) {
|
|
15
|
+
const b = typeof branch === 'string' ? branch : '';
|
|
16
|
+
if (!b.startsWith(PREFIX)) return null;
|
|
17
|
+
const id = b.slice(PREFIX.length);
|
|
18
|
+
return id ? id : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ branch: string|null, recordExists: boolean|null, commitsAhead?: number|null }} facts
|
|
23
|
+
* @returns {{ id: string|null, state: 'ok'|'no-initiative'|'no-record'|'unsafe-id'|'detached', warn: boolean, text: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function resolveInitiative(facts) {
|
|
26
|
+
const branch = facts.branch ?? null;
|
|
27
|
+
if (branch === null || branch === '' || branch === 'HEAD') {
|
|
28
|
+
return { id: null, state: 'detached', warn: false, text: 'no initiative (no branch: a detached head)' };
|
|
29
|
+
}
|
|
30
|
+
const id = initiativeOfBranch(branch);
|
|
31
|
+
if (id === null) {
|
|
32
|
+
const warn = (facts.commitsAhead ?? 0) > 0;
|
|
33
|
+
return {
|
|
34
|
+
id: null,
|
|
35
|
+
state: 'no-initiative',
|
|
36
|
+
warn,
|
|
37
|
+
text: `no initiative (branch ${branch} is not initiative/<id>)${warn ? '. It has commits, so the work has no initiative record.' : ''}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!SAFE_ID.test(id)) {
|
|
41
|
+
return { id: null, state: 'unsafe-id', warn: true, text: `no initiative (the id "${id}" is not a plain lowercase slug, so it is not used as a path)` };
|
|
42
|
+
}
|
|
43
|
+
if (facts.recordExists === false) {
|
|
44
|
+
return { id, state: 'no-record', warn: true, text: `no initiative record for ${id} (the branch names it and the file does not exist)` };
|
|
45
|
+
}
|
|
46
|
+
return { id, state: 'ok', warn: false, text: id };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The record paths the layout keeps initiatives at, for the caller's existence check. */
|
|
50
|
+
export function recordPath(layout, id) {
|
|
51
|
+
if (layout.kind === 'kit') return `${layout.root.replace(/\/+$/, '')}/pmos/state/initiatives/${id}.md`;
|
|
52
|
+
if (layout.kind === 'self') return `${layout.root.replace(/\/+$/, '')}/planning/initiatives/${id}.md`;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// layout.mjs — which PMOS layout is this repository, and where do its ledgers live?
|
|
2
|
+
//
|
|
3
|
+
// PMOS keeps its state in two shapes. PMOS's own repository keeps records in `planning/` and the
|
|
4
|
+
// ledgers in `planning/runs/`. A vendored kit keeps them under `pmos/state/`. The rule below is the
|
|
5
|
+
// one scripts/workflow-state.js uses (its `layout()`, `isKit = !selfDir && kitDir`), and a test pins
|
|
6
|
+
// the two together, so the mod and the ladder cannot disagree about which repository this is.
|
|
7
|
+
//
|
|
8
|
+
// Pure: it asks a `probe` whether a path exists and does nothing else.
|
|
9
|
+
|
|
10
|
+
export const KIT = 'kit';
|
|
11
|
+
export const SELF = 'self';
|
|
12
|
+
export const NONE = 'none';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ exists: (path: string) => Promise<boolean> | boolean }} probe
|
|
16
|
+
* @param {string} root the repository root (the session's working root)
|
|
17
|
+
* @returns {Promise<{kind: 'kit'|'self'|'none', root: string, stateRoot: string|null, runsFile: string|null, skillsNative: string|null, claudeSkills: string}>}
|
|
18
|
+
*/
|
|
19
|
+
export async function detectLayout(probe, root) {
|
|
20
|
+
const at = (...parts) => [root.replace(/\/+$/, ''), ...parts].join('/');
|
|
21
|
+
const hasSelf = await probe.exists(at('planning', 'initiatives'));
|
|
22
|
+
const hasKit = await probe.exists(at('pmos', 'state', 'initiatives'));
|
|
23
|
+
const claudeSkills = at('.claude', 'skills');
|
|
24
|
+
if (!hasSelf && hasKit) {
|
|
25
|
+
return { kind: KIT, root, stateRoot: at('pmos', 'state'), runsFile: 'runs.jsonl', skillsNative: at('pmos', 'skills-native'), claudeSkills };
|
|
26
|
+
}
|
|
27
|
+
if (hasSelf) {
|
|
28
|
+
return { kind: SELF, root, stateRoot: at('planning', 'runs'), runsFile: 'agent-runs.jsonl', skillsNative: null, claudeSkills };
|
|
29
|
+
}
|
|
30
|
+
return { kind: NONE, root, stateRoot: null, runsFile: null, skillsNative: null, claudeSkills };
|
|
31
|
+
}
|