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,634 @@
|
|
|
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
|
+
import { PROCESS_LIMIT_MS, isFresh, parseFactsOutput, sliceRows, summarize } from './lib/session-facts.mjs'
|
|
13
|
+
import { clean, formatStatus } from './lib/status-line.mjs'
|
|
14
|
+
import { SECTION_ID, buildSection } from './lib/prompt-section.mjs'
|
|
15
|
+
import { PROBES, aheadProbe, interpretGit } from './lib/probes.mjs'
|
|
16
|
+
|
|
17
|
+
const status = atom({ plugin: 'pmos-cockpit', key: 'status' } as const, null)
|
|
18
|
+
const isHidden = atom({ plugin: 'pmos-cockpit', key: 'isHidden' } as const, false)
|
|
19
|
+
// A run the person's last message may have accepted. It is an offer to press, never a record.
|
|
20
|
+
const offer = atom({ plugin: 'pmos-cockpit', key: 'offer' } as const, null)
|
|
21
|
+
// What the status line, the band, the prompt section and the slice share: one read of the checkout and
|
|
22
|
+
// of the ladder. `factsCache` holds the last ladder read, for one initiative at one commit.
|
|
23
|
+
const view = atom({ plugin: 'pmos-cockpit', key: 'view' } as const, null)
|
|
24
|
+
const factsCache = atom({ plugin: 'pmos-cockpit', key: 'factsCache' } as const, null)
|
|
25
|
+
// The width the person's terminal draws the band at, seen at the last render. Lost on a reload, which
|
|
26
|
+
// only returns the status line to its default width.
|
|
27
|
+
let lastColumns = 0
|
|
28
|
+
|
|
29
|
+
// ── helpers for the guards and commands ───────────────────────────────────────────────────────────
|
|
30
|
+
const out = (r: any): string => (r && typeof r.stdout === 'string' ? r.stdout.trim() : '')
|
|
31
|
+
async function sh($: any, argv: string[], init?: any): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
32
|
+
try { return await $.process.run(argv, init) } catch (e: any) { return { exitCode: 1, stdout: '', stderr: String(e?.message ?? e) } }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function rootAndLayout($: any) {
|
|
36
|
+
const root: string = await $.session.root()
|
|
37
|
+
const layout = await detectLayout({ exists: (p: string) => $.fs.exists(p) }, root)
|
|
38
|
+
return { root, layout: { ...layout, root } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// What git says about the session's checkout. A linked worktree answers for itself.
|
|
42
|
+
async function gitFacts($: any, root: string) {
|
|
43
|
+
const b = await sh($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
|
|
44
|
+
const branch = b.exitCode === 0 && out(b) !== '' ? out(b) : null
|
|
45
|
+
const d = await sh($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
|
|
46
|
+
const defaultBranch = d.exitCode === 0 && out(d) !== '' ? out(d).replace(/^origin\//, '') : null
|
|
47
|
+
let commitsAhead: number | null = null
|
|
48
|
+
if (branch !== null) {
|
|
49
|
+
const c = await sh($, ['git', 'rev-list', '--count', `${defaultBranch ?? 'main'}..HEAD`], { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
|
|
50
|
+
commitsAhead = c.exitCode === 0 && /^\d+$/.test(out(c)) ? Number(out(c)) : null
|
|
51
|
+
}
|
|
52
|
+
return { branch, defaultBranch, commitsAhead }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isPerson = (e: any): boolean => e?.origin?.kind === 'composer' || e?.origin?.kind === 'bridge'
|
|
56
|
+
|
|
57
|
+
async function readText($: any, path: string): Promise<string | null> {
|
|
58
|
+
try { return (await $.fs.exists(path)) ? await $.fs.read(path) : null } catch { return null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function whoAmI($: any, root: string): Promise<string> {
|
|
62
|
+
const r = await sh($, ['git', 'config', 'user.name'], { cwd: root })
|
|
63
|
+
return r.exitCode === 0 && out(r) !== '' ? out(r) : 'pm'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The local gate for one initiative. It reports what it ran, and what it cannot see.
|
|
67
|
+
async function localGate($: any, layout: any, root: string, id: string | null, branch: string | null) {
|
|
68
|
+
if (id === null) return { ran: false, failed: false, text: 'Local gate not run: the branch names no initiative.' }
|
|
69
|
+
const lines: string[] = []
|
|
70
|
+
let failed = false
|
|
71
|
+
let ran = false
|
|
72
|
+
const ws = layout.kind === 'kit' ? `${root}/pmos/scripts/workflow-state.js` : `${root}/scripts/workflow-state.js`
|
|
73
|
+
if (await $.fs.exists(ws)) {
|
|
74
|
+
const r = await sh($, ['node', ws, '--gate', id], { cwd: root })
|
|
75
|
+
ran = true
|
|
76
|
+
if (r.exitCode !== 0) failed = true
|
|
77
|
+
lines.push(`workflow-state --gate ${id}: ${r.exitCode === 0 ? 'passed' : `FAILED. ${out(r).split('\n').slice(-3).join(' ')}`}`)
|
|
78
|
+
}
|
|
79
|
+
if (layout.kind === 'kit' && (await $.fs.exists(`${root}/pmos/scripts/gate-initiative.sh`))) {
|
|
80
|
+
const r = await sh($, ['bash', `${root}/pmos/scripts/gate-initiative.sh`], { cwd: root, env: { HEAD_REF: branch ?? '', PR_BODY: '', PR_LABELS: '', KIT_ROOT: 'pmos' } })
|
|
81
|
+
ran = true
|
|
82
|
+
if (r.exitCode !== 0) failed = true
|
|
83
|
+
lines.push(`gate-initiative.sh: ${r.exitCode === 0 ? 'passed' : `FAILED. ${(out(r) || r.stderr).split('\n').slice(-2).join(' ')}`}`)
|
|
84
|
+
}
|
|
85
|
+
if (!ran) lines.push('Local gate not run: no gate script is installed here.')
|
|
86
|
+
lines.push('This run does not see: anchors (gate-initiative.sh does not resolve them; CI does), or anything on a remote.')
|
|
87
|
+
return { ran, failed, text: lines.join(' ') }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Candidate artifact names for `npm publish`: the package.json beside the target, then the session root.
|
|
91
|
+
async function publishNames($: any, root: string, f: any): Promise<string[]> {
|
|
92
|
+
const names: string[] = []
|
|
93
|
+
const dirs = typeof f.target === 'string' && !f.target.startsWith('-') ? [f.target.startsWith('/') ? f.target : `${root}/${f.target}`] : []
|
|
94
|
+
dirs.push(root)
|
|
95
|
+
for (const d of dirs) {
|
|
96
|
+
const text = await readText($, `${d}/package.json`)
|
|
97
|
+
if (text === null) continue
|
|
98
|
+
try { const n = JSON.parse(text).name; if (typeof n === 'string') names.push(n) } catch { /* not JSON: no name */ }
|
|
99
|
+
}
|
|
100
|
+
return [...new Set(names)]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One tool call, judged. Returns a deny text, or null to let it through. It also spends the grants
|
|
104
|
+
// it relied on, so a single-use grant is used up before the action runs.
|
|
105
|
+
async function judge($: any, e: any): Promise<string | null> {
|
|
106
|
+
const tool = String(e.tool)
|
|
107
|
+
if (tool !== 'Bash' && !isFileWriteTool(tool) && !tool.startsWith('mcp__')) return null
|
|
108
|
+
const { root, layout } = await rootAndLayout($)
|
|
109
|
+
const needsBranch = (tool === 'Bash' && /\bpush\b/.test(String(e.command ?? ''))) || tool.startsWith('mcp__github')
|
|
110
|
+
let facts = needsBranch ? await gitFacts($, root) : { branch: null, defaultBranch: null, commitsAhead: null }
|
|
111
|
+
const ctx = { root, layout, branch: facts.branch, defaultBranch: facts.defaultBranch }
|
|
112
|
+
const settingsPath = `${root}/.claude/settings.json`
|
|
113
|
+
const settingsText = isFileWriteTool(tool) ? await readText($, settingsPath) : null
|
|
114
|
+
const findings = findingsFor(e, ctx, settingsText)
|
|
115
|
+
// A path that is a link to a ledger is the ledger. Judge where each path really leads, as well.
|
|
116
|
+
if (isFileWriteTool(tool)) {
|
|
117
|
+
const real: any = { ...e }
|
|
118
|
+
let moved = false
|
|
119
|
+
for (const k of PATH_KEYS) {
|
|
120
|
+
const p = e[k]
|
|
121
|
+
if (typeof p !== 'string' || p === '') continue
|
|
122
|
+
const abs = p.startsWith('/') ? p : `${root}/${p}`
|
|
123
|
+
const st = await $.fs.stat(abs, { resolve: true }).catch(() => undefined)
|
|
124
|
+
if (st?.realPath && st.realPath !== abs) { real[k] = st.realPath; moved = true }
|
|
125
|
+
}
|
|
126
|
+
if (moved) findings.push(...findingsFor(real, ctx, settingsText))
|
|
127
|
+
}
|
|
128
|
+
if (findings.length === 0) return null
|
|
129
|
+
|
|
130
|
+
for (const kind of ['accept-grants', 'ledger', 'governing', 'cloud']) {
|
|
131
|
+
const f = findings.find((x) => x.kind === kind)
|
|
132
|
+
if (f) return denyMessage(f, ctx)
|
|
133
|
+
}
|
|
134
|
+
const merges = findings.filter((x) => x.kind === 'merge')
|
|
135
|
+
if (merges.length === 0) return null // an outward write: tool.check asks
|
|
136
|
+
|
|
137
|
+
// the gate needs the branch, whatever the action was
|
|
138
|
+
if (!needsBranch) facts = await gitFacts($, root)
|
|
139
|
+
|
|
140
|
+
const grantsFile = layout.stateRoot ? `${layout.stateRoot}/grants.jsonl` : null
|
|
141
|
+
const text = grantsFile ? ((await readText($, grantsFile)) ?? '') : ''
|
|
142
|
+
const now: number = await $.clock.now()
|
|
143
|
+
const used: { id: string; action: string }[] = []
|
|
144
|
+
for (const f of merges) {
|
|
145
|
+
if (grantsFile === null) return denyMerge(f, 'there is no PMOS state here to hold a grant.')
|
|
146
|
+
let checks: { kind: string; target: string }[]
|
|
147
|
+
if (f.publish) {
|
|
148
|
+
const names = await publishNames($, root, f)
|
|
149
|
+
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.')
|
|
150
|
+
checks = names.map((n) => ({ kind: 'publish', target: n }))
|
|
151
|
+
} else {
|
|
152
|
+
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.`)
|
|
153
|
+
checks = [{ kind: 'merge', target: String(f.target) }]
|
|
154
|
+
}
|
|
155
|
+
let hit: any = null
|
|
156
|
+
let why = ''
|
|
157
|
+
for (const c of checks) {
|
|
158
|
+
const r = checkGrant(text, c, now)
|
|
159
|
+
if (r.ok) { hit = r.grant; break }
|
|
160
|
+
why = r.reason
|
|
161
|
+
}
|
|
162
|
+
if (hit === null) return denyMerge(f.publish ? { ...f, target: checks[0].target } : f, `${why}`)
|
|
163
|
+
used.push({ id: hit.id, action: `${f.action}${f.target ? ` ${f.target}` : ''}` })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// every grant is live; now the local gate, which can still stop the action
|
|
167
|
+
const initiative = resolveInitiative({
|
|
168
|
+
branch: facts.branch,
|
|
169
|
+
recordExists: facts.branch ? await $.fs.exists(recordPath(layout, facts.branch.replace(/^initiative\//, '')) ?? '/nonexistent') : null,
|
|
170
|
+
commitsAhead: facts.commitsAhead,
|
|
171
|
+
})
|
|
172
|
+
const gate = await localGate($, layout, root, initiative.id, facts.branch)
|
|
173
|
+
$.ui.toast(`pmos-cockpit: ${gate.text}`)
|
|
174
|
+
if (gate.failed) return `Blocked by pmos-cockpit: the local gate failed, and a grant does not override it. ${gate.text}`
|
|
175
|
+
|
|
176
|
+
const w = writerOf(layout, root)
|
|
177
|
+
if (w === null) return denyMerge(merges[0], 'no writer is installed to record the use of the grant.')
|
|
178
|
+
for (const u of used) {
|
|
179
|
+
const r = await sh($, [...w.argv, 'grant-use', u.id, u.action], { cwd: root, env: w.env })
|
|
180
|
+
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.`
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Accepting a run: the same writer, whichever layout. Called only from a person's command or press.
|
|
186
|
+
async function writeAcceptance($: any, run: { run_id: string; initiative_id: string }, label: 'pass' | 'fail', note: string): Promise<{ ok: boolean; text: string }> {
|
|
187
|
+
const { root, layout } = await rootAndLayout($)
|
|
188
|
+
const plan = planAcceptance({ layout, root, run, label, note, pm: await whoAmI($, root), atIso: new Date(await $.clock.now()).toISOString() })
|
|
189
|
+
if (!plan.ok) return { ok: false, text: plan.text }
|
|
190
|
+
const r = await sh($, plan.argv, { cwd: root, env: plan.env })
|
|
191
|
+
return { ok: r.exitCode === 0, text: out(r) || r.stderr.trim() }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function runsOf($: any, layout: any): Promise<{ run_id: string; initiative_id: string }[]> {
|
|
195
|
+
if (!layout.stateRoot || !layout.runsFile) return []
|
|
196
|
+
const text = (await readText($, `${layout.stateRoot}/${layout.runsFile}`)) ?? ''
|
|
197
|
+
const rows: any[] = []
|
|
198
|
+
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 */ } }
|
|
199
|
+
return rows
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function acceptedIds($: any, layout: any): Promise<Set<string>> {
|
|
203
|
+
const text = layout.stateRoot ? ((await readText($, `${layout.stateRoot}/acceptances.jsonl`)) ?? '') : ''
|
|
204
|
+
const ids = new Set<string>()
|
|
205
|
+
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 */ } }
|
|
206
|
+
return ids
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The newest run of the session's initiative that has no acceptance row, or null.
|
|
210
|
+
async function buildOffer($: any, message: string) {
|
|
211
|
+
const { root, layout } = await rootAndLayout($)
|
|
212
|
+
const facts = await gitFacts($, root)
|
|
213
|
+
const id = facts.branch?.startsWith('initiative/') ? facts.branch.slice('initiative/'.length) : null
|
|
214
|
+
if (id === null) return null
|
|
215
|
+
const accepted = await acceptedIds($, layout)
|
|
216
|
+
const runs = (await runsOf($, layout)).filter((r) => r.initiative_id === id && !accepted.has(r.run_id))
|
|
217
|
+
const run = runs[runs.length - 1]
|
|
218
|
+
return run ? { run_id: run.run_id, initiative_id: run.initiative_id, message: message.slice(0, 300) } : null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// What the session can see, gathered through `$`. Kept out of the pure modules on purpose.
|
|
222
|
+
async function gather($: any, repaired: string[] = [], repairError: string | null = null): Promise<CockpitStatus> {
|
|
223
|
+
const root: string = await $.session.root()
|
|
224
|
+
const layout = await detectLayout({ exists: (p: string) => $.fs.exists(p) }, root)
|
|
225
|
+
const profileText: string | null = (await $.fs.exists(`${root}/.pmos-profile`))
|
|
226
|
+
? await $.fs.read(`${root}/.pmos-profile`).catch(() => null)
|
|
227
|
+
: null
|
|
228
|
+
const profile = parseProfile(profileText)
|
|
229
|
+
|
|
230
|
+
let skills: SkillsReport | null = null
|
|
231
|
+
if (layout.kind === 'kit' && layout.skillsNative && (await $.fs.exists(layout.skillsNative))) {
|
|
232
|
+
const entries = await $.fs.list(layout.skillsNative)
|
|
233
|
+
const native = []
|
|
234
|
+
for (const e of entries) {
|
|
235
|
+
if (e.kind !== 'dir') continue
|
|
236
|
+
const text = await $.fs.read(`${layout.skillsNative}/${e.name}/SKILL.md`).catch(() => '')
|
|
237
|
+
const real = await $.fs.stat(`${layout.skillsNative}/${e.name}`, { resolve: true }).catch(() => undefined)
|
|
238
|
+
native.push({ name: e.name, descriptionOk: parseDescription(text).ok, realPath: real?.realPath ?? null })
|
|
239
|
+
}
|
|
240
|
+
const linked = new Map()
|
|
241
|
+
if (await $.fs.exists(layout.claudeSkills)) {
|
|
242
|
+
for (const e of await $.fs.list(layout.claudeSkills)) {
|
|
243
|
+
const path = `${layout.claudeSkills}/${e.name}`
|
|
244
|
+
const st = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
|
|
245
|
+
let target: string | null = null
|
|
246
|
+
if (e.isLink) {
|
|
247
|
+
const r = await $.process.run(['readlink', path]).catch(() => undefined)
|
|
248
|
+
target = r && r.exitCode === 0 ? r.stdout.trim() : null
|
|
249
|
+
}
|
|
250
|
+
linked.set(e.name, {
|
|
251
|
+
kind: st?.kind ?? e.kind,
|
|
252
|
+
isLink: Boolean(e.isLink),
|
|
253
|
+
realPath: st?.realPath ?? null,
|
|
254
|
+
target,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
skills = diffSkills(native, linked)
|
|
259
|
+
}
|
|
260
|
+
const facts = layout.kind === 'none' ? { branch: null, defaultBranch: null, commitsAhead: null } : await gitFacts($, root)
|
|
261
|
+
const idGuess = facts.branch?.startsWith('initiative/') ? facts.branch.slice('initiative/'.length) : null
|
|
262
|
+
const rp = idGuess ? recordPath({ ...layout, root }, idGuess) : null
|
|
263
|
+
const initiative = resolveInitiative({
|
|
264
|
+
branch: facts.branch,
|
|
265
|
+
recordExists: rp ? await $.fs.exists(rp).catch(() => null) : null,
|
|
266
|
+
commitsAhead: facts.commitsAhead,
|
|
267
|
+
})
|
|
268
|
+
return {
|
|
269
|
+
layoutKind: layout.kind,
|
|
270
|
+
branch: facts.branch,
|
|
271
|
+
initiative,
|
|
272
|
+
stateRoot: layout.stateRoot,
|
|
273
|
+
profile: profile.profile,
|
|
274
|
+
profileDeclared: profile.declared,
|
|
275
|
+
profileNote: profile.note,
|
|
276
|
+
skills,
|
|
277
|
+
repaired,
|
|
278
|
+
repairError,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The one write this module makes: a symbolic link under .claude/skills for each skill the kit ships
|
|
283
|
+
// and the repository does not register. A name that is not a slug is never used as a path.
|
|
284
|
+
async function repair($: any): Promise<CockpitStatus> {
|
|
285
|
+
const before = await gather($)
|
|
286
|
+
const todo = before.skills?.repairable ?? []
|
|
287
|
+
const root: string = await $.session.root()
|
|
288
|
+
const done: string[] = []
|
|
289
|
+
let error: string | null = null
|
|
290
|
+
if (todo.length > 0) {
|
|
291
|
+
const dir = `${root}/.claude/skills`
|
|
292
|
+
const mk = await $.process.run(['mkdir', '-p', dir])
|
|
293
|
+
if (mk.exitCode !== 0) error = `could not create ${dir}: ${mk.stderr.trim()}`
|
|
294
|
+
else {
|
|
295
|
+
for (const name of todo) {
|
|
296
|
+
if (!NAME_RE.test(name)) continue
|
|
297
|
+
const path = `${dir}/${name}`
|
|
298
|
+
// a dangling link the kit made is replaced; nothing else at that name ever is
|
|
299
|
+
if ((await $.fs.stat(path).catch(() => undefined)) === undefined && (await $.fs.list(dir)).some((e: any) => e.name === name && e.isLink)) {
|
|
300
|
+
await $.process.run(['rm', '-f', path])
|
|
301
|
+
}
|
|
302
|
+
const r = await $.process.run(['ln', '-s', linkTarget(name), path])
|
|
303
|
+
if (r.exitCode === 0) done.push(name)
|
|
304
|
+
else { error = `ln failed for ${name}: ${r.stderr.trim()}`; break }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return gather($, done, error)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const baseName = (p: string): string => p.split('/').filter(Boolean).pop() ?? p
|
|
312
|
+
|
|
313
|
+
// The session view (M11): one read of the checkout and, at most once a minute for each commit, one read
|
|
314
|
+
// of the ladder for the one initiative the branch names. Read-only, bounded, and never in the way: a
|
|
315
|
+
// failure leaves the last view as it was, and nothing here reaches the turn that called it.
|
|
316
|
+
async function refreshView($: any, force = false): Promise<void> {
|
|
317
|
+
try {
|
|
318
|
+
const started: number = await $.clock.now()
|
|
319
|
+
const { root, layout } = await rootAndLayout($)
|
|
320
|
+
if (layout.kind === 'none') { $.ui.status(undefined); await update($, view, () => null); return }
|
|
321
|
+
// every process has its own limit, and the whole refresh has one: once it is spent, nothing more runs
|
|
322
|
+
const run = async (argv: string[]) =>
|
|
323
|
+
(await $.clock.now()) - started > PROCESS_LIMIT_MS ? { exitCode: 1, stdout: '', stderr: 'the view ran out of time' } : sh($, argv, { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
|
|
324
|
+
|
|
325
|
+
const r: Record<string, any> = {}
|
|
326
|
+
for (const k of ['inside', 'branch', 'sha', 'dirs', 'defaultBranch'] as const) r[k] = await run(PROBES[k])
|
|
327
|
+
let g = interpretGit(r)
|
|
328
|
+
if (!g.noGit && g.branch && !g.branch.startsWith('initiative/')) { r.ahead = await run(aheadProbe(g.defaultBranch)); g = interpretGit(r) }
|
|
329
|
+
|
|
330
|
+
const idGuess = g.branch?.startsWith('initiative/') ? g.branch.slice('initiative/'.length) : null
|
|
331
|
+
const rp = idGuess ? recordPath(layout, idGuess) : null
|
|
332
|
+
const initiative = resolveInitiative({
|
|
333
|
+
branch: g.noGit ? null : g.branch,
|
|
334
|
+
recordExists: rp ? await $.fs.exists(rp) : null,
|
|
335
|
+
commitsAhead: g.commitsAhead,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
let summary: any = null
|
|
339
|
+
let unavailable: string | null = null
|
|
340
|
+
let links: { record: string | null; prd: string | null; rubric: string | null } | null = null
|
|
341
|
+
if (!g.noGit && initiative.state === 'ok' && initiative.id) {
|
|
342
|
+
const now: number = await $.clock.now()
|
|
343
|
+
const sha = g.sha ?? 'none'
|
|
344
|
+
const cached: any = await read($, factsCache)
|
|
345
|
+
if (!force && isFresh(cached, initiative.id, sha, now)) summary = cached.summary
|
|
346
|
+
else {
|
|
347
|
+
const ws = layout.kind === 'kit' ? `${root}/pmos/scripts/workflow-state.js` : `${root}/scripts/workflow-state.js`
|
|
348
|
+
if (!(await $.fs.exists(ws))) unavailable = 'workflow-state.js is not installed here'
|
|
349
|
+
else {
|
|
350
|
+
const p = parseFactsOutput(await run(['node', ws, initiative.id, '--json']))
|
|
351
|
+
if (p.ok) {
|
|
352
|
+
summary = summarize(p.json)
|
|
353
|
+
await update($, factsCache, () => ({ id: initiative.id, sha, at: now, summary }))
|
|
354
|
+
} else unavailable = p.reason
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// the record's own paths, for the slice: shown to the person, never given to the model
|
|
358
|
+
const text = rp ? await readText($, rp) : null
|
|
359
|
+
const field = (name: string): string | null => {
|
|
360
|
+
const m = text === null ? null : new RegExp(`^${name}:[ \\t]*(\\S[^\\n]*)$`, 'm').exec(text)
|
|
361
|
+
return m ? clean(m[1].replace(/^["']|["']$/g, ''), 80) : null
|
|
362
|
+
}
|
|
363
|
+
links = { record: rp ? clean(rp.replace(`${root}/`, ''), 80) : null, prd: field('prd_path'), rubric: field('rubric_path') }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const text = formatStatus({
|
|
367
|
+
repo: baseName(root),
|
|
368
|
+
worktree: g.linked ? baseName(root) : null,
|
|
369
|
+
branch: g.branch,
|
|
370
|
+
noGit: g.noGit,
|
|
371
|
+
initiative,
|
|
372
|
+
facts: summary,
|
|
373
|
+
width: lastColumns > 0 ? lastColumns : undefined,
|
|
374
|
+
})
|
|
375
|
+
$.ui.status(text)
|
|
376
|
+
const stateRel = layout.stateRoot ? layout.stateRoot.replace(`${root}/`, '') : null
|
|
377
|
+
await update($, view, () => ({ text, initiative, summary, unavailable, links, sha: g.sha, at: started, stateRel, noGit: g.noGit }))
|
|
378
|
+
} catch {
|
|
379
|
+
/* the view is a convenience: on any failure the last view stays, and the turn is not told */
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const summary = (s: CockpitStatus): string => {
|
|
384
|
+
const k = s.skills
|
|
385
|
+
const lines = [
|
|
386
|
+
`layout ${s.layoutKind}${s.stateRoot ? ` (state: ${s.stateRoot})` : ''}`,
|
|
387
|
+
`profile ${s.profile}${s.profileDeclared ? '' : ` (${s.profileNote ?? 'not declared'}; treated as product-code)`}`,
|
|
388
|
+
`initiative ${s.initiative.text}`,
|
|
389
|
+
]
|
|
390
|
+
if (s.layoutKind !== 'kit') lines.push('skills registration is checked in a vendored kit only')
|
|
391
|
+
else if (k) {
|
|
392
|
+
lines.push(`skills ${k.ok.length} registered, ${k.missing.length} missing${k.repairable.length ? ` (${k.repairable.length} repairable)` : ''}`)
|
|
393
|
+
if (k.blocked.length) lines.push(`blocked ${k.blocked.map((b) => `${b.name} (${b.reason})`).join('; ')}`)
|
|
394
|
+
if (k.badDescription.length) lines.push(`no description ${k.badDescription.join(', ')} (not linked)`)
|
|
395
|
+
} else lines.push('skills no pmos/skills-native folder found')
|
|
396
|
+
if (s.repaired.length) lines.push(`repaired ${s.repaired.join(', ')}. Claude Code reads new links in the next session.`)
|
|
397
|
+
if (s.repairError) lines.push(`error ${s.repairError}`)
|
|
398
|
+
return lines.join('\n')
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const hasDrift = (s: CockpitStatus | null): boolean =>
|
|
402
|
+
s?.skills != null && (s.skills.missing.length > 0 || s.skills.blocked.length > 0 || s.skills.badDescription.length > 0)
|
|
403
|
+
|
|
404
|
+
async function pressAccept($: any, label: 'pass' | 'fail') {
|
|
405
|
+
const o: any = await read($, offer)
|
|
406
|
+
if (!o) return
|
|
407
|
+
const r = await writeAcceptance($, o, label, pressNote(o.message, label, o.run_id))
|
|
408
|
+
await update($, offer, () => null)
|
|
409
|
+
$.ui.toast(r.ok ? `pmos-cockpit: recorded ${label} for ${o.run_id}.` : `pmos-cockpit: could not record it. ${r.text}`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export const register: Register = (on) => {
|
|
413
|
+
on('session.start', async ($, e, next) => {
|
|
414
|
+
await $.command.register({ name: 'pmos-status', description: 'Show the PMOS layout, profile and skill registration' })
|
|
415
|
+
await $.command.register({ name: 'pmos-repair-skills', description: 'Link the kit skills that are not registered in .claude/skills' })
|
|
416
|
+
await $.command.register({ name: 'pmos-grant', description: 'Mint one single-use grant: /pmos-grant merge:<PR> | publish:<artifact> [minutes] [your words]' })
|
|
417
|
+
await $.command.register({ name: 'pmos-slice', description: 'Open the initiative slice: the ladder, step by step, for the branch you are on' })
|
|
418
|
+
await $.command.register({ name: 'pmos-accept', description: 'Record your Acceptance of a run: /pmos-accept <run_id> pass|fail <note>' })
|
|
419
|
+
const s = await gather($)
|
|
420
|
+
await update($, status, () => s)
|
|
421
|
+
await refreshView($)
|
|
422
|
+
if (s.skills && s.skills.missing.length > 0) {
|
|
423
|
+
$.ui.toast(`PMOS: ${s.skills.missing.length} kit skill(s) are not registered. Run /pmos-repair-skills.`)
|
|
424
|
+
}
|
|
425
|
+
return next(e)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
on('command.run', { command: 'pmos-status' }, async ($) => {
|
|
429
|
+
const s = await gather($)
|
|
430
|
+
await update($, status, () => s)
|
|
431
|
+
await refreshView($, true)
|
|
432
|
+
const v: any = await read($, view)
|
|
433
|
+
const lines = [summary(s)]
|
|
434
|
+
if (v?.summary) lines.push(`stage ${v.summary.stage}${v.summary.next ? ` (next: ${v.summary.next.key})` : ''}${v.summary.gateFailures.length ? `; blocking: ${v.summary.gateFailures.join(', ')}` : ''}`)
|
|
435
|
+
else if (v?.unavailable) lines.push(`stage unavailable: ${v.unavailable}`)
|
|
436
|
+
return { text: lines.join('\n') }
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
// The slice opens only when asked. Nothing in this module opens it unasked (modes, M12, come later).
|
|
440
|
+
on('command.run', { command: 'pmos-slice' }, async ($) => {
|
|
441
|
+
await refreshView($)
|
|
442
|
+
await $.ui.open({ id: 'pmos-slice', title: 'PMOS initiative' })
|
|
443
|
+
return { text: 'Opened the initiative slice. Esc closes it.' }
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
on('ui.render', { component: 'Pane', requestId: 'pmos-slice' }, async ($, e) => {
|
|
447
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
448
|
+
const v: any = await read($, view)
|
|
449
|
+
if (!v || !v.initiative || v.initiative.id === null) {
|
|
450
|
+
return (
|
|
451
|
+
<Box>
|
|
452
|
+
<Text>{clean(v?.noGit ? 'No git repository here.' : v?.initiative?.text ?? 'No PMOS state here.', 120)}</Text>
|
|
453
|
+
</Box>
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
const room = Math.max(4, (e.viewport?.rows ?? 24) - 8)
|
|
457
|
+
const rows = v.summary ? sliceRows(v.summary).slice(0, room) : []
|
|
458
|
+
const when = new Date(v.at).toISOString().slice(11, 19)
|
|
459
|
+
return (
|
|
460
|
+
<Box>
|
|
461
|
+
<Text>
|
|
462
|
+
{v.initiative.id} · {v.summary ? `${v.summary.lane} · ${v.summary.stage}` : 'stage unavailable'}
|
|
463
|
+
</Text>
|
|
464
|
+
{v.unavailable && <Text>{clean(v.unavailable, 80)}</Text>}
|
|
465
|
+
{rows.map((r: any) => (
|
|
466
|
+
<Text key={r.key}>
|
|
467
|
+
{r.glyph} {r.label}
|
|
468
|
+
</Text>
|
|
469
|
+
))}
|
|
470
|
+
{v.links?.record && <Text>record {v.links.record}</Text>}
|
|
471
|
+
{v.links?.prd && <Text>prd {v.links.prd}</Text>}
|
|
472
|
+
{v.links?.rubric && <Text>rubric {v.links.rubric}</Text>}
|
|
473
|
+
<Text>
|
|
474
|
+
derived {v.sha ? String(v.sha).slice(0, 7) : 'no commit'} at {when}Z
|
|
475
|
+
</Text>
|
|
476
|
+
</Box>
|
|
477
|
+
)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// The status line follows the branch: refreshed at the end of each turn, never per tool call.
|
|
481
|
+
on('turn.complete', async ($, e, next) => {
|
|
482
|
+
await refreshView($)
|
|
483
|
+
return next(e)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// The one thing this mod says to the model on its own. A fixed template over allow-listed fields,
|
|
487
|
+
// read from the last view, so composing the prompt runs no process.
|
|
488
|
+
on('prompt.compose', async ($, e, next) => {
|
|
489
|
+
const r = await next(e)
|
|
490
|
+
try {
|
|
491
|
+
const v: any = await read($, view)
|
|
492
|
+
if (!v?.initiative?.id) return r
|
|
493
|
+
const text = buildSection({
|
|
494
|
+
id: v.initiative.id,
|
|
495
|
+
lane: v.summary?.lane,
|
|
496
|
+
anchor: v.summary?.anchor,
|
|
497
|
+
stage: v.summary?.stage,
|
|
498
|
+
nextKey: v.summary?.next?.key ?? null,
|
|
499
|
+
stateRoot: v.stateRel,
|
|
500
|
+
profile: v.summary?.profile,
|
|
501
|
+
})
|
|
502
|
+
return text === null ? r : { ...r, sections: [...r.sections, { id: SECTION_ID, text, scope: 'session' as const }] }
|
|
503
|
+
} catch {
|
|
504
|
+
return r
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
on('command.run', { command: 'pmos-repair-skills' }, async ($) => {
|
|
509
|
+
const s = await repair($)
|
|
510
|
+
await update($, status, () => s)
|
|
511
|
+
return { text: summary(s) }
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
// ── the guards ──────────────────────────────────────────────────────────────────────────────
|
|
516
|
+
// Every tool call passes through here. Reading, editing and testing go straight on. If the guard
|
|
517
|
+
// itself fails, names alone decide: a merge, a publish and a write to a ledger or governing file
|
|
518
|
+
// are denied, and an ordinary call is allowed (M17).
|
|
519
|
+
on('tool.call', async ($, e, next) => {
|
|
520
|
+
let verdict: string | null
|
|
521
|
+
try {
|
|
522
|
+
verdict = await judge($, e)
|
|
523
|
+
} catch {
|
|
524
|
+
verdict = looseDeny(e)
|
|
525
|
+
}
|
|
526
|
+
if (verdict !== null) return { deny: verdict }
|
|
527
|
+
return next(e)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
// An outward write asks, each time. The answer is a person's press and is stored nowhere.
|
|
531
|
+
on('tool.check', async ($, e, next) => {
|
|
532
|
+
const v = await next(e)
|
|
533
|
+
if (v.decision !== 'allow') return v
|
|
534
|
+
try {
|
|
535
|
+
const f = classifyTool({ tool: e.tool, ...((e.input as object) ?? {}) }, { root: '', layout: null })
|
|
536
|
+
if (f && f.kind === 'outward') {
|
|
537
|
+
return { decision: 'ask', reason: `pmos-cockpit: ${e.tool} writes outside this repository. Confirm this item; a confirmation is not stored as a grant.` }
|
|
538
|
+
}
|
|
539
|
+
} catch { /* not an outward write that could be read: leave the engine's verdict */ }
|
|
540
|
+
return v
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
// ── commands that are a person's act ────────────────────────────────────────────────────────
|
|
544
|
+
on('command.run', { command: 'pmos-grant' }, async ($, e) => {
|
|
545
|
+
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.' }
|
|
546
|
+
const { root, layout } = await rootAndLayout($)
|
|
547
|
+
const plan = planGrant(e.args, { layout, root, pm: await whoAmI($, root) })
|
|
548
|
+
if (!plan.ok) return { text: plan.text }
|
|
549
|
+
const r = await sh($, plan.argv, { cwd: root, env: plan.env })
|
|
550
|
+
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()}` }
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
on('command.run', { command: 'pmos-accept' }, async ($, e) => {
|
|
554
|
+
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.' }
|
|
555
|
+
const a = parseAccept(e.args)
|
|
556
|
+
if (!a.ok) return { text: a.text }
|
|
557
|
+
const { layout } = await rootAndLayout($)
|
|
558
|
+
const run = (await runsOf($, layout)).find((r) => r.run_id === a.run_id)
|
|
559
|
+
if (!run) return { text: `No acceptance written. Run ${a.run_id} is not in ${layout.stateRoot ?? 'the state root'}.` }
|
|
560
|
+
const r = await writeAcceptance($, run, a.label as 'pass' | 'fail', a.note)
|
|
561
|
+
return { text: r.ok ? `Recorded ${a.label} for ${a.run_id}.` : `No acceptance written. ${r.text}` }
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
// A message that reads as approval gets a button. Nothing is written until a person presses it.
|
|
565
|
+
on('prompt.submit', async ($, e, next) => {
|
|
566
|
+
if (e.origin?.kind === 'composer' && readsAsApproval(e.text)) {
|
|
567
|
+
try { const o = await buildOffer($, e.text); await update($, offer, () => o) } catch { /* no offer */ }
|
|
568
|
+
}
|
|
569
|
+
return next(e)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
573
|
+
if (typeof e.viewport?.columns === 'number') lastColumns = e.viewport.columns
|
|
574
|
+
const s = await read($, status)
|
|
575
|
+
const o: any = await read($, offer)
|
|
576
|
+
if (o && !e.props.hasSurvey) {
|
|
577
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
578
|
+
return (
|
|
579
|
+
<Box>
|
|
580
|
+
<Text>PMOS: record your acceptance of run {o.run_id}? </Text>
|
|
581
|
+
<Button key="pass" label="Pass" variant="primary" onPress={() => pressAccept($, 'pass')} />
|
|
582
|
+
<Button key="fail" label="Fail" onPress={() => pressAccept($, 'fail')} />
|
|
583
|
+
<Button key="later" label="Not now" onPress={() => update($, offer, () => null)} />
|
|
584
|
+
</Box>
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
|
|
588
|
+
// one line, only when something blocks, in this order: skill drift, a blocking ladder step, a branch
|
|
589
|
+
// with commits and no initiative record. The text comes from checked fields, never from a record.
|
|
590
|
+
const v: any = await read($, view)
|
|
591
|
+
if (!hasDrift(s) && v?.initiative?.id && v?.summary?.gateFailures?.length) {
|
|
592
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
593
|
+
return (
|
|
594
|
+
<Box>
|
|
595
|
+
<Text>
|
|
596
|
+
PMOS: {v.initiative.id} has a blocking order violation ({v.summary.gateFailures.join(', ')}).{v.summary.next ? ` Next: ${v.summary.next.key}.` : ''}{' '}
|
|
597
|
+
</Text>
|
|
598
|
+
<Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
|
|
599
|
+
</Box>
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
if (!hasDrift(s) && v?.initiative?.warn) {
|
|
603
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
604
|
+
return (
|
|
605
|
+
<Box>
|
|
606
|
+
<Text>PMOS: {clean(v.initiative.text, 120)}. </Text>
|
|
607
|
+
<Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
|
|
608
|
+
</Box>
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
if (!hasDrift(s)) return next(e)
|
|
612
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
613
|
+
const k = s!.skills!
|
|
614
|
+
const parts = [`${k.missing.length} kit skill(s) are not registered`]
|
|
615
|
+
if (k.blocked.length) parts.push(`${k.blocked.length} blocked by something already in .claude/skills`)
|
|
616
|
+
if (k.badDescription.length) parts.push(`${k.badDescription.length} have no description`)
|
|
617
|
+
return (
|
|
618
|
+
<Box>
|
|
619
|
+
<Text>PMOS: {parts.join(', ')}. </Text>
|
|
620
|
+
{k.repairable.length > 0 && (
|
|
621
|
+
<Button
|
|
622
|
+
key="repair"
|
|
623
|
+
label="Repair"
|
|
624
|
+
onPress={async () => {
|
|
625
|
+
const r = await repair($)
|
|
626
|
+
await update($, status, () => r)
|
|
627
|
+
}}
|
|
628
|
+
/>
|
|
629
|
+
)}
|
|
630
|
+
<Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
|
|
631
|
+
</Box>
|
|
632
|
+
)
|
|
633
|
+
})
|
|
634
|
+
}
|