create-pmos 0.28.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 +45 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/pmos-log.sh +29 -2
- package/payload/scripts/workflow-state.js +40 -6
- package/payload/skills/evaluator.skill +45 -12
- package/payload/skills/pmos-router.skill +1 -1
- package/payload/skills-native/evaluator/SKILL.md +45 -12
- package/payload/skills-native/pmos-router/SKILL.md +1 -1
- package/workspace/lib/drive-guard.js +315 -14
- package/workspace/lib/engine/workflow-state.js +40 -6
- package/workspace/package.json +3 -3
- package/workspace/server.js +83 -8
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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -45,10 +45,37 @@ case "$cmd" in
|
|
|
45
45
|
append runs.jsonl "{\"run_id\":$(js "$1"),\"initiative_id\":$(js "$2"),\"human_corrections\":$corr,\"date\":\"$today\"$model_field}"
|
|
46
46
|
;;
|
|
47
47
|
eval)
|
|
48
|
-
|
|
48
|
+
# SKIPPED is a verdict this writer must be able to record.
|
|
49
|
+
#
|
|
50
|
+
# The enum here was PASS|PASS-WITH-FINDINGS|FAIL, while AGENTS.md tells every adopter: "a gate
|
|
51
|
+
# that was skipped is recorded as skipped — never leave a missing eval to be read as a pass."
|
|
52
|
+
# An adopter following that instruction hit `fail` and had no way to comply except by hand-
|
|
53
|
+
# appending, which is the drift this writer exists to prevent. PMOS-self's own ledger carries 16
|
|
54
|
+
# SKIPPED rows; the kit could not have written one of them.
|
|
55
|
+
#
|
|
56
|
+
# A skip is NOT a soft pass: it takes no score (a skip has no score to give), it requires a
|
|
57
|
+
# stated reason — an unexplained skip is indistinguishable from a forgotten one — and it leaves
|
|
58
|
+
# the verification the run owes still owing.
|
|
59
|
+
if [ "${4:-}" = "SKIPPED" ] || [ "${3:-}" = "SKIPPED" ]; then
|
|
60
|
+
# tolerated both argument positions: `eval <run> <init> SKIPPED <reason>` is the natural form
|
|
61
|
+
# when there is no score to give, and refusing it on argument order would just push people
|
|
62
|
+
# back to hand-appending.
|
|
63
|
+
if [ "${3:-}" = "SKIPPED" ]; then skip_reason="${4:-}"; skip_rubric="${5:-}"; skip_model="${6:-}"
|
|
64
|
+
else skip_reason="${5:-}"; skip_rubric="${6:-}"; skip_model="${7:-}"; fi
|
|
65
|
+
nonempty run_id "$1"; nonempty initiative_id "$2"
|
|
66
|
+
[ -n "$skip_reason" ] || fail "SKIPPED needs a reason: eval <run_id> <initiative_id> SKIPPED <reason> [rubric_path] [evaluator_model]"
|
|
67
|
+
rubric_field=""
|
|
68
|
+
if [ -n "$skip_rubric" ]; then rubric_field=",\"rubric_path\":$(js "$skip_rubric")"; fi
|
|
69
|
+
emodel_field=""
|
|
70
|
+
if [ -n "$skip_model" ]; then emodel_field=",\"evaluator_model\":$(js "$skip_model")"; fi
|
|
71
|
+
append evals.jsonl "{\"run_id\":$(js "$1"),\"initiative_id\":$(js "$2"),\"score\":null,\"verdict\":\"SKIPPED\",\"skipped_reason\":$(js "$skip_reason")$rubric_field,\"date\":\"$today\"$emodel_field}"
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
[ $# -ge 5 ] || fail "usage: eval <run_id> <initiative_id> <score 0..1> <PASS|PASS-WITH-FINDINGS|FAIL> <rubric_path> [evaluator_model]
|
|
75
|
+
or: eval <run_id> <initiative_id> SKIPPED <reason> [rubric_path] [evaluator_model]"
|
|
49
76
|
nonempty run_id "$1"; nonempty initiative_id "$2"
|
|
50
77
|
printf '%s' "$3" | grep -qE '^(0?\.[0-9]+|0|1(\.0+)?)$' || fail "score must be 0..1 written as 0, 1, N.N or .N — got '$3'."
|
|
51
|
-
case "$4" in PASS|PASS-WITH-FINDINGS|FAIL) ;; *) fail "verdict must be PASS | PASS-WITH-FINDINGS | FAIL, got '$4'." ;; esac
|
|
78
|
+
case "$4" in PASS|PASS-WITH-FINDINGS|FAIL) ;; *) fail "verdict must be PASS | PASS-WITH-FINDINGS | FAIL | SKIPPED, got '$4'." ;; esac
|
|
52
79
|
score="$3"; case "$score" in .*) score="0$score" ;; esac # ".5" -> "0.5": bare .5 is not valid JSON
|
|
53
80
|
[ -f "$KIT/$5" ] || [ -f "$5" ] || fail "rubric_path '$5' not found (relative to the kit or the repo)."
|
|
54
81
|
# optional evaluator-model provenance (which model rendered the verdict). Calibration (κ) is a
|
|
@@ -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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
869
|
-
|
|
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',
|
|
@@ -7,7 +7,7 @@ description: Adversarially evaluate an agent run's output against its rubric and
|
|
|
7
7
|
|
|
8
8
|
This skill is PMOS's **Review Gate** made a first-class, repeatable capability (P4). It formalizes the
|
|
9
9
|
adversarial independent evaluator run by hand through P1–P3: judge what an agent run produced against a
|
|
10
|
-
rubric, write a structured verdict
|
|
10
|
+
rubric, write a structured verdict through the validated writer (step 9), and surface it on the board. It runs in
|
|
11
11
|
the **interactive agent runtime** — any capable coding agent; heavy judgment belongs in the agent, not
|
|
12
12
|
an Edge Function ([D09](/DECISIONS.md)/[D34](/DECISIONS.md), amended 2026-07-23). PMOS-self runs it in
|
|
13
13
|
Claude Code, but the runtime is not Claude-specific.
|
|
@@ -32,7 +32,7 @@ Claude Code, but the runtime is not Claude-specific.
|
|
|
32
32
|
may under-reward an unfamiliar style. Either way: **default to skeptical**, and when a pass feels easy
|
|
33
33
|
or you are uncertain, **say so explicitly** and flag the run for the cross-family spot-check /
|
|
34
34
|
minority-veto jury ([calibration-method](/planning/evals/calibration-method.md)). Do not let stylistic
|
|
35
|
-
(un)familiarity read as quality. **Record your `evaluator_model` on the
|
|
35
|
+
(un)familiarity read as quality. **Record your `evaluator_model` on the verdict** — κ is
|
|
36
36
|
calibrated per evaluator model, and an unrecorded model silently pools calibration across model swaps.
|
|
37
37
|
- **Capability floor — this role assumes a frontier-tier reasoner.** The adversarial passes below
|
|
38
38
|
(default-to-refute, both-ways pairwise, semantic-pass-not-grep, injection resistance) degrade
|
|
@@ -57,8 +57,8 @@ Claude Code, but the runtime is not Claude-specific.
|
|
|
57
57
|
([injection-defenses](/planning/research/injection-defenses.md)).
|
|
58
58
|
- **Structural scope (binding).** You are **read-only**: no external-communication tool, no
|
|
59
59
|
repository writes, no artifact publishing; your **only write is the schema-constrained
|
|
60
|
-
|
|
61
|
-
that reads adversarial content must not be the component that can act. Any future widening of
|
|
60
|
+
verdict, written through the validated writer (step 9)**. This removes the exfiltration leg of the
|
|
61
|
+
lethal trifecta — the component that reads adversarial content must not be the component that can act. Any future widening of
|
|
62
62
|
this scope requires the trifecta audit in [AGENTS.md](/AGENTS.md) and a PM decision
|
|
63
63
|
([injection-defenses](/planning/research/injection-defenses.md)).
|
|
64
64
|
- **Advisory, not final.** Your verdict informs; it does not decide. The human PM **Acceptance Gate**
|
|
@@ -158,13 +158,45 @@ FAIL — never a clean PASS.) The split is **persisted**, not just narrated (ste
|
|
|
158
158
|
available, or state `pass-fraction: unknown (no backend)` in kit mode. A high pass-fraction is a
|
|
159
159
|
leniency-drift warning to the PM, not a compliment to the builders
|
|
160
160
|
([llm-judge-calibration](/planning/research/llm-judge-calibration.md)).
|
|
161
|
-
9. **Persist it
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
(
|
|
161
|
+
9. **Persist it through the validated writer — never by hand.** Which writer depends on where you
|
|
162
|
+
are running, and **both are validated; neither is a raw append**:
|
|
163
|
+
|
|
164
|
+
| you are in | the writer | why |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| **a vendored kit** (`pmos/` in a product repo) | `pmos/scripts/pmos-log.sh eval …` | ships with the kit; writes the kit's `state/evals.jsonl` |
|
|
167
|
+
| **PMOS-self** (file mode, [D64](/DECISIONS.md)) | `node scripts/record-eval.js --file verdict.json` | writes `planning/runs/evals.jsonl` |
|
|
168
|
+
| a hosted tenant | the `save_eval` MCP tool | **deprecated for PMOS-self**; does not exist in file mode |
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
# kit
|
|
172
|
+
pmos/scripts/pmos-log.sh eval <run_id> <initiative_id> <score> <PASS|PASS-WITH-FINDINGS|FAIL> <rubric_path> [evaluator_model]
|
|
173
|
+
pmos/scripts/pmos-log.sh eval <run_id> <initiative_id> SKIPPED "<reason>" [rubric_path] [evaluator_model]
|
|
174
|
+
|
|
175
|
+
# PMOS-self
|
|
176
|
+
node scripts/record-eval.js --file verdict.json # or pipe the JSON on stdin
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Both **fail loud**: a rejected verdict exits non-zero with the specific reason and writes nothing,
|
|
180
|
+
so a malformed record can never light the gate green. `record-eval.js` additionally refuses any
|
|
181
|
+
sink but the eval ledger — resolved, not by name — which is what keeps a Review-Gate verdict out
|
|
182
|
+
of the PM's acceptance ledger.
|
|
183
|
+
|
|
184
|
+
If the writer your context calls for is not present, **say so in the verdict and stop** — do not
|
|
185
|
+
hand-append. A ledger written by hand is how a field ends up meaning three things.
|
|
186
|
+
|
|
187
|
+
The record carries `{ initiative_id, run_id, rubric_path, score, verdict, evaluator_model,
|
|
188
|
+
recorded_at, dimensions }`, where `dimensions` is `{ <name>: { score, weight, note, evidence,
|
|
189
|
+
axis } }` — `evidence` carries the quote/pointer from step 6 (or the string `unverified`), and
|
|
190
|
+
**`axis` is `"spec"` or `"standards"`** so the two-axis split is persisted, not just narrated. Put
|
|
191
|
+
the two axis pass/fail summaries in the top-level `note` and tag every dimension with its axis.
|
|
192
|
+
|
|
193
|
+
A `SKIPPED` verdict is **declared, not omitted** — it requires `skipped_reason` and carries
|
|
194
|
+
`score: null`. A skip is an honest record that judging did not happen; it does not close the
|
|
195
|
+
verification the run owes, and the queue checker (PMOS-self: `scripts/check-verify-queue.js`)
|
|
196
|
+
will keep reporting the debt.
|
|
197
|
+
|
|
198
|
+
This is what makes the gate self-hosting: the verdict lands in the eval ledger and surfaces on the
|
|
199
|
+
board.
|
|
168
200
|
10. **Hand to the PM.** The Acceptance Gate is theirs, and the PM records the ground-truth `pm_label` to
|
|
169
201
|
`acceptances` — paired against your verdict by `v_calibration` to compute the kappa that calibrates this
|
|
170
202
|
gate ([D40](/DECISIONS.md), [calibration-method](/planning/evals/calibration-method.md)). If the PM
|
|
@@ -177,5 +209,6 @@ A short report: **most important finding first**, then the **two axis verdicts (
|
|
|
177
209
|
one line; Standards: pass/fail — one line)** and the **overall VERDICT** (score vs threshold, under
|
|
178
210
|
the combine rule) with the pass-fraction disclosure, the calibration exemplars used (step 3), the
|
|
179
211
|
numbered defects, and the per-dimension scores **with their evidence quotes/pointers and `axis`
|
|
180
|
-
tag** — followed by the
|
|
212
|
+
tag** — followed by the verdict record, written through **the writer for your context (step 9)**:
|
|
213
|
+
`pmos/scripts/pmos-log.sh eval …` in a vendored kit, `node scripts/record-eval.js …` in PMOS-self. Be concrete; cite specifics; if it is genuinely solid, say
|
|
181
214
|
so — but only after a real refutation attempt.
|
|
@@ -19,7 +19,7 @@ lanes and gates that already exist; where it and an authority disagree, the auth
|
|
|
19
19
|
router is the bug (fix it via the weekly harness scan).
|
|
20
20
|
|
|
21
21
|
> **Format note:** three-level progressive disclosure ([SKILL-FORMAT](/skills/core/SKILL-FORMAT.md)).
|
|
22
|
-
> Sources of truth: the AGENTS.md lane list, `.github/scripts/initiative-gate.sh
|
|
22
|
+
> Sources of truth: the AGENTS.md lane list, the initiative gate (PMOS-self: `.github/scripts/initiative-gate.sh`; a vendored kit: `pmos/scripts/gate-initiative.sh`), D56/D58/D60.
|
|
23
23
|
|
|
24
24
|
## Step 0 — three checks before any route
|
|
25
25
|
|
|
@@ -8,7 +8,7 @@ license: MIT
|
|
|
8
8
|
|
|
9
9
|
This skill is PMOS's **Review Gate** made a first-class, repeatable capability (P4). It formalizes the
|
|
10
10
|
adversarial independent evaluator run by hand through P1–P3: judge what an agent run produced against a
|
|
11
|
-
rubric, write a structured verdict
|
|
11
|
+
rubric, write a structured verdict through the validated writer (step 9), and surface it on the board. It runs in
|
|
12
12
|
the **interactive agent runtime** — any capable coding agent; heavy judgment belongs in the agent, not
|
|
13
13
|
an Edge Function ([D09](/DECISIONS.md)/[D34](/DECISIONS.md), amended 2026-07-23). PMOS-self runs it in
|
|
14
14
|
Claude Code, but the runtime is not Claude-specific.
|
|
@@ -33,7 +33,7 @@ Claude Code, but the runtime is not Claude-specific.
|
|
|
33
33
|
may under-reward an unfamiliar style. Either way: **default to skeptical**, and when a pass feels easy
|
|
34
34
|
or you are uncertain, **say so explicitly** and flag the run for the cross-family spot-check /
|
|
35
35
|
minority-veto jury ([calibration-method](/planning/evals/calibration-method.md)). Do not let stylistic
|
|
36
|
-
(un)familiarity read as quality. **Record your `evaluator_model` on the
|
|
36
|
+
(un)familiarity read as quality. **Record your `evaluator_model` on the verdict** — κ is
|
|
37
37
|
calibrated per evaluator model, and an unrecorded model silently pools calibration across model swaps.
|
|
38
38
|
- **Capability floor — this role assumes a frontier-tier reasoner.** The adversarial passes below
|
|
39
39
|
(default-to-refute, both-ways pairwise, semantic-pass-not-grep, injection resistance) degrade
|
|
@@ -58,8 +58,8 @@ Claude Code, but the runtime is not Claude-specific.
|
|
|
58
58
|
([injection-defenses](/planning/research/injection-defenses.md)).
|
|
59
59
|
- **Structural scope (binding).** You are **read-only**: no external-communication tool, no
|
|
60
60
|
repository writes, no artifact publishing; your **only write is the schema-constrained
|
|
61
|
-
|
|
62
|
-
that reads adversarial content must not be the component that can act. Any future widening of
|
|
61
|
+
verdict, written through the validated writer (step 9)**. This removes the exfiltration leg of the
|
|
62
|
+
lethal trifecta — the component that reads adversarial content must not be the component that can act. Any future widening of
|
|
63
63
|
this scope requires the trifecta audit in [AGENTS.md](/AGENTS.md) and a PM decision
|
|
64
64
|
([injection-defenses](/planning/research/injection-defenses.md)).
|
|
65
65
|
- **Advisory, not final.** Your verdict informs; it does not decide. The human PM **Acceptance Gate**
|
|
@@ -159,13 +159,45 @@ FAIL — never a clean PASS.) The split is **persisted**, not just narrated (ste
|
|
|
159
159
|
available, or state `pass-fraction: unknown (no backend)` in kit mode. A high pass-fraction is a
|
|
160
160
|
leniency-drift warning to the PM, not a compliment to the builders
|
|
161
161
|
([llm-judge-calibration](/planning/research/llm-judge-calibration.md)).
|
|
162
|
-
9. **Persist it
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
(
|
|
162
|
+
9. **Persist it through the validated writer — never by hand.** Which writer depends on where you
|
|
163
|
+
are running, and **both are validated; neither is a raw append**:
|
|
164
|
+
|
|
165
|
+
| you are in | the writer | why |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| **a vendored kit** (`pmos/` in a product repo) | `pmos/scripts/pmos-log.sh eval …` | ships with the kit; writes the kit's `state/evals.jsonl` |
|
|
168
|
+
| **PMOS-self** (file mode, [D64](/DECISIONS.md)) | `node scripts/record-eval.js --file verdict.json` | writes `planning/runs/evals.jsonl` |
|
|
169
|
+
| a hosted tenant | the `save_eval` MCP tool | **deprecated for PMOS-self**; does not exist in file mode |
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
# kit
|
|
173
|
+
pmos/scripts/pmos-log.sh eval <run_id> <initiative_id> <score> <PASS|PASS-WITH-FINDINGS|FAIL> <rubric_path> [evaluator_model]
|
|
174
|
+
pmos/scripts/pmos-log.sh eval <run_id> <initiative_id> SKIPPED "<reason>" [rubric_path] [evaluator_model]
|
|
175
|
+
|
|
176
|
+
# PMOS-self
|
|
177
|
+
node scripts/record-eval.js --file verdict.json # or pipe the JSON on stdin
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Both **fail loud**: a rejected verdict exits non-zero with the specific reason and writes nothing,
|
|
181
|
+
so a malformed record can never light the gate green. `record-eval.js` additionally refuses any
|
|
182
|
+
sink but the eval ledger — resolved, not by name — which is what keeps a Review-Gate verdict out
|
|
183
|
+
of the PM's acceptance ledger.
|
|
184
|
+
|
|
185
|
+
If the writer your context calls for is not present, **say so in the verdict and stop** — do not
|
|
186
|
+
hand-append. A ledger written by hand is how a field ends up meaning three things.
|
|
187
|
+
|
|
188
|
+
The record carries `{ initiative_id, run_id, rubric_path, score, verdict, evaluator_model,
|
|
189
|
+
recorded_at, dimensions }`, where `dimensions` is `{ <name>: { score, weight, note, evidence,
|
|
190
|
+
axis } }` — `evidence` carries the quote/pointer from step 6 (or the string `unverified`), and
|
|
191
|
+
**`axis` is `"spec"` or `"standards"`** so the two-axis split is persisted, not just narrated. Put
|
|
192
|
+
the two axis pass/fail summaries in the top-level `note` and tag every dimension with its axis.
|
|
193
|
+
|
|
194
|
+
A `SKIPPED` verdict is **declared, not omitted** — it requires `skipped_reason` and carries
|
|
195
|
+
`score: null`. A skip is an honest record that judging did not happen; it does not close the
|
|
196
|
+
verification the run owes, and the queue checker (PMOS-self: `scripts/check-verify-queue.js`)
|
|
197
|
+
will keep reporting the debt.
|
|
198
|
+
|
|
199
|
+
This is what makes the gate self-hosting: the verdict lands in the eval ledger and surfaces on the
|
|
200
|
+
board.
|
|
169
201
|
10. **Hand to the PM.** The Acceptance Gate is theirs, and the PM records the ground-truth `pm_label` to
|
|
170
202
|
`acceptances` — paired against your verdict by `v_calibration` to compute the kappa that calibrates this
|
|
171
203
|
gate ([D40](/DECISIONS.md), [calibration-method](/planning/evals/calibration-method.md)). If the PM
|
|
@@ -178,5 +210,6 @@ A short report: **most important finding first**, then the **two axis verdicts (
|
|
|
178
210
|
one line; Standards: pass/fail — one line)** and the **overall VERDICT** (score vs threshold, under
|
|
179
211
|
the combine rule) with the pass-fraction disclosure, the calibration exemplars used (step 3), the
|
|
180
212
|
numbered defects, and the per-dimension scores **with their evidence quotes/pointers and `axis`
|
|
181
|
-
tag** — followed by the
|
|
213
|
+
tag** — followed by the verdict record, written through **the writer for your context (step 9)**:
|
|
214
|
+
`pmos/scripts/pmos-log.sh eval …` in a vendored kit, `node scripts/record-eval.js …` in PMOS-self. Be concrete; cite specifics; if it is genuinely solid, say
|
|
182
215
|
so — but only after a real refutation attempt.
|
|
@@ -21,7 +21,7 @@ lanes and gates that already exist; where it and an authority disagree, the auth
|
|
|
21
21
|
router is the bug (fix it via the weekly harness scan).
|
|
22
22
|
|
|
23
23
|
> **Format note:** three-level progressive disclosure ([SKILL-FORMAT](/skills/core/SKILL-FORMAT.md)).
|
|
24
|
-
> Sources of truth: the AGENTS.md lane list, `.github/scripts/initiative-gate.sh
|
|
24
|
+
> Sources of truth: the AGENTS.md lane list, the initiative gate (PMOS-self: `.github/scripts/initiative-gate.sh`; a vendored kit: `pmos/scripts/gate-initiative.sh`), D56/D58/D60.
|
|
25
25
|
|
|
26
26
|
## Step 0 — three checks before any route
|
|
27
27
|
|
|
@@ -102,16 +102,38 @@ const ALLOWED_DIR_PREFIXES = Object.freeze([
|
|
|
102
102
|
* its own run is the free zone working as designed, and a fabricated `PM_approved` INSIDE that write
|
|
103
103
|
* is caught by the content guard below, not by refusing the file.
|
|
104
104
|
*/
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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',
|
|
109
121
|
'discoveries.md',
|
|
110
122
|
]);
|
|
111
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
|
+
|
|
112
127
|
/** Named explicitly so the refusal says WHY, rather than "not on the allowlist". Each of these
|
|
113
128
|
* would otherwise be caught by the allowlist anyway — the value here is the message. */
|
|
114
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',
|
|
115
137
|
'acceptances.jsonl': 'the Acceptance Gate is the PM\'s and non-delegable (D12)',
|
|
116
138
|
'evals.jsonl': 'a Review-Gate verdict on the driver\'s own work is self-grading — the driver may not record one (D40)',
|
|
117
139
|
'outcome_checks.jsonl': 'whether the bet paid off is the PM\'s judgement (D57)',
|
|
@@ -127,6 +149,196 @@ const HUMAN_DECISION_SINKS = Object.freeze({
|
|
|
127
149
|
* individually, which is the whole argument for the suite below. */
|
|
128
150
|
const DISPOSITION_RE = /^\s*"?\s*(PROMOTE(D)?|ITERATE(D)?|KILL(ED)?)\b/i;
|
|
129
151
|
|
|
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
|
+
/**
|
|
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
|
+
*/
|
|
225
|
+
const PROSE_LEAD = new Set([
|
|
226
|
+
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'its', 'their', 'our', 'his', 'her',
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* True when a status VALUE announces a PM disposition. Case-insensitive by construction; the
|
|
231
|
+
* carve-out, not the catch, is what is enumerated.
|
|
232
|
+
*/
|
|
233
|
+
function isDisposition(value) {
|
|
234
|
+
const v = String(value);
|
|
235
|
+
if (DISPOSITION_RE.test(v) || DISPOSITION_INTERNAL_RE.test(v)) return true;
|
|
236
|
+
const re = new RegExp(`(^|[^A-Za-z])(${DISPOSITION_VERB})\\b`, 'gi');
|
|
237
|
+
let m;
|
|
238
|
+
while ((m = re.exec(v)) !== null) {
|
|
239
|
+
const before = v.slice(0, m.index + m[1].length);
|
|
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
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* NORMALIZE BEFORE MATCHING — and the reason this rule is written as a rule.
|
|
261
|
+
*
|
|
262
|
+
* The predecessor tested `/PM_approved\s*[:=]\s*true/i`: unbounded whitespace. Its replacement — the
|
|
263
|
+
* "hardening" — tested `/PM_approved\W{0,4}(true|1|yes)/i`, which widened the VALUE side and, without
|
|
264
|
+
* anyone noticing, capped the SEPARATOR side at four non-word characters. An independent evaluator
|
|
265
|
+
* measured both modules side by side on 2026-08-04:
|
|
266
|
+
*
|
|
267
|
+
* PM_approved : true old=refused new=PERMITTED
|
|
268
|
+
* PM_approved: true old=refused new=PERMITTED
|
|
269
|
+
*
|
|
270
|
+
* A hardening round that shipped a strict regression, on the one predicate whose whole job is to stop
|
|
271
|
+
* a driver forging the PM's approval. `{"pre_run_contract" : {"PM_approved" : true}}` written
|
|
272
|
+
* as text to the allowlisted agent-runs.jsonl JSON.parses to `PM_approved === true`, which
|
|
273
|
+
* scripts/workflow-state.js accepts as O5 satisfaction.
|
|
274
|
+
*
|
|
275
|
+
* The lesson is not "use a bigger budget". Any budget is a denylist of the gaps you thought of, and
|
|
276
|
+
* the next one is defeated by a form nobody enumerated (the double-stringified payload defeated four
|
|
277
|
+
* with five escape characters). So: collapse the input to a canonical form FIRST — drop the
|
|
278
|
+
* characters an encoding can multiply, squeeze whitespace — and only then match, with unbounded
|
|
279
|
+
* separators. There is no budget to overrun because there is nothing left to pad with.
|
|
280
|
+
*/
|
|
281
|
+
/** Returned when normalization will not settle — the call site treats it as refusal, never as text. */
|
|
282
|
+
const NON_CONVERGENT = '\u0000NON_CONVERGENT\u0000';
|
|
283
|
+
|
|
284
|
+
function normalizeForMatch(s) {
|
|
285
|
+
let out = String(s);
|
|
286
|
+
/* DECODE, do not delete. The first attempt at this stripped backslashes outright — and a literal
|
|
287
|
+
* tab, which JSON.stringify writes as the two characters `\t`, became the letter `t`:
|
|
288
|
+
*
|
|
289
|
+
* PM_approved<TAB>:<TAB>true → "PM_approved\t:\ttrue" → PM_approvedt:ttrue
|
|
290
|
+
*
|
|
291
|
+
* …which matches nothing, so a tab-separated approval walked straight through the "fix". Caught by
|
|
292
|
+
* this round's own N-R1 assertion, which is the argument for attempting each form individually.
|
|
293
|
+
* Escape sequences are DATA and must be decoded to what they denote, never erased.
|
|
294
|
+
*
|
|
295
|
+
* Applied repeatedly so that multiply-stringified payloads (the ninth bypass) unwrap layer by
|
|
296
|
+
* layer rather than one level deep. Bounded, because a fixed point is not guaranteed on hostile
|
|
297
|
+
* input and an unbounded loop here is a denial of service. */
|
|
298
|
+
/* ONE left-to-right pass per round, not four independent global replaces.
|
|
299
|
+
*
|
|
300
|
+
* The four-replace form was itself defective, and an independent verifier found it: because
|
|
301
|
+
* `JSON.stringify` DOUBLES backslashes, the `\\u....` replace consumed the second backslash and
|
|
302
|
+
* left the first dangling in front of the decoded letter, which the `\\[tnrfvb0]` replace then
|
|
303
|
+
* ATE as a control char — turning `PM_approved` into `PM_app o ed` and permitting the write. The
|
|
304
|
+
* same `\t` → `t` corruption as the first attempt, one rule to the right, because the rules could
|
|
305
|
+
* see each other's output mid-pass. A single scanner consumes each escape exactly once.
|
|
306
|
+
*
|
|
307
|
+
* Loops to a FIXED POINT rather than a fixed count. The earlier `i < 4` was a budget — and this
|
|
308
|
+
* file argues at length that a budget is a denylist of the gaps you thought of; five layers of
|
|
309
|
+
* stringification walked past it. The cap here is a non-termination guard, not a budget, and
|
|
310
|
+
* hitting it means the input is still changing after 16 rounds, which is not something a
|
|
311
|
+
* legitimate write does: it fails CLOSED at the call site. */
|
|
312
|
+
const decodeOnce = (s) => {
|
|
313
|
+
let r = '';
|
|
314
|
+
for (let i = 0; i < s.length; i++) {
|
|
315
|
+
if (s[i] !== '\\') { r += s[i]; continue; }
|
|
316
|
+
const n = s[i + 1];
|
|
317
|
+
if (n === undefined) { r += ' '; break; }
|
|
318
|
+
if (n === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i + 2, i + 6))) {
|
|
319
|
+
r += String.fromCharCode(parseInt(s.slice(i + 2, i + 6), 16)); i += 5;
|
|
320
|
+
} else if (n === 'x' && /^[0-9a-fA-F]{2}$/.test(s.slice(i + 2, i + 4))) {
|
|
321
|
+
r += String.fromCharCode(parseInt(s.slice(i + 2, i + 4), 16)); i += 3;
|
|
322
|
+
} else if ('tnrfvb0'.includes(n)) { r += ' '; i += 1; }
|
|
323
|
+
else { r += n; i += 1; }
|
|
324
|
+
}
|
|
325
|
+
return r;
|
|
326
|
+
};
|
|
327
|
+
let converged = false;
|
|
328
|
+
for (let i = 0; i < 16; i++) {
|
|
329
|
+
const before = out;
|
|
330
|
+
out = decodeOnce(out);
|
|
331
|
+
if (out === before) { converged = true; break; }
|
|
332
|
+
}
|
|
333
|
+
if (!converged) return NON_CONVERGENT;
|
|
334
|
+
return out
|
|
335
|
+
.replace(/["'`]/g, '') // quoting is packaging, not content
|
|
336
|
+
.replace(/\s+/g, ' '); // any run of whitespace is one space
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Unbounded separators, by construction. Applied only to normalized text. */
|
|
340
|
+
const APPROVAL_RE = /PM_approved\s*[:=]\s*(true|1|yes)\b/i;
|
|
341
|
+
|
|
130
342
|
/** Truthy-tolerant: an approval the PM never gave is what is being refused; its JSON type is not the
|
|
131
343
|
* point. Closes bypass 2. */
|
|
132
344
|
const TRUTHY = new Set([true, 1, 'true', 'TRUE', 'True', 'yes', 'YES', '1']);
|
|
@@ -145,22 +357,69 @@ function carriesApproval(value, seen) {
|
|
|
145
357
|
return false;
|
|
146
358
|
}
|
|
147
359
|
|
|
148
|
-
/**
|
|
360
|
+
/**
|
|
361
|
+
* True when a `status`/`disposition` key ANYWHERE carries a disposition (bypass 5).
|
|
362
|
+
*
|
|
363
|
+
* Three type-loose gaps closed here, each measured on 2026-08-04: the key test was case-SENSITIVE
|
|
364
|
+
* (`Status` walked past), the value test required `typeof v === 'string'` (so `status: ['PROMOTED']`
|
|
365
|
+
* walked past), and a Buffer serialized to a byte array invisible to every scan.
|
|
366
|
+
*/
|
|
149
367
|
function carriesDisposition(value, seen) {
|
|
150
368
|
const s = seen || new Set();
|
|
151
369
|
if (!isObj(value) || s.has(value)) return false;
|
|
152
370
|
s.add(value);
|
|
153
371
|
for (const [k, v] of Object.entries(value)) {
|
|
154
|
-
|
|
372
|
+
const isStatusKey = k.toLowerCase() === 'status' || k.toLowerCase() === 'disposition';
|
|
373
|
+
if (isStatusKey) {
|
|
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. */
|
|
386
|
+
const candidates = Array.isArray(v) ? v : [v];
|
|
387
|
+
for (const c of candidates) {
|
|
388
|
+
if (typeof c === 'string' && isDisposition(c)) return true;
|
|
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
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (Buffer.isBuffer(v)) continue; // a Buffer's numeric indices are not an object graph
|
|
155
398
|
if (carriesDisposition(v, s)) return true;
|
|
156
399
|
}
|
|
157
400
|
return false;
|
|
158
401
|
}
|
|
159
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Repo-relative, canonical, or nothing.
|
|
405
|
+
*
|
|
406
|
+
* `path.posix.normalize` collapses `.` and `..` properly instead of the old substring test, which
|
|
407
|
+
* both over-refused (a legitimate `draft..v2.md`) and under-thought the problem. The backslash
|
|
408
|
+
* rewrite is gone: on POSIX a backslash is an ordinary filename character, and rewriting it let the
|
|
409
|
+
* single root-level file `planning\prd\x.md` pose as a file inside `planning/prd/`.
|
|
410
|
+
*/
|
|
160
411
|
function onAllowlist(file) {
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
412
|
+
const raw = String(file);
|
|
413
|
+
if (path.posix.isAbsolute(raw) || path.win32.isAbsolute(raw)) return false; // never outside the repo
|
|
414
|
+
const norm = path.posix.normalize(raw.replace(/^\.\//, ''));
|
|
415
|
+
if (norm === '.' || norm.startsWith('../')) return false; // escaped the repo root
|
|
416
|
+
if (norm.split('/').includes('..')) return false;
|
|
417
|
+
if (ALLOWED_FILES.includes(norm)) return true;
|
|
418
|
+
/* The prefix half admits DRAFT ARTIFACTS, never ledgers. Without this the module's own
|
|
419
|
+
* fourth-ledger argument fails against its own second directory: `planning/evals/anything.jsonl`
|
|
420
|
+
* was permitted, so the next append-only ledger anyone puts there is admitted by default —
|
|
421
|
+
* "exactly the class-failure this rewrite exists to close", as the comment above says. */
|
|
422
|
+
if (/\.jsonl$/i.test(norm)) return false;
|
|
164
423
|
return ALLOWED_DIR_PREFIXES.some((p) => norm.startsWith(p));
|
|
165
424
|
}
|
|
166
425
|
|
|
@@ -179,6 +438,20 @@ function assertWritable(req) {
|
|
|
179
438
|
throw new ForbiddenWrite('a request with no target', 'a write request must name its `file` — refused (fail-closed)');
|
|
180
439
|
}
|
|
181
440
|
|
|
441
|
+
/* A Buffer or TypedArray supplied AS the payload is bytes the writer will put on disk verbatim,
|
|
442
|
+
* and JSON.stringify renders it `{"type":"Buffer","data":[80,77,…]}` — invisible to the text scan
|
|
443
|
+
* and to the object walk alike. Both were handled NESTED and neither at the top level, which an
|
|
444
|
+
* independent verifier walked straight through with
|
|
445
|
+
* `payload: Buffer.from(JSON.stringify({pre_run_contract:{PM_approved:true}}))`. Decode to text
|
|
446
|
+
* and adjudicate it as text. */
|
|
447
|
+
if (Buffer.isBuffer(req.payload) || ArrayBuffer.isView(req.payload)) {
|
|
448
|
+
const asText = Buffer.from(
|
|
449
|
+
ArrayBuffer.isView(req.payload) && !Buffer.isBuffer(req.payload)
|
|
450
|
+
? Buffer.from(req.payload.buffer, req.payload.byteOffset, req.payload.byteLength)
|
|
451
|
+
: req.payload).toString('utf8');
|
|
452
|
+
return assertWritable({ file: req.file, text: `${req.text || ''}\n${asText}` });
|
|
453
|
+
}
|
|
454
|
+
|
|
182
455
|
const why = HUMAN_DECISION_SINKS[path.basename(req.file)];
|
|
183
456
|
if (why) throw new ForbiddenWrite(path.basename(req.file), why);
|
|
184
457
|
|
|
@@ -197,7 +470,12 @@ function assertWritable(req) {
|
|
|
197
470
|
throw new ForbiddenWrite('an unserialisable payload',
|
|
198
471
|
'a payload that cannot be inspected cannot be permitted — refused (fail-closed)');
|
|
199
472
|
}
|
|
200
|
-
|
|
473
|
+
const normalized = normalizeForMatch(serialized);
|
|
474
|
+
if (normalized === NON_CONVERGENT) {
|
|
475
|
+
throw new ForbiddenWrite('an input that will not normalize',
|
|
476
|
+
'a payload whose escaping does not settle cannot be inspected — refused (fail-closed)');
|
|
477
|
+
}
|
|
478
|
+
if (APPROVAL_RE.test(normalized) || carriesApproval(req.payload)) {
|
|
201
479
|
throw new ForbiddenWrite('pre_run_contract.PM_approved',
|
|
202
480
|
'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
|
|
203
481
|
}
|
|
@@ -209,11 +487,34 @@ function assertWritable(req) {
|
|
|
209
487
|
throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
|
|
210
488
|
}
|
|
211
489
|
if (typeof req.text === 'string') {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
490
|
+
// Split on /\r?\n/, not '\n'. The old split left a trailing '\r' on every CRLF line, and the
|
|
491
|
+
// matcher's `$` anchor (without the `m` flag) then matched nothing at all — a CRLF document
|
|
492
|
+
// sailed through the raw-text check entirely.
|
|
493
|
+
for (const line of req.text.split(/\r?\n/)) {
|
|
494
|
+
/* Markdown prefixes are how a status line is ACTUALLY written in the driver's own sinks —
|
|
495
|
+
* `- status:`, `**status:**`, `> status:`, `| status |` — and the old anchor allowed only
|
|
496
|
+
* leading whitespace, so every one of them walked past the check. */
|
|
497
|
+
const m = /^[\s>*\-+#|]*\**\s*(?:status|disposition)\**\s*[:|]\s*(.*?)\s*\|?\s*$/i.exec(line);
|
|
498
|
+
if (m && isDisposition(m[1])) {
|
|
215
499
|
throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
|
|
216
500
|
}
|
|
501
|
+
// The raw and structured paths must agree. A JSONL line supplied as `text` was permitted while
|
|
502
|
+
// the identical object supplied as `payload` was refused — the same content, two verdicts,
|
|
503
|
+
// decided by which field it arrived in.
|
|
504
|
+
const t = line.trim();
|
|
505
|
+
if (t.startsWith('{') || t.startsWith('[')) {
|
|
506
|
+
let parsed;
|
|
507
|
+
try { parsed = JSON.parse(t); } catch { parsed = undefined; }
|
|
508
|
+
if (parsed !== undefined) {
|
|
509
|
+
if (carriesDisposition(parsed)) {
|
|
510
|
+
throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
|
|
511
|
+
}
|
|
512
|
+
if (carriesApproval(parsed)) {
|
|
513
|
+
throw new ForbiddenWrite('pre_run_contract.PM_approved',
|
|
514
|
+
'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
217
518
|
}
|
|
218
519
|
}
|
|
219
520
|
return true;
|
|
@@ -228,6 +529,6 @@ function guardedWriter(write) {
|
|
|
228
529
|
}
|
|
229
530
|
|
|
230
531
|
module.exports = {
|
|
231
|
-
assertWritable, guardedWriter, carriesApproval, carriesDisposition, ForbiddenWrite,
|
|
532
|
+
assertWritable, guardedWriter, carriesApproval, carriesDisposition, isDisposition, PROSE_LEAD, ForbiddenWrite,
|
|
232
533
|
ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
|
|
233
534
|
};
|
|
@@ -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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
869
|
-
|
|
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',
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
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
|
|
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"
|
package/workspace/server.js
CHANGED
|
@@ -830,9 +830,20 @@ function searchIndex(root) {
|
|
|
830
830
|
const hit = searchIndexCache.get(root);
|
|
831
831
|
if (hit && now - hit.at < SEARCH_INDEX_TTL_MS) return hit;
|
|
832
832
|
const files = [];
|
|
833
|
-
let
|
|
833
|
+
let depthCut = false; // some branch was too deep — a LOCAL fact
|
|
834
|
+
let budgetCut = false; // the file budget ran out — a GLOBAL stop
|
|
835
|
+
/* Two truncation causes, two flags, and only ONE of them stops the walk.
|
|
836
|
+
*
|
|
837
|
+
* They used to share `truncated`, and the guard at the top of `walk` read it — so the first branch
|
|
838
|
+
* that hit SEARCH_MAX_DEPTH aborted every not-yet-visited subdirectory in the whole tree. Measured
|
|
839
|
+
* by an independent evaluator on 2026-08-04: 51 files at depth <= 1, `scanned: 50`, an entire
|
|
840
|
+
* in-bound sibling directory lost because an unrelated deep branch tripped the cap first — and
|
|
841
|
+
* which sibling survived depended on readdir order.
|
|
842
|
+
*
|
|
843
|
+
* Depth is a bound on ONE path. The file budget is a bound on the WHOLE walk. Only the second is
|
|
844
|
+
* a reason to stop walking. */
|
|
834
845
|
(function walk(dir, rel, depth) {
|
|
835
|
-
if (
|
|
846
|
+
if (budgetCut) return;
|
|
836
847
|
let ents = [];
|
|
837
848
|
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
838
849
|
for (const e of ents) {
|
|
@@ -840,20 +851,40 @@ function searchIndex(root) {
|
|
|
840
851
|
const full = path.join(dir, e.name);
|
|
841
852
|
const r = rel ? rel + '/' + e.name : e.name;
|
|
842
853
|
if (e.isDirectory()) {
|
|
843
|
-
// Depth is a bound like any other: reaching it means files below were NOT looked at
|
|
854
|
+
// Depth is a bound like any other: reaching it means files below were NOT looked at —
|
|
855
|
+
// but only BELOW HERE. Siblings elsewhere in the tree are still in bounds.
|
|
844
856
|
if (depth < SEARCH_MAX_DEPTH) walk(full, r, depth + 1);
|
|
845
|
-
else
|
|
857
|
+
else depthCut = true;
|
|
846
858
|
} else if (e.isFile()) {
|
|
847
|
-
if (files.length >= SEARCH_MAX_FILES) {
|
|
859
|
+
if (files.length >= SEARCH_MAX_FILES) { budgetCut = true; return; }
|
|
848
860
|
files.push({ path: r, name: e.name, dir: rel, kind: preview.previewKind(e.name) });
|
|
849
861
|
}
|
|
850
862
|
}
|
|
851
863
|
})(root, '', 0);
|
|
852
|
-
const entry = { at: now, files, truncated, scanned: files.length };
|
|
864
|
+
const entry = { at: now, files, truncated: depthCut || budgetCut, scanned: files.length };
|
|
853
865
|
searchIndexCache.set(root, entry);
|
|
854
866
|
return entry;
|
|
855
867
|
}
|
|
856
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
|
+
|
|
857
888
|
/** One matched line, trimmed and centred on the needle, so a minified file cannot flood the row. */
|
|
858
889
|
function searchSnippet(line, at) {
|
|
859
890
|
const s = String(line || '').replace(/\t/g, ' ');
|
|
@@ -882,15 +913,57 @@ function searchWorkspace(root, query, limit) {
|
|
|
882
913
|
const seen = new Set();
|
|
883
914
|
let overflow = false; // more matched than `cap` — a REAL cut, not a coincidence
|
|
884
915
|
|
|
916
|
+
/* `openable` means ONE thing: /api/doc will serve this. So it asks /api/doc's own reader rather
|
|
917
|
+
* than re-deciding from the extension.
|
|
918
|
+
*
|
|
919
|
+
* It used to be `SEARCH_OPENABLE.has(f.kind)` — extension only — while `readTextDoc` applies a
|
|
920
|
+
* SECOND refusal the search never replicated: a NUL byte anywhere in the file. A `.txt` holding
|
|
921
|
+
* binary therefore rendered as a clickable hit that opened a tab printing a read failure, which
|
|
922
|
+
* is the PRD's own constraint 6 violated ("the result says so rather than letting the PM click
|
|
923
|
+
* into a predictable failure"). An independent evaluator reproduced it on 2026-08-04:
|
|
924
|
+
*
|
|
925
|
+
* hit masquerade.txt kind=text openable=true -> /api/doc status=404
|
|
926
|
+
*
|
|
927
|
+
* Two notions of "servable" that can disagree is the defect; one definition, consulted, is the
|
|
928
|
+
* fix. Bounded by `cap` (<= 200 hits), and only for path matches — a content match already read
|
|
929
|
+
* the body and proved it NUL-free. */
|
|
930
|
+
const servable = (rel) => {
|
|
931
|
+
try {
|
|
932
|
+
/* SIZE-BOUNDED. `readTextDoc` has no size limit, so consulting it for every path hit read up
|
|
933
|
+
* to `cap` (200) WHOLE files on the server's only thread — measured by an independent verifier
|
|
934
|
+
* at 285 ms for 200 × 1 MB, and 5.7 s plus a 600 MB read for one oversized file. The comment
|
|
935
|
+
* twelve lines above SEARCH_CONTENT_BYTES says a bigger file is "path-matched only, never read
|
|
936
|
+
* whole"; the fix quietly broke that. A file past the content budget reports NOT openable —
|
|
937
|
+
* the honest answer is "we did not look", and the safe rendering of that is a hit the PM
|
|
938
|
+
* cannot click into an unknown. */
|
|
939
|
+
const st = fs.statSync(path.join(root, rel));
|
|
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));
|
|
947
|
+
return readTextDoc(root, rel) !== null;
|
|
948
|
+
} catch { return false; }
|
|
949
|
+
};
|
|
950
|
+
|
|
885
951
|
for (const f of idx.files) {
|
|
886
952
|
if (!f.path.toLowerCase().includes(needle)) continue;
|
|
887
953
|
seen.add(f.path);
|
|
888
954
|
if (hits.length >= cap) { overflow = true; break; }
|
|
889
|
-
hits.push({ ...f, match: 'path', line: null, snippet: null,
|
|
955
|
+
hits.push({ ...f, match: 'path', line: null, snippet: null,
|
|
956
|
+
openable: SEARCH_OPENABLE.has(f.kind) && servable(f.path) });
|
|
890
957
|
}
|
|
891
958
|
|
|
892
959
|
let read = 0;
|
|
893
960
|
let contentCut = false;
|
|
961
|
+
/* When path matches overflow the cap, the ENTIRE content pass is skipped — so every body in the
|
|
962
|
+
* workspace went unread. `content_truncated` reported `false` alongside `content_scanned: 0`,
|
|
963
|
+
* contradicting the flag's own documented meaning ("every file was named, but not every body was
|
|
964
|
+
* read"). Masked by `truncated: true`, so the PM was warned — but a flag that disagrees with
|
|
965
|
+
* reality is the failure the two-flag split exists to prevent. */
|
|
966
|
+
if (overflow) contentCut = true;
|
|
894
967
|
if (!overflow) {
|
|
895
968
|
for (const f of idx.files) {
|
|
896
969
|
if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
|
|
@@ -906,7 +979,9 @@ function searchWorkspace(root, query, limit) {
|
|
|
906
979
|
if (text.indexOf(String.fromCharCode(0)) !== -1) continue; // binary masquerading as text — never scanned
|
|
907
980
|
const at = text.toLowerCase().indexOf(needle);
|
|
908
981
|
if (at === -1) continue;
|
|
909
|
-
|
|
982
|
+
// The cap biting HERE also leaves bodies unread — the sibling branch of the same defect, and
|
|
983
|
+
// the half the first fix missed.
|
|
984
|
+
if (hits.length >= cap) { overflow = true; contentCut = true; break; }
|
|
910
985
|
const before = text.slice(0, at);
|
|
911
986
|
const lineNo = before.split('\n').length;
|
|
912
987
|
const lineStart = before.lastIndexOf('\n') + 1;
|