create-pmos 0.43.0 → 0.44.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 +60 -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/profile.mjs +17 -0
- package/payload/mods/pmos-cockpit/hooks/lib/skills.mjs +69 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +460 -0
- package/payload/mods/pmos-cockpit/types/index.d.ts +34 -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,17 @@
|
|
|
1
|
+
// profile.mjs — the repository profile (D68), read from `.pmos-profile`.
|
|
2
|
+
//
|
|
3
|
+
// Two profiles exist: `pm-artifacts` and `product-code`. A missing file, an empty file or a value
|
|
4
|
+
// that is neither is read as `product-code`, the stricter default (D81). The installer writes the
|
|
5
|
+
// file only on a fresh `pm-artifacts` vendor, so an older tenant has none. `declared` says whether
|
|
6
|
+
// the file named a profile, so a caller can tell "product-code" from "nothing said".
|
|
7
|
+
|
|
8
|
+
export const PROFILES = ['pm-artifacts', 'product-code'];
|
|
9
|
+
|
|
10
|
+
/** @param {string|null|undefined} text the file's text, or null when the file is missing */
|
|
11
|
+
export function parseProfile(text) {
|
|
12
|
+
if (typeof text !== 'string') return { profile: 'product-code', declared: false, note: 'no .pmos-profile file' };
|
|
13
|
+
const first = text.split('\n').map((l) => l.trim()).find((l) => l !== '' && !l.startsWith('#'));
|
|
14
|
+
if (first === undefined) return { profile: 'product-code', declared: false, note: 'the .pmos-profile file names no profile' };
|
|
15
|
+
if (PROFILES.includes(first)) return { profile: first, declared: true, note: null };
|
|
16
|
+
return { profile: 'product-code', declared: false, note: `unknown profile "${first}"` };
|
|
17
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// skills.mjs — are the kit's skills registered where Claude Code looks?
|
|
2
|
+
//
|
|
3
|
+
// The kit ships its skills as `pmos/skills-native/<name>/SKILL.md`. Claude Code reads project skills
|
|
4
|
+
// from `.claude/skills/<name>`, and nothing in the kit used to put them there, so a tenant ran for
|
|
5
|
+
// about two weeks with nine of nineteen skills present on disk and not loadable. This module decides
|
|
6
|
+
// what is wrong. It never touches the file system: the caller gathers facts and applies the repair.
|
|
7
|
+
//
|
|
8
|
+
// The rule that matters most: a name that is already taken in `.claude/skills/` by anything that is
|
|
9
|
+
// not a link to the kit's own skill is NEVER replaced. It may be a product's own skill.
|
|
10
|
+
|
|
11
|
+
/** a skill name is a short slug; it becomes a path segment, so nothing else is accepted */
|
|
12
|
+
export const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
13
|
+
|
|
14
|
+
const BLOCK_INDICATOR = /^[>|][-+]?$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The frontmatter `description` of a SKILL.md, and whether it carries text.
|
|
18
|
+
* A block scalar (`description: >`) is folded; a bare indicator with no text under it is not text.
|
|
19
|
+
* @param {string} text the SKILL.md text
|
|
20
|
+
* @returns {{ ok: boolean, description: string }}
|
|
21
|
+
*/
|
|
22
|
+
export function parseDescription(text) {
|
|
23
|
+
const lines = String(text).split('\n');
|
|
24
|
+
if (lines[0].trim() !== '---') return { ok: false, description: '' };
|
|
25
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
26
|
+
const fm = lines.slice(1, end === -1 ? lines.length : end);
|
|
27
|
+
const at = fm.findIndex((l) => /^description:/.test(l));
|
|
28
|
+
if (at === -1) return { ok: false, description: '' };
|
|
29
|
+
const rest = fm[at].replace(/^description:\s*/, '').trimEnd();
|
|
30
|
+
if (!BLOCK_INDICATOR.test(rest)) {
|
|
31
|
+
const unquoted = rest.replace(/^(["'])(.*)\1$/, '$2');
|
|
32
|
+
return { ok: unquoted.trim() !== '', description: unquoted };
|
|
33
|
+
}
|
|
34
|
+
const folded = [];
|
|
35
|
+
for (const l of fm.slice(at + 1)) {
|
|
36
|
+
if (l.trim() === '' || /^[ \t]/.test(l)) { if (l.trim()) folded.push(l.trim()); continue; }
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
const description = folded.join(' ');
|
|
40
|
+
return { ok: description !== '', description };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {{ name: string, descriptionOk: boolean, realPath: string|null }} NativeSkill
|
|
45
|
+
* @typedef {{ kind: string, isLink: boolean, realPath: string|null, target: string|null }} Linked
|
|
46
|
+
* @param {NativeSkill[]} native what the kit ships
|
|
47
|
+
* @param {Map<string, Linked>} linked what is in `.claude/skills`, by name
|
|
48
|
+
*/
|
|
49
|
+
export function diffSkills(native, linked) {
|
|
50
|
+
const out = { ok: [], missing: [], repairable: [], blocked: [], badDescription: [], badName: [] };
|
|
51
|
+
for (const s of native) {
|
|
52
|
+
if (!NAME_RE.test(s.name)) { out.badName.push(s.name); continue; }
|
|
53
|
+
if (!s.descriptionOk) { out.badDescription.push(s.name); continue; }
|
|
54
|
+
const have = linked.get(s.name);
|
|
55
|
+
if (!have) { out.missing.push(s.name); out.repairable.push(s.name); continue; }
|
|
56
|
+
if (have.isLink && have.realPath !== null && have.realPath === s.realPath) { out.ok.push(s.name); continue; }
|
|
57
|
+
if (have.isLink && have.realPath === null && have.target !== null && /(^|\/)pmos\/skills-native\//.test(have.target)) {
|
|
58
|
+
// a dangling link the kit itself made earlier: safe to make again
|
|
59
|
+
out.repairable.push(s.name); out.missing.push(s.name); continue;
|
|
60
|
+
}
|
|
61
|
+
out.blocked.push({ name: s.name, reason: have.isLink ? 'a link that points somewhere else' : 'a file or folder that is not a link (it may be a product skill)' });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** the relative target of the link `.claude/skills/<name>`: from `.claude/skills/` up two levels to the repository root */
|
|
67
|
+
export function linkTarget(name) {
|
|
68
|
+
return `../../pmos/skills-native/${name}`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { CockpitStatus, SkillsReport } from '../types'
|
|
5
|
+
import { checkGrant } from './lib/grants.mjs'
|
|
6
|
+
import { parseAccept, pressNote, planAcceptance, planGrant, writerOf } from './lib/commands.mjs'
|
|
7
|
+
import { PATH_KEYS, classifyTool, denyMerge, denyMessage, findingsFor, isFileWriteTool, looseDeny, readsAsApproval } from './lib/guard.mjs'
|
|
8
|
+
import { recordPath, resolveInitiative } from './lib/initiative.mjs'
|
|
9
|
+
import { detectLayout } from './lib/layout.mjs'
|
|
10
|
+
import { parseProfile } from './lib/profile.mjs'
|
|
11
|
+
import { diffSkills, parseDescription, linkTarget, NAME_RE } from './lib/skills.mjs'
|
|
12
|
+
|
|
13
|
+
const status = atom({ plugin: 'pmos-cockpit', key: 'status' } as const, null)
|
|
14
|
+
const isHidden = atom({ plugin: 'pmos-cockpit', key: 'isHidden' } as const, false)
|
|
15
|
+
// A run the person's last message may have accepted. It is an offer to press, never a record.
|
|
16
|
+
const offer = atom({ plugin: 'pmos-cockpit', key: 'offer' } as const, null)
|
|
17
|
+
|
|
18
|
+
// ── helpers for the guards and commands ───────────────────────────────────────────────────────────
|
|
19
|
+
const out = (r: any): string => (r && typeof r.stdout === 'string' ? r.stdout.trim() : '')
|
|
20
|
+
async function sh($: any, argv: string[], init?: any): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
21
|
+
try { return await $.process.run(argv, init) } catch (e: any) { return { exitCode: 1, stdout: '', stderr: String(e?.message ?? e) } }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function rootAndLayout($: any) {
|
|
25
|
+
const root: string = await $.session.root()
|
|
26
|
+
const layout = await detectLayout({ exists: (p: string) => $.fs.exists(p) }, root)
|
|
27
|
+
return { root, layout: { ...layout, root } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// What git says about the session's checkout. A linked worktree answers for itself.
|
|
31
|
+
async function gitFacts($: any, root: string) {
|
|
32
|
+
const b = await sh($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root })
|
|
33
|
+
const branch = b.exitCode === 0 && out(b) !== '' ? out(b) : null
|
|
34
|
+
const d = await sh($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: root })
|
|
35
|
+
const defaultBranch = d.exitCode === 0 && out(d) !== '' ? out(d).replace(/^origin\//, '') : null
|
|
36
|
+
let commitsAhead: number | null = null
|
|
37
|
+
if (branch !== null) {
|
|
38
|
+
const c = await sh($, ['git', 'rev-list', '--count', `${defaultBranch ?? 'main'}..HEAD`], { cwd: root })
|
|
39
|
+
commitsAhead = c.exitCode === 0 && /^\d+$/.test(out(c)) ? Number(out(c)) : null
|
|
40
|
+
}
|
|
41
|
+
return { branch, defaultBranch, commitsAhead }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isPerson = (e: any): boolean => e?.origin?.kind === 'composer' || e?.origin?.kind === 'bridge'
|
|
45
|
+
|
|
46
|
+
async function readText($: any, path: string): Promise<string | null> {
|
|
47
|
+
try { return (await $.fs.exists(path)) ? await $.fs.read(path) : null } catch { return null }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function whoAmI($: any, root: string): Promise<string> {
|
|
51
|
+
const r = await sh($, ['git', 'config', 'user.name'], { cwd: root })
|
|
52
|
+
return r.exitCode === 0 && out(r) !== '' ? out(r) : 'pm'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The local gate for one initiative. It reports what it ran, and what it cannot see.
|
|
56
|
+
async function localGate($: any, layout: any, root: string, id: string | null, branch: string | null) {
|
|
57
|
+
if (id === null) return { ran: false, failed: false, text: 'Local gate not run: the branch names no initiative.' }
|
|
58
|
+
const lines: string[] = []
|
|
59
|
+
let failed = false
|
|
60
|
+
let ran = false
|
|
61
|
+
const ws = layout.kind === 'kit' ? `${root}/pmos/scripts/workflow-state.js` : `${root}/scripts/workflow-state.js`
|
|
62
|
+
if (await $.fs.exists(ws)) {
|
|
63
|
+
const r = await sh($, ['node', ws, '--gate', id], { cwd: root })
|
|
64
|
+
ran = true
|
|
65
|
+
if (r.exitCode !== 0) failed = true
|
|
66
|
+
lines.push(`workflow-state --gate ${id}: ${r.exitCode === 0 ? 'passed' : `FAILED. ${out(r).split('\n').slice(-3).join(' ')}`}`)
|
|
67
|
+
}
|
|
68
|
+
if (layout.kind === 'kit' && (await $.fs.exists(`${root}/pmos/scripts/gate-initiative.sh`))) {
|
|
69
|
+
const r = await sh($, ['bash', `${root}/pmos/scripts/gate-initiative.sh`], { cwd: root, env: { HEAD_REF: branch ?? '', PR_BODY: '', PR_LABELS: '', KIT_ROOT: 'pmos' } })
|
|
70
|
+
ran = true
|
|
71
|
+
if (r.exitCode !== 0) failed = true
|
|
72
|
+
lines.push(`gate-initiative.sh: ${r.exitCode === 0 ? 'passed' : `FAILED. ${(out(r) || r.stderr).split('\n').slice(-2).join(' ')}`}`)
|
|
73
|
+
}
|
|
74
|
+
if (!ran) lines.push('Local gate not run: no gate script is installed here.')
|
|
75
|
+
lines.push('This run does not see: anchors (gate-initiative.sh does not resolve them; CI does), or anything on a remote.')
|
|
76
|
+
return { ran, failed, text: lines.join(' ') }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Candidate artifact names for `npm publish`: the package.json beside the target, then the session root.
|
|
80
|
+
async function publishNames($: any, root: string, f: any): Promise<string[]> {
|
|
81
|
+
const names: string[] = []
|
|
82
|
+
const dirs = typeof f.target === 'string' && !f.target.startsWith('-') ? [f.target.startsWith('/') ? f.target : `${root}/${f.target}`] : []
|
|
83
|
+
dirs.push(root)
|
|
84
|
+
for (const d of dirs) {
|
|
85
|
+
const text = await readText($, `${d}/package.json`)
|
|
86
|
+
if (text === null) continue
|
|
87
|
+
try { const n = JSON.parse(text).name; if (typeof n === 'string') names.push(n) } catch { /* not JSON: no name */ }
|
|
88
|
+
}
|
|
89
|
+
return [...new Set(names)]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// One tool call, judged. Returns a deny text, or null to let it through. It also spends the grants
|
|
93
|
+
// it relied on, so a single-use grant is used up before the action runs.
|
|
94
|
+
async function judge($: any, e: any): Promise<string | null> {
|
|
95
|
+
const tool = String(e.tool)
|
|
96
|
+
if (tool !== 'Bash' && !isFileWriteTool(tool) && !tool.startsWith('mcp__')) return null
|
|
97
|
+
const { root, layout } = await rootAndLayout($)
|
|
98
|
+
const needsBranch = (tool === 'Bash' && /\bpush\b/.test(String(e.command ?? ''))) || tool.startsWith('mcp__github')
|
|
99
|
+
let facts = needsBranch ? await gitFacts($, root) : { branch: null, defaultBranch: null, commitsAhead: null }
|
|
100
|
+
const ctx = { root, layout, branch: facts.branch, defaultBranch: facts.defaultBranch }
|
|
101
|
+
const settingsPath = `${root}/.claude/settings.json`
|
|
102
|
+
const settingsText = isFileWriteTool(tool) ? await readText($, settingsPath) : null
|
|
103
|
+
const findings = findingsFor(e, ctx, settingsText)
|
|
104
|
+
// A path that is a link to a ledger is the ledger. Judge where each path really leads, as well.
|
|
105
|
+
if (isFileWriteTool(tool)) {
|
|
106
|
+
const real: any = { ...e }
|
|
107
|
+
let moved = false
|
|
108
|
+
for (const k of PATH_KEYS) {
|
|
109
|
+
const p = e[k]
|
|
110
|
+
if (typeof p !== 'string' || p === '') continue
|
|
111
|
+
const abs = p.startsWith('/') ? p : `${root}/${p}`
|
|
112
|
+
const st = await $.fs.stat(abs, { resolve: true }).catch(() => undefined)
|
|
113
|
+
if (st?.realPath && st.realPath !== abs) { real[k] = st.realPath; moved = true }
|
|
114
|
+
}
|
|
115
|
+
if (moved) findings.push(...findingsFor(real, ctx, settingsText))
|
|
116
|
+
}
|
|
117
|
+
if (findings.length === 0) return null
|
|
118
|
+
|
|
119
|
+
for (const kind of ['accept-grants', 'ledger', 'governing', 'cloud']) {
|
|
120
|
+
const f = findings.find((x) => x.kind === kind)
|
|
121
|
+
if (f) return denyMessage(f, ctx)
|
|
122
|
+
}
|
|
123
|
+
const merges = findings.filter((x) => x.kind === 'merge')
|
|
124
|
+
if (merges.length === 0) return null // an outward write: tool.check asks
|
|
125
|
+
|
|
126
|
+
// the gate needs the branch, whatever the action was
|
|
127
|
+
if (!needsBranch) facts = await gitFacts($, root)
|
|
128
|
+
|
|
129
|
+
const grantsFile = layout.stateRoot ? `${layout.stateRoot}/grants.jsonl` : null
|
|
130
|
+
const text = grantsFile ? ((await readText($, grantsFile)) ?? '') : ''
|
|
131
|
+
const now: number = await $.clock.now()
|
|
132
|
+
const used: { id: string; action: string }[] = []
|
|
133
|
+
for (const f of merges) {
|
|
134
|
+
if (grantsFile === null) return denyMerge(f, 'there is no PMOS state here to hold a grant.')
|
|
135
|
+
let checks: { kind: string; target: string }[]
|
|
136
|
+
if (f.publish) {
|
|
137
|
+
const names = await publishNames($, root, f)
|
|
138
|
+
if (names.length === 0) return denyMerge({ ...f, target: null }, 'the artifact name could not be read from a package.json, so no grant can match.')
|
|
139
|
+
checks = names.map((n) => ({ kind: 'publish', target: n }))
|
|
140
|
+
} else {
|
|
141
|
+
if (f.target === null || f.target === undefined) return denyMerge(f, `${f.action} names no PR number or branch, so no grant can match. Name it.`)
|
|
142
|
+
checks = [{ kind: 'merge', target: String(f.target) }]
|
|
143
|
+
}
|
|
144
|
+
let hit: any = null
|
|
145
|
+
let why = ''
|
|
146
|
+
for (const c of checks) {
|
|
147
|
+
const r = checkGrant(text, c, now)
|
|
148
|
+
if (r.ok) { hit = r.grant; break }
|
|
149
|
+
why = r.reason
|
|
150
|
+
}
|
|
151
|
+
if (hit === null) return denyMerge(f.publish ? { ...f, target: checks[0].target } : f, `${why}`)
|
|
152
|
+
used.push({ id: hit.id, action: `${f.action}${f.target ? ` ${f.target}` : ''}` })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// every grant is live; now the local gate, which can still stop the action
|
|
156
|
+
const initiative = resolveInitiative({
|
|
157
|
+
branch: facts.branch,
|
|
158
|
+
recordExists: facts.branch ? await $.fs.exists(recordPath(layout, facts.branch.replace(/^initiative\//, '')) ?? '/nonexistent') : null,
|
|
159
|
+
commitsAhead: facts.commitsAhead,
|
|
160
|
+
})
|
|
161
|
+
const gate = await localGate($, layout, root, initiative.id, facts.branch)
|
|
162
|
+
$.ui.toast(`pmos-cockpit: ${gate.text}`)
|
|
163
|
+
if (gate.failed) return `Blocked by pmos-cockpit: the local gate failed, and a grant does not override it. ${gate.text}`
|
|
164
|
+
|
|
165
|
+
const w = writerOf(layout, root)
|
|
166
|
+
if (w === null) return denyMerge(merges[0], 'no writer is installed to record the use of the grant.')
|
|
167
|
+
for (const u of used) {
|
|
168
|
+
const r = await sh($, [...w.argv, 'grant-use', u.id, u.action], { cwd: root, env: w.env })
|
|
169
|
+
if (r.exitCode !== 0) return `Blocked by pmos-cockpit: the use of grant ${u.id} could not be recorded (${(r.stderr || out(r)).trim()}), so the action is denied.`
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Accepting a run: the same writer, whichever layout. Called only from a person's command or press.
|
|
175
|
+
async function writeAcceptance($: any, run: { run_id: string; initiative_id: string }, label: 'pass' | 'fail', note: string): Promise<{ ok: boolean; text: string }> {
|
|
176
|
+
const { root, layout } = await rootAndLayout($)
|
|
177
|
+
const plan = planAcceptance({ layout, root, run, label, note, pm: await whoAmI($, root), atIso: new Date(await $.clock.now()).toISOString() })
|
|
178
|
+
if (!plan.ok) return { ok: false, text: plan.text }
|
|
179
|
+
const r = await sh($, plan.argv, { cwd: root, env: plan.env })
|
|
180
|
+
return { ok: r.exitCode === 0, text: out(r) || r.stderr.trim() }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runsOf($: any, layout: any): Promise<{ run_id: string; initiative_id: string }[]> {
|
|
184
|
+
if (!layout.stateRoot || !layout.runsFile) return []
|
|
185
|
+
const text = (await readText($, `${layout.stateRoot}/${layout.runsFile}`)) ?? ''
|
|
186
|
+
const rows: any[] = []
|
|
187
|
+
for (const l of text.split('\n')) { try { const r = JSON.parse(l); if (r && typeof r.run_id === 'string' && typeof r.initiative_id === 'string') rows.push(r) } catch { /* skipped */ } }
|
|
188
|
+
return rows
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function acceptedIds($: any, layout: any): Promise<Set<string>> {
|
|
192
|
+
const text = layout.stateRoot ? ((await readText($, `${layout.stateRoot}/acceptances.jsonl`)) ?? '') : ''
|
|
193
|
+
const ids = new Set<string>()
|
|
194
|
+
for (const l of text.split('\n')) { try { const r = JSON.parse(l); if (r && typeof r.run_id === 'string') ids.add(r.run_id) } catch { /* skipped */ } }
|
|
195
|
+
return ids
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The newest run of the session's initiative that has no acceptance row, or null.
|
|
199
|
+
async function buildOffer($: any, message: string) {
|
|
200
|
+
const { root, layout } = await rootAndLayout($)
|
|
201
|
+
const facts = await gitFacts($, root)
|
|
202
|
+
const id = facts.branch?.startsWith('initiative/') ? facts.branch.slice('initiative/'.length) : null
|
|
203
|
+
if (id === null) return null
|
|
204
|
+
const accepted = await acceptedIds($, layout)
|
|
205
|
+
const runs = (await runsOf($, layout)).filter((r) => r.initiative_id === id && !accepted.has(r.run_id))
|
|
206
|
+
const run = runs[runs.length - 1]
|
|
207
|
+
return run ? { run_id: run.run_id, initiative_id: run.initiative_id, message: message.slice(0, 300) } : null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// What the session can see, gathered through `$`. Kept out of the pure modules on purpose.
|
|
211
|
+
async function gather($: any, repaired: string[] = [], repairError: string | null = null): Promise<CockpitStatus> {
|
|
212
|
+
const root: string = await $.session.root()
|
|
213
|
+
const layout = await detectLayout({ exists: (p: string) => $.fs.exists(p) }, root)
|
|
214
|
+
const profileText: string | null = (await $.fs.exists(`${root}/.pmos-profile`))
|
|
215
|
+
? await $.fs.read(`${root}/.pmos-profile`).catch(() => null)
|
|
216
|
+
: null
|
|
217
|
+
const profile = parseProfile(profileText)
|
|
218
|
+
|
|
219
|
+
let skills: SkillsReport | null = null
|
|
220
|
+
if (layout.kind === 'kit' && layout.skillsNative && (await $.fs.exists(layout.skillsNative))) {
|
|
221
|
+
const entries = await $.fs.list(layout.skillsNative)
|
|
222
|
+
const native = []
|
|
223
|
+
for (const e of entries) {
|
|
224
|
+
if (e.kind !== 'dir') continue
|
|
225
|
+
const text = await $.fs.read(`${layout.skillsNative}/${e.name}/SKILL.md`).catch(() => '')
|
|
226
|
+
const real = await $.fs.stat(`${layout.skillsNative}/${e.name}`, { resolve: true }).catch(() => undefined)
|
|
227
|
+
native.push({ name: e.name, descriptionOk: parseDescription(text).ok, realPath: real?.realPath ?? null })
|
|
228
|
+
}
|
|
229
|
+
const linked = new Map()
|
|
230
|
+
if (await $.fs.exists(layout.claudeSkills)) {
|
|
231
|
+
for (const e of await $.fs.list(layout.claudeSkills)) {
|
|
232
|
+
const path = `${layout.claudeSkills}/${e.name}`
|
|
233
|
+
const st = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
|
|
234
|
+
let target: string | null = null
|
|
235
|
+
if (e.isLink) {
|
|
236
|
+
const r = await $.process.run(['readlink', path]).catch(() => undefined)
|
|
237
|
+
target = r && r.exitCode === 0 ? r.stdout.trim() : null
|
|
238
|
+
}
|
|
239
|
+
linked.set(e.name, {
|
|
240
|
+
kind: st?.kind ?? e.kind,
|
|
241
|
+
isLink: Boolean(e.isLink),
|
|
242
|
+
realPath: st?.realPath ?? null,
|
|
243
|
+
target,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
skills = diffSkills(native, linked)
|
|
248
|
+
}
|
|
249
|
+
const facts = layout.kind === 'none' ? { branch: null, defaultBranch: null, commitsAhead: null } : await gitFacts($, root)
|
|
250
|
+
const idGuess = facts.branch?.startsWith('initiative/') ? facts.branch.slice('initiative/'.length) : null
|
|
251
|
+
const rp = idGuess ? recordPath({ ...layout, root }, idGuess) : null
|
|
252
|
+
const initiative = resolveInitiative({
|
|
253
|
+
branch: facts.branch,
|
|
254
|
+
recordExists: rp ? await $.fs.exists(rp).catch(() => null) : null,
|
|
255
|
+
commitsAhead: facts.commitsAhead,
|
|
256
|
+
})
|
|
257
|
+
return {
|
|
258
|
+
layoutKind: layout.kind,
|
|
259
|
+
branch: facts.branch,
|
|
260
|
+
initiative,
|
|
261
|
+
stateRoot: layout.stateRoot,
|
|
262
|
+
profile: profile.profile,
|
|
263
|
+
profileDeclared: profile.declared,
|
|
264
|
+
profileNote: profile.note,
|
|
265
|
+
skills,
|
|
266
|
+
repaired,
|
|
267
|
+
repairError,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The one write this module makes: a symbolic link under .claude/skills for each skill the kit ships
|
|
272
|
+
// and the repository does not register. A name that is not a slug is never used as a path.
|
|
273
|
+
async function repair($: any): Promise<CockpitStatus> {
|
|
274
|
+
const before = await gather($)
|
|
275
|
+
const todo = before.skills?.repairable ?? []
|
|
276
|
+
const root: string = await $.session.root()
|
|
277
|
+
const done: string[] = []
|
|
278
|
+
let error: string | null = null
|
|
279
|
+
if (todo.length > 0) {
|
|
280
|
+
const dir = `${root}/.claude/skills`
|
|
281
|
+
const mk = await $.process.run(['mkdir', '-p', dir])
|
|
282
|
+
if (mk.exitCode !== 0) error = `could not create ${dir}: ${mk.stderr.trim()}`
|
|
283
|
+
else {
|
|
284
|
+
for (const name of todo) {
|
|
285
|
+
if (!NAME_RE.test(name)) continue
|
|
286
|
+
const path = `${dir}/${name}`
|
|
287
|
+
// a dangling link the kit made is replaced; nothing else at that name ever is
|
|
288
|
+
if ((await $.fs.stat(path).catch(() => undefined)) === undefined && (await $.fs.list(dir)).some((e: any) => e.name === name && e.isLink)) {
|
|
289
|
+
await $.process.run(['rm', '-f', path])
|
|
290
|
+
}
|
|
291
|
+
const r = await $.process.run(['ln', '-s', linkTarget(name), path])
|
|
292
|
+
if (r.exitCode === 0) done.push(name)
|
|
293
|
+
else { error = `ln failed for ${name}: ${r.stderr.trim()}`; break }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return gather($, done, error)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const summary = (s: CockpitStatus): string => {
|
|
301
|
+
const k = s.skills
|
|
302
|
+
const lines = [
|
|
303
|
+
`layout ${s.layoutKind}${s.stateRoot ? ` (state: ${s.stateRoot})` : ''}`,
|
|
304
|
+
`profile ${s.profile}${s.profileDeclared ? '' : ` (${s.profileNote ?? 'not declared'}; treated as product-code)`}`,
|
|
305
|
+
`initiative ${s.initiative.text}`,
|
|
306
|
+
]
|
|
307
|
+
if (s.layoutKind !== 'kit') lines.push('skills registration is checked in a vendored kit only')
|
|
308
|
+
else if (k) {
|
|
309
|
+
lines.push(`skills ${k.ok.length} registered, ${k.missing.length} missing${k.repairable.length ? ` (${k.repairable.length} repairable)` : ''}`)
|
|
310
|
+
if (k.blocked.length) lines.push(`blocked ${k.blocked.map((b) => `${b.name} (${b.reason})`).join('; ')}`)
|
|
311
|
+
if (k.badDescription.length) lines.push(`no description ${k.badDescription.join(', ')} (not linked)`)
|
|
312
|
+
} else lines.push('skills no pmos/skills-native folder found')
|
|
313
|
+
if (s.repaired.length) lines.push(`repaired ${s.repaired.join(', ')}. Claude Code reads new links in the next session.`)
|
|
314
|
+
if (s.repairError) lines.push(`error ${s.repairError}`)
|
|
315
|
+
return lines.join('\n')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const hasDrift = (s: CockpitStatus | null): boolean =>
|
|
319
|
+
s?.skills != null && (s.skills.missing.length > 0 || s.skills.blocked.length > 0 || s.skills.badDescription.length > 0)
|
|
320
|
+
|
|
321
|
+
async function pressAccept($: any, label: 'pass' | 'fail') {
|
|
322
|
+
const o: any = await read($, offer)
|
|
323
|
+
if (!o) return
|
|
324
|
+
const r = await writeAcceptance($, o, label, pressNote(o.message, label, o.run_id))
|
|
325
|
+
await update($, offer, () => null)
|
|
326
|
+
$.ui.toast(r.ok ? `pmos-cockpit: recorded ${label} for ${o.run_id}.` : `pmos-cockpit: could not record it. ${r.text}`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const register: Register = (on) => {
|
|
330
|
+
on('session.start', async ($, e, next) => {
|
|
331
|
+
await $.command.register({ name: 'pmos-status', description: 'Show the PMOS layout, profile and skill registration' })
|
|
332
|
+
await $.command.register({ name: 'pmos-repair-skills', description: 'Link the kit skills that are not registered in .claude/skills' })
|
|
333
|
+
await $.command.register({ name: 'pmos-grant', description: 'Mint one single-use grant: /pmos-grant merge:<PR> | publish:<artifact> [minutes] [your words]' })
|
|
334
|
+
await $.command.register({ name: 'pmos-accept', description: 'Record your Acceptance of a run: /pmos-accept <run_id> pass|fail <note>' })
|
|
335
|
+
const s = await gather($)
|
|
336
|
+
await update($, status, () => s)
|
|
337
|
+
if (s.skills && s.skills.missing.length > 0) {
|
|
338
|
+
$.ui.toast(`PMOS: ${s.skills.missing.length} kit skill(s) are not registered. Run /pmos-repair-skills.`)
|
|
339
|
+
}
|
|
340
|
+
return next(e)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
on('command.run', { command: 'pmos-status' }, async ($) => {
|
|
344
|
+
const s = await gather($)
|
|
345
|
+
await update($, status, () => s)
|
|
346
|
+
return { text: summary(s) }
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
on('command.run', { command: 'pmos-repair-skills' }, async ($) => {
|
|
350
|
+
const s = await repair($)
|
|
351
|
+
await update($, status, () => s)
|
|
352
|
+
return { text: summary(s) }
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
// ── the guards ──────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
// Every tool call passes through here. Reading, editing and testing go straight on. If the guard
|
|
358
|
+
// itself fails, names alone decide: a merge, a publish and a write to a ledger or governing file
|
|
359
|
+
// are denied, and an ordinary call is allowed (M17).
|
|
360
|
+
on('tool.call', async ($, e, next) => {
|
|
361
|
+
let verdict: string | null
|
|
362
|
+
try {
|
|
363
|
+
verdict = await judge($, e)
|
|
364
|
+
} catch {
|
|
365
|
+
verdict = looseDeny(e)
|
|
366
|
+
}
|
|
367
|
+
if (verdict !== null) return { deny: verdict }
|
|
368
|
+
return next(e)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// An outward write asks, each time. The answer is a person's press and is stored nowhere.
|
|
372
|
+
on('tool.check', async ($, e, next) => {
|
|
373
|
+
const v = await next(e)
|
|
374
|
+
if (v.decision !== 'allow') return v
|
|
375
|
+
try {
|
|
376
|
+
const f = classifyTool({ tool: e.tool, ...((e.input as object) ?? {}) }, { root: '', layout: null })
|
|
377
|
+
if (f && f.kind === 'outward') {
|
|
378
|
+
return { decision: 'ask', reason: `pmos-cockpit: ${e.tool} writes outside this repository. Confirm this item; a confirmation is not stored as a grant.` }
|
|
379
|
+
}
|
|
380
|
+
} catch { /* not an outward write that could be read: leave the engine's verdict */ }
|
|
381
|
+
return v
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// ── commands that are a person's act ────────────────────────────────────────────────────────
|
|
385
|
+
on('command.run', { command: 'pmos-grant' }, async ($, e) => {
|
|
386
|
+
if (!isPerson(e)) return { text: 'A grant is minted only by your own typed command or press. This run did not come from you, so nothing was written.' }
|
|
387
|
+
const { root, layout } = await rootAndLayout($)
|
|
388
|
+
const plan = planGrant(e.args, { layout, root, pm: await whoAmI($, root) })
|
|
389
|
+
if (!plan.ok) return { text: plan.text }
|
|
390
|
+
const r = await sh($, plan.argv, { cwd: root, env: plan.env })
|
|
391
|
+
return { text: r.exitCode === 0 ? `Granted ${plan.scope.kind}:${plan.scope.target}, single use, ${plan.ttl} minutes. ${out(r).split('\n').pop()}` : `No grant written. ${(r.stderr || out(r)).trim()}` }
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
on('command.run', { command: 'pmos-accept' }, async ($, e) => {
|
|
395
|
+
if (!isPerson(e)) return { text: 'An acceptance is recorded only by your own typed command or press. This run did not come from you, so nothing was written.' }
|
|
396
|
+
const a = parseAccept(e.args)
|
|
397
|
+
if (!a.ok) return { text: a.text }
|
|
398
|
+
const { layout } = await rootAndLayout($)
|
|
399
|
+
const run = (await runsOf($, layout)).find((r) => r.run_id === a.run_id)
|
|
400
|
+
if (!run) return { text: `No acceptance written. Run ${a.run_id} is not in ${layout.stateRoot ?? 'the state root'}.` }
|
|
401
|
+
const r = await writeAcceptance($, run, a.label as 'pass' | 'fail', a.note)
|
|
402
|
+
return { text: r.ok ? `Recorded ${a.label} for ${a.run_id}.` : `No acceptance written. ${r.text}` }
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
// A message that reads as approval gets a button. Nothing is written until a person presses it.
|
|
406
|
+
on('prompt.submit', async ($, e, next) => {
|
|
407
|
+
if (e.origin?.kind === 'composer' && readsAsApproval(e.text)) {
|
|
408
|
+
try { const o = await buildOffer($, e.text); await update($, offer, () => o) } catch { /* no offer */ }
|
|
409
|
+
}
|
|
410
|
+
return next(e)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
414
|
+
const s = await read($, status)
|
|
415
|
+
const o: any = await read($, offer)
|
|
416
|
+
if (o && !e.props.hasSurvey) {
|
|
417
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
418
|
+
return (
|
|
419
|
+
<Box>
|
|
420
|
+
<Text>PMOS: record your acceptance of run {o.run_id}? </Text>
|
|
421
|
+
<Button key="pass" label="Pass" variant="primary" onPress={() => pressAccept($, 'pass')} />
|
|
422
|
+
<Button key="fail" label="Fail" onPress={() => pressAccept($, 'fail')} />
|
|
423
|
+
<Button key="later" label="Not now" onPress={() => update($, offer, () => null)} />
|
|
424
|
+
</Box>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
|
|
428
|
+
if (!hasDrift(s) && s?.initiative?.warn) {
|
|
429
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
430
|
+
return (
|
|
431
|
+
<Box>
|
|
432
|
+
<Text>PMOS: {s.initiative.text}. </Text>
|
|
433
|
+
<Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
|
|
434
|
+
</Box>
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
if (!hasDrift(s)) return next(e)
|
|
438
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
439
|
+
const k = s!.skills!
|
|
440
|
+
const parts = [`${k.missing.length} kit skill(s) are not registered`]
|
|
441
|
+
if (k.blocked.length) parts.push(`${k.blocked.length} blocked by something already in .claude/skills`)
|
|
442
|
+
if (k.badDescription.length) parts.push(`${k.badDescription.length} have no description`)
|
|
443
|
+
return (
|
|
444
|
+
<Box>
|
|
445
|
+
<Text>PMOS: {parts.join(', ')}. </Text>
|
|
446
|
+
{k.repairable.length > 0 && (
|
|
447
|
+
<Button
|
|
448
|
+
key="repair"
|
|
449
|
+
label="Repair"
|
|
450
|
+
onPress={async () => {
|
|
451
|
+
const r = await repair($)
|
|
452
|
+
await update($, status, () => r)
|
|
453
|
+
}}
|
|
454
|
+
/>
|
|
455
|
+
)}
|
|
456
|
+
<Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
|
|
457
|
+
</Box>
|
|
458
|
+
)
|
|
459
|
+
})
|
|
460
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type SkillsReport = {
|
|
2
|
+
ok: string[]
|
|
3
|
+
missing: string[]
|
|
4
|
+
repairable: string[]
|
|
5
|
+
blocked: { name: string; reason: string }[]
|
|
6
|
+
badDescription: string[]
|
|
7
|
+
badName: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type InitiativeFact = {
|
|
11
|
+
id: string | null
|
|
12
|
+
state: 'ok' | 'no-initiative' | 'no-record' | 'unsafe-id' | 'detached'
|
|
13
|
+
warn: boolean
|
|
14
|
+
text: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CockpitStatus = {
|
|
18
|
+
layoutKind: 'kit' | 'self' | 'none'
|
|
19
|
+
branch: string | null
|
|
20
|
+
initiative: InitiativeFact
|
|
21
|
+
stateRoot: string | null
|
|
22
|
+
profile: string
|
|
23
|
+
profileDeclared: boolean
|
|
24
|
+
profileNote: string | null
|
|
25
|
+
skills: SkillsReport | null
|
|
26
|
+
repaired: string[]
|
|
27
|
+
repairError: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare module 'claude-code' {
|
|
31
|
+
interface PluginState {
|
|
32
|
+
'pmos-cockpit': { status: CockpitStatus | null; isHidden: boolean; offer: { run_id: string; initiative_id: string; message: string } | null }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-04T08:22:32Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-04T08:22:32Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|