create-pmos 0.27.0 → 0.29.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.
@@ -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,406 @@ 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
+ /**
106
+ * RESOLVE BEFORE ALLOWING — each ledger paired with the ONE directory it may live in.
107
+ *
108
+ * This was a bare basename list matched with `path.basename` in any directory, and an independent
109
+ * evaluator walked through it on 2026-08-04 with `/etc/agent-runs.jsonl`,
110
+ * `/home/user/.ssh/discoveries.md` and `workspace/lib/discoveries.md` — all permitted, because the
111
+ * name was the whole identity. Worse, the two halves of the allowlist disagreed: the PREFIX half
112
+ * correctly refused absolute paths while the basename half waved them through, so which rule
113
+ * happened to match decided whether `/etc/…` was inside the repo.
114
+ *
115
+ * A ledger is now its path, not its name.
116
+ */
117
+ const ALLOWED_FILES = Object.freeze([
118
+ 'planning/runs/agent-runs.jsonl',
119
+ 'planning/runs/verify-queue.jsonl',
120
+ 'planning/runs/work-items.jsonl',
121
+ 'discoveries.md',
122
+ ]);
123
+
124
+ /** Kept for the refusal message only — never for deciding permission. */
125
+ const ALLOWED_BASENAMES = Object.freeze(ALLOWED_FILES.map((f) => path.posix.basename(f)));
126
+
127
+ /** Named explicitly so the refusal says WHY, rather than "not on the allowlist". Each of these
128
+ * would otherwise be caught by the allowlist anyway — the value here is the message. */
129
+ const HUMAN_DECISION_SINKS = Object.freeze({
130
+ // The module argues that a DIRECTORY prefix "would admit the FOURTH ledger somebody adds next year
131
+ // without anyone deciding to — exactly the class-failure this rewrite exists to close" — and then
132
+ // allows `planning/evals/` as a prefix, which admits the PM's own override log and any future
133
+ // planning/evals/*.jsonl. The argument is right; it just was not applied to its own second
134
+ // directory. These two are the PM's calibration record, not the driver's scratch space.
135
+ 'eval-calibration.md': 'the PM\'s log of every override of an evaluator verdict is the PM\'s (D40)',
136
+ 'calibration-status.md': 'the declared calibration state is the PM\'s to assert, not the driver\'s',
137
+ 'acceptances.jsonl': 'the Acceptance Gate is the PM\'s and non-delegable (D12)',
138
+ 'evals.jsonl': 'a Review-Gate verdict on the driver\'s own work is self-grading — the driver may not record one (D40)',
139
+ 'outcome_checks.jsonl': 'whether the bet paid off is the PM\'s judgement (D57)',
140
+ 'DECISIONS.md': 'an architectural decision is the PM\'s (D45)',
141
+ 'pm-decisions.md': 'the PM\'s decision journal is the PM\'s',
142
+ });
41
143
 
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;
144
+ /** Disposition tokens (D60), widened to the bare verbs bypass 4 slipped through, and anchored so a
145
+ * status VALUE that begins with one is a disposition while prose mentioning one is not. */
146
+ /* Explicit groups, not a trailing `?` on the last letter. The first attempt at this widening wrote
147
+ * `KILLED?`, which is "KILLE" plus an optional "D" and therefore does NOT match the bare `KILL` —
148
+ * reintroducing bypass 4 while appearing to fix it. Caught only because every bypass is attempted
149
+ * individually, which is the whole argument for the suite below. */
150
+ const DISPOSITION_RE = /^\s*"?\s*(PROMOTE(D)?|ITERATE(D)?|KILL(ED)?)\b/i;
44
151
 
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;
152
+ /**
153
+ * The leading-token rule above is correct and insufficient, and the measurement says so.
154
+ *
155
+ * Anchoring to the value's first token bought the false positive back (`derived from the promoted
156
+ * prototype` is permitted, rightly) and paid for it in false NEGATIVES nobody counted. Swept over
157
+ * every disposition-bearing `status:` line in this repo's own planning/initiatives/ on 2026-08-04:
158
+ * **26 of 30 were permitted**, including four real recorded PM dispositions —
159
+ *
160
+ * status: "CLOSED — disposed PROMOTE by PM wawan 2026-07-28…"
161
+ * status: "CLOSED — PROMOTED 2026-07-28…"
162
+ * status: "…PM disposition 2026-07-29: ITERATE A…"
163
+ * status: "**PM disposition 2026-07-30: ITERATE C**"
164
+ *
165
+ * — because each leads with CLOSED, or with the word "PM". A guard validated against six test
166
+ * strings and never against the corpus it protects. So the leading-token rule stays, and these
167
+ * value-INTERNAL forms join it: a disposition announced as a disposition, wherever it sits in the
168
+ * line. Prose that merely mentions a verb still passes, because these require the announcing noun.
169
+ */
170
+ const DISPOSITION_INTERNAL_RE = new RegExp(
171
+ '(?:' +
172
+ 'dispositions?\\s*[:=]\\s*|' + // "disposition: PROMOTE", "PM disposition: ITERATE A"
173
+ 'dispos(?:ed|ition)\\s+(?:to\\s+)?|' + // "disposed PROMOTE", "disposed to KILL"
174
+ 'disposition\\s+\\S{0,24}?\\s*[:=]\\s*' + // "PM disposition 2026-07-30: ITERATE C"
175
+ ')' +
176
+ '\\W{0,4}(PROMOTE(?:D)?|ITERATE(?:D)?|KILL(?:ED)?)\\b',
177
+ 'i');
178
+
179
+ /**
180
+ * The third rule, rewritten after independent verification refuted its first form.
181
+ *
182
+ * The first attempt keyed on CASE: dispositions are written `PROMOTED`, prose writes "the promoted
183
+ * prototype". Swept over the corpus it looked perfect — 7 all-caps lines, all 7 genuine, zero false
184
+ * positives. An independent verifier then pointed out the obvious: **a driver forging a disposition
185
+ * defeats a case-sensitive guard by not pressing shift.**
186
+ *
187
+ * status: CLOSED — promoted 2026-07-28 by PM wawan prev=refused → mine=PERMITTED
188
+ *
189
+ * That is `workspace-doc-editing.md`'s real recorded disposition in lower case, and it was a strict
190
+ * regression against the module I was fixing — the same veto I wrote the rubric to catch, in the fix
191
+ * for the round that failed it. It also carried the SAME `KILLED?` typo this file documents itself
192
+ * catching twenty lines up ("KILLE" plus an optional D never matches a bare `KILL`).
193
+ *
194
+ * So case is not the signal. The signal is GRAMMAR: a disposition is the verb used as a verb, and
195
+ * the false positive this round must preserve is the verb used as an ADJECTIVE or in a PASSIVE
196
+ * clause — "derived from *the promoted* prototype", "M3 *was killed* off upstream". Both are marked
197
+ * by a small closed set of words immediately before the verb. Everything else is a disposition,
198
+ * whatever its case.
199
+ *
200
+ * Default refuse, carve out narrowly, and enumerate the carve-out — rather than default permit and
201
+ * enumerate the catches, which is the denylist shape this whole round exists to remove.
202
+ */
203
+ const DISPOSITION_VERB = '(?:PROMOTE[SD]?|ITERATE[SD]?|KILL(?:ED|S)?)';
204
+
205
+ /** Immediately-preceding words that make the verb a modifier or a passive, not an act. */
206
+ const PROSE_LEAD = new Set([
207
+ 'the', 'a', 'an', 'this', 'that', 'these', 'those', 'its', 'their', 'our', 'his', 'her',
208
+ 'was', 'were', 'been', 'being', 'is', 'are', 'be', 'get', 'gets', 'got', 'gotten',
209
+ 'not', 'never', 'unless', 'if', 'when', 'would', 'could', 'should', 'may', 'might',
210
+ ]);
211
+
212
+ /**
213
+ * True when a status VALUE announces a PM disposition. Case-insensitive by construction; the
214
+ * carve-out, not the catch, is what is enumerated.
215
+ */
216
+ function isDisposition(value) {
217
+ const v = String(value);
218
+ if (DISPOSITION_RE.test(v) || DISPOSITION_INTERNAL_RE.test(v)) return true;
219
+ const re = new RegExp(`(^|[^A-Za-z])(${DISPOSITION_VERB})\\b`, 'gi');
220
+ let m;
221
+ while ((m = re.exec(v)) !== null) {
222
+ const before = v.slice(0, m.index + m[1].length);
223
+ const prior = (before.match(/([A-Za-z']+)[^A-Za-z']*$/) || [])[1];
224
+ if (prior && PROSE_LEAD.has(prior.toLowerCase())) continue; // adjectival or passive — prose
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+
230
+ /**
231
+ * NORMALIZE BEFORE MATCHING — and the reason this rule is written as a rule.
232
+ *
233
+ * The predecessor tested `/PM_approved\s*[:=]\s*true/i`: unbounded whitespace. Its replacement — the
234
+ * "hardening" — tested `/PM_approved\W{0,4}(true|1|yes)/i`, which widened the VALUE side and, without
235
+ * anyone noticing, capped the SEPARATOR side at four non-word characters. An independent evaluator
236
+ * measured both modules side by side on 2026-08-04:
237
+ *
238
+ * PM_approved : true old=refused new=PERMITTED
239
+ * PM_approved: true old=refused new=PERMITTED
240
+ *
241
+ * A hardening round that shipped a strict regression, on the one predicate whose whole job is to stop
242
+ * a driver forging the PM's approval. `{"pre_run_contract" : {"PM_approved" : true}}` written
243
+ * as text to the allowlisted agent-runs.jsonl JSON.parses to `PM_approved === true`, which
244
+ * scripts/workflow-state.js accepts as O5 satisfaction.
245
+ *
246
+ * The lesson is not "use a bigger budget". Any budget is a denylist of the gaps you thought of, and
247
+ * the next one is defeated by a form nobody enumerated (the double-stringified payload defeated four
248
+ * with five escape characters). So: collapse the input to a canonical form FIRST — drop the
249
+ * characters an encoding can multiply, squeeze whitespace — and only then match, with unbounded
250
+ * separators. There is no budget to overrun because there is nothing left to pad with.
251
+ */
252
+ /** Returned when normalization will not settle — the call site treats it as refusal, never as text. */
253
+ const NON_CONVERGENT = '\u0000NON_CONVERGENT\u0000';
254
+
255
+ function normalizeForMatch(s) {
256
+ let out = String(s);
257
+ /* DECODE, do not delete. The first attempt at this stripped backslashes outright — and a literal
258
+ * tab, which JSON.stringify writes as the two characters `\t`, became the letter `t`:
259
+ *
260
+ * PM_approved<TAB>:<TAB>true → "PM_approved\t:\ttrue" → PM_approvedt:ttrue
261
+ *
262
+ * …which matches nothing, so a tab-separated approval walked straight through the "fix". Caught by
263
+ * this round's own N-R1 assertion, which is the argument for attempting each form individually.
264
+ * Escape sequences are DATA and must be decoded to what they denote, never erased.
265
+ *
266
+ * Applied repeatedly so that multiply-stringified payloads (the ninth bypass) unwrap layer by
267
+ * layer rather than one level deep. Bounded, because a fixed point is not guaranteed on hostile
268
+ * input and an unbounded loop here is a denial of service. */
269
+ /* ONE left-to-right pass per round, not four independent global replaces.
270
+ *
271
+ * The four-replace form was itself defective, and an independent verifier found it: because
272
+ * `JSON.stringify` DOUBLES backslashes, the `\\u....` replace consumed the second backslash and
273
+ * left the first dangling in front of the decoded letter, which the `\\[tnrfvb0]` replace then
274
+ * ATE as a control char — turning `PM_approved` into `PM_app o ed` and permitting the write. The
275
+ * same `\t` → `t` corruption as the first attempt, one rule to the right, because the rules could
276
+ * see each other's output mid-pass. A single scanner consumes each escape exactly once.
277
+ *
278
+ * Loops to a FIXED POINT rather than a fixed count. The earlier `i < 4` was a budget — and this
279
+ * file argues at length that a budget is a denylist of the gaps you thought of; five layers of
280
+ * stringification walked past it. The cap here is a non-termination guard, not a budget, and
281
+ * hitting it means the input is still changing after 16 rounds, which is not something a
282
+ * legitimate write does: it fails CLOSED at the call site. */
283
+ const decodeOnce = (s) => {
284
+ let r = '';
285
+ for (let i = 0; i < s.length; i++) {
286
+ if (s[i] !== '\\') { r += s[i]; continue; }
287
+ const n = s[i + 1];
288
+ if (n === undefined) { r += ' '; break; }
289
+ if (n === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i + 2, i + 6))) {
290
+ r += String.fromCharCode(parseInt(s.slice(i + 2, i + 6), 16)); i += 5;
291
+ } else if (n === 'x' && /^[0-9a-fA-F]{2}$/.test(s.slice(i + 2, i + 4))) {
292
+ r += String.fromCharCode(parseInt(s.slice(i + 2, i + 4), 16)); i += 3;
293
+ } else if ('tnrfvb0'.includes(n)) { r += ' '; i += 1; }
294
+ else { r += n; i += 1; }
295
+ }
296
+ return r;
297
+ };
298
+ let converged = false;
299
+ for (let i = 0; i < 16; i++) {
300
+ const before = out;
301
+ out = decodeOnce(out);
302
+ if (out === before) { converged = true; break; }
303
+ }
304
+ if (!converged) return NON_CONVERGENT;
305
+ return out
306
+ .replace(/["'`]/g, '') // quoting is packaging, not content
307
+ .replace(/\s+/g, ' '); // any run of whitespace is one space
308
+ }
309
+
310
+ /** Unbounded separators, by construction. Applied only to normalized text. */
311
+ const APPROVAL_RE = /PM_approved\s*[:=]\s*(true|1|yes)\b/i;
312
+
313
+ /** Truthy-tolerant: an approval the PM never gave is what is being refused; its JSON type is not the
314
+ * point. Closes bypass 2. */
315
+ const TRUTHY = new Set([true, 1, 'true', 'TRUE', 'True', 'yes', 'YES', '1']);
316
+
317
+ const isObj = (v) => !!v && typeof v === 'object';
318
+
319
+ /** True when `value` carries a truthy PM_approved anywhere. No depth cap (bypass 1); cycle-safe. */
320
+ function carriesApproval(value, seen) {
321
+ const s = seen || new Set();
322
+ if (!isObj(value) || s.has(value)) return false;
323
+ s.add(value);
48
324
  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;
325
+ if (k === 'PM_approved' && TRUTHY.has(v)) return true;
326
+ if (carriesApproval(v, s)) return true;
52
327
  }
53
328
  return false;
54
329
  }
55
330
 
331
+ /**
332
+ * True when a `status`/`disposition` key ANYWHERE carries a disposition (bypass 5).
333
+ *
334
+ * Three type-loose gaps closed here, each measured on 2026-08-04: the key test was case-SENSITIVE
335
+ * (`Status` walked past), the value test required `typeof v === 'string'` (so `status: ['PROMOTED']`
336
+ * walked past), and a Buffer serialized to a byte array invisible to every scan.
337
+ */
338
+ function carriesDisposition(value, seen) {
339
+ const s = seen || new Set();
340
+ if (!isObj(value) || s.has(value)) return false;
341
+ s.add(value);
342
+ for (const [k, v] of Object.entries(value)) {
343
+ const isStatusKey = k.toLowerCase() === 'status' || k.toLowerCase() === 'disposition';
344
+ if (isStatusKey) {
345
+ // A status value may be a string, a Buffer, or a list of candidate values. All are inspected.
346
+ const candidates = Array.isArray(v) ? v : [v];
347
+ for (const c of candidates) {
348
+ if (typeof c === 'string' && isDisposition(c)) return true;
349
+ if (Buffer.isBuffer(c) && isDisposition(c.toString('utf8'))) return true;
350
+ }
351
+ }
352
+ if (Buffer.isBuffer(v)) continue; // a Buffer's numeric indices are not an object graph
353
+ if (carriesDisposition(v, s)) return true;
354
+ }
355
+ return false;
356
+ }
357
+
358
+ /**
359
+ * Repo-relative, canonical, or nothing.
360
+ *
361
+ * `path.posix.normalize` collapses `.` and `..` properly instead of the old substring test, which
362
+ * both over-refused (a legitimate `draft..v2.md`) and under-thought the problem. The backslash
363
+ * rewrite is gone: on POSIX a backslash is an ordinary filename character, and rewriting it let the
364
+ * single root-level file `planning\prd\x.md` pose as a file inside `planning/prd/`.
365
+ */
366
+ function onAllowlist(file) {
367
+ const raw = String(file);
368
+ if (path.posix.isAbsolute(raw) || path.win32.isAbsolute(raw)) return false; // never outside the repo
369
+ const norm = path.posix.normalize(raw.replace(/^\.\//, ''));
370
+ if (norm === '.' || norm.startsWith('../')) return false; // escaped the repo root
371
+ if (norm.split('/').includes('..')) return false;
372
+ if (ALLOWED_FILES.includes(norm)) return true;
373
+ /* The prefix half admits DRAFT ARTIFACTS, never ledgers. Without this the module's own
374
+ * fourth-ledger argument fails against its own second directory: `planning/evals/anything.jsonl`
375
+ * was permitted, so the next append-only ledger anyone puts there is admitted by default —
376
+ * "exactly the class-failure this rewrite exists to close", as the comment above says. */
377
+ if (/\.jsonl$/i.test(norm)) return false;
378
+ return ALLOWED_DIR_PREFIXES.some((p) => norm.startsWith(p));
379
+ }
380
+
56
381
  /**
57
382
  * The chokepoint. Every driver write goes through here or does not happen.
58
383
  * @param {{file:string, payload?:any, text?:string}} req
59
384
  * @throws {ForbiddenWrite}
60
385
  */
61
386
  function assertWritable(req) {
62
- const file = (req && req.file) || '';
63
- const base = path.basename(file);
387
+ // (6) FAIL CLOSED. The predecessor returned `true` here, which is the worst of the seven: a
388
+ // request this function cannot understand is precisely the one it must not wave through.
389
+ if (!isObj(req) || Array.isArray(req)) {
390
+ throw new ForbiddenWrite('an unreadable request', 'a write request must be an object — refused (fail-closed)');
391
+ }
392
+ if (typeof req.file !== 'string' || !req.file.trim()) {
393
+ throw new ForbiddenWrite('a request with no target', 'a write request must name its `file` — refused (fail-closed)');
394
+ }
64
395
 
65
- if (FORBIDDEN_FILES.includes(base)) {
66
- throw new ForbiddenWrite('an acceptance', 'the Acceptance Gate is the PM\'s and non-delegable (D12)');
396
+ /* A Buffer or TypedArray supplied AS the payload is bytes the writer will put on disk verbatim,
397
+ * and JSON.stringify renders it `{"type":"Buffer","data":[80,77,…]}` — invisible to the text scan
398
+ * and to the object walk alike. Both were handled NESTED and neither at the top level, which an
399
+ * independent verifier walked straight through with
400
+ * `payload: Buffer.from(JSON.stringify({pre_run_contract:{PM_approved:true}}))`. Decode to text
401
+ * and adjudicate it as text. */
402
+ if (Buffer.isBuffer(req.payload) || ArrayBuffer.isView(req.payload)) {
403
+ const asText = Buffer.from(
404
+ ArrayBuffer.isView(req.payload) && !Buffer.isBuffer(req.payload)
405
+ ? Buffer.from(req.payload.buffer, req.payload.byteOffset, req.payload.byteLength)
406
+ : req.payload).toString('utf8');
407
+ return assertWritable({ file: req.file, text: `${req.text || ''}\n${asText}` });
67
408
  }
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)');
409
+
410
+ const why = HUMAN_DECISION_SINKS[path.basename(req.file)];
411
+ if (why) throw new ForbiddenWrite(path.basename(req.file), why);
412
+
413
+ // (7) closed as a CLASS: anything not sanctioned is refused, named or not.
414
+ if (!onAllowlist(req.file)) {
415
+ throw new ForbiddenWrite(req.file,
416
+ `not on the driver's allowlist — it may write ${ALLOWED_DIR_PREFIXES.join(', ')} or ${ALLOWED_BASENAMES.join(', ')}`);
71
417
  }
72
- if (req && typeof req.text === 'string' && /PM_approved\s*[:=]\s*true/i.test(req.text)) {
418
+
419
+ // (1)(2)(3) closed together by one look at the text the write would actually produce. A
420
+ // stringified payload, a "true", and an 11-deep nesting are all plainly visible here.
421
+ let serialized;
422
+ try {
423
+ serialized = JSON.stringify({ p: req.payload === undefined ? null : req.payload, t: req.text || '' });
424
+ } catch {
425
+ throw new ForbiddenWrite('an unserialisable payload',
426
+ 'a payload that cannot be inspected cannot be permitted — refused (fail-closed)');
427
+ }
428
+ const normalized = normalizeForMatch(serialized);
429
+ if (normalized === NON_CONVERGENT) {
430
+ throw new ForbiddenWrite('an input that will not normalize',
431
+ 'a payload whose escaping does not settle cannot be inspected — refused (fail-closed)');
432
+ }
433
+ if (APPROVAL_RE.test(normalized) || carriesApproval(req.payload)) {
73
434
  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');
435
+ 'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
75
436
  }
76
437
 
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)) {
438
+ // (4)(5) and the false positive: matched on a status VALUE's leading token, structured or raw, so
439
+ // `status: "PROMOTED by PM wawan"` is refused and `status: "derived from the promoted prototype"`
440
+ // is not. Prose that mentions a disposition is not a disposition.
441
+ if (carriesDisposition(req.payload)) {
81
442
  throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
82
443
  }
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)');
444
+ if (typeof req.text === 'string') {
445
+ // Split on /\r?\n/, not '\n'. The old split left a trailing '\r' on every CRLF line, and the
446
+ // matcher's `$` anchor (without the `m` flag) then matched nothing at all — a CRLF document
447
+ // sailed through the raw-text check entirely.
448
+ for (const line of req.text.split(/\r?\n/)) {
449
+ /* Markdown prefixes are how a status line is ACTUALLY written in the driver's own sinks —
450
+ * `- status:`, `**status:**`, `> status:`, `| status |` — and the old anchor allowed only
451
+ * leading whitespace, so every one of them walked past the check. */
452
+ const m = /^[\s>*\-+#|]*\**\s*(?:status|disposition)\**\s*[:|]\s*(.*?)\s*\|?\s*$/i.exec(line);
453
+ if (m && isDisposition(m[1])) {
454
+ throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
455
+ }
456
+ // The raw and structured paths must agree. A JSONL line supplied as `text` was permitted while
457
+ // the identical object supplied as `payload` was refused — the same content, two verdicts,
458
+ // decided by which field it arrived in.
459
+ const t = line.trim();
460
+ if (t.startsWith('{') || t.startsWith('[')) {
461
+ let parsed;
462
+ try { parsed = JSON.parse(t); } catch { parsed = undefined; }
463
+ if (parsed !== undefined) {
464
+ if (carriesDisposition(parsed)) {
465
+ throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
466
+ }
467
+ if (carriesApproval(parsed)) {
468
+ throw new ForbiddenWrite('pre_run_contract.PM_approved',
469
+ 'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
470
+ }
471
+ }
472
+ }
473
+ }
86
474
  }
87
475
  return true;
88
476
  }
@@ -95,4 +483,7 @@ function guardedWriter(write) {
95
483
  };
96
484
  }
97
485
 
98
- module.exports = { assertWritable, guardedWriter, carriesApproval, ForbiddenWrite, FORBIDDEN_FILES };
486
+ module.exports = {
487
+ assertWritable, guardedWriter, carriesApproval, carriesDisposition, ForbiddenWrite,
488
+ ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
489
+ };
@@ -859,14 +859,48 @@ function computeWorkflow(id, opts) {
859
859
  // ── review ─ the Review Gate's verdict, where the lane has a rubric ──────────────────────────
860
860
  const evals = readJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
861
861
  {
862
- if (effLane === 'chore' || effLane === 'prototype') {
863
- steps.push(step('review', 'review', 'na', effLane === 'prototype'
864
- ? 'leg A is grilled as dialogue, never scored (D60)'
865
- : 'chore lane carries no rubric (D56)'));
862
+ /* The rung reads the RUBRIC, not the lane.
863
+ *
864
+ * It keyed on `lane` alone, so a chore that deliberately carries a rubric could never surface
865
+ * its Review Gate — the ladder said "chore lane carries no rubric (D56)" while a SKIPPED verdict
866
+ * sat in evals.jsonl unread. That is how `guard-and-ledger-hardening`, a chore whose own record
867
+ * argues "blast radius overrides lane" and which carried a rubric on purpose, reached `accept`
868
+ * with the gate never run. The board could not show what the ledger already knew.
869
+ *
870
+ * A prototype is still never scored — D60 grills leg A as dialogue, and that is a property of
871
+ * the lane, not of whether someone attached a rubric. Every other lane defers to the artifact. */
872
+ const declaresRubric = !!String(fm.rubric_path || '').trim();
873
+ /* EVERY lane defers to the artifact — the first version said so in the comment and applied it
874
+ * only to `chore`, so a discovery or incident initiative with no rubric (legitimate under D56)
875
+ * showed a permanent `no Review-Gate verdict recorded`: a rung it could never satisfy. The lanes
876
+ * that carry no rubric by design are named once, here. */
877
+ const RUBRIC_OPTIONAL = ['chore', 'discovery', 'incident', 'content'];
878
+ if (effLane === 'prototype') {
879
+ steps.push(step('review', 'review', 'na', 'leg A is grilled as dialogue, never scored (D60)'));
880
+ } else if (RUBRIC_OPTIONAL.includes(effLane) && !declaresRubric && !evals.length) {
881
+ steps.push(step('review', 'review', 'na', `${effLane} lane carries no rubric (D56)`));
866
882
  } else if (evals.length) {
867
883
  const last = evals[evals.length - 1];
868
- steps.push(step('review', 'review', 'done',
869
- `${last.verdict}${last.score != null ? ` (${last.score})` : ''} — advisory until calibrated per evaluator model (D40)`));
884
+ const verdict = String(last.verdict || '').toUpperCase();
885
+ const label = `${last.verdict}${last.score != null ? ` (${last.score})` : ''} — advisory until calibrated per evaluator model (D40)`;
886
+ /* A SKIPPED gate is NOT a satisfied gate, and a FAIL is emphatically not one.
887
+ *
888
+ * This rung rendered both as a green ✓, which is the watermelon this repo names as its own
889
+ * failure mode: the board said the Review Gate was done over a row that says judging never
890
+ * happened. Thirteen initiatives currently render `✓ review` over a SKIPPED verdict, and the
891
+ * fix that made a chore's gate visible ADDED one. The module's own rule, repeated in three
892
+ * files — "a skip is an honest RECORD that verification did not happen; it is not
893
+ * verification" — now applies to the surface that reports it. */
894
+ if (verdict === 'SKIPPED') {
895
+ steps.push(step('review', 'review', 'pending', label,
896
+ 'the gate was skipped, so it is still owed — commission an independent evaluator and record a real verdict',
897
+ { rule: 'R3' }));
898
+ } else if (verdict === 'FAIL') {
899
+ steps.push(step('review', 'review', 'blocked', label,
900
+ 'the Review Gate FAILED — remediate the findings and record a new verdict', { rule: 'R3' }));
901
+ } else {
902
+ steps.push(step('review', 'review', 'done', label));
903
+ }
870
904
  } else {
871
905
  steps.push(step('review', 'review', 'pending', 'no Review-Gate verdict recorded',
872
906
  'score the build against the rubric (build-skills/evaluator.skill), then append to evals.jsonl',
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.26.0",
9
+ "version": "0.28.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1093,6 +1093,23 @@ export async function readDoc(path) {
1093
1093
  return res.text();
1094
1094
  }
1095
1095
 
1096
+ /**
1097
+ * panel-initiatives.js — `searchFiles(q, limit)`. Every file under THIS workspace's root whose path
1098
+ * or contents match, from the one endpoint that walks the folder. Bounded on the server, and the
1099
+ * bound comes back in the payload (`truncated`, `scanned`) rather than being silently applied — a
1100
+ * capped list rendered as a complete one is the defect the search box already had.
1101
+ *
1102
+ * FIXTURE mode resolves to an empty, untruncated result WITHOUT fetching: rule 1 of this file is
1103
+ * that a fixture render is provably not a real read wearing a costume, and `no-network.test.js`
1104
+ * checks that claim at source level.
1105
+ */
1106
+ export async function searchFiles(query, limit) {
1107
+ const q = String(query == null ? '' : query).trim();
1108
+ if (FIXTURE || !q) return { ok: true, q, hits: [], truncated: false, scanned: 0 };
1109
+ const p = products.get(currentProductName());
1110
+ return getJson('/api/search/files', { ws: state.wsId || (p ? p.ws : ''), q, limit });
1111
+ }
1112
+
1096
1113
  /**
1097
1114
  * tab-doc.js — `saveDoc({ws, path, base, text})`. Refuse-on-stale: the server compares `base`
1098
1115
  * against the file on disk and REFUSES rather than overwriting a file that changed underneath.