create-pmos 0.43.0 → 0.44.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 (32) hide show
  1. package/bin/cockpit-install.js +130 -0
  2. package/bin/create-pmos.js +47 -4
  3. package/package.json +1 -1
  4. package/payload/AGENTS-HUMAN.md +10 -25
  5. package/payload/AGENTS.md +4 -0
  6. package/payload/KIT-VERSION +1 -1
  7. package/payload/mods/.claude-plugin/marketplace.json +12 -0
  8. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +9 -0
  9. package/payload/mods/pmos-cockpit/README.md +60 -0
  10. package/payload/mods/pmos-cockpit/hooks/hooks.json +1 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +53 -0
  12. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +88 -0
  13. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +561 -0
  14. package/payload/mods/pmos-cockpit/hooks/lib/initiative.mjs +54 -0
  15. package/payload/mods/pmos-cockpit/hooks/lib/layout.mjs +31 -0
  16. package/payload/mods/pmos-cockpit/hooks/lib/profile.mjs +17 -0
  17. package/payload/mods/pmos-cockpit/hooks/lib/skills.mjs +69 -0
  18. package/payload/mods/pmos-cockpit/hooks/register.tsx +460 -0
  19. package/payload/mods/pmos-cockpit/types/index.d.ts +34 -0
  20. package/payload/planning/evals/eval-calibration.md +1 -1
  21. package/payload/planning/okrs/health-budgets.md +1 -1
  22. package/payload/scripts/pmos-log.sh +119 -7
  23. package/payload/skills/pmos-router.skill +1 -1
  24. package/payload/skills/prototype.skill +1 -1
  25. package/payload/skills/strategic-brief.skill +1 -1
  26. package/payload/skills-native/harness-refine/SKILL.md +1 -1
  27. package/payload/skills-native/pmos-router/SKILL.md +1 -1
  28. package/payload/skills-native/prototype/SKILL.md +1 -1
  29. package/payload/skills-native/strategic-brief/SKILL.md +1 -1
  30. package/payload/state/discoveries.md +1 -1
  31. package/workspace/package-lock.json +1 -1
  32. package/workspace/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+ /* cockpit-install.js — the two things create-pmos does for the cockpit mod (D81).
3
+ *
4
+ * mergeSettings(repoRoot) offer the mod through the repository's .claude/settings.json
5
+ * linkSkills(repoRoot) register the kit's skills where Claude Code looks (.claude/skills)
6
+ *
7
+ * Both are conservative on purpose. They add; they never replace. A settings key that already holds a
8
+ * value is kept, a settings file that is not valid JSON is left alone, and a name already taken in
9
+ * .claude/skills is left alone, because it may be the product's own skill. Zero dependencies, no
10
+ * network. Kept in its own module so a test can import it; create-pmos.js runs its CLI at load.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const MARKETPLACE = 'pmos-mods';
16
+ const PLUGIN = 'pmos-cockpit';
17
+ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
18
+
19
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
20
+
21
+ /** What the settings file must carry for Claude Code to offer the mod. */
22
+ function wanted() {
23
+ return {
24
+ extraKnownMarketplaces: { [MARKETPLACE]: { source: { source: 'directory', path: './pmos/mods' } } },
25
+ enabledPlugins: { [`${PLUGIN}@${MARKETPLACE}`]: true },
26
+ };
27
+ }
28
+
29
+ /** The two commands that do by hand what the settings entry does, for `--no-mod` and for a refusal. */
30
+ function manualCommands() {
31
+ return [`claude plugin marketplace add ./pmos/mods`, `claude plugin install ${PLUGIN}@${MARKETPLACE}`];
32
+ }
33
+
34
+ /**
35
+ * Deep-merge the marketplace entry and `enabledPlugins` into `<repo>/.claude/settings.json`.
36
+ * Existing keys are kept as they are. Nothing is written when nothing is missing.
37
+ * @returns {{ changed: boolean, created: boolean, kept: string[], refused: string|null, file: string }}
38
+ */
39
+ function mergeSettings(repoRoot) {
40
+ const file = path.join(repoRoot, '.claude', 'settings.json');
41
+ const existed = fs.existsSync(file);
42
+ let cur = {};
43
+ if (existed) {
44
+ try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); }
45
+ catch (e) { return { changed: false, created: false, kept: [], refused: `${file} is not valid JSON (${e.message}); left untouched`, file }; }
46
+ if (!isPlainObject(cur)) return { changed: false, created: false, kept: [], refused: `${file} is not a JSON object; left untouched`, file };
47
+ }
48
+ const kept = [];
49
+ let changed = false;
50
+ const want = wanted();
51
+ for (const top of Object.keys(want)) {
52
+ if (cur[top] === undefined) { cur[top] = want[top]; changed = true; continue; }
53
+ if (!isPlainObject(cur[top])) { kept.push(`${top} (not an object, kept)`); continue; }
54
+ for (const key of Object.keys(want[top])) {
55
+ if (cur[top][key] === undefined) { cur[top][key] = want[top][key]; changed = true; }
56
+ else if (JSON.stringify(cur[top][key]) !== JSON.stringify(want[top][key])) kept.push(`${top}.${key} (kept your value)`);
57
+ }
58
+ }
59
+ if (changed) {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ fs.writeFileSync(file, JSON.stringify(cur, null, 2) + '\n');
62
+ }
63
+ return { changed, created: changed && !existed, kept, refused: null, file };
64
+ }
65
+
66
+ /** Does a SKILL.md carry a description with text? A bare block-scalar indicator (">") does not. */
67
+ function descriptionHasText(text) {
68
+ const lines = String(text).split('\n');
69
+ if (lines[0].trim() !== '---') return false;
70
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
71
+ const fm = lines.slice(1, end === -1 ? lines.length : end);
72
+ const at = fm.findIndex((l) => /^description:/.test(l));
73
+ if (at === -1) return false;
74
+ const rest = fm[at].replace(/^description:\s*/, '').trimEnd();
75
+ if (!/^[>|][-+]?$/.test(rest)) return rest.replace(/^(["'])(.*)\1$/, '$2').trim() !== '';
76
+ for (const l of fm.slice(at + 1)) {
77
+ if (l.trim() === '' || /^[ \t]/.test(l)) { if (l.trim()) return true; continue; }
78
+ break;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ const lstat = (p) => { try { return fs.lstatSync(p); } catch { return null; } };
84
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
85
+
86
+ /**
87
+ * Link `.claude/skills/<name>` to `pmos/skills-native/<name>` for every kit skill that has none.
88
+ * @returns {{ linked: string[], ok: string[], blocked: {name:string, reason:string}[], badDescription: string[], errors: string[] }}
89
+ */
90
+ function linkSkills(repoRoot) {
91
+ const out = { linked: [], ok: [], blocked: [], badDescription: [], errors: [] };
92
+ const native = path.join(repoRoot, 'pmos', 'skills-native');
93
+ if (!fs.existsSync(native)) return out;
94
+ const dest = path.join(repoRoot, '.claude', 'skills');
95
+ for (const e of fs.readdirSync(native, { withFileTypes: true })) {
96
+ if (!e.isDirectory() || !NAME_RE.test(e.name)) continue;
97
+ const skillMd = path.join(native, e.name, 'SKILL.md');
98
+ let text = '';
99
+ try { text = fs.readFileSync(skillMd, 'utf8'); } catch { /* reported as no description */ }
100
+ if (!descriptionHasText(text)) { out.badDescription.push(e.name); continue; }
101
+
102
+ const link = path.join(dest, e.name);
103
+ const target = `../../pmos/skills-native/${e.name}`;
104
+ const st = lstat(link);
105
+ if (st === null) {
106
+ try { fs.mkdirSync(dest, { recursive: true }); fs.symlinkSync(target, link, 'dir'); out.linked.push(e.name); }
107
+ catch (err) { out.errors.push(`${e.name}: ${err.message}`); }
108
+ continue;
109
+ }
110
+ if (st.isSymbolicLink()) {
111
+ const r = real(link);
112
+ if (r !== null && r === real(path.join(native, e.name))) { out.ok.push(e.name); continue; }
113
+ if (r === null) {
114
+ let was = '';
115
+ try { was = fs.readlinkSync(link); } catch { /* keep blocked */ }
116
+ if (/(^|\/)pmos\/skills-native\//.test(was)) {
117
+ try { fs.unlinkSync(link); fs.symlinkSync(target, link, 'dir'); out.linked.push(e.name); }
118
+ catch (err) { out.errors.push(`${e.name}: ${err.message}`); }
119
+ continue;
120
+ }
121
+ }
122
+ out.blocked.push({ name: e.name, reason: 'a link that points somewhere else' });
123
+ continue;
124
+ }
125
+ out.blocked.push({ name: e.name, reason: 'a file or folder that is not a link (it may be a product skill)' });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ module.exports = { MARKETPLACE, PLUGIN, wanted, manualCommands, mergeSettings, linkSkills, descriptionHasText };
@@ -26,6 +26,7 @@ const { spawnSync } = require('child_process');
26
26
  const PAYLOAD = path.join(__dirname, '..', 'payload'); // the assembled pmos/ kit (file-mode)
27
27
  const WORKSPACE = path.join(__dirname, '..', 'workspace'); // the bundled Terminal Workspace app (D62)
28
28
  const PKG = require(path.join(__dirname, '..', 'package.json'));
29
+ const cockpit = require('./cockpit-install.js'); // D81 — the mod offer and the skill links
29
30
 
30
31
  // Kernel paths (relative to the kit root) that --update may rewrite. Everything else is state.
31
32
  const KERNEL = [
@@ -38,6 +39,7 @@ const KERNEL = [
38
39
  'okf/core',
39
40
  'scripts',
40
41
  'templates',
42
+ 'mods', // D81 — the cockpit mod; --update refreshes it, never the settings entry
41
43
  'planning/evals/check_rubrics.py',
42
44
  ];
43
45
 
@@ -59,6 +61,35 @@ function stampInstall(kitDir, mode) {
59
61
  fs.appendFileSync(path.join(kitDir, 'KIT-VERSION'), stamp);
60
62
  }
61
63
 
64
+ /* D81 — register the kit's skills where Claude Code looks, and (unless --no-mod) offer the cockpit mod
65
+ * through the repository's own settings. Additive only: it never replaces a settings key or a name
66
+ * already taken in .claude/skills. Claude Code asks the person to trust the project; this cannot
67
+ * install the mod silently. */
68
+ function reportSkillLinks(r) {
69
+ if (r.linked.length) console.log(` skills: linked ${r.linked.length} into .claude/skills (${r.linked.join(', ')})`);
70
+ if (r.ok.length && !r.linked.length) console.log(` skills: ${r.ok.length} already registered in .claude/skills`);
71
+ for (const b of r.blocked) console.log(` skills: left ${b.name} alone — ${b.reason}`);
72
+ if (r.badDescription.length) console.log(` skills: not linked, no description: ${r.badDescription.join(', ')}`);
73
+ for (const e of r.errors) console.log(` skills: could not link ${e}`);
74
+ }
75
+ function offerMod(repoRoot, opts) {
76
+ if (opts && opts.noMod) {
77
+ console.log(' mod: skipped (--no-mod). To add it later:');
78
+ for (const c of cockpit.manualCommands()) console.log(` ${c}`);
79
+ return;
80
+ }
81
+ const m = cockpit.mergeSettings(repoRoot);
82
+ if (m.refused) {
83
+ console.log(` mod: not offered — ${m.refused}. To add it by hand:`);
84
+ for (const c of cockpit.manualCommands()) console.log(` ${c}`);
85
+ return;
86
+ }
87
+ if (m.changed) console.log(` mod: pmos-cockpit offered in ${path.relative(repoRoot, m.file) || m.file}${m.created ? ' (created)' : ''}. If Claude Code does not offer it after you trust the project, run the two commands below.`);
88
+ if (m.changed) for (const c of cockpit.manualCommands()) console.log(` ${c}`);
89
+ else console.log(' mod: already offered in .claude/settings.json');
90
+ for (const k of m.kept) console.log(` mod: kept ${k}`);
91
+ }
92
+
62
93
  function vendor(targetRoot, opts) {
63
94
  payloadOrDie();
64
95
  const kit = path.join(targetRoot, 'pmos');
@@ -85,6 +116,8 @@ function vendor(targetRoot, opts) {
85
116
  }
86
117
  console.log(`✅ PMOS kit vendored at ${kit} (${kitVersion().split('\n')[0]})`);
87
118
  console.log(` profile: ${fs.readFileSync(profileFile, 'utf8').split('\n')[0]} (${profileFile})`);
119
+ reportSkillLinks(cockpit.linkSkills(targetRoot));
120
+ offerMod(targetRoot, opts);
88
121
  console.log(`
89
122
  Next steps in ${targetRoot}:
90
123
  1. If the repo is not git yet: git init
@@ -94,7 +127,7 @@ Next steps in ${targetRoot}:
94
127
  4. Commit the pmos/ folder. Then follow pmos/AGENTS.md for every run.`);
95
128
  }
96
129
 
97
- function update(dirArg) {
130
+ function update(dirArg, opts) {
98
131
  payloadOrDie();
99
132
  // Accept either the kit dir itself or its parent repo root.
100
133
  let kit = path.resolve(dirArg || '.');
@@ -104,6 +137,7 @@ function update(dirArg) {
104
137
  if (!fs.existsSync(path.join(kit, 'state'))) {
105
138
  die(`${kit} does not look like a vendored kit (no state/) — refusing to update.`);
106
139
  }
140
+ const hadMods = fs.existsSync(path.join(kit, 'mods'));
107
141
  for (const rel of KERNEL) {
108
142
  const src = path.join(PAYLOAD, rel);
109
143
  if (!fs.existsSync(src)) continue;
@@ -118,6 +152,14 @@ function update(dirArg) {
118
152
  }
119
153
  stampInstall(kit, 'update');
120
154
  console.log(`✅ kit kernel updated at ${kit} (${kitVersion().split('\n')[0]})`);
155
+ reportSkillLinks(cockpit.linkSkills(path.dirname(kit)));
156
+ // --update never edits .claude/settings.json on its own. A kit that had no mod before gets one line
157
+ // saying how to offer it; --offer-mod does the same merge a fresh vendor does.
158
+ if (opts && opts.offerMod) offerMod(path.dirname(kit), {});
159
+ else if (!hadMods && fs.existsSync(path.join(kit, 'mods'))) {
160
+ console.log(' mod: pmos-cockpit is now in pmos/mods. To offer it, re-run with --offer-mod, or by hand:');
161
+ for (const c of cockpit.manualCommands()) console.log(` ${c}`);
162
+ }
121
163
  console.log('state/, planning/ (beyond check_rubrics.py), and okf/product/ were not touched. Review the diff as a normal PR.');
122
164
  }
123
165
 
@@ -285,7 +327,8 @@ if (args.includes('--help') || args.includes('-h')) {
285
327
  console.log('usage:');
286
328
  console.log(' create-pmos [target-dir] vendor the file-mode kit into a repo (pm-artifacts profile, D68)');
287
329
  console.log(' create-pmos [target-dir] --code …as a product-code repository instead (code lanes and ladder)');
288
- console.log(' create-pmos --update [kit-or-repo] refresh only the kit kernel');
330
+ console.log(' create-pmos [target-dir] --no-mod …without offering the pmos-cockpit mod in .claude/settings.json');
331
+ console.log(' create-pmos --update [kit-or-repo] refresh only the kit kernel (add --offer-mod to offer the mod in .claude/settings.json)');
289
332
  console.log(' create-pmos workspace [repo-dir] install/upgrade + launch the Terminal Workspace GUI');
290
333
  console.log(' create-pmos workspace --reinstall force-reinstall the workspace app (drop a stale global copy)');
291
334
  console.log(' create-pmos --version print version + bundled kernel provenance');
@@ -296,7 +339,7 @@ if (args.includes('--help') || args.includes('-h')) {
296
339
  } else if (positional[0] === 'workspace') {
297
340
  workspace(positional[1], { reinstall: args.includes('--reinstall') });
298
341
  } else if (args.includes('--update')) {
299
- update(positional[0]);
342
+ update(positional[0], { offerMod: args.includes('--offer-mod') });
300
343
  } else {
301
- vendor(path.resolve(positional[0] || '.'), { code: args.includes('--code') });
344
+ vendor(path.resolve(positional[0] || '.'), { code: args.includes('--code'), noMod: args.includes('--no-mod') });
302
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.43.0",
3
+ "version": "0.44.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": {
@@ -6,22 +6,12 @@ timestamp: 2026-07-18
6
6
 
7
7
  # AGENTS-HUMAN.md — the PMOS kit for a human builder (D58)
8
8
 
9
- You're a person, not an agent — same loop, plain terms, one helper. The full protocol lives in
10
- [AGENTS.md](AGENTS.md); this page is everything you need day-to-day. **Never edit `pmos/state/`
11
- files by hand — use the helper**, which validates and appends (records are append-only; a
12
- correction is a new record, never an edit).
9
+ You're a person, not an agent — same loop, plain terms, one helper. The full protocol lives in [AGENTS.md](AGENTS.md); this page is everything you need day-to-day. **Never edit `pmos/state/` files by hand — use the helper**, which validates and appends (records are append-only; a correction is a new record, never an edit).
13
10
 
14
11
  ## The loop (per piece of work)
15
12
 
16
- 1. **Open the work.** Create `pmos/state/initiatives/<id>.md` with a lane
17
- (`type: feature | discovery | chore | incident | content`) and its artifact — feature: PRD +
18
- rubric (copy the templates from `pmos/okf/core/templates/`); discovery: a spike brief; chore:
19
- an `intent:` line; incident: fix first, record within 48h; content: a content brief. Anchor to
20
- a KR in `pmos/planning/okrs/` — or, for maintenance work with no honest KR, to a declared
21
- health budget (`parent_kr: health:<id>` from `pmos/planning/okrs/health-budgets.md`). Always
22
- one or the other; never unanchored.
23
- 2. **Branch `initiative/<id>`, do the work.** For feature work, write your tests first where you
24
- can — the CI you wired via `templates/product-ci-starter.yml` is your Quality Gate.
13
+ 1. **Open the work.** Create `pmos/state/initiatives/<id>.md` with a lane (`type: feature | discovery | chore | incident | content`) and its artifact — feature: PRD + rubric (copy the templates from `pmos/okf/core/templates/`); discovery: a spike brief; chore: an `intent:` line; incident: fix first, record within 48h; content: a content brief. Anchor to a KR in `pmos/planning/okrs/` — or, for maintenance work with no honest KR, to a declared health budget (`parent_kr: health:<id>` from `pmos/planning/okrs/health-budgets.md`). Always one or the other; never unanchored.
14
+ 2. **Branch `initiative/<id>`, do the work.** For feature work, write your tests first where you can — the CI you wired via `templates/product-ci-starter.yml` is your Quality Gate.
25
15
  3. **Record as you go** (each command prints exactly what it appended):
26
16
 
27
17
  ```sh
@@ -32,18 +22,13 @@ correction is a new record, never an edit).
32
22
  pmos/scripts/pmos-log.sh friction "what fought you"
33
23
  ```
34
24
 
35
- The helper rejects bad values with a clear message (wrong verdict/disposition, a
36
- validated/refuted outcome with no evidence, malformed score) — the same rules the hosted
37
- backend enforces.
38
- 4. **Merge = the Acceptance Gate.** The **accepting human (the PM)** merges and records it
39
- (`accept … pass`) — if you built it and someone else accepts, they merge, not you. A solo
40
- operator is their own PM and merges with that hat on. If
41
- you declared an `outcome_metric` on the initiative, you're not done until you dispose it at
42
- its horizon (`outcome …`).
43
- 5. **Weekly (15 min):** run `python3 pmos/scripts/metrics.py` and read the red first; skim
44
- `pmos/state/discoveries.md`.
25
+ Two more subcommands belong to the Claude Code mod and to you, not to an agent: `pmos/scripts/pmos-log.sh grant <merge:<PR>|publish:<artifact>> <you> "<your words>" [minutes]` mints one single-use grant, and `grant-use` records that it was spent. A standing scope (`docs-sync`, `outward:*`) is refused. The mod denies an agent that runs `accept`, `grant` or `grant-use`.
26
+
27
+ The helper rejects bad values with a clear message (wrong verdict/disposition, a validated/refuted outcome with no evidence, malformed score). It also refuses an acceptance that has no note, an acceptance for a run you have not logged with `run`, and any ledger file that is a symlink or has another hard link. Nothing is written when it refuses.
28
+
29
+ 4. **Merge = the Acceptance Gate.** The **accepting human (the PM)** merges and records it (`accept … pass`) — if you built it and someone else accepts, they merge, not you. A solo operator is their own PM and merges with that hat on. If you declared an `outcome_metric` on the initiative, you're not done until you dispose it at its horizon (`outcome …`).
30
+ 5. **Weekly (15 min):** run `python3 pmos/scripts/metrics.py` and read the red first; skim `pmos/state/discoveries.md`.
45
31
 
46
32
  ## Working with an agent sometimes?
47
33
 
48
- Mixed teams are fine: agents follow `AGENTS.md`, you follow this page — both write the same state
49
- through the same conventions, and `metrics.py` doesn't care who built.
34
+ Mixed teams are fine: agents follow `AGENTS.md`, you follow this page — both write the same state through the same conventions, and `metrics.py` doesn't care who built.
package/payload/AGENTS.md CHANGED
@@ -88,6 +88,10 @@ Every initiative carries a `type` — the lane picks what the gate requires **be
88
88
  + `pmos/planning/evals/check_rubrics.py`); **Review** = score against the rubric with the evaluator skill (adversarial, outcome-only, default-to-refute) and append to `evals.jsonl`; **Acceptance** = the human PM merges and disposes — final and non-delegable. Never merge your own PR.
89
89
  8. Log the run (`runs.jsonl`) and any friction (`discoveries.md`, `#friction`).
90
90
 
91
+ ## The Claude Code mod (D81)
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.
94
+
91
95
  ## Human builders & multi-repo initiatives (D58)
92
96
 
93
97
  - **A human building instead of an agent?** Same loop, plain terms: [AGENTS-HUMAN.md](AGENTS-HUMAN.md). State is written with `scripts/pmos-log.sh` (validated, append-only) — never hand-edited. Mixed human+agent teams write the same state through the same conventions.
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@e4f29fe
1
+ kernel-source: pmos@7879e0c
2
2
  assembled: 2026-10-04
3
3
  mode: vendor
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "pmos-mods",
3
+ "owner": { "name": "PMOS" },
4
+ "metadata": { "description": "Claude Code mods that ship with the PMOS kit." },
5
+ "plugins": [
6
+ {
7
+ "name": "pmos-cockpit",
8
+ "source": "./pmos-cockpit",
9
+ "description": "Guards one Claude Code session for a PMOS repository and shows where the work stands."
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "pmos-cockpit",
3
+ "version": "0.2.0",
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
+ "types": "./types/index.d.ts",
6
+ "author": {
7
+ "name": "PMOS"
8
+ }
9
+ }
@@ -0,0 +1,60 @@
1
+ ---
2
+ type: Reference
3
+ title: "pmos-cockpit — the PMOS Claude Code mod"
4
+ ---
5
+
6
+ # pmos-cockpit
7
+
8
+ A Claude Code mod that ships in the PMOS kit as `pmos/mods/pmos-cockpit`. It is a plugin of function hooks. It makes no network request and sends no telemetry. The decision behind it is [D81](/okf/products/pmos/adr/d81-cockpit-mod-and-grants.md).
9
+
10
+ ## What it does
11
+
12
+ **Skills and layout (Run 1).**
13
+
14
+ - **Checks skill registration.** The kit ships its skills in `pmos/skills-native/`. Claude Code reads project skills from `.claude/skills/`. At session start the mod compares the two and names every kit skill that is not registered.
15
+ - **Repairs it on request.** `/pmos-repair-skills`, or the Repair button in the band above the prompt, creates a symbolic link `.claude/skills/<name>` for each missing skill. It never replaces a file or folder that is already there, because that may be your own skill. It never links a skill that has no description.
16
+ - **Shows the layout, profile and initiative.** `/pmos-status` names the layout (a vendored kit or PMOS's own repository), the state root, the profile and the initiative. A missing `.pmos-profile` is read as `product-code`. The initiative is the `<id>` in a branch named `initiative/<id>`, the same rule as the workspace. Any other branch gives "no initiative"; the mod never guesses from a similar name.
17
+
18
+ **Guards and grants (Run 2).** The guards run on every tool call. Reading, editing and testing are never blocked. When the guard itself fails, it allows an ordinary call and denies the routes below, and it says so.
19
+
20
+ | Route | What the guard does |
21
+ |---|---|
22
+ | Hand-write to a `*.jsonl` ledger in the state root (Edit, Write, redirect, heredoc, `tee`, `sed -i`, an inline interpreter, a write through a link) | Denies. The message names the writer: `pmos/scripts/pmos-log.sh`, or in PMOS's own repository `log-run.js`, `record-eval.js` and `record-outcome.js`. A read is allowed. |
23
+ | Write to `acceptances.jsonl` or `grants.jsonl`, `record-acceptance.js`, `pmos-log.sh accept`, `grant` or `grant-use` | Denies. These are yours. |
24
+ | Write to the installed mod (`pmos/mods`), `.claude/pmos-cockpit.json` or the plugin entries in `.claude/settings.json` | Denies. In PMOS's own repository `kit/mods/**` is source and is not protected. |
25
+ | `gh pr merge`, the GitHub merge tools, a push to the default branch, a forced push, `npm publish` | Denies unless there is a live grant for exactly that PR, branch or package, and the local gate passes. The use of the grant is recorded before the action runs. |
26
+ | A cloud-mutating command from `AGENTS.md` (`supabase db push` and the rest) | Denies. No grant covers it in this release. You run it. |
27
+ | A Confluence, Jira, Slack, Gmail, Drive or Calendar write tool | Asks you for each item. The answer is not stored as a grant. |
28
+
29
+ A grant is one line in `grants.jsonl`. You mint it by typing `/pmos-grant merge:361` or `/pmos-grant publish:create-pmos`, optionally with a number of minutes and your own words. It is single use, lasts 120 minutes unless you give fewer (24 hours at most), and covers one target. A push to the default branch or a forced push takes `merge:<branch>`. There are no standing grants: `docs-sync`, every `outward:` scope and any other scope is refused, and ignored if found in the ledger. The model cannot mint a grant, and the commands refuse a run that did not come from you.
30
+
31
+ `/pmos-accept <run_id> pass|fail <note>` records your Acceptance through the same writer, with `pm_label` set. When your own message reads as an approval, a band offers Pass, Fail and Not now for the newest run of the branch's initiative that has no acceptance. Your press writes the row; the note quotes your message. Nothing is written from inferred text.
32
+
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
+
35
+ ## What it does not cover
36
+
37
+ 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:
38
+
39
+ - a script written first and run second (`bash /tmp/x.sh`);
40
+ - a path built from pieces no pattern can join (`'runs' + '.js' + 'onl'`);
41
+ - a patch that touches a ledger (`git apply`);
42
+ - 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
+ - `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
+
45
+ The outward ask depends on the permission mode's decider. It is not a guard when permissions are bypassed.
46
+
47
+ ## Install
48
+
49
+ `create-pmos` writes a marketplace entry and `enabledPlugins` into your `.claude/settings.json`. Claude Code asks you to trust the project, then offers the plugin. To skip the entry, run `create-pmos` with `--no-mod`. To install by hand, run `claude plugin marketplace add ./pmos/mods` and then `claude plugin install pmos-cockpit@pmos-mods`.
50
+
51
+ New links are read by Claude Code in the next session.
52
+
53
+ ## Test
54
+
55
+ - `node --test "kit/mods/pmos-cockpit/hooks/lib/*.test.mjs"` runs the pure logic with plain node.
56
+ - `claude plugin validate kit/mods/pmos-cockpit` and `claude plugin test kit/mods/pmos-cockpit` run the manifest checks and the hook tests. They need the Claude Code CLI.
57
+
58
+ ## Limits
59
+
60
+ Verified only in a terminal on Claude Code 2.1.289. Whether the desktop app, VS Code or the web load a function-hook mod is not verified, and Claude Code's documentation says a cloud session does not load a marketplace that a repository lists in its settings. The registration check runs in a vendored kit only. A project-relative marketplace path in project settings is not verified.
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,53 @@
1
+ // What the person's two commands do, as plain data. The module that wires them runs the argv through
2
+ // `$.process`; keeping the planning here lets a node test check it without raising a command as the
3
+ // person, which no test can do.
4
+ import { DEFAULT_TTL_MINUTES, parseScope } from './grants.mjs';
5
+
6
+ /** The kit's writer, and where it writes. PMOS's own repository has no grants writer, so it uses the kit's over planning/runs. */
7
+ export function writerOf(layout, root) {
8
+ const r = String(root).replace(/\/+$/, '');
9
+ if (layout.kind === 'kit') return { argv: ['bash', `${r}/pmos/scripts/pmos-log.sh`], env: {} };
10
+ if (layout.kind === 'self') return { argv: ['bash', `${r}/kit/scripts/pmos-log.sh`], env: { PMOS_STATE_DIR: layout.stateRoot } };
11
+ return null;
12
+ }
13
+
14
+ /** /pmos-grant <scope> [minutes] [your words] -> the writer call, or the reason there is none. */
15
+ export function planGrant(argsText, { layout, root, pm }) {
16
+ const words = String(argsText).trim().split(/\s+/).filter(Boolean);
17
+ const scope = parseScope(words[0] ?? '');
18
+ if (!scope.ok) return { ok: false, text: `No grant written. ${scope.reason}` };
19
+ const rest = words.slice(1);
20
+ let ttl = DEFAULT_TTL_MINUTES;
21
+ if (rest.length > 0 && /^\d+$/.test(rest[0])) ttl = Number(rest.shift());
22
+ const instruction = rest.join(' ') || `/pmos-grant ${String(argsText).trim()}`;
23
+ const w = writerOf(layout, root);
24
+ if (w === null) return { ok: false, text: 'No grant written. There is no PMOS state here.' };
25
+ return { ok: true, argv: [...w.argv, 'grant', `${scope.kind}:${scope.target}`, pm, instruction, String(ttl)], env: w.env, scope, ttl };
26
+ }
27
+
28
+ /** /pmos-accept <run_id> pass|fail <note>. The note is required. */
29
+ export function parseAccept(argsText) {
30
+ const m = /^(\S+)\s+(pass|fail)\s+([\s\S]+)$/.exec(String(argsText).trim());
31
+ if (!m) return { ok: false, text: 'Usage: /pmos-accept <run_id> pass|fail <note>. The note is required.' };
32
+ return { ok: true, run_id: m[1], label: m[2], note: m[3].trim() };
33
+ }
34
+
35
+ /** The writer call that records one acceptance, or the reason there is none. */
36
+ export function planAcceptance({ layout, root, run, label, note, pm, atIso }) {
37
+ const r = String(root).replace(/\/+$/, '');
38
+ if (layout.kind === 'kit') {
39
+ const w = writerOf(layout, root);
40
+ return { ok: true, argv: [...w.argv, 'accept', run.run_id, run.initiative_id, label, note], env: w.env };
41
+ }
42
+ if (layout.kind === 'self') {
43
+ const row = { run_id: run.run_id, initiative_id: run.initiative_id, accepted: label === 'pass', pm_label: label, pm, at: atIso, note };
44
+ return { ok: true, argv: ['node', `${r}/scripts/record-acceptance.js`, JSON.stringify(row)], env: {} };
45
+ }
46
+ return { ok: false, text: 'There is no PMOS state here, so there is nowhere to record an acceptance.' };
47
+ }
48
+
49
+ /** The note a press writes: the person's own message, and the press, and no claim of hand-verification. */
50
+ export function pressNote(message, label, runId) {
51
+ const quoted = String(message).replace(/\s+/g, ' ').trim().slice(0, 300);
52
+ return `Accepted on the PM's message "${quoted}" and the PM's press of ${label === 'pass' ? 'Pass' : 'Fail'} on run ${runId}. Recorded by the cockpit mod. NO HAND-VERIFICATION IS CLAIMED ON THE PM SIDE and none is invented.`;
53
+ }
@@ -0,0 +1,88 @@
1
+ // Grants (D81, M7): what the PM typed, read back as a decision the guards can act on.
2
+ //
3
+ // A grant is one line in `grants.jsonl` in the state root, minted by the typed /pmos-grant command or
4
+ // a person's press through the kit's writer, never by a model tool call. A use is a second line,
5
+ // because the ledger is append-only.
6
+ //
7
+ // {"type":"grant","id":"g-…","scope":"merge","target":"361","pm":"…","at":"…","expires_at":"…","uses":1,"instruction":"…"}
8
+ // {"type":"use","grant_id":"g-…","at":"…","action":"…"}
9
+ //
10
+ // This release has two scopes and no standing grant. `merge` covers one PR number (or one branch name
11
+ // for a push), `publish` covers one artifact. A scope the code does not know, including `docs-sync`
12
+ // and every `outward:` scope, is refused when minted and ignored when read. A grant for one target
13
+ // never covers another. A grant is live when it has not expired and has not been used.
14
+ //
15
+ // Pure: it reads text and a clock value it is given.
16
+
17
+ export const SCOPES = ['merge', 'publish'];
18
+ export const DEFAULT_TTL_MINUTES = 120;
19
+ export const MAX_TTL_MINUTES = 24 * 60;
20
+
21
+ const TARGET = {
22
+ merge: /^(?:\d{1,9}|[A-Za-z0-9][A-Za-z0-9._\/-]{0,79})$/,
23
+ publish: /^[A-Za-z0-9@][A-Za-z0-9@._\/-]{0,99}$/,
24
+ };
25
+
26
+ /** Parse "merge:361" into its parts, or say why not. */
27
+ export function parseScope(text) {
28
+ const raw = String(text ?? '').trim();
29
+ if (raw === '') return { ok: false, reason: 'a scope is required, for example merge:361 or publish:create-pmos' };
30
+ if (/^docs-sync(?::|$)/.test(raw) || /^outward(?::|$)/.test(raw)) {
31
+ return { ok: false, reason: `"${raw}" is a standing scope. Standing grants are not allowed in this release (D81). Mint one single-use grant per action.` };
32
+ }
33
+ const at = raw.indexOf(':');
34
+ if (at === -1) return { ok: false, reason: `unknown scope "${raw}". Use merge:<PR number or branch> or publish:<artifact>.` };
35
+ const kind = raw.slice(0, at);
36
+ const target = raw.slice(at + 1);
37
+ if (!SCOPES.includes(kind)) return { ok: false, reason: `unknown scope "${kind}". Use merge:<PR number or branch> or publish:<artifact>.` };
38
+ if (target === '' || target.includes('*') || target.includes('..') || !TARGET[kind].test(target)) {
39
+ return { ok: false, reason: `"${target}" is not a single ${kind === 'merge' ? 'PR number or branch name' : 'artifact name'}. A grant names one target; a wildcard is refused.` };
40
+ }
41
+ return { ok: true, kind, target };
42
+ }
43
+
44
+ /** Parse the ledger text. Rows that are not valid grants or uses are counted and ignored. */
45
+ export function parseLedger(text) {
46
+ const grants = new Map();
47
+ const uses = new Map();
48
+ let ignored = 0;
49
+ for (const line of String(text ?? '').split('\n')) {
50
+ const l = line.trim();
51
+ if (l === '') continue;
52
+ let r;
53
+ try { r = JSON.parse(l); } catch { ignored += 1; continue; }
54
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) { ignored += 1; continue; }
55
+ if (r.type === 'use' && typeof r.grant_id === 'string') { uses.set(r.grant_id, (uses.get(r.grant_id) ?? 0) + 1); continue; }
56
+ if (r.type !== 'grant' || typeof r.id !== 'string' || !SCOPES.includes(r.scope) || typeof r.target !== 'string') { ignored += 1; continue; }
57
+ if (!TARGET[r.scope].test(r.target) || r.target.includes('*') || r.target.includes('..')) { ignored += 1; continue; }
58
+ const at = Date.parse(r.at);
59
+ const exp = Date.parse(r.expires_at);
60
+ if (Number.isNaN(at) || Number.isNaN(exp) || exp <= at || exp - at > MAX_TTL_MINUTES * 60_000 || r.uses !== 1) { ignored += 1; continue; }
61
+ grants.set(r.id, { id: r.id, scope: r.scope, target: r.target, at, expires: exp, pm: r.pm, instruction: r.instruction });
62
+ }
63
+ return { grants, uses, ignored };
64
+ }
65
+
66
+ /**
67
+ * Is there a live grant for exactly this action?
68
+ * @returns {{ ok: true, grant: object } | { ok: false, reason: string }}
69
+ */
70
+ export function checkGrant(text, want, nowMs) {
71
+ const { grants, uses } = parseLedger(text);
72
+ let sawExpired = false;
73
+ let sawUsed = false;
74
+ let sawOther = false;
75
+ for (const g of grants.values()) {
76
+ if (g.scope !== want.kind) continue;
77
+ if (g.target !== String(want.target)) { sawOther = true; continue; }
78
+ if (g.at > nowMs + 5 * 60_000) continue; // minted in the future: not trusted
79
+ if ((uses.get(g.id) ?? 0) >= 1) { sawUsed = true; continue; }
80
+ if (nowMs >= g.expires) { sawExpired = true; continue; }
81
+ return { ok: true, grant: g };
82
+ }
83
+ const named = `${want.kind}:${want.target}`;
84
+ if (sawUsed) return { ok: false, reason: `the grant for ${named} was already used. A grant is single use.` };
85
+ if (sawExpired) return { ok: false, reason: `the grant for ${named} has expired.` };
86
+ if (sawOther) return { ok: false, reason: `there is a grant, but not for ${named}. A grant covers one target.` };
87
+ return { ok: false, reason: `there is no grant for ${named}.` };
88
+ }