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.
Files changed (36) hide show
  1. package/bin/cockpit-install.js +130 -0
  2. package/bin/create-pmos.js +47 -4
  3. package/package.json +1 -1
  4. package/payload/AGENTS-HUMAN.md +10 -25
  5. package/payload/AGENTS.md +4 -0
  6. package/payload/KIT-VERSION +1 -1
  7. package/payload/mods/.claude-plugin/marketplace.json +12 -0
  8. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +9 -0
  9. package/payload/mods/pmos-cockpit/README.md +71 -0
  10. package/payload/mods/pmos-cockpit/hooks/hooks.json +1 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +53 -0
  12. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +88 -0
  13. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +561 -0
  14. package/payload/mods/pmos-cockpit/hooks/lib/initiative.mjs +54 -0
  15. package/payload/mods/pmos-cockpit/hooks/lib/layout.mjs +31 -0
  16. package/payload/mods/pmos-cockpit/hooks/lib/probes.mjs +34 -0
  17. package/payload/mods/pmos-cockpit/hooks/lib/profile.mjs +17 -0
  18. package/payload/mods/pmos-cockpit/hooks/lib/prompt-section.mjs +49 -0
  19. package/payload/mods/pmos-cockpit/hooks/lib/session-facts.mjs +110 -0
  20. package/payload/mods/pmos-cockpit/hooks/lib/skills.mjs +69 -0
  21. package/payload/mods/pmos-cockpit/hooks/lib/status-line.mjs +64 -0
  22. package/payload/mods/pmos-cockpit/hooks/register.tsx +634 -0
  23. package/payload/mods/pmos-cockpit/types/index.d.ts +60 -0
  24. package/payload/planning/evals/eval-calibration.md +1 -1
  25. package/payload/planning/okrs/health-budgets.md +1 -1
  26. package/payload/scripts/pmos-log.sh +119 -7
  27. package/payload/skills/pmos-router.skill +1 -1
  28. package/payload/skills/prototype.skill +1 -1
  29. package/payload/skills/strategic-brief.skill +1 -1
  30. package/payload/skills-native/harness-refine/SKILL.md +1 -1
  31. package/payload/skills-native/pmos-router/SKILL.md +1 -1
  32. package/payload/skills-native/prototype/SKILL.md +1 -1
  33. package/payload/skills-native/strategic-brief/SKILL.md +1 -1
  34. package/payload/state/discoveries.md +1 -1
  35. package/workspace/package-lock.json +1 -1
  36. 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
+ }