create-pmos 0.46.0 → 0.48.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/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +13 -3
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
- package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
- package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
- package/payload/okf/core/concepts/frontmatter-format.md +6 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +204 -0
- package/payload/scripts/workflow-state.js +52 -0
- package/payload/skills/SKILL-FORMAT.md +1 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +3 -3
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/initiatives-and-next-steps.md +16 -2
- package/workspace/guide/knowledge-graph.md +89 -2
- package/workspace/guide/layout.md +30 -2
- package/workspace/guide/proactive-engine.md +26 -4
- package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
- package/workspace/guide/statuses.md +1 -0
- package/workspace/guide/validate-with-typesafe.md +17 -1
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +204 -0
- package/workspace/lib/engine/workflow-state.js +52 -0
- package/workspace/lib/jev-categorize.js +399 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-route.js +129 -0
- package/workspace/lib/kg-cli.js +5 -2
- package/workspace/lib/kg-dangling-baseline.json +1 -0
- package/workspace/lib/kg-dangling-rules.json +1 -0
- package/workspace/lib/kg.js +26 -9
- package/workspace/lib/learn.js +282 -0
- package/workspace/lib/live-notes.js +890 -0
- package/workspace/lib/proactive-cli.js +49 -1
- package/workspace/lib/proactive.js +8 -7
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +58 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +267 -21
- package/workspace/public/pm/panel-cockpit.js +636 -0
- package/workspace/public/pm/panel-detail.js +210 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +145 -27
- package/workspace/public/pm/state.js +45 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +394 -10
- package/workspace/skills/live-notes.skill +58 -0
- package/workspace/skills/morning-brief.skill +1 -0
|
@@ -15,6 +15,8 @@ import { SECTION_ID, buildSection } from './lib/prompt-section.mjs'
|
|
|
15
15
|
import { PROBES, aheadProbe, interpretGit } from './lib/probes.mjs'
|
|
16
16
|
import { describeMode, detectMode, slicePolicy } from './lib/mode.mjs'
|
|
17
17
|
import { UNPUSHED_PROBES, cloudBandText, interpretUnpushed } from './lib/unpushed.mjs'
|
|
18
|
+
import { HANDOVER_PROBES, buildPlan, countSince, interpretSince, notePaths, parseNote, planStillGood, renderNote, resumeText, sinceProbes, subjectsProbe } from './lib/handover.mjs'
|
|
19
|
+
import { carryOut } from './lib/handover-run.mjs'
|
|
18
20
|
import { REVIEWER_AGENT, REVIEWER_PROMPT, REVIEWER_TOOLS, REVIEWER_TYPE, buildReviewPrompt, offerText, parseVerdict, pickReviewerModel, planEval } from './lib/review.mjs'
|
|
19
21
|
|
|
20
22
|
const status = atom({ plugin: 'pmos-cockpit', key: 'status' } as const, null)
|
|
@@ -28,6 +30,12 @@ const factsCache = atom({ plugin: 'pmos-cockpit', key: 'factsCache' } as const,
|
|
|
28
30
|
// Where this session runs (M12). Read from two environment variables when the mod loads and kept here;
|
|
29
31
|
// nothing a tool call does afterwards moves it. It changes what is drawn, never what is guarded.
|
|
30
32
|
const modeAtom = atom({ plugin: 'pmos-cockpit', key: 'mode' } as const, null)
|
|
33
|
+
// Continuity (M13). The plan `/pmos-handover` showed, waiting for a person's confirmation; it holds the
|
|
34
|
+
// facts of the checkout it was made for. The resume summary read at session start, and whether the
|
|
35
|
+
// person dismissed it. None of the three is ever given to the model.
|
|
36
|
+
const handoverPlan = atom({ plugin: 'pmos-cockpit', key: 'handoverPlan' } as const, null)
|
|
37
|
+
const resume = atom({ plugin: 'pmos-cockpit', key: 'resume' } as const, null)
|
|
38
|
+
const resumeGone = atom({ plugin: 'pmos-cockpit', key: 'resumeGone' } as const, false)
|
|
31
39
|
// The independent evaluator (M14). `review` is the one reviewer in flight; `reviewOffer` is its answer,
|
|
32
40
|
// held until a person presses Record or types /pmos-record-review. Neither is ever written to a ledger.
|
|
33
41
|
const review = atom({ plugin: 'pmos-cockpit', key: 'review' } as const, null)
|
|
@@ -81,10 +89,12 @@ async function whoAmI($: any, root: string): Promise<string> {
|
|
|
81
89
|
return r.exitCode === 0 && out(r) !== '' ? out(r) : 'pm'
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
// The local gate for one initiative. It reports what it ran, and what it cannot see.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
// The local gate for one initiative. It reports what it ran, and what it cannot see. `fromGrant` is true
|
|
93
|
+
// when the id comes from the grant (Run 7) because the branch names none: the kit's gate then reads the
|
|
94
|
+
// id from `Initiative: <id>` in the PR body, the form the CI gate accepts in place of the branch name.
|
|
95
|
+
async function localGate($: any, layout: any, root: string, id: string | null, branch: string | null, fromGrant = false) {
|
|
96
|
+
if (id === null) return { ran: false, failed: false, text: 'Local gate not run: the branch names no initiative, and neither does the grant.' }
|
|
97
|
+
const lines: string[] = fromGrant ? [`Local gate run for initiative ${id}, named on the grant.`] : []
|
|
88
98
|
let failed = false
|
|
89
99
|
let ran = false
|
|
90
100
|
const ws = layout.kind === 'kit' ? `${root}/pmos/scripts/workflow-state.js` : `${root}/scripts/workflow-state.js`
|
|
@@ -95,7 +105,7 @@ async function localGate($: any, layout: any, root: string, id: string | null, b
|
|
|
95
105
|
lines.push(`workflow-state --gate ${id}: ${r.exitCode === 0 ? 'passed' : `FAILED. ${out(r).split('\n').slice(-3).join(' ')}`}`)
|
|
96
106
|
}
|
|
97
107
|
if (layout.kind === 'kit' && (await $.fs.exists(`${root}/pmos/scripts/gate-initiative.sh`))) {
|
|
98
|
-
const r = await sh($, ['bash', `${root}/pmos/scripts/gate-initiative.sh`], { cwd: root, env: { HEAD_REF: branch ?? '', PR_BODY: '', PR_LABELS: '', KIT_ROOT: 'pmos' } })
|
|
108
|
+
const r = await sh($, ['bash', `${root}/pmos/scripts/gate-initiative.sh`], { cwd: root, env: { HEAD_REF: branch ?? '', PR_BODY: fromGrant ? `Initiative: ${id}` : '', PR_LABELS: '', KIT_ROOT: 'pmos' } })
|
|
99
109
|
ran = true
|
|
100
110
|
if (r.exitCode !== 0) failed = true
|
|
101
111
|
lines.push(`gate-initiative.sh: ${r.exitCode === 0 ? 'passed' : `FAILED. ${(out(r) || r.stderr).split('\n').slice(-2).join(' ')}`}`)
|
|
@@ -155,10 +165,17 @@ async function judge($: any, e: any): Promise<string | null> {
|
|
|
155
165
|
// the gate needs the branch, whatever the action was
|
|
156
166
|
if (!needsBranch) facts = await gitFacts($, root)
|
|
157
167
|
|
|
168
|
+
// the initiative the branch names, if any: a grant minted for another one never covers this action
|
|
169
|
+
const initiative = resolveInitiative({
|
|
170
|
+
branch: facts.branch,
|
|
171
|
+
recordExists: facts.branch ? await $.fs.exists(recordPath(layout, facts.branch.replace(/^initiative\//, '')) ?? '/nonexistent') : null,
|
|
172
|
+
commitsAhead: facts.commitsAhead,
|
|
173
|
+
})
|
|
174
|
+
|
|
158
175
|
const grantsFile = layout.stateRoot ? `${layout.stateRoot}/grants.jsonl` : null
|
|
159
176
|
const text = grantsFile ? ((await readText($, grantsFile)) ?? '') : ''
|
|
160
177
|
const now: number = await $.clock.now()
|
|
161
|
-
const used: { id: string; action: string }[] = []
|
|
178
|
+
const used: { id: string; action: string; initiative: string | null }[] = []
|
|
162
179
|
for (const f of merges) {
|
|
163
180
|
if (grantsFile === null) return denyMerge(f, 'there is no PMOS state here to hold a grant.')
|
|
164
181
|
let checks: { kind: string; target: string }[]
|
|
@@ -173,21 +190,22 @@ async function judge($: any, e: any): Promise<string | null> {
|
|
|
173
190
|
let hit: any = null
|
|
174
191
|
let why = ''
|
|
175
192
|
for (const c of checks) {
|
|
176
|
-
const r = checkGrant(text, c, now)
|
|
193
|
+
const r = checkGrant(text, { ...c, branchInitiative: initiative.id }, now)
|
|
177
194
|
if (r.ok) { hit = r.grant; break }
|
|
178
195
|
why = r.reason
|
|
179
196
|
}
|
|
180
197
|
if (hit === null) return denyMerge(f.publish ? { ...f, target: checks[0].target } : f, `${why}`)
|
|
181
|
-
used.push({ id: hit.id, action: `${f.action}${f.target ? ` ${f.target}` : ''}
|
|
198
|
+
used.push({ id: hit.id, action: `${f.action}${f.target ? ` ${f.target}` : ''}`, initiative: hit.initiative ?? null })
|
|
182
199
|
}
|
|
183
200
|
|
|
184
|
-
// every grant is live; now the local gate, which can still stop the action
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
const
|
|
201
|
+
// every grant is live; now the local gate, which can still stop the action. It runs for the branch's
|
|
202
|
+
// initiative, or else for the one the grants name. Grants that name different ones decide nothing.
|
|
203
|
+
const named = [...new Set(used.map((u) => u.initiative).filter((x): x is string => x !== null))]
|
|
204
|
+
if (initiative.id === null && named.length > 1) {
|
|
205
|
+
return `Blocked by pmos-cockpit: the grants for this action name different initiatives (${named.join(', ')}), so there is no one initiative to run the local gate against. Ask the PM for grants that name one.`
|
|
206
|
+
}
|
|
207
|
+
const gateId = initiative.id ?? named[0] ?? null
|
|
208
|
+
const gate = await localGate($, layout, root, gateId, facts.branch, initiative.id === null && gateId !== null)
|
|
191
209
|
$.ui.toast(`pmos-cockpit: ${gate.text}`)
|
|
192
210
|
if (gate.failed) return `Blocked by pmos-cockpit: the local gate failed, and a grant does not override it. ${gate.text}`
|
|
193
211
|
|
|
@@ -326,6 +344,111 @@ async function repair($: any): Promise<CockpitStatus> {
|
|
|
326
344
|
return gather($, done, error)
|
|
327
345
|
}
|
|
328
346
|
|
|
347
|
+
// ── continuity (M13) ─────────────────────────────────────────────────────────────────────────────
|
|
348
|
+
// What the checkout looks like to a handover: git, the initiative the branch names, and what is changed
|
|
349
|
+
// or unpushed. Read-only; every process has the 5 second limit.
|
|
350
|
+
async function readCheckout($: any, root: string, layout: any) {
|
|
351
|
+
const run = (argv: string[], limit = PROCESS_LIMIT_MS) => sh($, argv, { cwd: root, timeoutMs: limit })
|
|
352
|
+
const r: Record<string, any> = {}
|
|
353
|
+
for (const k of ['inside', 'branch', 'sha', 'dirs', 'defaultBranch'] as const) r[k] = await run(PROBES[k])
|
|
354
|
+
const g = interpretGit(r)
|
|
355
|
+
const idGuess = g.branch?.startsWith('initiative/') ? g.branch.slice('initiative/'.length) : null
|
|
356
|
+
const rp = idGuess ? recordPath(layout, idGuess) : null
|
|
357
|
+
const initiative = resolveInitiative({ branch: g.noGit ? null : g.branch, recordExists: rp ? await $.fs.exists(rp) : null, commitsAhead: null })
|
|
358
|
+
let porcelain = ''
|
|
359
|
+
let hasOrigin = false
|
|
360
|
+
let unpushed = { unpushed: false }
|
|
361
|
+
if (!g.noGit) {
|
|
362
|
+
const st = await run(HANDOVER_PROBES.status as string[])
|
|
363
|
+
porcelain = st.exitCode === 0 ? st.stdout : ''
|
|
364
|
+
hasOrigin = (await run(HANDOVER_PROBES.origin as string[])).exitCode === 0
|
|
365
|
+
const u: Record<string, any> = {}
|
|
366
|
+
for (const [k, argv] of Object.entries(UNPUSHED_PROBES)) u[k] = await run(argv as string[])
|
|
367
|
+
unpushed = interpretUnpushed(u)
|
|
368
|
+
}
|
|
369
|
+
return { run, g, initiative, porcelain, hasOrigin, unpushed }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// What the plan, and the confirmation that follows it, compare: the facts a plan was made for.
|
|
373
|
+
const keyOf = (c: any, words: string) => ({ branch: c.g.branch, sha: c.g.sha, porcelain: c.porcelain, words })
|
|
374
|
+
|
|
375
|
+
async function planHandover($: any, words: string): Promise<string> {
|
|
376
|
+
const { root, layout } = await rootAndLayout($)
|
|
377
|
+
const c = await readCheckout($, root, layout)
|
|
378
|
+
const p = buildPlan({ layout, root, initiative: c.initiative, git: c.g, porcelain: c.porcelain, hasOrigin: c.hasOrigin, unpushed: c.unpushed, words, nowMs: await $.clock.now() })
|
|
379
|
+
if (!p.ok) { await update($, handoverPlan, () => null); return p.text }
|
|
380
|
+
await update($, handoverPlan, () => p.plan)
|
|
381
|
+
return p.text
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Called only from a person's typed command or press. The plan must still match the checkout.
|
|
385
|
+
async function confirmHandover($: any, push: boolean): Promise<string> {
|
|
386
|
+
const plan: any = await read($, handoverPlan)
|
|
387
|
+
const { root, layout } = await rootAndLayout($)
|
|
388
|
+
const c = await readCheckout($, root, layout)
|
|
389
|
+
const now: number = await $.clock.now()
|
|
390
|
+
const good = planStillGood(plan, plan ? keyOf(c, plan.words) : { branch: null, sha: null, porcelain: '', words: '' }, now)
|
|
391
|
+
if (!good.ok) {
|
|
392
|
+
await update($, handoverPlan, () => null)
|
|
393
|
+
return `Nothing was written. ${good.why}${plan ? ' Run /pmos-handover again to see a new plan.' : ''}`
|
|
394
|
+
}
|
|
395
|
+
// the note's facts, read fresh: the ladder for this initiative, and how far the branch is from the default
|
|
396
|
+
await refreshView($, true)
|
|
397
|
+
const v: any = await read($, view)
|
|
398
|
+
const def: string = c.g.defaultBranch ?? 'main'
|
|
399
|
+
const ahead = await c.run(['git', 'rev-list', '--count', `${def}..HEAD`])
|
|
400
|
+
const subj = await c.run(subjectsProbe(def) as string[])
|
|
401
|
+
const noteText = renderNote({
|
|
402
|
+
id: plan.id, branch: plan.branch, sha: c.g.sha, summary: v?.initiative?.id === plan.id ? v.summary : null,
|
|
403
|
+
commits: ahead.exitCode === 0 && /^\d{1,9}$/.test(out(ahead)) ? Number(out(ahead)) : 0, defaultBranch: def,
|
|
404
|
+
subjects: subj.exitCode === 0 ? out(subj).split('\n').filter(Boolean) : [], paths: plan.paths, words: plan.words, atIso: new Date(now).toISOString(),
|
|
405
|
+
})
|
|
406
|
+
// a note path that is a link, or that sits behind one, is never written through
|
|
407
|
+
const rootReal = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
|
|
408
|
+
const noteDir = plan.noteRel.split('/').slice(0, -1).join('/')
|
|
409
|
+
const write = async (path: string, text: string) => {
|
|
410
|
+
for (const [p, expect] of [[path, `${rootReal}/${plan.noteRel}`], [`${root}/${noteDir}`, `${rootReal}/${noteDir}`]] as const) {
|
|
411
|
+
const st = await $.fs.stat(p, { resolve: true }).catch(() => undefined)
|
|
412
|
+
if (st && (st.isLink || (st.realPath && st.realPath !== expect))) throw new Error(`${p} is, or sits behind, a link`)
|
|
413
|
+
}
|
|
414
|
+
await $.fs.write(path, text)
|
|
415
|
+
}
|
|
416
|
+
const r = await carryOut({ plan, push, noteText, run: c.run, write })
|
|
417
|
+
await update($, handoverPlan, () => null)
|
|
418
|
+
await update($, resume, () => null)
|
|
419
|
+
await refreshView($, true)
|
|
420
|
+
return r.text
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function pressHandover($: any, push: boolean) {
|
|
424
|
+
try { $.ui.toast(`pmos-cockpit: ${await confirmHandover($, push)}`) } catch (e: any) { $.ui.toast(`pmos-cockpit: the handover did not run. ${String(e?.message ?? e)}`) }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// At session start: the newest note for this branch's initiative, and how far the branch has moved.
|
|
428
|
+
async function loadResume($: any): Promise<void> {
|
|
429
|
+
try {
|
|
430
|
+
const { root, layout } = await rootAndLayout($)
|
|
431
|
+
const v: any = await read($, view)
|
|
432
|
+
const id: string | null = v?.initiative?.state === 'ok' ? v.initiative.id : null
|
|
433
|
+
const np = id === null ? null : notePaths(layout, root, id)
|
|
434
|
+
const text = np === null ? null : await readText($, np.abs)
|
|
435
|
+
const note = text === null ? null : parseNote(text)
|
|
436
|
+
if (id === null || np === null || note === null || note.id !== id || note.headSha === null) { await update($, resume, () => null); return }
|
|
437
|
+
const run = (argv: string[]) => sh($, argv, { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
|
|
438
|
+
const pr = sinceProbes(note, np.rel) as any
|
|
439
|
+
const r: Record<string, any> = { ancestor: await run(pr.ancestor) }
|
|
440
|
+
if (r.ancestor.exitCode === 0) {
|
|
441
|
+
r.handoverCommit = await run(pr.handoverCommit)
|
|
442
|
+
const h = out(r.handoverCommit)
|
|
443
|
+
r.count = await run(countSince(/^[0-9a-f]{40,64}$/.test(h) ? h : note.headSha) as string[])
|
|
444
|
+
}
|
|
445
|
+
const line = resumeText(note, id, interpretSince(r))
|
|
446
|
+
await update($, resume, () => (line === null ? null : { text: line }))
|
|
447
|
+
} catch {
|
|
448
|
+
await update($, resume, () => null)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
329
452
|
// Recording a reviewer's verdict: called only from a person's press or typed command. The offer is
|
|
330
453
|
// cleared before the writer runs, so a second press records nothing.
|
|
331
454
|
async function recordReview($: any): Promise<{ ok: boolean; text: string }> {
|
|
@@ -470,8 +593,9 @@ export const register: Register = (on) => {
|
|
|
470
593
|
on('session.start', async ($, e, next) => {
|
|
471
594
|
await $.command.register({ name: 'pmos-status', description: 'Show the PMOS layout, profile and skill registration' })
|
|
472
595
|
await $.command.register({ name: 'pmos-repair-skills', description: 'Link the kit skills that are not registered in .claude/skills' })
|
|
473
|
-
await $.command.register({ name: 'pmos-grant', description: 'Mint one single-use grant: /pmos-grant merge:<PR> | publish:<artifact> [minutes] [your words]' })
|
|
596
|
+
await $.command.register({ name: 'pmos-grant', description: 'Mint one single-use grant: /pmos-grant merge:<PR> | publish:<artifact> [minutes] [initiative:<id>] [your words]' })
|
|
474
597
|
await $.command.register({ name: 'pmos-slice', description: 'Open the initiative slice: the ladder, step by step, for the branch you are on' })
|
|
598
|
+
await $.command.register({ name: 'pmos-handover', description: 'Hand the branch over: /pmos-handover [your words] shows a plan; /pmos-handover confirm [local] commits and pushes it' })
|
|
475
599
|
await $.command.register({ name: 'pmos-accept', description: 'Record your Acceptance of a run: /pmos-accept <run_id> pass|fail <note>' })
|
|
476
600
|
await $.command.register({ name: 'pmos-review', description: 'Ask a read-only reviewer on another model to judge the newest run: /pmos-review [model]' })
|
|
477
601
|
await $.command.register({ name: 'pmos-record-review', description: 'Record the reviewer verdict that is waiting (the same as pressing Record)' })
|
|
@@ -483,6 +607,8 @@ export const register: Register = (on) => {
|
|
|
483
607
|
const s = await gather($)
|
|
484
608
|
await update($, status, () => s)
|
|
485
609
|
await refreshView($)
|
|
610
|
+
await update($, resumeGone, () => false)
|
|
611
|
+
await loadResume($)
|
|
486
612
|
if (s.skills && s.skills.missing.length > 0) {
|
|
487
613
|
$.ui.toast(`PMOS: ${s.skills.missing.length} kit skill(s) are not registered. Run /pmos-repair-skills.`)
|
|
488
614
|
}
|
|
@@ -497,6 +623,8 @@ export const register: Register = (on) => {
|
|
|
497
623
|
const lines = [summary(s)]
|
|
498
624
|
const m: any = await read($, modeAtom)
|
|
499
625
|
if (m) lines.push(describeMode(m))
|
|
626
|
+
const rs: any = await read($, resume)
|
|
627
|
+
if (rs?.text) lines.push(`resume ${String(rs.text).replace(/^PMOS: /, '')}`)
|
|
500
628
|
try { lines.push(`surfaces ${(await $.session.surfaces()).join(', ') || 'none reported'}`) } catch { lines.push('surfaces unavailable') }
|
|
501
629
|
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(', ')}` : ''}`)
|
|
502
630
|
else if (v?.unavailable) lines.push(`stage unavailable: ${v.unavailable}`)
|
|
@@ -652,10 +780,31 @@ export const register: Register = (on) => {
|
|
|
652
780
|
on('command.run', { command: 'pmos-grant' }, async ($, e) => {
|
|
653
781
|
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.' }
|
|
654
782
|
const { root, layout } = await rootAndLayout($)
|
|
655
|
-
|
|
783
|
+
// the branch's initiative is recorded on the grant by default, when its record exists
|
|
784
|
+
const facts = await gitFacts($, root)
|
|
785
|
+
const idGuess = facts.branch?.startsWith('initiative/') ? facts.branch.slice('initiative/'.length) : null
|
|
786
|
+
const rp = idGuess ? recordPath(layout, idGuess) : null
|
|
787
|
+
const bi = resolveInitiative({ branch: facts.branch, recordExists: rp ? await $.fs.exists(rp) : null, commitsAhead: null })
|
|
788
|
+
const plan = planGrant(e.args, { layout, root, pm: await whoAmI($, root), branchInitiative: bi.state === 'ok' ? bi.id : null })
|
|
656
789
|
if (!plan.ok) return { text: plan.text }
|
|
790
|
+
if (plan.initiative !== null) {
|
|
791
|
+
const rec = recordPath(layout, plan.initiative)
|
|
792
|
+
if (rec === null || !(await $.fs.exists(rec))) return { text: `No grant written. Initiative ${plan.initiative} has no record${rec ? ` at ${rec.replace(`${root}/`, '')}` : ''}.` }
|
|
793
|
+
}
|
|
657
794
|
const r = await sh($, plan.argv, { cwd: root, env: plan.env })
|
|
658
|
-
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()}` }
|
|
795
|
+
return { text: r.exitCode === 0 ? `Granted ${plan.scope.kind}:${plan.scope.target}, single use, ${plan.ttl} minutes. ${plan.note} ${out(r).split('\n').pop()}` : `No grant written. ${(r.stderr || out(r)).trim()}` }
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
// A handover is a person's act: a plan first, and only a typed confirm or a press carries it out.
|
|
799
|
+
on('command.run', { command: 'pmos-handover' }, async ($, e) => {
|
|
800
|
+
if (!isPerson(e)) return { text: 'A handover commits and pushes only on your own typed command or press. This run did not come from you, so nothing was done.' }
|
|
801
|
+
const args = String(e.args ?? '').trim()
|
|
802
|
+
const m = /^confirm(?:[ \t]+(local))?$/.exec(args)
|
|
803
|
+
try {
|
|
804
|
+
return { text: m ? await confirmHandover($, m[1] !== 'local') : await planHandover($, args) }
|
|
805
|
+
} catch (err: any) {
|
|
806
|
+
return { text: `Nothing was done. The handover could not run: ${String(err?.message ?? err)}` }
|
|
807
|
+
}
|
|
659
808
|
})
|
|
660
809
|
|
|
661
810
|
on('command.run', { command: 'pmos-accept' }, async ($, e) => {
|
|
@@ -692,6 +841,18 @@ export const register: Register = (on) => {
|
|
|
692
841
|
</Box>
|
|
693
842
|
)
|
|
694
843
|
}
|
|
844
|
+
const hp: any = await read($, handoverPlan)
|
|
845
|
+
if (hp && !e.props.hasSurvey) {
|
|
846
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
847
|
+
return (
|
|
848
|
+
<Box>
|
|
849
|
+
<Text>PMOS: handover plan for {hp.id}, {hp.paths.length} path(s){hp.push.run ? `, then ${hp.push.text}` : ', no push'}. </Text>
|
|
850
|
+
{hp.push.run && <Button key="go" label="Commit and push" variant="primary" onPress={() => pressHandover($, true)} />}
|
|
851
|
+
<Button key="local" label="Commit only" onPress={() => pressHandover($, false)} />
|
|
852
|
+
<Button key="cancel" label="Cancel" onPress={() => update($, handoverPlan, () => null)} />
|
|
853
|
+
</Box>
|
|
854
|
+
)
|
|
855
|
+
}
|
|
695
856
|
const rv: any = await read($, reviewOffer)
|
|
696
857
|
if (rv && !e.props.hasSurvey) {
|
|
697
858
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
@@ -736,6 +897,16 @@ export const register: Register = (on) => {
|
|
|
736
897
|
</Box>
|
|
737
898
|
)
|
|
738
899
|
}
|
|
900
|
+
const rs: any = await read($, resume)
|
|
901
|
+
if (!hasDrift(s) && rs?.text && !(await read($, resumeGone))) {
|
|
902
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
903
|
+
return (
|
|
904
|
+
<Box>
|
|
905
|
+
<Text>{clean(rs.text, 400)} </Text>
|
|
906
|
+
<Button key="dismiss" label="Dismiss" onPress={() => update($, resumeGone, () => true)} />
|
|
907
|
+
</Box>
|
|
908
|
+
)
|
|
909
|
+
}
|
|
739
910
|
if (!hasDrift(s)) return next(e)
|
|
740
911
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
741
912
|
const k = s!.skills!
|
|
@@ -54,8 +54,23 @@ export type ViewState = {
|
|
|
54
54
|
|
|
55
55
|
export type FactsCache = { id: string | null; sha: string; at: number; summary: NonNullable<ViewState['summary']> }
|
|
56
56
|
|
|
57
|
+
export type HandoverPlan = {
|
|
58
|
+
id: string
|
|
59
|
+
branch: string
|
|
60
|
+
noteRel: string
|
|
61
|
+
noteAbs: string
|
|
62
|
+
paths: string[]
|
|
63
|
+
addPaths: string[]
|
|
64
|
+
push: { run: boolean; argv: string[] | null; text: string }
|
|
65
|
+
message: string
|
|
66
|
+
words: string
|
|
67
|
+
key: string
|
|
68
|
+
at: number
|
|
69
|
+
sha: string | null
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
declare module 'claude-code' {
|
|
58
73
|
interface PluginState {
|
|
59
|
-
'pmos-cockpit': { status: CockpitStatus | null; isHidden: boolean; offer: { run_id: string; initiative_id: string; message: string } | null; view: ViewState | null; factsCache: FactsCache | null; mode: { mode: 'local' | 'hosted' | 'cloud'; signal: string | null } | null; review: { agentId: string; run_id: string; initiative_id: string; rubric_path: string; model: string } | null; reviewOffer: { run_id: string; initiative_id: string; rubric_path: string; verdict: string; score: number; evaluator_model: string; findings: string } | null }
|
|
74
|
+
'pmos-cockpit': { status: CockpitStatus | null; isHidden: boolean; offer: { run_id: string; initiative_id: string; message: string } | null; view: ViewState | null; factsCache: FactsCache | null; mode: { mode: 'local' | 'hosted' | 'cloud'; signal: string | null } | null; review: { agentId: string; run_id: string; initiative_id: string; rubric_path: string; model: string } | null; reviewOffer: { run_id: string; initiative_id: string; rubric_path: string; verdict: string; score: number; evaluator_model: string; findings: string } | null; handoverPlan: HandoverPlan | null; resume: { text: string } | null; resumeGone: boolean }
|
|
60
75
|
}
|
|
61
76
|
}
|
|
@@ -46,9 +46,10 @@ OKF's reserved filenames carry **no** frontmatter and are skipped by both the mi
|
|
|
46
46
|
The enforced list lives in [`scripts/frontmatter_registry.txt`](/scripts/frontmatter_registry.txt) — the single source `frontmatter_lint.py` reads. A `type:` value not in it fails CI (this catches typos).
|
|
47
47
|
|
|
48
48
|
- **OKF core:** `Concept`, `ADR`, `Template`, `Playbook`, `Reference`
|
|
49
|
-
- **Planning:** `Initiative`, `PRD`, `Story`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
49
|
+
- **Planning:** `Initiative`, `PRD`, `Story`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`, `Handover`
|
|
50
50
|
- `Initiative` is the repo-native initiative record in [`planning/initiatives/`](/planning/initiatives/), read by the [Initiative Gate](/.github/scripts/initiative-gate.sh). Its D56 **work-type lane** rides a separate `lane:` key — not `type:` — so the lane vocabulary and this registry never collide.
|
|
51
51
|
- `Story` is a **user-story document** — the decomposition of a spec into the units of work handed to whoever builds them. It is the one type in this registry that is also **checked positionally** (see below), and it may carry an optional `jira:` / `confluence:` / `google_doc:` key naming the tracker record it corresponds to. That key is a *declaration* the workspace displays, never a lookup: PMOS holds no credential and makes no outbound request.
|
|
52
|
+
- `Handover` is the note the cockpit mod's `/pmos-handover` writes (`planning/handovers/<id>.md`, or `pmos/state/handovers/<id>.md` in a kit): one per initiative, replaced on each handover, with checked fields (`id`, `head_sha`, `branch`, `stage`, `next`, `commits`, `files`, `handover_at`) read back by the resume summary ([D81](/okf/products/pmos/adr/d81-cockpit-mod-and-grants.md)).
|
|
52
53
|
- **Root governance:** `Decision`, `Principle`, `Debt`, `Discovery`, `Guide`
|
|
53
54
|
|
|
54
55
|
### When the path decides the type
|
|
@@ -72,6 +73,10 @@ Add **one line** to `scripts/frontmatter_registry.txt` and a matching row above.
|
|
|
72
73
|
- [`scripts/frontmatter_lint.py`](/scripts/frontmatter_lint.py) — deterministic, no secrets; asserts the three required keys (and `type` ∈ registry) on every non-reserved tracked `.md`. Run in `.github/workflows/frontmatter-ci.yml`.
|
|
73
74
|
- [`scripts/backfill_frontmatter.py`](/scripts/backfill_frontmatter.py) — the one-time migration: prepend-only, idempotent; reuses a file's already-declared `type`/`date` when present, else infers from path (`type`), the first H1 (`title`), and the last git-commit date (`timestamp`).
|
|
74
75
|
|
|
76
|
+
## Typed relation keys (opt-in)
|
|
77
|
+
|
|
78
|
+
A document may carry `supersedes`, `amended_by`, `cites` and `initiative` as one-line lists of paths to `.md` files. The knowledge graph (`pmos kg`) reads them as stated edges of that type. The keys are optional and are not backfilled. See [the knowledge-graph guide](/workspace/guide/knowledge-graph.md).
|
|
79
|
+
|
|
75
80
|
## Tenant repositories follow OKF v0.2 (D73)
|
|
76
81
|
|
|
77
82
|
Since [D74](/okf/products/pmos/adr/d74-pmos-self-drops-timestamp.md) PMOS's own repository follows OKF v0.2 too: the Frontmatter Gate requires `type` and `title`, `timestamp` is optional and still checked when present, and `generated`, `sources`, `status`, `verified` and `stale_after` are accepted. Nothing was backfilled. The documents PMOS and its agents write into a **tenant** repository, such as a PM's, follow [OKF v0.2](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) instead: `type`, `title` and `description`, then `generated: {by, at}` in place of `timestamp`, `sources` in place of a body `# Citations` list, and `status` (`draft | stable | deprecated`), `verified` and `stale_after` as they apply. Every key sits on **one line**, because five of PMOS's frontmatter readers stop at the first indented line. Actors are `claude-code/<skill>` for an agent run, `human:<id>` for a person and `process:<name>` for a tool.
|
|
@@ -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-04T21:21:45Z"}
|
|
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-04T21:21:45Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
# pmos-log.sh accept <run_id> <initiative_id> <pass|fail> <note>
|
|
15
15
|
# pmos-log.sh outcome <initiative_id> <validated|refuted|inconclusive> [evidence]
|
|
16
16
|
# pmos-log.sh friction <note...>
|
|
17
|
-
# pmos-log.sh grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes]
|
|
17
|
+
# pmos-log.sh grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes] [initiative_id]
|
|
18
18
|
# pmos-log.sh grant-use <grant_id> <action>
|
|
19
19
|
#
|
|
20
20
|
# `grant` and `grant-use` belong to the cockpit mod (D81). A grant is the PM's typed permission for
|
|
21
21
|
# ONE action: single use, 120 minutes unless a shorter time is given (24 hours at most), one target. A
|
|
22
22
|
# standing scope (docs-sync, outward:*) and any other scope is refused. The mod mints a grant only from
|
|
23
|
-
# the PM's typed /pmos-grant or a press, and denies a model that runs either subcommand.
|
|
23
|
+
# the PM's typed /pmos-grant or a press, and denies a model that runs either subcommand. A grant may
|
|
24
|
+
# name its initiative (a slug with a record in state/initiatives/), so the mod's local gate has an
|
|
25
|
+
# initiative to run against on a branch that names none (Run 7). Without it the row is unchanged.
|
|
24
26
|
# PMOS_STATE_DIR names the folder of the ledgers when it is not <kit>/state (PMOS's own repository).
|
|
25
27
|
set -euo pipefail
|
|
26
28
|
|
|
@@ -167,11 +169,11 @@ case "$cmd" in
|
|
|
167
169
|
echo "✅ appended to state/discoveries.md: - $today $* #friction"
|
|
168
170
|
;;
|
|
169
171
|
grant)
|
|
170
|
-
[ $# -ge 3 ] || fail "usage: grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes]"
|
|
172
|
+
[ $# -ge 3 ] || fail "usage: grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes] [initiative_id]"
|
|
171
173
|
nonempty pm "$2"; nonempty instruction "$3"
|
|
172
|
-
row="$(python3 - "$1" "$2" "$3" "${4:-120}" <<'PY'
|
|
173
|
-
import datetime, json, re, secrets, sys
|
|
174
|
-
scope, pm, instruction, ttl = sys.argv[1:
|
|
174
|
+
row="$(python3 - "$1" "$2" "$3" "${4:-120}" "${5:-}" "$STATE" <<'PY'
|
|
175
|
+
import datetime, json, os, re, secrets, sys
|
|
176
|
+
scope, pm, instruction, ttl, initiative, state = sys.argv[1:7]
|
|
175
177
|
def die(m):
|
|
176
178
|
sys.stderr.write("pmos-log: ERROR — %s\n" % m); sys.exit(1)
|
|
177
179
|
if re.match(r"^(docs-sync|outward)(:|$)", scope):
|
|
@@ -184,10 +186,18 @@ if not re.match(pat, target) or "*" in target or ".." in target:
|
|
|
184
186
|
die('"%s" is not a single %s. A grant names one target; a wildcard is refused.' % (target, "PR number or branch name" if kind == "merge" else "artifact name"))
|
|
185
187
|
if not re.match(r"^[0-9]{1,4}$", ttl) or not (1 <= int(ttl) <= 1440):
|
|
186
188
|
die("ttl_minutes must be a whole number from 1 to 1440, got '%s'." % ttl)
|
|
189
|
+
if initiative:
|
|
190
|
+
if not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", initiative):
|
|
191
|
+
die('initiative "%s" is not a plain lowercase slug.' % initiative)
|
|
192
|
+
if not os.path.isfile(os.path.join(state, "initiatives", initiative + ".md")):
|
|
193
|
+
die('initiative "%s" has no record at state/initiatives/%s.md.' % (initiative, initiative))
|
|
187
194
|
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
|
|
188
195
|
fmt = lambda d: d.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
189
|
-
|
|
190
|
-
|
|
196
|
+
row = {"type": "grant", "id": "g-%d-%s" % (int(now.timestamp()), secrets.token_hex(3)), "scope": kind, "target": target,
|
|
197
|
+
"pm": pm, "at": fmt(now), "expires_at": fmt(now + datetime.timedelta(minutes=int(ttl))), "uses": 1, "instruction": instruction}
|
|
198
|
+
if initiative:
|
|
199
|
+
row["initiative"] = initiative
|
|
200
|
+
print(json.dumps(row, separators=(",", ":")))
|
|
191
201
|
PY
|
|
192
202
|
)" || exit 1
|
|
193
203
|
append grants.jsonl "$row"
|