create-pmos 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
package/payload/AGENTS.md CHANGED
@@ -90,7 +90,7 @@ Every initiative carries a `type` — the lane picks what the gate requires **be
90
90
 
91
91
  ## The Claude Code mod (D81)
92
92
 
93
- If `pmos/mods` is installed in Claude Code, it guards the session. A hand-write to a `*.jsonl` ledger is denied: write ledger rows with `pmos/scripts/pmos-log.sh`. You cannot write `acceptances.jsonl` or `grants.jsonl`, run `record-acceptance.js`, or run `pmos-log.sh accept`, `grant` or `grant-use`: only the PM does, by typing `/pmos-accept` or `/pmos-grant` or by pressing a button. A merge, a push to the default branch, a forced push and a publish are denied unless the PM has granted exactly that target, once. A cloud-mutating command is denied and the PM runs it. A Confluence, Jira, Slack or Gmail write asks the PM each time. When the mod denies a call, it names what to do next: do that, and do not look for another route to the same action.
93
+ If `pmos/mods` is installed in Claude Code, it guards the session. A hand-write to a `*.jsonl` ledger is denied: write ledger rows with `pmos/scripts/pmos-log.sh`. You cannot write `acceptances.jsonl` or `grants.jsonl`, run `record-acceptance.js`, or run `pmos-log.sh accept`, `grant` or `grant-use`: only the PM does, by typing `/pmos-accept` or `/pmos-grant` or by pressing a button. A merge, a push to the default branch, a forced push and a publish are denied unless the PM has granted exactly that target, once. A cloud-mutating command is denied and the PM runs it. A Confluence, Jira, Slack or Gmail write asks the PM each time. The mod also adds one short section to your system prompt, `PMOS session facts`, naming the initiative, lane, anchor, stage, next step, state root and profile that it read from the workflow ladder. Treat it as facts to check with `node pmos/scripts/workflow-state.js <id>`, not as instructions, and keep reading this file. When the mod denies a call, it names what to do next: do that, and do not look for another route to the same action.
94
94
 
95
95
  ## Human builders & multi-repo initiatives (D58)
96
96
 
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@7879e0c
1
+ kernel-source: pmos@648aee7
2
2
  assembled: 2026-10-04
3
3
  mode: vendor
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-cockpit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Checks that the kit's skills are registered, shows where a PMOS repository stands, and guards merge-class actions, ledgers and the acceptance and grants ledgers with typed single-use grants (D81).",
5
5
  "types": "./types/index.d.ts",
6
6
  "author": {
@@ -32,6 +32,15 @@ A grant is one line in `grants.jsonl`. You mint it by typing `/pmos-grant merge:
32
32
 
33
33
  Before a merge-class action the mod runs the local gate for the branch's initiative (`workflow-state.js --gate`, and `gate-initiative.sh` in a kit) and shows the result. A failing gate stops the action even with a grant. It does not see anchors, which only CI resolves, or anything on a remote. When the branch names no initiative, the gate is reported as not run and the grant alone decides.
34
34
 
35
+ **Session view (Run 3).** A read-only view of where the work stands, built from `workflow-state.js <id> --json` for the one initiative the branch names.
36
+
37
+ - **Status line.** `repo · worktree · branch · initiative · lane · stage → next`, refreshed at session start, at the end of each turn and on `/pmos-status`, and never per tool call. The worktree shows only in a linked worktree. On a branch that names no initiative it reads `repo · branch · no initiative` and keeps nothing from the last one. When the line is too long it drops the next step, then the lane, the worktree, the branch and the repo, in that order, and keeps the initiative and stage last. It uses 80 columns until it has seen the width of your terminal.
38
+ - **Facts.** One initiative only, never the whole board. The result is cached for the initiative at one commit for 60 seconds. Each process has a 5 second limit, the whole refresh has one too, and output over 256 KB is refused. A failed read shows "stage unavailable", and `/pmos-status` says why.
39
+ - **Band.** One line, only when something blocks, in this order: skill drift, a blocking ladder step (its rule id and the next step), a branch with commits and no initiative record.
40
+ - **Prompt section.** The one thing the mod says to the model on its own: `pmos-cockpit:session`, a fixed sentence over the initiative id, lane, anchor, stage, next step, state root and profile. Each value must match its pattern or it is left out. No title, evidence, label or branch name is ever included, and the section is at most 600 characters and absent when there is no initiative. It states facts, not instructions, and it does not replace reading `AGENTS.md`. The `userConfig` switch to turn it off is not built.
41
+ - **`/pmos-slice`.** Opens a pane with the ladder step by step: ✓ done, ✗ blocked, ○ pending, ◐ the pending step the ladder names next, ? unknown, – not applicable. It also shows the record, PRD and rubric paths and when and at which commit the facts were derived. It opens only on this command, and Esc closes it. A docked pane needs a fullscreen terminal of 110 columns or more; otherwise it sits above the prompt.
42
+ - **Two ladders.** A `product-code` repository has thirteen steps. A `pm-artifacts` repository (D68) has route, brief, draft, review, approved and handoff. The view reads both, and a test pins both sets, with the stages, lanes and profiles, to `scripts/workflow-state.js`.
43
+
35
44
  ## What it does not cover
36
45
 
37
46
  A shell can do what a matcher does not name. The guards are defence in depth. The writers validate, and branch protection or CI stays the real gate where a remote exists. These routes are not caught, and `hooks/lib/bypass.test.mjs` asserts that so the list stays honest:
@@ -42,6 +51,8 @@ A shell can do what a matcher does not name. The guards are defence in depth. Th
42
51
  - a Bash write through a link that points at a ledger (Edit and Write are resolved to where the link leads; Bash is not);
43
52
  - `cd npm-pkg && npm publish`: the mod reads the package name from the session root, so a publish from another folder finds no matching grant and is denied. Run it yourself, or run it from the folder.
44
53
 
54
+ The session view is a snapshot, not a status. The facts can be a minute old, an artifact created and not yet committed is not seen until a commit or the cache expires, and the stage comes from which artifacts exist and in what order, not from whether they are any good. In a kit, the ladder reads the lane from `lane:` in the initiative record and the kit's gate reads it from `type:`; a record with only `type:` reads as `feature` in the view (a known friction, logged in `discoveries.md`).
55
+
45
56
  The outward ask depends on the permission mode's decider. It is not a guard when permissions are bypassed.
46
57
 
47
58
  ## Install
@@ -0,0 +1,34 @@
1
+ // The git questions the session view asks, as argv lists, and how to read the answers. The register
2
+ // module runs them through `$.process`; a node test runs the same argv in real temporary
3
+ // repositories (a plain checkout, a linked worktree, a detached head, no commits, no remote, no
4
+ // repository at all), so the two cannot drift.
5
+ //
6
+ // Pure: no I/O. Each probe is bounded by the caller's time limit.
7
+
8
+ export const PROBES = {
9
+ inside: ['git', 'rev-parse', '--is-inside-work-tree'],
10
+ branch: ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
11
+ sha: ['git', 'rev-parse', 'HEAD'],
12
+ dirs: ['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'],
13
+ defaultBranch: ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
14
+ };
15
+
16
+ /** The commits-ahead probe depends on the default branch, so it is built when asked. */
17
+ export const aheadProbe = (defaultBranch) => ['git', 'rev-list', '--count', `${defaultBranch ?? 'main'}..HEAD`];
18
+
19
+ const text = (r) => (r && r.exitCode === 0 && typeof r.stdout === 'string' ? r.stdout.trim() : '');
20
+
21
+ /**
22
+ * @param {Record<string, { exitCode: number, stdout: string }|undefined>} r results by probe name
23
+ * @returns {{ noGit: boolean, branch: string|null, sha: string|null, linked: boolean, defaultBranch: string|null, commitsAhead: number|null }}
24
+ */
25
+ export function interpretGit(r) {
26
+ if (text(r.inside) !== 'true') return { noGit: true, branch: null, sha: null, linked: false, defaultBranch: null, commitsAhead: null };
27
+ const branch = text(r.branch) || null;
28
+ const sha = /^[0-9a-f]{40,64}$/.test(text(r.sha)) ? text(r.sha) : null;
29
+ const dirs = text(r.dirs).split('\n').map((l) => l.trim()).filter(Boolean);
30
+ const linked = dirs.length === 2 && dirs[0] !== dirs[1];
31
+ const def = text(r.defaultBranch).replace(/^origin\//, '') || null;
32
+ const n = text(r.ahead);
33
+ return { noGit: false, branch, sha, linked, defaultBranch: def, commitsAhead: /^\d{1,9}$/.test(n) ? Number(n) : null };
34
+ }
@@ -0,0 +1,49 @@
1
+ // The one thing the cockpit mod says to the model on its own (D81, M11): a short system-prompt
2
+ // section giving the session's initiative facts.
3
+ //
4
+ // It is built from a FIXED TEMPLATE over ALLOW-LISTED FIELDS. Each value must match its pattern or
5
+ // be in its set, or it is left out. It is never escaped, because a value that needs escaping is a
6
+ // value that should not be there. No title, evidence, label, branch name or other free text can
7
+ // enter: a record or a branch is written by people, and a model reads this text as part of its
8
+ // instructions. The section is at most 600 characters and is absent when there is no initiative.
9
+ //
10
+ // Pure: no I/O.
11
+
12
+ import { KNOWN_STEPS, LANES, PROFILES, STAGES } from './session-facts.mjs';
13
+
14
+ export const SECTION_ID = 'pmos-cockpit:session';
15
+ export const MAX_SECTION_CHARS = 600;
16
+
17
+ const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/;
18
+ const ANCHOR = /^(?:q[1-4]-\d{4}:o\d{1,2}:kr\d{1,2}|health:[a-z0-9][a-z0-9-]{0,40})$/;
19
+ // a path inside the repository, written relative to it: letters, digits, dot, dash, underscore and slash
20
+ const REL_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+){0,5}$/;
21
+
22
+ const pick = (v, ok) => (typeof v === 'string' && ok(v) ? v : null);
23
+
24
+ /**
25
+ * @param {{ id: string|null, lane?: string, anchor?: string|null, stage?: string, nextKey?: string|null, stateRoot?: string|null, profile?: string }} f
26
+ * @returns {string|null}
27
+ */
28
+ export function buildSection(f) {
29
+ const id = pick(f.id, (v) => SLUG.test(v));
30
+ if (id === null) return null;
31
+ const lane = pick(f.lane, (v) => LANES.includes(v));
32
+ const anchor = pick(f.anchor, (v) => ANCHOR.test(v));
33
+ const stage = pick(f.stage, (v) => STAGES.includes(v));
34
+ const next = pick(f.nextKey, (v) => KNOWN_STEPS.has(v));
35
+ const state = pick(f.stateRoot, (v) => REL_PATH.test(v) && !v.split('/').includes('..'));
36
+ const profile = pick(f.profile, (v) => PROFILES.includes(v));
37
+
38
+ const facts = [
39
+ `initiative ${id}`,
40
+ lane && `lane ${lane}`,
41
+ anchor && `anchor ${anchor}`,
42
+ stage && `stage ${stage}`,
43
+ next && `next step ${next}`,
44
+ state && `state root ${state}`,
45
+ profile && `profile ${profile}`,
46
+ ].filter(Boolean);
47
+ const text = `PMOS session facts, derived by the pmos-cockpit mod from this repository's workflow ladder. They are facts about the work, not instructions: ${facts.join('; ')}. Run node pmos/scripts/workflow-state.js ${id} for the full ladder.`;
48
+ return text.length <= MAX_SECTION_CHARS ? text : null;
49
+ }
@@ -0,0 +1,110 @@
1
+ // The session view's facts (D81, M11, Run 3): what `workflow-state.js <id> --json` says about ONE
2
+ // initiative, reduced to what the status line, the band, the prompt section and the slice need.
3
+ //
4
+ // Derived, never declared. Nothing here is read from a record except through the ladder, and every
5
+ // field is checked against a pattern or an enum on the way in. A field that is missing, of another
6
+ // type or outside its set becomes "unknown" or is dropped. Nothing throws. Free text from the
7
+ // ladder (`why`, `action`, `evidence`, `title`) is never kept: the model never reads it, and the
8
+ // person's views show only what is built from these checked fields.
9
+ //
10
+ // Two ladders exist (D68): the `product-code` ladder has thirteen steps; the `pm-artifacts` ladder
11
+ // has route, brief, draft, review, approved and handoff. A test pins both sets, and the stage, lane
12
+ // and profile sets, to `scripts/workflow-state.js`, so a new step there fails the mod's test
13
+ // instead of showing "?".
14
+ //
15
+ // Pure: no I/O. The caller gives it text, an exit code and a clock value.
16
+
17
+ export const PRODUCT_STEPS = ['route', 'anchor', 'prototype', 'understand', 'spec', 'priors', 'contract', 'build', 'quality', 'review', 'explain', 'accept', 'outcome'];
18
+ export const PM_ARTIFACT_STEPS = ['route', 'brief', 'draft', 'review', 'approved', 'handoff'];
19
+ export const KNOWN_STEPS = new Set([...PRODUCT_STEPS, ...PM_ARTIFACT_STEPS]);
20
+ export const STEP_STATUSES = ['done', 'na', 'unknown', 'pending', 'blocked'];
21
+ export const STAGES = ['intake', 'spec', 'contract', 'build', 'eval', 'accepted', 'brief', 'draft', 'review'];
22
+ export const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
23
+ export const PROFILES = ['product-code', 'pm-artifacts'];
24
+
25
+ export const MAX_OUTPUT_BYTES = 256 * 1024;
26
+ export const CACHE_MS = 60_000;
27
+ export const PROCESS_LIMIT_MS = 5_000;
28
+
29
+ const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/;
30
+ const STEP_KEY = /^[a-z][a-z0-9_-]{0,30}$/;
31
+ const ANCHOR = /^(?:q[1-4]-\d{4}:o\d{1,2}:kr\d{1,2}|health:[a-z0-9][a-z0-9-]{0,40})$/;
32
+ const RULE = /^[A-Z][A-Z0-9]{0,3}$/;
33
+
34
+ // Our own labels. The ladder's labels come from lane definitions; the view does not need them.
35
+ const LABELS = {
36
+ route: 'route', anchor: 'anchor', prototype: 'prototype', understand: 'understand', spec: 'spec', priors: 'priors', contract: 'contract',
37
+ build: 'build', quality: 'quality', review: 'review', explain: 'explain', accept: 'accept', outcome: 'outcome',
38
+ brief: 'brief', draft: 'draft', approved: 'approved', handoff: 'handed off',
39
+ };
40
+ export const labelOf = (key) => LABELS[key] ?? key;
41
+
42
+ /** What the process gave back, as parsed JSON or the reason there is none. */
43
+ export function parseFactsOutput({ exitCode, stdout }) {
44
+ if (exitCode !== 0) return { ok: false, reason: `workflow-state exited ${exitCode}` };
45
+ const text = typeof stdout === 'string' ? stdout : '';
46
+ if (text.length > MAX_OUTPUT_BYTES) return { ok: false, reason: 'workflow-state output is over 256 KB' };
47
+ let json;
48
+ try { json = JSON.parse(text); } catch { return { ok: false, reason: 'workflow-state output is not JSON' }; }
49
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) return { ok: false, reason: 'workflow-state output is not an object' };
50
+ if (json.ok !== true) return { ok: false, reason: 'the ladder reported an error for this initiative' };
51
+ return { ok: true, json };
52
+ }
53
+
54
+ const inSet = (v, set) => (typeof v === 'string' && set.includes(v) ? v : 'unknown');
55
+
56
+ /**
57
+ * Reduce the ladder's JSON to checked fields. Never throws.
58
+ * @returns {{ id: string|null, lane: string, stage: string, profile: string, anchor: string|null, accepted: boolean, stopped: boolean,
59
+ * gateFailures: string[], blockers: string[], next: { key: string }|null, steps: { key: string, label: string, status: string }[] }}
60
+ */
61
+ export function summarize(json) {
62
+ const j = json !== null && typeof json === 'object' ? json : {};
63
+ const steps = [];
64
+ if (Array.isArray(j.steps)) {
65
+ for (const s of j.steps) {
66
+ if (s === null || typeof s !== 'object') continue;
67
+ if (typeof s.key !== 'string' || !STEP_KEY.test(s.key)) continue;
68
+ steps.push({ key: s.key, label: labelOf(s.key), status: inSet(s.status, STEP_STATUSES) });
69
+ }
70
+ }
71
+ const keys = (v) => (Array.isArray(v) ? v.filter((k) => typeof k === 'string' && STEP_KEY.test(k)) : []);
72
+ const rules = (v) => (Array.isArray(v) ? v.filter((k) => typeof k === 'string' && (RULE.test(k) || STEP_KEY.test(k))) : []);
73
+ const nextKey = j.next !== null && typeof j.next === 'object' && typeof j.next.step === 'string' && STEP_KEY.test(j.next.step) ? j.next.step : null;
74
+ return {
75
+ id: typeof j.id === 'string' && SLUG.test(j.id) ? j.id : null,
76
+ lane: inSet(j.lane, LANES),
77
+ stage: inSet(j.stage, STAGES),
78
+ profile: inSet(j.profile, PROFILES),
79
+ anchor: typeof j.anchor === 'string' && ANCHOR.test(j.anchor) ? j.anchor : null,
80
+ accepted: j.accepted === true,
81
+ stopped: j.stopped !== null && j.stopped !== undefined && typeof j.stopped === 'object',
82
+ gateFailures: rules(j.gate_failures),
83
+ blockers: keys(j.blockers),
84
+ next: nextKey === null ? null : { key: nextKey },
85
+ steps,
86
+ };
87
+ }
88
+
89
+ /** Is a cached entry good for this initiative at this commit, at this time? */
90
+ export function isFresh(entry, id, sha, nowMs) {
91
+ return entry !== null && typeof entry === 'object'
92
+ && entry.id === id && entry.sha === sha && typeof sha === 'string' && sha !== ''
93
+ && typeof entry.at === 'number' && nowMs >= entry.at && nowMs - entry.at < CACHE_MS;
94
+ }
95
+
96
+ export const GLYPH = { done: '✓', blocked: '✗', pending: '○', unknown: '?', na: '–' };
97
+ export const NEXT_GLYPH = '◐';
98
+
99
+ /**
100
+ * One row per step. The glyph follows the status; the one step the ladder names next shows ◐ in
101
+ * place of ○ while it is pending. A blocked step stays ✗, because blocked says more than next.
102
+ */
103
+ export function sliceRows(summary) {
104
+ const nextKey = summary.next?.key ?? null;
105
+ return summary.steps.map((s) => {
106
+ let glyph = GLYPH[s.status] ?? GLYPH.unknown;
107
+ if (s.key === nextKey && s.status === 'pending') glyph = NEXT_GLYPH;
108
+ return { key: s.key, label: s.label, status: s.status, glyph, isNext: s.key === nextKey };
109
+ });
110
+ }
@@ -0,0 +1,64 @@
1
+ // The one-line status (D81, M11): `repo · worktree · branch · initiative · lane · stage → next`.
2
+ //
3
+ // Honest first: on a branch with no initiative it says so and never keeps the previous initiative's
4
+ // facts. When the line is too long it drops parts in a stated order: the next step, then the lane,
5
+ // then the worktree, then the branch, then the repo. The initiative and the stage go last. Every
6
+ // piece is cleaned of control and direction-override characters, because a branch name is text a
7
+ // stranger may have chosen.
8
+ //
9
+ // Pure: no I/O.
10
+
11
+ export const DEFAULT_WIDTH = 80;
12
+ const SEP = ' · ';
13
+ const ARROW = ' → ';
14
+
15
+ // C0 and C1 controls, DEL, and the bidirectional overrides and isolates.
16
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f‪-‮⁦-⁩‎‏]/g;
17
+
18
+ /** Text safe to draw on one line, at most `max` characters. */
19
+ export function clean(value, max = 60) {
20
+ const s = String(value ?? '').replace(UNSAFE, ' ').replace(/\s+/g, ' ').trim();
21
+ return s.length > max ? `${s.slice(0, Math.max(1, max - 1))}…` : s;
22
+ }
23
+
24
+ const fit = (parts, next, width) => {
25
+ const text = parts.join(SEP) + (next ? ARROW + next : '');
26
+ return text.length <= width ? text : null;
27
+ };
28
+
29
+ /**
30
+ * @param {{ repo: string, worktree?: string|null, branch: string|null, noGit?: boolean,
31
+ * initiative: { id: string|null, state: string },
32
+ * facts: { stage: string, lane: string, next: { key: string }|null } | null,
33
+ * unavailable?: string|null, width?: number }} v
34
+ */
35
+ export function formatStatus(v) {
36
+ const width = Number.isInteger(v.width) && v.width >= 20 ? v.width : DEFAULT_WIDTH;
37
+ const repo = clean(v.repo, 30) || 'repo';
38
+ if (v.noGit) return clean(`${repo}${SEP}no git`, width);
39
+ const branch = v.branch === null || v.branch === '' || v.branch === 'HEAD' ? '(detached)' : clean(v.branch, 40);
40
+
41
+ if (v.initiative.id === null) return clean(`${repo}${SEP}${branch}${SEP}no initiative`, width);
42
+ const id = clean(v.initiative.id, 40);
43
+ if (v.initiative.state === 'no-record') return clean(`${repo}${SEP}${branch}${SEP}no record for ${id}`, width);
44
+
45
+ const stage = v.facts ? clean(v.facts.stage, 12) : 'stage unavailable';
46
+ const lane = v.facts ? clean(v.facts.lane, 12) : null;
47
+ const next = v.facts?.next ? clean(v.facts.next.key, 16) : null;
48
+ const worktree = v.worktree ? clean(v.worktree, 24) : null;
49
+
50
+ // each attempt drops one more part, in the stated order
51
+ const attempts = [
52
+ [[repo, worktree, branch, id, lane, stage], next],
53
+ [[repo, worktree, branch, id, lane, stage], null],
54
+ [[repo, worktree, branch, id, stage], null],
55
+ [[repo, branch, id, stage], null],
56
+ [[repo, id, stage], null],
57
+ [[id, stage], null],
58
+ ];
59
+ for (const [parts, n] of attempts) {
60
+ const text = fit(parts.filter((p) => p), n, width);
61
+ if (text !== null) return text;
62
+ }
63
+ return clean(`${id}${SEP}${stage}`, width);
64
+ }
@@ -9,11 +9,22 @@ import { recordPath, resolveInitiative } from './lib/initiative.mjs'
9
9
  import { detectLayout } from './lib/layout.mjs'
10
10
  import { parseProfile } from './lib/profile.mjs'
11
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'
12
16
 
13
17
  const status = atom({ plugin: 'pmos-cockpit', key: 'status' } as const, null)
14
18
  const isHidden = atom({ plugin: 'pmos-cockpit', key: 'isHidden' } as const, false)
15
19
  // A run the person's last message may have accepted. It is an offer to press, never a record.
16
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
17
28
 
18
29
  // ── helpers for the guards and commands ───────────────────────────────────────────────────────────
19
30
  const out = (r: any): string => (r && typeof r.stdout === 'string' ? r.stdout.trim() : '')
@@ -29,13 +40,13 @@ async function rootAndLayout($: any) {
29
40
 
30
41
  // What git says about the session's checkout. A linked worktree answers for itself.
31
42
  async function gitFacts($: any, root: string) {
32
- const b = await sh($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root })
43
+ const b = await sh($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
33
44
  const branch = b.exitCode === 0 && out(b) !== '' ? out(b) : null
34
- const d = await sh($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: root })
45
+ const d = await sh($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
35
46
  const defaultBranch = d.exitCode === 0 && out(d) !== '' ? out(d).replace(/^origin\//, '') : null
36
47
  let commitsAhead: number | null = null
37
48
  if (branch !== null) {
38
- const c = await sh($, ['git', 'rev-list', '--count', `${defaultBranch ?? 'main'}..HEAD`], { cwd: root })
49
+ const c = await sh($, ['git', 'rev-list', '--count', `${defaultBranch ?? 'main'}..HEAD`], { cwd: root, timeoutMs: PROCESS_LIMIT_MS })
39
50
  commitsAhead = c.exitCode === 0 && /^\d+$/.test(out(c)) ? Number(out(c)) : null
40
51
  }
41
52
  return { branch, defaultBranch, commitsAhead }
@@ -297,6 +308,78 @@ async function repair($: any): Promise<CockpitStatus> {
297
308
  return gather($, done, error)
298
309
  }
299
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
+
300
383
  const summary = (s: CockpitStatus): string => {
301
384
  const k = s.skills
302
385
  const lines = [
@@ -331,9 +414,11 @@ export const register: Register = (on) => {
331
414
  await $.command.register({ name: 'pmos-status', description: 'Show the PMOS layout, profile and skill registration' })
332
415
  await $.command.register({ name: 'pmos-repair-skills', description: 'Link the kit skills that are not registered in .claude/skills' })
333
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' })
334
418
  await $.command.register({ name: 'pmos-accept', description: 'Record your Acceptance of a run: /pmos-accept <run_id> pass|fail <note>' })
335
419
  const s = await gather($)
336
420
  await update($, status, () => s)
421
+ await refreshView($)
337
422
  if (s.skills && s.skills.missing.length > 0) {
338
423
  $.ui.toast(`PMOS: ${s.skills.missing.length} kit skill(s) are not registered. Run /pmos-repair-skills.`)
339
424
  }
@@ -343,7 +428,81 @@ export const register: Register = (on) => {
343
428
  on('command.run', { command: 'pmos-status' }, async ($) => {
344
429
  const s = await gather($)
345
430
  await update($, status, () => s)
346
- return { text: summary(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
+ }
347
506
  })
348
507
 
349
508
  on('command.run', { command: 'pmos-repair-skills' }, async ($) => {
@@ -411,6 +570,7 @@ export const register: Register = (on) => {
411
570
  })
412
571
 
413
572
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
573
+ if (typeof e.viewport?.columns === 'number') lastColumns = e.viewport.columns
414
574
  const s = await read($, status)
415
575
  const o: any = await read($, offer)
416
576
  if (o && !e.props.hasSurvey) {
@@ -425,11 +585,25 @@ export const register: Register = (on) => {
425
585
  )
426
586
  }
427
587
  if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
428
- if (!hasDrift(s) && s?.initiative?.warn) {
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) {
429
603
  const { Box, Button, Text } = $.ui.resolve(e)
430
604
  return (
431
605
  <Box>
432
- <Text>PMOS: {s.initiative.text}. </Text>
606
+ <Text>PMOS: {clean(v.initiative.text, 120)}. </Text>
433
607
  <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
434
608
  </Box>
435
609
  )
@@ -27,8 +27,34 @@ export type CockpitStatus = {
27
27
  repairError: string | null
28
28
  }
29
29
 
30
+ export type ViewState = {
31
+ text: string
32
+ initiative: InitiativeFact
33
+ summary: {
34
+ id: string | null
35
+ lane: string
36
+ stage: string
37
+ profile: string
38
+ anchor: string | null
39
+ accepted: boolean
40
+ stopped: boolean
41
+ gateFailures: string[]
42
+ blockers: string[]
43
+ next: { key: string } | null
44
+ steps: { key: string; label: string; status: string }[]
45
+ } | null
46
+ unavailable: string | null
47
+ links: { record: string | null; prd: string | null; rubric: string | null } | null
48
+ sha: string | null
49
+ at: number
50
+ stateRel: string | null
51
+ noGit: boolean
52
+ }
53
+
54
+ export type FactsCache = { id: string | null; sha: string; at: number; summary: NonNullable<ViewState['summary']> }
55
+
30
56
  declare module 'claude-code' {
31
57
  interface PluginState {
32
- 'pmos-cockpit': { status: CockpitStatus | null; isHidden: boolean; offer: { run_id: string; initiative_id: string; message: string } | null }
58
+ 'pmos-cockpit': { status: CockpitStatus | null; isHidden: boolean; offer: { run_id: string; initiative_id: string; message: string } | null; view: ViewState | null; factsCache: FactsCache | null }
33
59
  }
34
60
  }
@@ -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-04T08:22:32Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T09:05:06Z"}
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-04T08:22:32Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T09:05:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-04T08:22:32Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T09:05:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",