create-pmos 0.26.0 → 0.27.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.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@6f4c86b
2
- assembled: 2026-07-31
1
+ kernel-source: pmos@86ae610
2
+ assembled: 2026-08-02
3
3
  mode: vendor
@@ -17,7 +17,13 @@ true terminal emulator on the other end. The CLI runs **untouched** — its own
17
17
  subscription included), slash commands, keybindings. The workspace is the terminal, not a wrapper.
18
18
 
19
19
  ## Everything survives a session
20
- The server holds PTYs in memory and mirrors each session's scrollback + metadata to `state/`.
20
+ The server holds PTYs in memory and mirrors each session's scrollback + metadata to the runtime
21
+ state directory — **`~/Library/Application Support/pmos` on macOS**, `$XDG_STATE_HOME/pmos` or
22
+ `~/.local/state/pmos` elsewhere, `PMOS_STATE_DIR` overriding both. It is deliberately OUTSIDE the
23
+ app directory: sessions, the workspace registry, layouts, scratchpads, schedules and your authored
24
+ skills used to live at `workspace/state/`, inside the directory the test suite runs from, and were
25
+ twice destroyed by a test. An existing in-repo `state/` is moved there once, on first run; two
26
+ populated directories are never merged (you get told, and nothing moves).
21
27
  Close/reload the client → re-attach replays scrollback and the run continues (detach, not kill).
22
28
  Restart the server → prior sessions reload as `exited` with full history and one-click respawn.
23
29
 
@@ -123,12 +129,16 @@ would be the watermelon this repo has a word for.
123
129
  clear the matching needs entry — only ever on your click.
124
130
 
125
131
  **The record lands in the repo that earned it.** Each workspace resolves its own state dir —
126
- `pmos/state/` (the kit's layout, D54), then `state/`, then `.pmos/`; the home workspace uses the
127
- server's own `state/`. A PMOS-shaped repo with no state dir yet gets the kit layout on first
128
- write. That path matters: `scripts/metrics.py` reads `pmos/state/acceptances.jsonl` for the north
129
- star and κ, so an acceptance recorded anywhere else is a green click over an empty record. A
130
- plain (non-PMOS) repo has no layout to invent — the gate records into the server's `state/` and
131
- the response says `fallback: true` rather than guessing.
132
+ `pmos/state/` (the kit's layout, D54), then `state/`, then `.pmos/`; an embedded PMOS-self checkout
133
+ with none of those yet uses the install's own `workspace/state/`. A PMOS-shaped repo with no state
134
+ dir yet gets the kit layout on first write. That path matters: `scripts/metrics.py` reads
135
+ `pmos/state/acceptances.jsonl` for the north star and κ, so an acceptance recorded anywhere else is
136
+ a green click over an empty record. A plain (non-PMOS) repo has no layout to invent — the gate
137
+ records into `workspace/state/` and the response says `fallback: true` rather than guessing.
138
+
139
+ This is D64's **system of record** and it did **not** follow the runtime state out to the per-user
140
+ directory above. A record belongs to the repository that earned it; only the churny, machine-written
141
+ runtime moved.
132
142
 
133
143
  Records carry the kit's `accepted` field (`accept` → true, `return` → false) so file-mode metrics
134
144
  count them. `pm_label` is **never** derived from a click — κ labels are the PM's own word (D40).
@@ -28,9 +28,13 @@ const fs = require('fs');
28
28
  const path = require('path');
29
29
  const { spawn, spawnSync } = require('child_process');
30
30
  const D = require('../lib/server-discovery');
31
+ const stateRoot = require('../lib/state-root');
31
32
 
32
33
  const WS = path.resolve(__dirname, '..'); // workspace/
33
- const STATE = path.join(WS, 'state');
34
+ // The launcher and the server must agree on WHERE state is, or the launcher looks for pid files in
35
+ // a directory nothing writes to and starts a second server every time. Same resolver, same answer
36
+ // — including the one-time migration out of the app directory (lib/state-root.js).
37
+ const STATE = stateRoot.resolveStateRoot({ env: process.env, legacy: path.join(WS, 'state') }).root;
34
38
  // An explicit PMOS_WS_PORT is a PIN: this launcher then talks to that port and no other. Without
35
39
  // one, the port comes from discovery — the default first, then whatever the running servers
36
40
  // recorded for themselves.
@@ -124,7 +124,11 @@ function binaryArch(file) {
124
124
  // when none is running, whether the port it would use is free and who holds it if not.
125
125
  {
126
126
  const D = require('./lib/server-discovery');
127
- const STATE = path.join(__dirname, 'state');
127
+ const stateRoot = require('./lib/state-root');
128
+ // The same resolver the server and the launcher use — a doctor that looked in the app
129
+ // directory would report "not running" over a live workspace (lib/state-root.js). No migration
130
+ // is triggered from here: a diagnostic must not move the PM's files.
131
+ const STATE = stateRoot.defaultRoot({ env: process.env });
128
132
  const pinned = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : null;
129
133
  const ports = D.candidatePorts({ stateDir: STATE, pinned });
130
134
  const probe = (port) => new Promise((res) => {
@@ -96,10 +96,173 @@ function loadSkills(dirs) {
96
96
  return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
97
97
  }
98
98
 
99
+ /* ── THE FILING CONTRACT OF A SKILL THAT ALREADY EXISTS ────────────────────────────────────────
100
+ *
101
+ * Every skill scaffolded before the runtime state moved out of the application directory says, in
102
+ * its body, "write the deliverable to `workspace/state/deliverables/<run-id>.md` and append to
103
+ * `workspace/state/needs.json`". After the move those are two paths nothing reads. An agent that
104
+ * follows them succeeds, reports success, and the deliverable never appears in the needs-you queue
105
+ * — a REPORT THAT SILENTLY NEVER ARRIVES, which is the exact failure this initiative exists to
106
+ * end. The PM has eleven such skills (draft-me, release-notes and nine idpf-*), all authored
107
+ * against the old contract.
108
+ *
109
+ * Injecting the paths into the SCAFFOLD only fixes skills that do not exist yet. So the retarget
110
+ * also happens at FIRE TIME, to the prompt as it is handed to the session — which covers the
111
+ * eleven, covers a skill hand-copied from an old machine, and covers a file the PM edits back to
112
+ * the old wording. It also has to be per-fire rather than a one-off rewrite of the files, because
113
+ * `needs.json` is resolved PER WORKSPACE (server.js#needsFileFor): one skill fired against two
114
+ * repos files into two different queues, and a path baked into the file could only ever name one.
115
+ *
116
+ * ── WHAT COUNTS AS "THE OLD STATE ROOT" ───────────────────────────────────────────────────────
117
+ *
118
+ * The first cut of this matched on the CHARACTER BEFORE the path — start-of-line, whitespace, a
119
+ * quote, a backtick, a paren or a bracket — and that covered 2 of the PM's 11 skills. The other
120
+ * nine (every idpf-*) do not spell the path relatively at all; they spell it ABSOLUTELY:
121
+ *
122
+ * /Users/…/pmos/workspace/state/deliverables/<run-id>.md
123
+ *
124
+ * The character before `workspace/state/` there is `/`, which was not in the class, so nothing
125
+ * matched and nothing was rewritten. That is not an edge case: every one of the PM's 45 schedules
126
+ * fires an idpf-* skill, and the two skills the old rule DID repair have zero schedules between
127
+ * them. A near-miss on a character class was therefore 0% coverage of the real workload.
128
+ *
129
+ * So the question is no longer "what character precedes this" but "IS THIS PATH'S PARENT THE OLD
130
+ * RUNTIME STATE ROOT". We answer it by recovering the whole path token and asking about its
131
+ * DIRECTORY, which is decidable regardless of how the author chose to spell it:
132
+ *
133
+ * `state/…` `./state/…` — relative to the app directory
134
+ * `workspace/state/…` `./workspace/state/…` — relative to a repository root
135
+ * `/any/abs/path/workspace/state/…` — absolute, any install (the nine idpf-*)
136
+ * `<filing.legacy>/…` — the app directory's OWN state dir, derived by the
137
+ * caller from __dirname, whatever it is named
138
+ *
139
+ * And it must still leave `pmos/state/…` alone — D64's file-mode system of record, which did NOT
140
+ * move. That now falls out STRUCTURALLY rather than from a character class: `pmos/` is a directory
141
+ * segment that is neither `workspace/` nor the derived legacy root, so it is refused by the same
142
+ * test that accepts the others. `sched-state/deliverables` is refused for the same reason — the
143
+ * `state` we rewrite has to be a whole path segment, not the tail of a longer name.
144
+ */
145
+
146
+ /* ── WHY THE WALK-BACK HAS TO KNOW ABOUT SCHEMES AND BOUNDARIES ────────────────────────────────
147
+ *
148
+ * The rule above ("is this path's parent the old runtime state root") is answered by recovering the
149
+ * whole path token and asking about its directory. Recovering it by walking LEFT through path
150
+ * characters is right, but a walk-back that stops at the first non-path character does not know
151
+ * WHAT it stopped at — and three of the things it can stop at mean the text to the right is not a
152
+ * path token at all but the tail of something larger:
153
+ *
154
+ * https://example.com/workspace/state/deliverables/x.md stops at `:` → `https:` + the rewrite
155
+ * C:/Users/x/workspace/state/deliverables/x.md stops at `:` → `C:` + the rewrite
156
+ * C:\Users\x\workspace/state/deliverables/x.md stops at `\`
157
+ *
158
+ * In each of those the URL or the drive path is DESTROYED, not merely over-rewritten — and this is
159
+ * not confined to fire time: POST /api/skills and /api/skills/draft run scaffoldSkill, which
160
+ * retargets the body and PERSISTS it, so a PM pasting a body containing such a URL gets it mangled
161
+ * on disk. So the character that STOPPED the walk-back is itself part of the decision: the token is
162
+ * only a path when what precedes it is a real boundary (start of text, whitespace, a quote, a
163
+ * bracket, a comma). A `:`, a `\`, a `?`, a `&`, an `=` or an `@` means we are inside a larger
164
+ * token whose grammar this module does not know, and the only safe answer is to leave it alone.
165
+ *
166
+ * The same defect in the other direction is the TAIL: `needs.json` carried no trailing boundary
167
+ * where `deliverables` did, so `workspace/state/needs.jsonl` matched `…needs.json` and left a
168
+ * dangling `l` on the rewritten path. Both alternatives now end on the same boundary class.
169
+ *
170
+ * ── AND: A `workspace/` DIRECTORY IS NOT AUTOMATICALLY A PMOS INSTALL ─────────────────────────
171
+ *
172
+ * `…/notes/workspace/state/deliverables/` has the same SHAPE as the old root and is not it. For an
173
+ * ABSOLUTE path we can simply look, and the three answers are all different:
174
+ *
175
+ * absent from this machine → ACCEPT. A path that no longer exists is the case the retarget was
176
+ * written for: a skill authored on an older checkout or another
177
+ * machine, naming a directory that has since moved. Refusing here
178
+ * would restore the silent no-show for exactly the bodies that need
179
+ * the rewrite most.
180
+ * present and a PMOS workspace → ACCEPT. Another checkout of this application; its `state/` is
181
+ * the old root by construction.
182
+ * present and something else → REFUSE. Somebody's `workspace/` folder, not ours.
183
+ *
184
+ * A RELATIVE path cannot be resolved without a working directory, so only the two spellings the
185
+ * scaffold and the PM's skills actually use are accepted (`workspace/state/…`, `./workspace/…`).
186
+ * A deeper relative prefix (`example.com/workspace/`, `../workspace/`) is refused: it is either not
187
+ * a path at all or not one this module can judge.
188
+ */
189
+
190
+ // The tail: `state/deliverables` or `state/needs.json`. Everything to its LEFT is recovered by
191
+ // walking back through path characters, not by a lookbehind — a path token can be arbitrarily long
192
+ // and we need the whole of it to judge the directory. BOTH alternatives end on the boundary class:
193
+ // without it `state/needs.jsonl` rewrote to `<needs>l`.
194
+ const TAIL_END = "(?=$|[/`'\")\\],\\s])";
195
+ const LEGACY_TAIL = new RegExp(`state\\/(?:needs\\.json${TAIL_END}|deliverables${TAIL_END})`, 'g');
196
+ const PATH_CHAR = /[A-Za-z0-9_.~/-]/;
197
+ // What may sit immediately LEFT of a whole path token. Deliberately an allow-list: `:` (a URL
198
+ // scheme, a Windows drive), `\`, `?`, `&`, `=`, `@`, `#` and every other character are refused,
199
+ // because they mean the walk-back stopped inside a larger token rather than at its start.
200
+ const TOKEN_BOUNDARY = /[\s`'"([{<,;|>]/;
201
+
202
+ /** Absent → true (unknowable, and the stale-path case this rewrite exists for); else: is it ours? */
203
+ function isPmosWorkspaceDir(dir) {
204
+ try {
205
+ if (!fs.existsSync(dir)) return true;
206
+ let name = '';
207
+ try { name = String((JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) || {}).name || ''); }
208
+ catch { /* no package.json, or not JSON — fall through to the shape check */ }
209
+ if (name === 'pmos-workspace') return true;
210
+ return fs.existsSync(path.join(dir, 'server.js')) && fs.existsSync(path.join(dir, 'lib', 'skill-store.js'));
211
+ } catch { return true; } // a directory we cannot even stat is not evidence AGAINST the rewrite
212
+ }
213
+
214
+ /** Is `prefix` (the text immediately left of `state/…`, up to a non-path character) the old root? */
215
+ function isLegacyStateParent(prefix, legacyRoot) {
216
+ // `state` must be a WHOLE segment: either the token starts here, or its parent ends in a slash.
217
+ if (prefix !== '' && !prefix.endsWith('/')) return false;
218
+ if (prefix === '' || prefix === './') return true; // state/… · ./state/…
219
+ if (prefix === 'workspace/' || prefix === './workspace/') return true; // the relative spelling
220
+ // The app directory's own state dir, derived by the caller from __dirname. Covers an install
221
+ // that does not sit under a directory literally named `workspace`. Checked BEFORE the probe, so
222
+ // the install we are running inside is never subject to it.
223
+ if (legacyRoot) {
224
+ const parent = legacyRoot.slice(0, legacyRoot.lastIndexOf('/') + 1);
225
+ const name = legacyRoot.slice(legacyRoot.lastIndexOf('/') + 1);
226
+ if (name === 'state' && parent && (prefix === parent || prefix === '.' + parent)) return true;
227
+ }
228
+ // …/workspace/state/… — absolute only (`//host/…` is an authority, not a path), and only when
229
+ // that `workspace` is, or plausibly was, a PMOS install.
230
+ if (prefix.startsWith('/') && !prefix.startsWith('//') && prefix.endsWith('/workspace/')) {
231
+ return isPmosWorkspaceDir(prefix.slice(0, -1));
232
+ }
233
+ return false;
234
+ }
235
+
236
+ /**
237
+ * Rewrite every reference to the OLD runtime state root onto the paths this fire actually files to.
238
+ * `filing` is `{ deliverables, needs, legacy? }`; `legacy` is the application directory's own
239
+ * `state/` (server.js#LEGACY_STATE) and is optional — the segment rules above stand without it.
240
+ */
241
+ function retargetFiling(text, filing) {
242
+ if (!text || !filing) return text;
243
+ const legacyRoot = filing.legacy ? String(filing.legacy).replace(/\/+$/, '') : '';
244
+ const src = String(text);
245
+ let out = ''; let cursor = 0; let m;
246
+ LEGACY_TAIL.lastIndex = 0;
247
+ while ((m = LEGACY_TAIL.exec(src))) {
248
+ let start = m.index;
249
+ while (start > 0 && PATH_CHAR.test(src[start - 1])) start--;
250
+ if (start < cursor) continue; // overlaps a rewrite already emitted
251
+ // The character that STOPPED the walk-back decides whether we recovered a whole path token or
252
+ // the tail of a URL / a drive path / a query parameter. See the block comment above.
253
+ if (start > 0 && !TOKEN_BOUNDARY.test(src[start - 1])) continue;
254
+ const target = m[0].startsWith('state/needs') ? filing.needs : filing.deliverables;
255
+ if (!target || !isLegacyStateParent(src.slice(start, m.index), legacyRoot)) continue;
256
+ out += src.slice(cursor, start) + target;
257
+ cursor = m.index + m[0].length;
258
+ }
259
+ return out + src.slice(cursor);
260
+ }
261
+
99
262
  // Scaffold a well-formed user skill file from the New Skill flow's fields. Returns { ok, slug, content }
100
263
  // or { ok:false, error }. User skills default to run:agent — a user-authored skill reasons through a
101
264
  // claude session (the existing agent path); the deterministic fast-path is reserved for the built-ins.
102
- function scaffoldSkill({ name, description, output, body } = {}) {
265
+ function scaffoldSkill({ name, description, output, body, filing } = {}) {
103
266
  const slug = slugify(name);
104
267
  if (!NAME_RE.test(slug)) return { ok: false, error: 'name must be a short slug (letters, digits, hyphens)' };
105
268
  const label = titleCase(name) || slug;
@@ -107,7 +270,9 @@ function scaffoldSkill({ name, description, output, body } = {}) {
107
270
  let out = Array.isArray(output) ? output : String(output || '').split(/[\n,]/);
108
271
  out = out.map((x) => String(x).replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, MAX_OUTPUT);
109
272
  if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
110
- const proc = String(body || '').trim() || defaultBody(label, desc, out, slug);
273
+ // A hand-written body is retargeted too: a PM pasting the old contract text in gets a skill that
274
+ // files where the console reads, not one that quietly writes into a directory nobody opens.
275
+ const proc = retargetFiling(String(body || '').trim(), filing) || defaultBody(label, desc, out, slug, filing);
111
276
  const content = [
112
277
  '---',
113
278
  `name: ${slug}`,
@@ -131,8 +296,16 @@ function scaffoldSkill({ name, description, output, body } = {}) {
131
296
  // write the deliverable and append a STRUCTURED needs.json object, or the console reds the queue
132
297
  // (needsResult treats a non-array queue as corrupt). No prose "note to needs-you", ever. This is a
133
298
  // starting shape a PM or the AI-draft session then sharpens with the task's real steps.
134
- function defaultBody(label, desc, out, slug) {
299
+ // `filing` names the two paths the run must write to. They are INJECTED, not hard-coded, because
300
+ // the workspace's runtime state no longer lives at a path that can be spelled relative to a
301
+ // repository — it moved out of the application directory (lib/state-root.js) precisely so the test
302
+ // suite could not reach it. A scaffold that still said "workspace/state/deliverables/" would send
303
+ // every scheduled agent's report to a directory the console never reads: a silent, permanent
304
+ // no-show in the needs-you queue. The defaults keep this module usable standalone.
305
+ function defaultBody(label, desc, out, slug, filing) {
135
306
  const id = slug || slugify(label) || 'run';
307
+ const deliverables = (filing && filing.deliverables) || 'workspace/state/deliverables';
308
+ const needs = (filing && filing.needs) || 'workspace/state/needs.json';
136
309
  return [
137
310
  `You are running the "${label}" skill in this PMOS workspace (local, file mode). Work only from`,
138
311
  "this repository's own files, git, and CLIs — no network (D54).",
@@ -148,8 +321,8 @@ function defaultBody(label, desc, out, slug) {
148
321
  ' An empty section is the literal line "None this period." — never a dropped heading, never invented content.',
149
322
  ' Done when: every contract section is filled or explicitly "None this period.".',
150
323
  `3. File the result so the PM sees it. Pick a unique run id (e.g. \`${id}-<UTC-timestamp>\`), then:`,
151
- ' a. Write the deliverable Markdown to `workspace/state/deliverables/<run-id>.md`.',
152
- ' b. Read `workspace/state/needs.json` (treat a missing file as `[]`), append EXACTLY this object,',
324
+ ` a. Write the deliverable Markdown to \`${deliverables}/<run-id>.md\`.`,
325
+ ` b. Read \`${needs}\` (treat a missing file as \`[]\`), append EXACTLY this object,`,
153
326
  ' and write the whole array back — it MUST stay a valid JSON array or the console reds the queue:',
154
327
  ` {"run_id":"<run-id>","kind":"report","initiative_id":"${id}","question":"${label} — ready","deliverable":"<run-id>"}`,
155
328
  ' Done when: the deliverable file exists and needs.json is a valid array containing the new object.',
@@ -159,4 +332,5 @@ function defaultBody(label, desc, out, slug) {
159
332
  ].join('\n');
160
333
  }
161
334
 
162
- module.exports = { NAME_RE, slugify, titleCase, parseSkill, loadDir, loadSkills, scaffoldSkill };
335
+ module.exports = { NAME_RE, slugify, titleCase, parseSkill, loadDir, loadSkills, scaffoldSkill,
336
+ retargetFiling, isLegacyStateParent };