create-pmos 0.26.0 → 0.28.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/README.md CHANGED
@@ -333,6 +333,32 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.28.0
337
+
338
+ **`search everything…` now searches every file in the folder, and any file it finds opens in a tab.**
339
+ Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **The box searched three lists, and the folder was not one of them.** It matched initiative slugs,
342
+ paths *declared on* an initiative record, and session names — so a file PMOS had never been told
343
+ about could not be found by typing its exact name. Worse, the miss read *"nothing matches — a
344
+ successful read of nothing"*, which is the phrase for *we looked and there was nothing there*,
345
+ printed after a search that never opened the folder.
346
+ - **Type any part of a name or a path.** Every file under the workspace root now matches, at any
347
+ depth, whether or not any record mentions it — `README.md`, a script, a CSV you dropped in
348
+ yesterday. `node_modules/`, `dist/`, `.git/` and the other junk directories are skipped, exactly as
349
+ the folder lens already skips them, so search and the file tree agree about what is in there.
350
+ - **Type a phrase that is *inside* a file.** Text files are searched by content too, and the result
351
+ names the file **and the line**, so you can see the match before you open anything.
352
+ - **Click a result and it opens in a document tab** — the same reader you already use, with `view` ·
353
+ `raw` · `edit` · `diff`. A file the reader cannot show (an image, a binary) is still listed, greyed,
354
+ and says so, instead of opening a tab onto a failure.
355
+ - **It works in every mode.** The search box was rendered in all seven and did something in exactly
356
+ one — including doing nothing in the library lens, the one screen that lists files.
357
+ - **It tells you what it did not look at.** The walk is bounded so a huge folder can never hang the
358
+ box, and any bound that actually bit is stated in the heading. `11 of 1424 searched` is a
359
+ different sentence from `BOUNDED — files beyond the walk limit were not looked at`, and you get
360
+ whichever one is true. Nothing leaves your machine, as ever.
361
+
336
362
  ## What's new in 0.26.0
337
363
 
338
364
  **Your sessions now know which initiative they belong to — whatever branch you are on — and each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.26.0",
3
+ "version": "0.28.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@d333cd5
2
+ assembled: 2026-08-04
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).
@@ -146,6 +156,28 @@ tokens, JWTs, `Authorization:` values, api keys): scrollback holds whatever the
146
156
  so D08's never-log-headers discipline applies to what this hands back. Matching runs against
147
157
  the *scrubbed* text, so the endpoint cannot be used to grep a secret out.
148
158
 
159
+ ## `search everything…` searches the folder (the file lens)
160
+ The search box shipped matching three derived lists — initiative slugs, paths **declared on** an
161
+ initiative record, and session names — so a file PMOS had never been told about could not be found
162
+ by typing its exact name, and the miss printed *"nothing matches — a successful read of nothing"*
163
+ after a search that had never opened the folder.
164
+
165
+ `GET /api/search/files?q=…&ws=…&limit=…` is the folder corpus: every file under the workspace root,
166
+ matched by **path** and by **content**, with the matching line and its number. Clicking a hit opens
167
+ it in the ordinary document tab. It is a **different corpus** from `/api/search` above — that one is
168
+ scrollback, this one is disk — and they are deliberately separate endpoints, because their hits
169
+ share no field and one payload would have to lie about what it looked at.
170
+
171
+ Three guarantees. The walk uses the **folder lens's own rules** (`node_modules`, `dist`, `.git` and
172
+ the rest skipped, dotted names excluded), so search and the file tree can never disagree about what
173
+ is in the folder. There is **no path parameter** — the query is a needle, not a location — and the
174
+ walk starts at the root and never follows a symlink out of it, so nothing becomes reachable that
175
+ `/api/doc` would refuse; `openable` on each hit reports whether the reader can actually show it.
176
+ And it is **bounded, out loud**: `truncated` means files were never looked at, `content_truncated`
177
+ means every file was named but not every body was read, `scanned`/`content_scanned` give the counts.
178
+ Two caps, two flags — merged into one, the serious flag would light on any repository with a lock
179
+ file in it, which is the same as retiring it.
180
+
149
181
  ## Branches: a new one doesn't move the others
150
182
 
151
183
  Starting a session, the branch control is **optional** — the default is "stay on current" and it
@@ -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) => {
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
- /* drive-guard — the driver's ONLY write path (pmos-autonomous-drive-build, R9).
2
+ /* drive-guard — the driver's ONLY write path (pmos-autonomous-drive-build, R9;
3
+ * hardened from a denylist to an allowlist by `guard-and-ledger-hardening`, 2026-08-04).
3
4
  *
4
5
  * THE PROBLEM THIS EXISTS FOR, stated plainly because it is easy to under-rate:
5
6
  * nothing in PMOS checks an identity. `/api/gate` sets `pm: true` unconditionally
@@ -20,10 +21,44 @@
20
21
  * that throws, and the tests ATTEMPT each forbidden write and assert the throw. A rule the driver
21
22
  * is trusted to follow is exactly what leg A showed cannot be trusted.
22
23
  *
23
- * SCOPE, honestly. This binds the DRIVER, not the repo: a human, another agent, or a careless
24
- * `fs.writeFileSync` elsewhere is unaffected. It is a seatbelt on one vehicle, not a law of
24
+ * ── WHY THIS IS NOW AN ALLOWLIST ────────────────────────────────────────────────────────────────
25
+ * The first version was a denylist, and its suite DID attempt each forbidden write and assert the
26
+ * throw — six attempts, six assertions, exactly as the paragraph above claims. It still shipped
27
+ * SEVEN bypasses, each measured by an independent pass
28
+ * (planning/explainers/work-item-entity-surface-explainer.md) by running it against this module:
29
+ *
30
+ * 1. `PM_approved` nested 11 deep — recursion capped at `depth > 8`
31
+ * 2. `PM_approved: "true"` or `: 1` — strict `v === true`
32
+ * 3. the payload JSON.stringify'd — the `"` defeated /PM_approved\s*[:=]\s*true/
33
+ * 4. `status: 'PROMOTE'` / `'KILL'` — the regex required -D / -ED
34
+ * 5. `payload.frontmatter.status` — the structured status check was top-level only
35
+ * 6. `assertWritable({})` — FAILED OPEN: no file, no payload, no text → returned true
36
+ * 7. `parent_kr`, an evals verdict,
37
+ * `DECISIONS.md` — never named at all
38
+ *
39
+ * Plus a FALSE POSITIVE the other way: a legitimate initiative record was refused because its
40
+ * `status:` prose contained the word "promoted".
41
+ *
42
+ * The suite probed THE LIST. Nothing probed THE SHAPE OF THE LIST — and a denylist's shape is
43
+ * "everything somebody thought of". So:
44
+ *
45
+ * - sinks are ALLOWLISTED, closing (7) as a class rather than as three strings;
46
+ * - the content guard reads the SERIALIZED request, closing (1), (2) and (3) together — depth,
47
+ * type-looseness and stringification all vanish when you look at the text the write would
48
+ * actually produce;
49
+ * - an unreadable request THROWS, closing (6), the worst of the seven;
50
+ * - the disposition match is widened (4), recurses (5), and is anchored to the status value's
51
+ * LEADING TOKEN, so a real disposition is refused and prose that merely says "promoted" is not.
52
+ *
53
+ * SCOPE, honestly, and unchanged: this binds the DRIVER, not the repo. A human, another agent, or a
54
+ * careless `fs.writeFileSync` elsewhere is unaffected. It is a seatbelt on one vehicle, not a law of
25
55
  * physics — and that limit is the argument for making O5's boolean falsifiable at the engine, which
26
56
  * is a separate initiative this one does not pretend to have done.
57
+ *
58
+ * AND THE LIMIT WORTH SAYING OUT LOUD: **nothing imports this module** — only workspace/test.js.
59
+ * The live acceptance writer at `POST /api/gate` appends directly and never calls it. This hardening
60
+ * makes the boundary correct; it does not make it load-bearing. Its whole value is that when
61
+ * something does adopt it, it will not adopt seven bypasses with it.
27
62
  */
28
63
 
29
64
  const path = require('path');
@@ -36,53 +71,150 @@ class ForbiddenWrite extends Error {
36
71
  }
37
72
  }
38
73
 
39
- /** Ledgers that record a human decision. The driver may never append to these. */
40
- const FORBIDDEN_FILES = Object.freeze(['acceptances.jsonl']);
74
+ /**
75
+ * The sinks the driver's own free-zone policy sanctions (pmos-autonomous-drive, round 2): research,
76
+ * routing, the surface explainer, a disposable prototype, and DRAFT specs. Anything not here is
77
+ * refused — including every ledger, decision log and record nobody thought to enumerate.
78
+ *
79
+ * Directory prefixes rather than basenames, because these artifact classes name their files per
80
+ * initiative. `planning/evals/` admits a draft rubric and NOT `evals.jsonl`, which lives in
81
+ * `planning/runs/` and is refused with a reason below.
82
+ */
83
+ const ALLOWED_DIR_PREFIXES = Object.freeze([
84
+ 'planning/prd/',
85
+ 'planning/evals/',
86
+ 'planning/explainers/',
87
+ 'planning/research/',
88
+ 'planning/briefs/',
89
+ 'prototype/',
90
+ ]);
91
+
92
+ /**
93
+ * Machine ledgers the driver legitimately appends to: its own run record, the verification it owes,
94
+ * the work-items it declares, and the friction it hits.
95
+ *
96
+ * These are BASENAMES rather than a `planning/runs/` prefix, deliberately. Allowing the directory
97
+ * would admit `acceptances.jsonl`, `evals.jsonl` and `outcome_checks.jsonl` by default, and would
98
+ * admit the FOURTH ledger somebody adds next year without anyone deciding to — which is exactly the
99
+ * class-failure this rewrite exists to close. A new ledger is refused until it is named here.
100
+ *
101
+ * `agent-runs.jsonl` is on this list even though it carries `pre_run_contract`: the driver logging
102
+ * its own run is the free zone working as designed, and a fabricated `PM_approved` INSIDE that write
103
+ * is caught by the content guard below, not by refusing the file.
104
+ */
105
+ const ALLOWED_BASENAMES = Object.freeze([
106
+ 'agent-runs.jsonl',
107
+ 'verify-queue.jsonl',
108
+ 'work-items.jsonl',
109
+ 'discoveries.md',
110
+ ]);
111
+
112
+ /** Named explicitly so the refusal says WHY, rather than "not on the allowlist". Each of these
113
+ * would otherwise be caught by the allowlist anyway — the value here is the message. */
114
+ const HUMAN_DECISION_SINKS = Object.freeze({
115
+ 'acceptances.jsonl': 'the Acceptance Gate is the PM\'s and non-delegable (D12)',
116
+ 'evals.jsonl': 'a Review-Gate verdict on the driver\'s own work is self-grading — the driver may not record one (D40)',
117
+ 'outcome_checks.jsonl': 'whether the bet paid off is the PM\'s judgement (D57)',
118
+ 'DECISIONS.md': 'an architectural decision is the PM\'s (D45)',
119
+ 'pm-decisions.md': 'the PM\'s decision journal is the PM\'s',
120
+ });
121
+
122
+ /** Disposition tokens (D60), widened to the bare verbs bypass 4 slipped through, and anchored so a
123
+ * status VALUE that begins with one is a disposition while prose mentioning one is not. */
124
+ /* Explicit groups, not a trailing `?` on the last letter. The first attempt at this widening wrote
125
+ * `KILLED?`, which is "KILLE" plus an optional "D" and therefore does NOT match the bare `KILL` —
126
+ * reintroducing bypass 4 while appearing to fix it. Caught only because every bypass is attempted
127
+ * individually, which is the whole argument for the suite below. */
128
+ const DISPOSITION_RE = /^\s*"?\s*(PROMOTE(D)?|ITERATE(D)?|KILL(ED)?)\b/i;
41
129
 
42
- /** Disposition tokens (D60). Case-insensitive; a status carrying one is a PM act. */
43
- const DISPOSITION_RE = /\b(PROMOTED|ITERATE[D]?|KILLED)\b/i;
130
+ /** Truthy-tolerant: an approval the PM never gave is what is being refused; its JSON type is not the
131
+ * point. Closes bypass 2. */
132
+ const TRUTHY = new Set([true, 1, 'true', 'TRUE', 'True', 'yes', 'YES', '1']);
44
133
 
45
- /** True when `value` contains a truthy PM_approved anywhere in its object graph. */
46
- function carriesApproval(value, depth = 0) {
47
- if (depth > 8 || value === null || typeof value !== 'object') return false;
134
+ const isObj = (v) => !!v && typeof v === 'object';
135
+
136
+ /** True when `value` carries a truthy PM_approved anywhere. No depth cap (bypass 1); cycle-safe. */
137
+ function carriesApproval(value, seen) {
138
+ const s = seen || new Set();
139
+ if (!isObj(value) || s.has(value)) return false;
140
+ s.add(value);
141
+ for (const [k, v] of Object.entries(value)) {
142
+ if (k === 'PM_approved' && TRUTHY.has(v)) return true;
143
+ if (carriesApproval(v, s)) return true;
144
+ }
145
+ return false;
146
+ }
147
+
148
+ /** True when a `status`/`disposition` key ANYWHERE carries a leading disposition token (bypass 5). */
149
+ function carriesDisposition(value, seen) {
150
+ const s = seen || new Set();
151
+ if (!isObj(value) || s.has(value)) return false;
152
+ s.add(value);
48
153
  for (const [k, v] of Object.entries(value)) {
49
- if (k === 'PM_approved' && v === true) return true;
50
- if (k === 'pre_run_contract' && v && typeof v === 'object' && v.PM_approved === true) return true;
51
- if (carriesApproval(v, depth + 1)) return true;
154
+ if ((k === 'status' || k === 'disposition') && typeof v === 'string' && DISPOSITION_RE.test(v)) return true;
155
+ if (carriesDisposition(v, s)) return true;
52
156
  }
53
157
  return false;
54
158
  }
55
159
 
160
+ function onAllowlist(file) {
161
+ const norm = String(file).replace(/\\/g, '/').replace(/^\.\//, '');
162
+ if (norm.includes('..')) return false; // no traversal out of an allowed directory
163
+ if (ALLOWED_BASENAMES.includes(path.basename(norm))) return true;
164
+ return ALLOWED_DIR_PREFIXES.some((p) => norm.startsWith(p));
165
+ }
166
+
56
167
  /**
57
168
  * The chokepoint. Every driver write goes through here or does not happen.
58
169
  * @param {{file:string, payload?:any, text?:string}} req
59
170
  * @throws {ForbiddenWrite}
60
171
  */
61
172
  function assertWritable(req) {
62
- const file = (req && req.file) || '';
63
- const base = path.basename(file);
173
+ // (6) FAIL CLOSED. The predecessor returned `true` here, which is the worst of the seven: a
174
+ // request this function cannot understand is precisely the one it must not wave through.
175
+ if (!isObj(req) || Array.isArray(req)) {
176
+ throw new ForbiddenWrite('an unreadable request', 'a write request must be an object — refused (fail-closed)');
177
+ }
178
+ if (typeof req.file !== 'string' || !req.file.trim()) {
179
+ throw new ForbiddenWrite('a request with no target', 'a write request must name its `file` — refused (fail-closed)');
180
+ }
64
181
 
65
- if (FORBIDDEN_FILES.includes(base)) {
66
- throw new ForbiddenWrite('an acceptance', 'the Acceptance Gate is the PM\'s and non-delegable (D12)');
182
+ const why = HUMAN_DECISION_SINKS[path.basename(req.file)];
183
+ if (why) throw new ForbiddenWrite(path.basename(req.file), why);
184
+
185
+ // (7) closed as a CLASS: anything not sanctioned is refused, named or not.
186
+ if (!onAllowlist(req.file)) {
187
+ throw new ForbiddenWrite(req.file,
188
+ `not on the driver's allowlist — it may write ${ALLOWED_DIR_PREFIXES.join(', ')} or ${ALLOWED_BASENAMES.join(', ')}`);
67
189
  }
68
- if (req && req.payload !== undefined && carriesApproval(req.payload)) {
69
- throw new ForbiddenWrite('pre_run_contract.PM_approved',
70
- 'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
190
+
191
+ // (1)(2)(3) closed together by one look at the text the write would actually produce. A
192
+ // stringified payload, a "true", and an 11-deep nesting are all plainly visible here.
193
+ let serialized;
194
+ try {
195
+ serialized = JSON.stringify({ p: req.payload === undefined ? null : req.payload, t: req.text || '' });
196
+ } catch {
197
+ throw new ForbiddenWrite('an unserialisable payload',
198
+ 'a payload that cannot be inspected cannot be permitted — refused (fail-closed)');
71
199
  }
72
- if (req && typeof req.text === 'string' && /PM_approved\s*[:=]\s*true/i.test(req.text)) {
200
+ if (/PM_approved\W{0,4}(true|1|yes)/i.test(serialized) || carriesApproval(req.payload)) {
73
201
  throw new ForbiddenWrite('pre_run_contract.PM_approved',
74
- 'the contract is the PM\'s to approve before any build (D16) — caught in raw text');
202
+ 'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
75
203
  }
76
204
 
77
- // A disposition is a PM act (D60) wherever it lands: the record's `status:` is where it is
78
- // conventionally written, so both the structured and the raw-text shapes are refused.
79
- const status = req && req.payload && typeof req.payload === 'object' ? req.payload.status : null;
80
- if (typeof status === 'string' && DISPOSITION_RE.test(status)) {
205
+ // (4)(5) and the false positive: matched on a status VALUE's leading token, structured or raw, so
206
+ // `status: "PROMOTED by PM wawan"` is refused and `status: "derived from the promoted prototype"`
207
+ // is not. Prose that mentions a disposition is not a disposition.
208
+ if (carriesDisposition(req.payload)) {
81
209
  throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
82
210
  }
83
- if (req && typeof req.text === 'string' && /^status:.*$/im.test(req.text)
84
- && DISPOSITION_RE.test(req.text.match(/^status:.*$/im)[0])) {
85
- throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
211
+ if (typeof req.text === 'string') {
212
+ for (const line of req.text.split('\n')) {
213
+ const m = /^\s*(?:status|disposition)\s*:\s*(.*)$/i.exec(line);
214
+ if (m && DISPOSITION_RE.test(m[1])) {
215
+ throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
216
+ }
217
+ }
86
218
  }
87
219
  return true;
88
220
  }
@@ -95,4 +227,7 @@ function guardedWriter(write) {
95
227
  };
96
228
  }
97
229
 
98
- module.exports = { assertWritable, guardedWriter, carriesApproval, ForbiddenWrite, FORBIDDEN_FILES };
230
+ module.exports = {
231
+ assertWritable, guardedWriter, carriesApproval, carriesDisposition, ForbiddenWrite,
232
+ ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
233
+ };
@@ -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 };