create-pmos 0.29.0 → 0.29.1

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,51 @@ 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.29.1
337
+
338
+ **0.29.0 could not reach an existing install. This is that release, delivered.**
339
+ Run `npx create-pmos@latest workspace`.
340
+
341
+ - **If you are on 0.28.0, 0.29.0 never upgraded you.** The launcher decides whether to reinstall by
342
+ comparing the bundled workspace-app version against the installed one, and 0.29.0 shipped with the
343
+ app version left at `0.28.0` — so the comparison came out equal and the upgrade was silently
344
+ skipped. Everything listed below was in the 0.29.0 tarball and unreachable from an existing
345
+ install. 0.29.1 bumps both versions together, which is what actually delivers it.
346
+ - **The check that catches this already existed and did not run.** An assertion pins the two versions
347
+ in lockstep; CI only ran it when `workspace/**` changed, and a release PR touches
348
+ `npm-pkg/package.json`. The path filter now includes it, so the guard runs on the diff that breaks
349
+ it. This is the second time a version bump has shipped without reaching adopters (0.9.1 was the
350
+ first), and the first time the repo could have known.
351
+
352
+ **What 0.29.0 carried, now actually delivered:**
353
+
354
+ - **A skipped Review Gate no longer shows as a green tick.** `workflow-state.js` rendered a
355
+ `SKIPPED` verdict — a row that says *judging did not happen* — with the same ✓ it uses for a real
356
+ pass, and a `FAIL` likewise. A skip is now `pending` and a fail is `blocked`. If your board looked
357
+ greener than your ledger, this is why.
358
+ - **A chore that carries a rubric now shows its Review Gate.** The rung decided from the
359
+ initiative's **lane**, so any lane that *may* skip a rubric could never surface a verdict it
360
+ actually had — the ladder printed *"chore lane carries no rubric"* while a verdict sat unread. It
361
+ reads the rubric now, in every lane. This is how a real initiative reached `accept` with its gate
362
+ never run.
363
+ - **`evaluator.skill`'s final step is runnable.** It told you to persist the verdict through
364
+ `save_eval` — a hosted-backend tool that **does not exist in file mode**, which is the mode the kit
365
+ runs in. The last instruction of the Review Gate could not be followed. It now names the writer for
366
+ your context: `pmos/scripts/pmos-log.sh eval …`.
367
+ - **`pmos-log.sh` can record a skipped gate.** Its verdict enum was `PASS|PASS-WITH-FINDINGS|FAIL`,
368
+ while `AGENTS.md` tells you *"a gate that was skipped is recorded as skipped — never leave a missing
369
+ eval to be read as a pass."* Following that instruction hit an error, and the only compliant option
370
+ was to edit the ledger by hand. Now: `pmos-log.sh eval <run> <initiative> SKIPPED "<reason>"`,
371
+ which requires the reason and records `score: null`.
372
+ - **The kit build now refuses to ship a skill that names a tool you do not have.** That class had
373
+ already shipped twice in the same sentence of the same skill. The check runs at build time and
374
+ fails the build; its one bound (per-reference, not per-line) is documented in `build-kit.sh`.
375
+
376
+ *Not in this release, stated so you do not go looking:* the same upstream round hardened two modules
377
+ that the kit does not run — `drive-guard.js` ships inside the workspace bundle but nothing requires
378
+ it, and `record-eval.js` is not in the payload at all. Those fixes are real, and they are not
379
+ something your kit was relying on.
380
+
336
381
  ## What's new in 0.28.0
337
382
 
338
383
  **`search everything…` now searches every file in the folder, and any file it finds opens in a tab.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
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@4104fde
1
+ kernel-source: pmos@b59909d
2
2
  assembled: 2026-08-04
3
3
  mode: vendor
@@ -202,11 +202,28 @@ const DISPOSITION_INTERNAL_RE = new RegExp(
202
202
  */
203
203
  const DISPOSITION_VERB = '(?:PROMOTE[SD]?|ITERATE[SD]?|KILL(?:ED|S)?)';
204
204
 
205
- /** Immediately-preceding words that make the verb a modifier or a passive, not an act. */
205
+ /**
206
+ * DETERMINERS ONLY, and the narrowing is measured rather than argued.
207
+ *
208
+ * The first version of this set carried 40 words — determiners, auxiliaries and modals — on the
209
+ * reasoning that a passive clause ("was killed off upstream") is prose too. A third independent pass
210
+ * showed what that bought an attacker:
211
+ *
212
+ * status: got PROMOTED by PM wawan on 2026-07-28 prev=refused → PERMITTED
213
+ * status: not KILLED — shipped 2026-07-28 prev=refused → PERMITTED
214
+ *
215
+ * Neither is prose. Both are forged dispositions wearing an auxiliary. Swept against this
216
+ * repository's own records, the ONLY genuine prose that needs an exemption at all is
217
+ * determiner-led — "derived from *the promoted* prototype" — which is exactly the false positive the
218
+ * previous round was contracted to fix. The auxiliaries were paying for a case the corpus does not
219
+ * contain, in forgeable forms.
220
+ *
221
+ * A status line that reads "if a specimen is promoted" is now REFUSED. That is over-refusal, it is
222
+ * the safe direction, and it is the trade this guard exists to make: the cost is a driver rephrasing
223
+ * one status line; the cost of the other direction is a forged PM disposition.
224
+ */
206
225
  const PROSE_LEAD = new Set([
207
226
  '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
227
  ]);
211
228
 
212
229
  /**
@@ -220,8 +237,20 @@ function isDisposition(value) {
220
237
  let m;
221
238
  while ((m = re.exec(v)) !== null) {
222
239
  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
240
+ /* ADJACENT, separated by whitespace and nothing else.
241
+ *
242
+ * This was `[^A-Za-z']*$` — "the last run of letters, skipping ANY non-letters" — so a colon, a
243
+ * comma, a quote or an em dash between the determiner and the verb kept the carve-out firing
244
+ * while destroying the grammar that justifies it. A third independent pass walked through it:
245
+ *
246
+ * status: CLOSED — verdict is "PROMOTED" (PM wawan) prev=refused → PERMITTED
247
+ * status: CLOSED. Leg A is: PROMOTED 2026-07-28 prev=refused → PERMITTED
248
+ *
249
+ * "is" is not modifying "PROMOTED" across a colon and a quote mark; it is a different clause.
250
+ * An exemption that ignores what sits between its two words is not a grammatical rule, it is a
251
+ * proximity coincidence. */
252
+ const prior = (before.match(/([A-Za-z']+)[ \t]*$/) || [])[1];
253
+ if (prior && PROSE_LEAD.has(prior.toLowerCase())) continue; // adjectival — prose
225
254
  return true;
226
255
  }
227
256
  return false;
@@ -343,10 +372,26 @@ function carriesDisposition(value, seen) {
343
372
  const isStatusKey = k.toLowerCase() === 'status' || k.toLowerCase() === 'disposition';
344
373
  if (isStatusKey) {
345
374
  // A status value may be a string, a Buffer, or a list of candidate values. All are inspected.
375
+ /* Judge what will be WRITTEN, not what the value's JS type happens to be.
376
+ *
377
+ * `typeof c === 'string'` let two shapes through that both serialize to a real disposition:
378
+ *
379
+ * { status: { toJSON() { return 'PROMOTED'; } } } → {"status":"PROMOTED"}
380
+ * { status: new String('PROMOTED') } → {"status":"PROMOTED"}
381
+ *
382
+ * The bytes that land on disk are a forged PM disposition; only the in-memory type differed.
383
+ * A third independent pass found both, and noted the asymmetry that gives it away: the same
384
+ * trick aimed at PM_approved was already caught, because THAT check reads the serialized form.
385
+ * This one now does too. */
346
386
  const candidates = Array.isArray(v) ? v : [v];
347
387
  for (const c of candidates) {
348
388
  if (typeof c === 'string' && isDisposition(c)) return true;
349
389
  if (Buffer.isBuffer(c) && isDisposition(c.toString('utf8'))) return true;
390
+ if (c !== null && typeof c === 'object' && !Buffer.isBuffer(c)) {
391
+ let ser;
392
+ try { ser = JSON.stringify(c); } catch { return true; } // uninspectable → refuse
393
+ if (typeof ser === 'string' && isDisposition(ser)) return true;
394
+ }
350
395
  }
351
396
  }
352
397
  if (Buffer.isBuffer(v)) continue; // a Buffer's numeric indices are not an object graph
@@ -484,6 +529,6 @@ function guardedWriter(write) {
484
529
  }
485
530
 
486
531
  module.exports = {
487
- assertWritable, guardedWriter, carriesApproval, carriesDisposition, ForbiddenWrite,
532
+ assertWritable, guardedWriter, carriesApproval, carriesDisposition, isDisposition, PROSE_LEAD, ForbiddenWrite,
488
533
  ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
489
534
  };
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
5
+ "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "pmos": "bin/pmos.js"
@@ -29,7 +29,7 @@
29
29
  "playwright-core": "^1.61.1",
30
30
  "ws": "^8.18.0"
31
31
  },
32
- "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
32
+ "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs \u2014 it just stops measuring pixels, and says so.",
33
33
  "devDependencies": {
34
34
  "pixelmatch": "^7.1.0",
35
35
  "pngjs": "^7.0.0"
@@ -866,6 +866,25 @@ function searchIndex(root) {
866
866
  return entry;
867
867
  }
868
868
 
869
+ /**
870
+ * Is the FIRST chunk of this file free of NUL bytes? The bounded form of the reader's own refusal.
871
+ *
872
+ * `readTextDoc` scans the whole file, which is right for serving it and wrong for labelling 200
873
+ * search hits. This reads a fixed head — enough to catch the binary-masquerading-as-text case the
874
+ * reader refuses on, at a cost that does not scale with file size. A file whose head is clean and
875
+ * whose tail hides a NUL will still be labelled openable and still fail to open; that is a strictly
876
+ * smaller lie than labelling every large text file unopenable, and it is stated rather than hidden.
877
+ */
878
+ function headIsText(full) {
879
+ let fd = null;
880
+ try {
881
+ fd = fs.openSync(full, 'r');
882
+ const buf = Buffer.alloc(64 * 1024);
883
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
884
+ return buf.subarray(0, n).indexOf(0) === -1;
885
+ } catch { return false; } finally { if (fd !== null) { try { fs.closeSync(fd); } catch { /* ignore */ } } }
886
+ }
887
+
869
888
  /** One matched line, trimmed and centred on the needle, so a minified file cannot flood the row. */
870
889
  function searchSnippet(line, at) {
871
890
  const s = String(line || '').replace(/\t/g, ' ');
@@ -918,7 +937,13 @@ function searchWorkspace(root, query, limit) {
918
937
  * the honest answer is "we did not look", and the safe rendering of that is a hit the PM
919
938
  * cannot click into an unknown. */
920
939
  const st = fs.statSync(path.join(root, rel));
921
- if (!st.isFile() || st.size > SEARCH_CONTENT_BYTES) return false;
940
+ if (!st.isFile()) return false;
941
+ /* Past the byte budget we do not READ the file — but "we did not look" is not "the reader
942
+ * refuses it". Returning false there swapped one false label for its opposite: a legitimate
943
+ * 316 KB markdown doc reported openable:false while /api/doc served it 200 with 324,017 bytes.
944
+ * An independent pass measured it. The NUL test only needs the HEAD of the file, so bound the
945
+ * read instead of abandoning the question. */
946
+ if (st.size > SEARCH_CONTENT_BYTES) return headIsText(path.join(root, rel));
922
947
  return readTextDoc(root, rel) !== null;
923
948
  } catch { return false; }
924
949
  };