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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +1 -1
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +13 -3
  7. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
  8. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
  9. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
  10. package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
  12. package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
  13. package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
  14. package/payload/okf/core/concepts/frontmatter-format.md +6 -1
  15. package/payload/planning/evals/eval-calibration.md +1 -1
  16. package/payload/planning/okrs/health-budgets.md +1 -1
  17. package/payload/scripts/pmos-log.sh +18 -8
  18. package/payload/scripts/pmos-model.js +233 -2
  19. package/payload/scripts/status-facts.js +204 -0
  20. package/payload/scripts/workflow-state.js +52 -0
  21. package/payload/skills/SKILL-FORMAT.md +1 -0
  22. package/payload/state/discoveries.md +1 -1
  23. package/workspace/README.md +3 -3
  24. package/workspace/bin/live-notes.js +133 -0
  25. package/workspace/design-parity.js +328 -21
  26. package/workspace/doc-category-bench.js +165 -0
  27. package/workspace/fixtures/doc-category-labelled.json +167 -0
  28. package/workspace/guide/initiatives-and-next-steps.md +16 -2
  29. package/workspace/guide/knowledge-graph.md +89 -2
  30. package/workspace/guide/layout.md +30 -2
  31. package/workspace/guide/proactive-engine.md +26 -4
  32. package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
  33. package/workspace/guide/statuses.md +1 -0
  34. package/workspace/guide/validate-with-typesafe.md +17 -1
  35. package/workspace/lib/claims.js +51 -2
  36. package/workspace/lib/doc-category-corrections.js +113 -0
  37. package/workspace/lib/doc-category-writeback.js +110 -0
  38. package/workspace/lib/doc-category.js +225 -0
  39. package/workspace/lib/engine/pmos-model.js +233 -2
  40. package/workspace/lib/engine/status-facts.js +204 -0
  41. package/workspace/lib/engine/workflow-state.js +52 -0
  42. package/workspace/lib/jev-categorize.js +399 -0
  43. package/workspace/lib/jev-claims.js +59 -15
  44. package/workspace/lib/jev-route.js +129 -0
  45. package/workspace/lib/kg-cli.js +5 -2
  46. package/workspace/lib/kg-dangling-baseline.json +1 -0
  47. package/workspace/lib/kg-dangling-rules.json +1 -0
  48. package/workspace/lib/kg.js +26 -9
  49. package/workspace/lib/learn.js +282 -0
  50. package/workspace/lib/live-notes.js +890 -0
  51. package/workspace/lib/proactive-cli.js +49 -1
  52. package/workspace/lib/proactive.js +8 -7
  53. package/workspace/lib/typesafe-settings.js +10 -2
  54. package/workspace/package-lock.json +1 -1
  55. package/workspace/package.json +5 -4
  56. package/workspace/public/pm/app.js +148 -79
  57. package/workspace/public/pm/data.js +58 -0
  58. package/workspace/public/pm/fixture.js +40 -2
  59. package/workspace/public/pm/modes-live.js +267 -21
  60. package/workspace/public/pm/panel-cockpit.js +636 -0
  61. package/workspace/public/pm/panel-detail.js +210 -8
  62. package/workspace/public/pm/panel-initiatives.js +9 -9
  63. package/workspace/public/pm/panel-newsess.js +145 -86
  64. package/workspace/public/pm/panel-repos.js +34 -17
  65. package/workspace/public/pm/panel-tabs.js +145 -27
  66. package/workspace/public/pm/state.js +45 -4
  67. package/workspace/public/pm/tab-doc.js +244 -16
  68. package/workspace/public/pm/tab-term.js +11 -1
  69. package/workspace/public/pm/tokens.css +4 -3
  70. package/workspace/public/pm/view-addrepo.js +207 -0
  71. package/workspace/public/pm/view-files.js +197 -0
  72. package/workspace/public/pm/view-guide.js +141 -0
  73. package/workspace/public/pm/view-knowledge.js +225 -0
  74. package/workspace/public/pm/view-memory.js +122 -0
  75. package/workspace/public/pm/view-portfolio.js +153 -0
  76. package/workspace/public/pm/view-reports.js +190 -0
  77. package/workspace/public/pm/view-scheduled.js +161 -0
  78. package/workspace/public/pm/view-skills.js +216 -0
  79. package/workspace/public/pm/view-today.js +168 -0
  80. package/workspace/public/pm/view-validate.js +171 -0
  81. package/workspace/server.js +394 -10
  82. package/workspace/skills/live-notes.skill +58 -0
  83. 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
- async function localGate($: any, layout: any, root: string, id: string | null, branch: string | null) {
86
- if (id === null) return { ran: false, failed: false, text: 'Local gate not run: the branch names no initiative.' }
87
- const lines: string[] = []
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
- const initiative = resolveInitiative({
186
- branch: facts.branch,
187
- recordExists: facts.branch ? await $.fs.exists(recordPath(layout, facts.branch.replace(/^initiative\//, '')) ?? '/nonexistent') : null,
188
- commitsAhead: facts.commitsAhead,
189
- })
190
- const gate = await localGate($, layout, root, initiative.id, facts.branch)
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
- const plan = planGrant(e.args, { layout, root, pm: await whoAmI($, root) })
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-04T09:45:16Z"}
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-04T09:45:16Z"}
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:5]
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
- print(json.dumps({"type": "grant", "id": "g-%d-%s" % (int(now.timestamp()), secrets.token_hex(3)), "scope": kind, "target": target,
190
- "pm": pm, "at": fmt(now), "expires_at": fmt(now + datetime.timedelta(minutes=int(ttl))), "uses": 1, "instruction": instruction}, separators=(",", ":")))
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"