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 +26 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +39 -7
- package/workspace/bin/pmos.js +5 -1
- package/workspace/doctor.js +5 -1
- package/workspace/lib/drive-guard.js +165 -30
- package/workspace/lib/skill-store.js +180 -6
- package/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +80 -0
- package/workspace/public/pm/panel-initiatives.js +169 -4
- package/workspace/public/pm/panel-newsess.js +507 -0
- package/workspace/public/pm/panel-repos.js +31 -0
- package/workspace/public/pm/panel-tabs.js +34 -2
- package/workspace/public/pm/state.js +40 -1
- package/workspace/public/pm/tab-term.js +277 -16
- package/workspace/server.js +370 -26
- package/workspace/test-run.js +84 -0
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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-
|
|
1
|
+
kernel-source: pmos@d333cd5
|
|
2
|
+
assembled: 2026-08-04
|
|
3
3
|
mode: vendor
|
package/workspace/README.md
CHANGED
|
@@ -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
|
|
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/`;
|
|
127
|
-
|
|
128
|
-
write. That path matters: `scripts/metrics.py` reads
|
|
129
|
-
star and κ, so an acceptance recorded anywhere else is
|
|
130
|
-
plain (non-PMOS) repo has no layout to invent — the gate
|
|
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
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/workspace/doctor.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
-
/**
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 === '
|
|
50
|
-
if (
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
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
|
|
202
|
+
'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
|
|
75
203
|
}
|
|
76
204
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
if (
|
|
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 (
|
|
84
|
-
|
|
85
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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 };
|