create-pmos 0.31.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -232,6 +232,27 @@ npx create-pmos@latest --update
232
232
 
233
233
  Refreshes the skills, concepts, and scripts only. Your records — `state/`, `planning/`, `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says exactly what you're running.
234
234
 
235
+ ## What's new in 0.33.0
236
+
237
+ **Three shipped documents stop telling you to do things that were never possible in your repo.** Run `npx create-pmos@latest --update`. No behaviour changes — this is a correctness release for the instructions the kit hands your agent.
238
+
239
+ - **`SKILL-FORMAT.md` no longer sends you after a seed you never had.** When you edited a `.skill`, it told you to "regenerate the seed in the same PR" and to grep an RLS test suite for pinned metadata. Those instructions described a hosted mirror table that only ever existed in PMOS's own backend — never in a vendored kit. That backend has now been removed entirely, and the section is rewritten to say what is actually true for you: **the frontmatter on disk is the only copy**, which is why the whole class of mirror-drift bug it warned about cannot reach you. The trade is stated rather than glossed: nothing validates your skill metadata, so a wrong registry key is simply wrong.
240
+ - **`ingest-repo` and `harness-refine` stop linking at reference docs your kit does not contain.** Both pointed at PMOS's hosted data-model and monitoring-view docs. `ingest-repo` now describes its write path as what it is in file mode — drafts into your own repo, approved in PR review — and `harness-refine` reads its evidence from `discoveries.md` and your run log instead of from SQL views.
241
+
242
+ **Why now.** PMOS removed its own hosted backend in this release's parent change, and these three files were the places where instructions written for that backend had leaked into what your kit ships. None of it was ever reachable from a kit install, so nothing was broken — it was wrong documentation, and it is now right.
243
+
244
+ **Unchanged:** the payload's skill set, the state layout, every gate, and the zero-network guarantee. Your records are untouched, as always.
245
+
246
+ ## What's new in 0.32.0
247
+
248
+ **The PM's own accept button now writes through the guarded writer, instead of appending to the acceptance log directly.** Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
249
+
250
+ - **`POST /api/gate` records your acceptance through the sink guard.** It resolves the path before allowing it, opens with `O_NOFOLLOW`, refuses a hard-linked target, writes to the descriptor it vetted rather than reopening the path afterwards, and rolls back a partial write instead of leaving half a record. It used to be a bare append — the one write path in the app that had never been hardened, while the module written for exactly this job had no caller at all.
251
+ - **A failed write is an HTTP 500, not a green tick.** Under D64 the file-mode acceptance log is the system of record; a swallowed append with `{ok:true}` would light the gate green over a record that never landed.
252
+ - **`drive-guard`'s refusal messages are read from a classification file rather than restated inside it.** If that file is absent — which it is in a plain kit install — the write is still refused; only the wording falls back to the generic message. Nothing about permission depends on a file that might not be there.
253
+
254
+ **Not in this release, said plainly.** The bigger piece of this wave — a declared registry of which records belong to the PM versus an agent, and `check-separation.js`, which names by `file:line` any module that writes one of the PM's records directly — is **PMOS-self only** for now and is not in your payload. Its paths are written for this repository's layout, and shipping them unchanged would give you a checker that reports confidently about directories you do not have. Porting it to the kit layout is its own piece of work, not a line in a release note.
255
+
235
256
  ## What's new in 0.31.1
236
257
 
237
258
  **Your specs stop arriving in Jira as ragged half-lines. One command repairs the ones already written, and the workspace does it in one click.** Run `npx create-pmos@latest --update` for the command, or `npx create-pmos@latest workspace` for the button.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.31.1",
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.",
3
+ "version": "0.33.0",
4
+ "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → 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": {
7
7
  "create-pmos": "bin/create-pmos.js"
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@9d15bfe
2
- assembled: 2026-08-05
1
+ kernel-source: pmos@51a8275
2
+ assembled: 2026-08-06
3
3
  mode: vendor
@@ -196,8 +196,31 @@ function readBudgets(root) {
196
196
  * `index` comes from parseOkrIndex; `budgets` from readBudgets (optional — omit to skip the
197
197
  * registry check, which callers that only need the FORM can do).
198
198
  */
199
+ /** Strip ONE layer of matching surrounding quotes.
200
+ *
201
+ * `parent_kr` is a YAML scalar, and a value whose prose contains a colon-space MUST be quoted or
202
+ * the document does not parse. The flat frontmatter reader hands the value through with its quotes
203
+ * intact, so a legitimately-quoted anchor arrived here as `"health:security …` and matched no form.
204
+ *
205
+ * This defect occurred TWICE — `sink-discipline-backport` on 2026-08-04 and
206
+ * `separation-invariant-enforcement` on 2026-08-05 — and the first time it was fixed by unquoting
207
+ * the one file. That is the wave's own lesson landing on the person who wrote it down: the unit of
208
+ * attention was the file, the unit of the defect was the rule. "Do not quote it" is a rule that
209
+ * lives in prose and depends on an author remembering; tolerating the quoting is a rule that lives
210
+ * in code.
211
+ *
212
+ * Only a MATCHING outer pair, and only one layer: a stray leading quote is still malformed, and
213
+ * reporting it as such is the point. */
214
+ function unquote(s) {
215
+ const t = s.trim();
216
+ if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
217
+ return t.slice(1, -1).trim();
218
+ }
219
+ return t;
220
+ }
221
+
199
222
  function resolveAnchor(raw, index, budgets) {
200
- const original = String(raw == null ? '' : raw).trim();
223
+ const original = unquote(String(raw == null ? '' : raw).trim());
201
224
  const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null };
202
225
  if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
203
226
 
@@ -269,6 +292,7 @@ function canonical(resolved, fallbackPeriod) {
269
292
  }
270
293
 
271
294
  module.exports = {
295
+ unquote,
272
296
  parseOkrIndex, readBudgets, resolveAnchor, canonical,
273
297
  objectiveId, krId, stripCommentary, listOkrFiles,
274
298
  ACCEPTED_FORMS,
@@ -6,23 +6,17 @@ timestamp: 2026-07-23
6
6
 
7
7
  # SKILL-FORMAT.md — Required Format for PMOS Skills
8
8
 
9
- This document defines the **required** structure for every `.skill` file in PMOS. It is the
10
- authority that all seed skills (`prd`, `eval-rubric`, `okr`, `strategic-brief`, `source-to-concept`)
11
- and every future skill must conform to.
9
+ This document defines the **required** structure for every `.skill` file in PMOS. It is the authority that all seed skills (`prd`, `eval-rubric`, `okr`, `strategic-brief`, `source-to-concept`) and every future skill must conform to.
12
10
 
13
11
  ## Why a fixed format
14
12
 
15
- Skills are how PMOS gives an agent procedural competence on demand without flooding its context.
16
- The format exists to make that economical: cheap to keep many skills available, expensive to load
17
- only the one in use. The mechanism is **three-level progressive disclosure**.
13
+ Skills are how PMOS gives an agent procedural competence on demand without flooding its context. The format exists to make that economical: cheap to keep many skills available, expensive to load only the one in use. The mechanism is **three-level progressive disclosure**.
18
14
 
19
15
  ## The three levels
20
16
 
21
17
  ### Level 1 — Frontmatter (always loaded)
22
18
 
23
- YAML frontmatter containing **only `name` and `description`**. This is pre-loaded for **all** skills
24
- at agent startup, so it must stay small — it is the *trigger signal* the agent uses to decide
25
- whether the skill is relevant.
19
+ YAML frontmatter containing **only `name` and `description`**. This is pre-loaded for **all** skills at agent startup, so it must stay small — it is the *trigger signal* the agent uses to decide whether the skill is relevant.
26
20
 
27
21
  ```yaml
28
22
  ---
@@ -32,189 +26,96 @@ description: Write a Product Requirements Document for an initiative. Use when a
32
26
  ```
33
27
 
34
28
  Rules:
29
+
35
30
  - `name` — lowercase, matches the filename stem (`prd` → `prd.skill`). **Required.**
36
- - `description` — one or two sentences. State **what** the skill does and **when** to use it.
37
- This is the only text the agent sees for an unloaded skill, so the "when" is what makes it fire.
38
- **Required.**
39
- - `maturity_mode` — **optional**. Present **only** on skills that are not yet active (e.g. `bootstrap`);
40
- omit it entirely on active skills. It lives at L1 because "is this skill executable yet?" is an
41
- execution-gating signal a loader/CI must be able to read without parsing the body. Values: `bootstrap`
42
- (specified, non-executable) → graduates to active (key removed) by a PM decision in `DECISIONS.md`.
43
- - **Optional Phase-B registry metadata (D49)** — a skill may also carry `version`, `owner`, `risk`,
44
- `category`, and `scope`. These are **optional**, producer-defined registry fields that
45
- the resync generator [`gen_okf_mirror_seed.py`](/supabase/gen_okf_mirror_seed.py) sources into the `skills` table so the skill
46
- library can group/version/scope skills. A skill with only `name` + `description` is still fully
47
- conformant (e.g. `build-skills/evaluator.skill`); carry the registry keys on durable `skills/core/`
48
- entries for consistency with their siblings.
49
- - `lifecycle` — **optional**, in the same D49 registry slot. Values: `active` (the default when the
50
- key is absent) | `deprecated`. A deprecated skill **stays on disk and stays in the registry** —
51
- `get_skill` returns it in full with a `deprecated_notice` so a retrieving agent is *told* not to
52
- start new work on it. Retirement is a state you can read; deletion is a separate act (see the
53
- invariant below). Distinct from `maturity_mode`, which is about a skill not yet executable —
54
- `lifecycle` is about one on its way out.
55
- - **No other L1 keys.** Beyond the required `name` + `description`, the optional `maturity_mode`, and the
56
- optional Phase-B registry keys above, procedural detail does not belong in L1 — it lives in the body.
57
-
58
- ### The disk ⇄ registry invariant (enforced in CI)
59
-
60
- **Every `.skill` on disk is seeded, and every seeded skill exists on disk — exactly.** The `skills`
61
- table is a *mirror*, and `get_skill` serves it to agents in other people's repos, so a mirror that
62
- has quietly drifted is a wrong answer delivered confidently.
63
-
64
- The `REGISTRY` check in [`check_okf_drift.py`](/supabase/check_okf_drift.py) fails the build on:
65
- adding or renaming a `.skill` without regenerating the seed; a seeded skill whose file is gone; and
66
- any registry-metadata value that disagrees with the file's own frontmatter. That last one is not
67
- theoretical — a generator whose INSERT omitted the Phase-B columns silently nulled
68
- `version/owner/risk/category/scope` for **all 21 skills for about three weeks** in 2026-07, and was
69
- caught only by an incidental version pin in an RLS test. So:
70
-
71
- > **When you add, rename, delete, or edit the frontmatter of a `.skill`, regenerate the seed in the
72
- > same PR** ([okf-sync playbook](/okf/core/playbooks/okf-sync.md)) — and sweep `supabase/tests/` for
73
- > pins on that skill's registry metadata before you do (the prd-grilling F1 rule).
31
+ - `description` — one or two sentences. State **what** the skill does and **when** to use it. This is the only text the agent sees for an unloaded skill, so the "when" is what makes it fire. **Required.**
32
+ - `maturity_mode` — **optional**. Present **only** on skills that are not yet active (e.g. `bootstrap`); omit it entirely on active skills. It lives at L1 because "is this skill executable yet?" is an execution-gating signal a loader/CI must be able to read without parsing the body. Values: `bootstrap` (specified, non-executable) → graduates to active (key removed) by a PM decision in `DECISIONS.md`.
33
+ - **Optional Phase-B registry metadata (D49)** — a skill may also carry `version`, `owner`, `risk`, `category`, and `scope`. These are **optional**, producer-defined registry fields that the workspace's skill library reads to group/version/scope skills. They used to be sourced into a hosted `skills` table by a resync generator; [D66](/DECISIONS.md) removed that mirror along with the backend, so the frontmatter on disk is now the only registry there is. A skill with only `name` + `description` is still fully conformant (e.g. `build-skills/evaluator.skill`); carry the registry keys on durable `skills/core/` entries for consistency with their siblings.
34
+ - `lifecycle` — **optional**, in the same D49 registry slot. Values: `active` (the default when the key is absent) | `deprecated`. A deprecated skill **stays on disk** — a reader gets it in full and is *told* not to start new work on it. Retirement is a state you can read; deletion is a separate act (see the invariant below). Distinct from `maturity_mode`, which is about a skill not yet executable — `lifecycle` is about one on its way out.
35
+ - **No other L1 keys.** Beyond the required `name` + `description`, the optional `maturity_mode`, and the optional Phase-B registry keys above, procedural detail does not belong in L1 — it lives in the body.
36
+
37
+ ### The disk ⇄ registry invariant — retired with the mirror (D66)
38
+
39
+ There used to be a second copy of every skill: a hosted `skills` table that `get_skill` served to agents in other people's repos. A `REGISTRY` check in the drift gate held the two in sync, because a mirror that has quietly drifted is a wrong answer delivered confidently — and it was not theoretical. A generator whose INSERT omitted the Phase-B columns silently nulled `version/owner/risk/category/scope` for **all 21 skills for about three weeks** in 2026-07, caught only by an incidental version pin in an RLS test.
40
+
41
+ [D66](/DECISIONS.md) deleted the mirror, the generator and that check together with the hosted backend. **The file on disk is now the only copy**, which is why the class of bug above cannot recur: there is nothing left to disagree with. The cost is that skill metadata is no longer validated by CI at all — if a registry key is wrong in the frontmatter, it is simply wrong. Read the frontmatter as authoritative and keep it accurate by hand.
74
42
 
75
43
  ### Level 2 — SKILL.md body (loaded when relevant)
76
44
 
77
- The body below the frontmatter holds the **full procedural detail**: steps, heuristics, required
78
- fields, gate criteria, examples. The agent reads this only after Level 1 has signalled relevance.
45
+ The body below the frontmatter holds the **full procedural detail**: steps, heuristics, required fields, gate criteria, examples. The agent reads this only after Level 1 has signalled relevance.
79
46
 
80
- This is where the actual "how to do the task" lives. Write it at optimal altitude — concrete
81
- heuristics that guide judgment, not a brittle decision tree and not a vague mandate.
47
+ This is where the actual "how to do the task" lives. Write it at optimal altitude — concrete heuristics that guide judgment, not a brittle decision tree and not a vague mandate.
82
48
 
83
49
  ### Level 3 — Linked sub-files (loaded on demand)
84
50
 
85
- Rare sub-cases, long reference tables, or edge-case procedures live in **separate files**,
86
- referenced **by filename** from the Level 2 body. The agent reads them only when the body points it
87
- there for the specific case in hand.
51
+ Rare sub-cases, long reference tables, or edge-case procedures live in **separate files**, referenced **by filename** from the Level 2 body. The agent reads them only when the body points it there for the specific case in hand.
88
52
 
89
53
  Example reference from a body: *"For multi-product PRDs, follow `prd-multiproduct.md`."*
90
54
 
91
- Use Level 3 to keep Level 2 focused. If a section applies to <20% of runs, it is a Level 3
92
- candidate.
55
+ Use Level 3 to keep Level 2 focused. If a section applies to <20% of runs, it is a Level 3 candidate.
93
56
 
94
57
  ## Quality — what makes a skill good (not just well-formed)
95
58
 
96
- The three levels are *structure*. This section is the *bar*. A skill can be perfectly structured and
97
- still be junk; these are the tests that keep it worth its context.
59
+ The three levels are *structure*. This section is the *bar*. A skill can be perfectly structured and still be junk; these are the tests that keep it worth its context.
98
60
 
99
61
  ### Predictability is the root virtue
100
62
 
101
- A skill exists to **wring determinism out of a stochastic system**: it makes the agent take the same
102
- *process* every run — not produce the same *output* (an evaluator should predictably *judge*; its
103
- words vary, its behavior doesn't). Every rule below serves predictability; cost and readability are
104
- symptoms of it, not rival goals. When unsure whether a line belongs, ask: *does it make the agent's
105
- process more repeatable?* If not, cut it.
63
+ A skill exists to **wring determinism out of a stochastic system**: it makes the agent take the same *process* every run — not produce the same *output* (an evaluator should predictably *judge*; its words vary, its behavior doesn't). Every rule below serves predictability; cost and readability are symptoms of it, not rival goals. When unsure whether a line belongs, ask: *does it make the agent's process more repeatable?* If not, cut it.
106
64
 
107
65
  ### Per-step completion criteria
108
66
 
109
- Every procedural step ends on a **checkable done-condition** — the agent must be able to tell done
110
- from not-done. Where it matters, make the criterion **exhaustive**, not vague: *"every living mirror
111
- of the rule accounted for"*, not *"produce a change list"*. A fuzzy criterion invites **premature
112
- completion** — the agent stops when it feels done, not when it is. The
113
- [semantic-pass rule](/build-skills/evaluator.skill) ("enumerate the live-doc set, then read each for
114
- meaning — a grep is evidence, never done") is the exemplar this generalizes: it exists because a
115
- vague "sweep it" criterion let real stale mirrors survive five "clean" passes.
67
+ Every procedural step ends on a **checkable done-condition** — the agent must be able to tell done from not-done. Where it matters, make the criterion **exhaustive**, not vague: *"every living mirror of the rule accounted for"*, not *"produce a change list"*. A fuzzy criterion invites **premature completion** — the agent stops when it feels done, not when it is. The [semantic-pass rule](/build-skills/evaluator.skill) ("enumerate the live-doc set, then read each for meaning — a grep is evidence, never done") is the exemplar this generalizes: it exists because a vague "sweep it" criterion let real stale mirrors survive five "clean" passes.
116
68
 
117
69
  ### Pruning — hunt these named failure modes
118
70
 
119
71
  A skill without a pruning discipline only ever grows. Hunt, sentence by sentence:
120
72
 
121
- - **No-op** — a line the model already obeys by default, so you pay context to say nothing. Test:
122
- does it change behavior vs. the default? *"Be thorough"* to an already-thorough agent is a no-op;
123
- the fix is a stronger word (a leading word), not more words.
124
- - **Sediment** — stale layers that settle because adding feels safe and removing feels risky. The
125
- default fate of any skill without this discipline.
126
- - **Sprawl** — simply too long, even when every line is live. Cure: disclose reference to Level 3;
127
- split by branch.
128
- - **Duplication** — the same meaning in more than one place. Costs tokens and maintenance, and
129
- inflates a meaning's apparent importance. Keep one source of truth.
130
- - **Negation** — steering by prohibition backfires: *"don't think of an elephant"* names the
131
- elephant. State the **positive** target behavior; keep a prohibition only as a hard guardrail you
132
- can't phrase positively, and pair it with what to do instead.
133
-
134
- **This is the checklist the quarterly harness-assumption review runs** (DECISIONS.md cadence): the
135
- review's question — *what scaffolding can be removed because the model now handles it natively?* — is
136
- the no-op and sediment hunt applied across every skill.
73
+ - **No-op** — a line the model already obeys by default, so you pay context to say nothing. Test: does it change behavior vs. the default? *"Be thorough"* to an already-thorough agent is a no-op; the fix is a stronger word (a leading word), not more words.
74
+ - **Sediment** — stale layers that settle because adding feels safe and removing feels risky. The default fate of any skill without this discipline.
75
+ - **Sprawl** — simply too long, even when every line is live. Cure: disclose reference to Level 3; split by branch.
76
+ - **Duplication** — the same meaning in more than one place. Costs tokens and maintenance, and inflates a meaning's apparent importance. Keep one source of truth.
77
+ - **Negation** — steering by prohibition backfires: *"don't think of an elephant"* names the elephant. State the **positive** target behavior; keep a prohibition only as a hard guardrail you can't phrase positively, and pair it with what to do instead.
78
+
79
+ **This is the checklist the quarterly harness-assumption review runs** (DECISIONS.md cadence): the review's question — *what scaffolding can be removed because the model now handles it natively?* — is the no-op and sediment hunt applied across every skill.
137
80
 
138
81
  ### Leading words
139
82
 
140
- A **leading word** is a compact concept already in the model's pretraining that anchors a whole
141
- region of behavior in one token — *tight* (loop), *watermelon*, *default-to-refute*, *silent drift*,
142
- *Summary Gate*. Repeated across a skill, it accumulates a distributed definition and recruits priors
143
- the model already holds. A triad spelled out at three sites (*"fast, deterministic, low-overhead"*)
144
- is begging to **collapse** into one word (*tight*). You win twice: fewer tokens, and a sharper hook
145
- the agent hangs its behavior on. Hunt every skill for restatements a leading word retires.
83
+ A **leading word** is a compact concept already in the model's pretraining that anchors a whole region of behavior in one token — *tight* (loop), *watermelon*, *default-to-refute*, *silent drift*, *Summary Gate*. Repeated across a skill, it accumulates a distributed definition and recruits priors the model already holds. A triad spelled out at three sites (*"fast, deterministic, low-overhead"*) is begging to **collapse** into one word (*tight*). You win twice: fewer tokens, and a sharper hook the agent hangs its behavior on. Hunt every skill for restatements a leading word retires.
146
84
 
147
85
  ### Invocation (advisory)
148
86
 
149
- The Agent-Skills world splits skills by *who can invoke them* — **model-invoked** (description always
150
- loaded, agent can auto-fire it, pays permanent context load) vs **user-invoked** (invisible to the
151
- agent, human-only, zero context load). PMOS's model differs — skills are **retrieved on demand**
152
- (see the retrieval rationale under *Ecosystem compatibility* below), so Level-1 frontmatter is the
153
- always-available trigger and there is no auto-fire to gate. Treat the axis as an authoring lens —
154
- *is this skill's description earning its keep as an always-available trigger?* — **not** a required
155
- field. (A formal `invocation:` key is a deferred registry question, not part of this format.)
87
+ The Agent-Skills world splits skills by *who can invoke them* — **model-invoked** (description always loaded, agent can auto-fire it, pays permanent context load) vs **user-invoked** (invisible to the agent, human-only, zero context load). PMOS's model differs — skills are **retrieved on demand** (see the retrieval rationale under *Ecosystem compatibility* below), so Level-1 frontmatter is the always-available trigger and there is no auto-fire to gate. Treat the axis as an authoring lens — *is this skill's description earning its keep as an always-available trigger?* — **not** a required field. (A formal `invocation:` key is a deferred registry question, not part of this format.)
156
88
 
157
89
  ## Ecosystem compatibility — the Agent Skills standard
158
90
 
159
- The open **Agent Skills standard** (Claude Code, Codex, and other harnesses) packages a skill as a
160
- **directory** `<skill-name>/SKILL.md` with YAML frontmatter (`name`, `description`, optionally
161
- `license`, `allowed-tools`) plus bundled sub-files. It **validates PMOS's three-level disclosure** —
162
- same frontmatter-as-trigger, body-as-procedure, sub-files-on-demand shape.
91
+ The open **Agent Skills standard** (Claude Code, Codex, and other harnesses) packages a skill as a **directory** `<skill-name>/SKILL.md` with YAML frontmatter (`name`, `description`, optionally `license`, `allowed-tools`) plus bundled sub-files. It **validates PMOS's three-level disclosure** — same frontmatter-as-trigger, body-as-procedure, sub-files-on-demand shape.
163
92
 
164
- PMOS **diverges** deliberately: skills are flat `<name>.skill` files, **not** `<name>/SKILL.md`
165
- directories. The rationale is PMOS's retrieval model — skills are fetched on demand via `get_skill`
166
- or referenced from AGENTS.md, **not** auto-discovered by a harness scanning for `SKILL.md`, so the
167
- directory ceremony buys nothing here. The **mapping is lossless**, which is what keeps the divergence
168
- cheap:
93
+ PMOS **diverges** deliberately: skills are flat `<name>.skill` files, **not** `<name>/SKILL.md` directories. The rationale is PMOS's retrieval model — skills are fetched on demand via `get_skill` or referenced from AGENTS.md, **not** auto-discovered by a harness scanning for `SKILL.md`, so the directory ceremony buys nothing here. The **mapping is lossless**, which is what keeps the divergence cheap:
169
94
 
170
95
  - a `<name>.skill` file **≅** a standard `SKILL.md` body;
171
96
  - PMOS's required `name` + `description` frontmatter is **already** the standard's required pair;
172
97
  - Level-3 sub-files **≅** the standard's bundled files.
173
98
 
174
- (One nuance for the follow-on below: PMOS's *optional* D49 registry keys —
175
- `version`/`owner`/`risk`/`category`/`scope`/`lifecycle` — are top-level custom keys; the standard would expect
176
- producer-defined extensions under a `metadata:` map. The **core** shape is lossless; relocating the
177
- registry keys belongs to the native-format packaging follow-on, not this flat form.)
99
+ (One nuance for the follow-on below: PMOS's *optional* D49 registry keys — `version`/`owner`/`risk`/`category`/`scope`/`lifecycle` — are top-level custom keys; the standard would expect producer-defined extensions under a `metadata:` map. The **core** shape is lossless; relocating the registry keys belongs to the native-format packaging follow-on, not this flat form.)
178
100
 
179
101
  ### Kit native-format packaging (E6 — shipped)
180
102
 
181
- The **kit** now ships every skill in native Agent-Skills directory form *as well as* the flat file —
182
- a lossless portability layer, generated **from** the flat `.skill` at build time (never
183
- hand-authored, so it cannot drift from its source). For each shipped skill, `kit/build-kit.sh` emits
184
- `skills-native/<name>/SKILL.md` where:
185
-
186
- - the directory name **equals** the frontmatter `name` (the standard's hard rule), and required
187
- `name` + `description` are present;
188
- - PMOS's D49 registry keys (`version`/`owner`/`risk`/`category`/`scope`/`lifecycle`) are relocated
189
- **under a `metadata:` map with a `pmos-` prefix** — the standard's producer-extension slot — never
190
- as top-level custom keys, so a conformant runtime ignores them cleanly;
191
- - a **fail-closed conformance self-check** in `build-kit.sh` aborts the build on any violation
192
- (name≠dirname, missing required field, or a top-level custom key), the same posture as the
193
- containment check.
194
-
195
- The flat `.skill` files remain the file-mode source of truth (the AGENTS.md state adapter references
196
- `pmos/skills/<name>.skill`); the native tree is the ecosystem-portable mirror. **Still a named
197
- follow-on (not built here):** wiring `create-pmos` to install the native tree into a runtime's
198
- auto-discovery locations (`.claude/skills/`, `.agents/skills/`). E6 delivers the conformant
199
- *packaging*; the *install location* is a separate change, revisited when native-discovery demand in
200
- kit repos is proven.
103
+ The **kit** now ships every skill in native Agent-Skills directory form *as well as* the flat file — a lossless portability layer, generated **from** the flat `.skill` at build time (never hand-authored, so it cannot drift from its source). For each shipped skill, `kit/build-kit.sh` emits `skills-native/<name>/SKILL.md` where:
104
+
105
+ - the directory name **equals** the frontmatter `name` (the standard's hard rule), and required `name` + `description` are present;
106
+ - PMOS's D49 registry keys (`version`/`owner`/`risk`/`category`/`scope`/`lifecycle`) are relocated **under a `metadata:` map with a `pmos-` prefix** — the standard's producer-extension slot — never as top-level custom keys, so a conformant runtime ignores them cleanly;
107
+ - a **fail-closed conformance self-check** in `build-kit.sh` aborts the build on any violation (name≠dirname, missing required field, or a top-level custom key), the same posture as the containment check.
108
+
109
+ The flat `.skill` files remain the file-mode source of truth (the AGENTS.md state adapter references `pmos/skills/<name>.skill`); the native tree is the ecosystem-portable mirror. **Still a named follow-on (not built here):** wiring `create-pmos` to install the native tree into a runtime's auto-discovery locations (`.claude/skills/`, `.agents/skills/`). E6 delivers the conformant *packaging*; the *install location* is a separate change, revisited when native-discovery demand in kit repos is proven.
201
110
 
202
111
  ### Per-skill eval harness (E8 — convention shipped, benchmark deferred)
203
112
 
204
- A skill may carry an **opt-in** eval file at `skills/evals/<name>.evals.json` (mapping to the
205
- standard's `<name>/evals/evals.json` in native packaging), adopting the skill-creator harness shape:
113
+ A skill may carry an **opt-in** eval file at `skills/evals/<name>.evals.json` (mapping to the standard's `<name>/evals/evals.json` in native packaging), adopting the skill-creator harness shape:
206
114
 
207
- - **`description_triggers`** — `should_fire` / `should_not_fire` prompt lists that test whether the
208
- L1 description fires on the right requests and stays silent on the wrong ones (trigger-accuracy).
209
- - **`cases`** — each a `{ name, prompt, expect, with_without }`, where `expect` is an **outcome
210
- property** a grader checks (never a script) and `with_without` toggles whether the case is meant
211
- for with-vs-without-the-skill benchmarking.
115
+ - **`description_triggers`** — `should_fire` / `should_not_fire` prompt lists that test whether the L1 description fires on the right requests and stays silent on the wrong ones (trigger-accuracy).
116
+ - **`cases`** — each a `{ name, prompt, expect, with_without }`, where `expect` is an **outcome property** a grader checks (never a script) and `with_without` toggles whether the case is meant for with-vs-without-the-skill benchmarking.
212
117
 
213
- `scripts/check_skill_evals.py` (run in the `okf-drift` job) validates the **shape** of any present
214
- eval file and that it names a real skill. It **never runs a benchmark** — actually scoring
215
- with-vs-without needs labeled outcomes + compute and is **deferred** (there is no negative class
216
- yet); running models in CI would make the gate nondeterministic. The convention and a worked example
217
- (`skills/evals/prd.evals.json`) ship now; the verdict "this skill measurably helps" does not.
118
+ `scripts/check_skill_evals.py` (run in the `okf-drift` job) validates the **shape** of any present eval file and that it names a real skill. It **never runs a benchmark** — actually scoring with-vs-without needs labeled outcomes + compute and is **deferred** (there is no negative class yet); running models in CI would make the gate nondeterministic. The convention and a worked example (`skills/evals/prd.evals.json`) ship now; the verdict "this skill measurably helps" does not.
218
119
 
219
120
  ## File naming
220
121
 
@@ -232,9 +133,7 @@ yet); running models in CI would make the gate nondeterministic. The convention
232
133
 
233
134
  A skill is well-formed when:
234
135
 
235
- 1. Level 1 frontmatter has `name` + `description` (both required), plus optionally `maturity_mode` (on
236
- non-active skills) and the Phase-B registry keys `version`/`owner`/`risk`/`category`/`scope`/
237
- `lifecycle` (D49); no other L1 keys.
136
+ 1. Level 1 frontmatter has `name` + `description` (both required), plus optionally `maturity_mode` (on non-active skills) and the Phase-B registry keys `version`/`owner`/`risk`/`category`/`scope`/ `lifecycle` (D49); no other L1 keys.
238
137
  2. `name` matches the filename stem.
239
138
  3. `description` states both what the skill does and when to use it.
240
139
  4. The body (Level 2) carries the full procedure and is self-sufficient for the common case.
@@ -242,13 +141,9 @@ A skill is well-formed when:
242
141
 
243
142
  **Quality bar** (the "good", not just "well-formed" — see Quality above):
244
143
 
245
- 6. **Predictability served** — every line makes the agent's *process* more repeatable; cut what
246
- doesn't.
247
- 7. **Per-step completion criteria** — each procedural step ends on a checkable (and where it matters
248
- exhaustive) done-condition, not a vague "do X".
249
- 8. **Pruned** — no no-ops, no sediment, no duplication; prohibitions phrased as positive targets;
250
- sprawl disclosed to Level 3.
144
+ 6. **Predictability served** — every line makes the agent's *process* more repeatable; cut what doesn't.
145
+ 7. **Per-step completion criteria** — each procedural step ends on a checkable (and where it matters exhaustive) done-condition, not a vague "do X".
146
+ 8. **Pruned** — no no-ops, no sediment, no duplication; prohibitions phrased as positive targets; sprawl disclosed to Level 3.
251
147
  9. **Leading words** — restated qualities collapsed into pretrained tokens where one earns its keep.
252
148
 
253
- All seed skills are authored against this checklist; the quarterly harness-assumption review re-runs
254
- items 6–9 across every skill.
149
+ All seed skills are authored against this checklist; the quarterly harness-assumption review re-runs items 6–9 across every skill.
@@ -87,8 +87,9 @@ field's "agents improving their own tools" loop while staying at the **L3 ceilin
87
87
  bounding decision [D47](/okf/products/pmos/adr/d47-self-refining-harness.md).
88
88
  - The sibling detect→draft→PR loop: [okf-author](/build-skills/okf-author.skill),
89
89
  [D42](/okf/products/pmos/adr/d42-okf-author-on-demand.md).
90
- - The evidence surfaces: [v_friction_recurrence](/okf/products/pmos/references/metrics/v_friction_recurrence.md),
91
- [v_context_budget](/okf/products/pmos/references/metrics/v_context_budget.md),
90
+ - The evidence surfaces: recurring friction themes and self-reported context pressure, read from
91
+ file-mode state ([discoveries.md](/discoveries.md) and `runs.jsonl`; the hosted
92
+ `v_friction_recurrence` / `v_context_budget` views went with the backend in D66) plus
92
93
  [eval-calibration.md](/planning/evals/eval-calibration.md).
93
94
  - The cadence it serves: [D23](/okf/products/pmos/adr/d23-quarterly-cadence.md) (harness-assumption
94
95
  review); the altitude standard: [agent-harness](/okf/core/concepts/agent-harness.md).
@@ -13,8 +13,8 @@ scope: read:tenant-repo, write:tenant-okf-drafts, write:product_repos
13
13
  This skill is the **existing-repo on-ramp**: a PM arrives with a product that already ships and already
14
14
  has a repo, and PMOS needs knowledge of it so a build agent doesn't cold-start on every run (re-deriving
15
15
  the stack, assuming wrong, producing work the PM must heavily correct — the north-star cost). It is the
16
- **inverse of [analyze-product-infra](/build-skills/analyze-product-infra.skill)** (which goes PRD →
17
- what the product *needs*): ingest-repo goes **code → what the product *is***. It emits a **draft** OKF
16
+ **inverse** of deriving an infrastructure spec from a PRD (PRD → what the product *needs*):
17
+ ingest-repo goes **code → what the product *is***. It emits a **draft** OKF
18
18
  reference layer + a repo registration and then **stops for PM approval**.
19
19
 
20
20
  > **Format:** three-level progressive disclosure ([SKILL-FORMAT](/skills/core/SKILL-FORMAT.md)). L1
@@ -101,18 +101,19 @@ reference layer + a repo registration and then **stops for PM approval**.
101
101
  - **`register_product_repo` semantics:** designed for provisioning output — here `app_surface`/
102
102
  `backend_model` are *observed*, a usage-only difference (no schema change); note it.
103
103
  - **B5 measurability:** the value thesis is "warm start → fewer corrections"; the first post-ingest
104
- build run should retrieve these concepts (B5 > 0 for the tenant, via
105
- [v_knowledge_reuse](/okf/products/pmos/references/metrics/v_knowledge_reuse.md) — now written by
106
- get_okf_concepts FETCH). That real correction-delta, not the doc count, is the win.
104
+ build run should retrieve these concepts. That real correction-delta, not the doc count, is the
105
+ win — measure it as corrections on the first post-ingest run versus the cold-start baseline.
106
+ (The hosted `v_knowledge_reuse` view that automated this count went with the backend in D66.)
107
107
 
108
108
  ## Level 3 — references
109
109
 
110
- - The write path + PM gate: [okf-author](/build-skills/okf-author.skill) Mode B, [D37](/okf/products/pmos/adr/d37-tenant-okf-db-native.md).
110
+ - The write path + PM gate: write the docs into the product's own repo as drafts and let the PM
111
+ approve them in PR review — the file-mode form of draft→approved. (D37's database-native tenant OKF
112
+ and its `save_okf_doc` tool were retired by [D66](/DECISIONS.md).)
111
113
  - The ledger method: [design-loop concept](/okf/core/concepts/design-loop.md),
112
114
  [design-reconcile](/skills/core/design-reconcile.skill), [watermelon-flag](/okf/core/concepts/watermelon-flag.md).
113
115
  - Extraction discipline: [source-to-concept](/skills/core/source-to-concept.skill).
114
- - Scan location: [D51](/okf/products/pmos/adr/d51-scan-location.md). Registry:
115
- [register_product_repo](/okf/products/pmos/references/mcp-tools/register_product_repo.md),
116
- [product_repos](/okf/products/pmos/data-model/product_repos.md).
117
- - Sibling on-ramps: [analyze-product-infra](/build-skills/analyze-product-infra.skill),
118
- [product-onboarding playbook](/okf/core/playbooks/product-onboarding.md).
116
+ - Scan location: [D51](/okf/products/pmos/adr/d51-scan-location.md). There is no longer a central
117
+ registry to register into — the hosted `product_repos` table and `register_product_repo` went with
118
+ the backend (D66); the product repo that carries the kit IS the registration.
119
+ - Sibling on-ramp: [product-onboarding playbook](/okf/core/playbooks/product-onboarding.md).
@@ -82,8 +82,9 @@ field's "agents improving their own tools" loop while staying at the **L3 ceilin
82
82
  bounding decision [D47](/okf/products/pmos/adr/d47-self-refining-harness.md).
83
83
  - The sibling detect→draft→PR loop: [okf-author](/build-skills/okf-author.skill),
84
84
  [D42](/okf/products/pmos/adr/d42-okf-author-on-demand.md).
85
- - The evidence surfaces: [v_friction_recurrence](/okf/products/pmos/references/metrics/v_friction_recurrence.md),
86
- [v_context_budget](/okf/products/pmos/references/metrics/v_context_budget.md),
85
+ - The evidence surfaces: recurring friction themes and self-reported context pressure, read from
86
+ file-mode state ([discoveries.md](/discoveries.md) and `runs.jsonl`; the hosted
87
+ `v_friction_recurrence` / `v_context_budget` views went with the backend in D66) plus
87
88
  [eval-calibration.md](/planning/evals/eval-calibration.md).
88
89
  - The cadence it serves: [D23](/okf/products/pmos/adr/d23-quarterly-cadence.md) (harness-assumption
89
90
  review); the altitude standard: [agent-harness](/okf/core/concepts/agent-harness.md).
@@ -15,8 +15,8 @@ metadata:
15
15
  This skill is the **existing-repo on-ramp**: a PM arrives with a product that already ships and already
16
16
  has a repo, and PMOS needs knowledge of it so a build agent doesn't cold-start on every run (re-deriving
17
17
  the stack, assuming wrong, producing work the PM must heavily correct — the north-star cost). It is the
18
- **inverse of [analyze-product-infra](/build-skills/analyze-product-infra.skill)** (which goes PRD →
19
- what the product *needs*): ingest-repo goes **code → what the product *is***. It emits a **draft** OKF
18
+ **inverse** of deriving an infrastructure spec from a PRD (PRD → what the product *needs*):
19
+ ingest-repo goes **code → what the product *is***. It emits a **draft** OKF
20
20
  reference layer + a repo registration and then **stops for PM approval**.
21
21
 
22
22
  > **Format:** three-level progressive disclosure ([SKILL-FORMAT](/skills/core/SKILL-FORMAT.md)). L1
@@ -103,18 +103,19 @@ reference layer + a repo registration and then **stops for PM approval**.
103
103
  - **`register_product_repo` semantics:** designed for provisioning output — here `app_surface`/
104
104
  `backend_model` are *observed*, a usage-only difference (no schema change); note it.
105
105
  - **B5 measurability:** the value thesis is "warm start → fewer corrections"; the first post-ingest
106
- build run should retrieve these concepts (B5 > 0 for the tenant, via
107
- [v_knowledge_reuse](/okf/products/pmos/references/metrics/v_knowledge_reuse.md) — now written by
108
- get_okf_concepts FETCH). That real correction-delta, not the doc count, is the win.
106
+ build run should retrieve these concepts. That real correction-delta, not the doc count, is the
107
+ win — measure it as corrections on the first post-ingest run versus the cold-start baseline.
108
+ (The hosted `v_knowledge_reuse` view that automated this count went with the backend in D66.)
109
109
 
110
110
  ## Level 3 — references
111
111
 
112
- - The write path + PM gate: [okf-author](/build-skills/okf-author.skill) Mode B, [D37](/okf/products/pmos/adr/d37-tenant-okf-db-native.md).
112
+ - The write path + PM gate: write the docs into the product's own repo as drafts and let the PM
113
+ approve them in PR review — the file-mode form of draft→approved. (D37's database-native tenant OKF
114
+ and its `save_okf_doc` tool were retired by [D66](/DECISIONS.md).)
113
115
  - The ledger method: [design-loop concept](/okf/core/concepts/design-loop.md),
114
116
  [design-reconcile](/skills/core/design-reconcile.skill), [watermelon-flag](/okf/core/concepts/watermelon-flag.md).
115
117
  - Extraction discipline: [source-to-concept](/skills/core/source-to-concept.skill).
116
- - Scan location: [D51](/okf/products/pmos/adr/d51-scan-location.md). Registry:
117
- [register_product_repo](/okf/products/pmos/references/mcp-tools/register_product_repo.md),
118
- [product_repos](/okf/products/pmos/data-model/product_repos.md).
119
- - Sibling on-ramps: [analyze-product-infra](/build-skills/analyze-product-infra.skill),
120
- [product-onboarding playbook](/okf/core/playbooks/product-onboarding.md).
118
+ - Scan location: [D51](/okf/products/pmos/adr/d51-scan-location.md). There is no longer a central
119
+ registry to register into — the hosted `product_repos` table and `register_product_repo` went with
120
+ the backend (D66); the product repo that carries the kit IS the registration.
121
+ - Sibling on-ramp: [product-onboarding playbook](/okf/core/playbooks/product-onboarding.md).
@@ -61,6 +61,7 @@
61
61
  * something does adopt it, it will not adopt seven bypasses with it.
62
62
  */
63
63
 
64
+ const fs = require('fs');
64
65
  const path = require('path');
65
66
 
66
67
  class ForbiddenWrite extends Error {
@@ -117,29 +118,69 @@ const ALLOWED_DIR_PREFIXES = Object.freeze([
117
118
  const ALLOWED_FILES = Object.freeze([
118
119
  'planning/runs/agent-runs.jsonl',
119
120
  'planning/runs/verify-queue.jsonl',
120
- 'planning/runs/work-items.jsonl',
121
+ /* `planning/work-items.jsonl`, NOT `planning/runs/work-items.jsonl`. The latter was allowlisted
122
+ * here for weeks and has never existed — scripts/work-item.js records that directly. An allowlist
123
+ * entry for a path that does not exist permits nothing and protects nothing; what it does is make
124
+ * the list look like it covers the factory's ledger when it does not. Found by an independent pass
125
+ * comparing this list against pmos/graph-membership.json, which is the whole argument for the
126
+ * derivation below: two enumerations of the same thing disagree, and nothing notices. */
127
+ 'planning/work-items.jsonl',
121
128
  'discoveries.md',
122
129
  ]);
123
130
 
124
131
  /** Kept for the refusal message only — never for deciding permission. */
125
132
  const ALLOWED_BASENAMES = Object.freeze(ALLOWED_FILES.map((f) => path.posix.basename(f)));
126
133
 
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
- });
134
+ /** ── DERIVED FROM THE REGISTRY, NOT RESTATED ────────────────────────────────────────────────────
135
+ *
136
+ * This was an inline map of seven basenames to reasons — a second enumeration of exactly the
137
+ * control-graph classification that `pmos/graph-membership.json` holds, with the D-numbers mirrored
138
+ * in both. Two copies of a rule are two places for it to be wrong, and these two ALREADY disagreed:
139
+ * the allowlist above named a ledger that has never existed while the registry named the real one.
140
+ *
141
+ * So the reasons come from the registry's `why` for every record it classifies `control`. Keyed by
142
+ * basename, because that is what the refusal path looks up.
143
+ *
144
+ * FAIL-OPEN IS NOT A RISK HERE, and it is worth saying why rather than trusting it: this map only
145
+ * supplies the REASON in a refusal message. Permission is decided by `onAllowlist` below, which
146
+ * refuses everything not sanctioned whether or not a reason was found. If the registry is missing —
147
+ * a vendored kit, a partial checkout — the write is still refused; the message is just the generic
148
+ * one. A guard whose enforcement depended on a file it might not have would be a different and much
149
+ * worse design. */
150
+ let REGISTRY_UNAVAILABLE = null;
151
+ function loadHumanDecisionSinks() {
152
+ const out = {};
153
+ try {
154
+ /* TWO LAYOUTS, the same two-path resolution scripts/md-newlines.js uses for its normalizer.
155
+ *
156
+ * In a checkout this file is `<repo>/workspace/lib/`, so the registry is two levels up. In an
157
+ * npm install the workspace app is bundled BESIDE the vendored kit, not inside it, so the
158
+ * registry — if the adopter has one — is `<install>/pmos/graph-membership.json`. Neither path
159
+ * is guaranteed, and that is fine: this map only supplies the REASON in a refusal message.
160
+ * The write is refused either way by `onAllowlist` below. */
161
+ const tried = [
162
+ path.join(__dirname, '..', '..', 'pmos', 'graph-membership.json'), // a checkout
163
+ path.join(__dirname, '..', '..', '..', 'pmos', 'graph-membership.json'), // beside a vendored kit
164
+ ];
165
+ const found = tried.find((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
166
+ if (!found) throw new Error(`no graph-membership.json in: ${tried.join(', ')}`);
167
+ const raw = fs.readFileSync(found, 'utf8');
168
+ for (const r of (JSON.parse(raw).records || [])) {
169
+ if (!r || r.graph !== 'control' || typeof r.path !== 'string' || r.path.endsWith('/')) continue;
170
+ const base = path.posix.basename(r.path);
171
+ if (ALLOWED_BASENAMES.includes(base)) continue; // the driver's own sanctioned ledgers
172
+ if (!out[base]) out[base] = String(r.why || 'a control-graph record — the PM\'s, not the driver\'s');
173
+ }
174
+ } catch (e) {
175
+ /* RECORDED, not swallowed. The first version caught silently, and a missing `require('fs')` in
176
+ * this very file turned into an empty map with no signal at all — every reasoned refusal quietly
177
+ * became the generic one and two suite assertions were the only thing that noticed. A catch that
178
+ * hides its own cause is how a degradation becomes permanent. */
179
+ REGISTRY_UNAVAILABLE = e && e.message ? e.message : String(e);
180
+ }
181
+ return out;
182
+ }
183
+ const HUMAN_DECISION_SINKS = Object.freeze(loadHumanDecisionSinks());
143
184
 
144
185
  /** Disposition tokens (D60), widened to the bare verbs bypass 4 slipped through, and anchored so a
145
186
  * status VALUE that begins with one is a disposition while prose mentioning one is not. */
@@ -530,5 +571,6 @@ function guardedWriter(write) {
530
571
 
531
572
  module.exports = {
532
573
  assertWritable, guardedWriter, carriesApproval, carriesDisposition, isDisposition, PROSE_LEAD, ForbiddenWrite,
533
- ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
574
+ ALLOWED_DIR_PREFIXES, ALLOWED_FILES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
575
+ registryUnavailable: () => REGISTRY_UNAVAILABLE,
534
576
  };
@@ -196,8 +196,31 @@ function readBudgets(root) {
196
196
  * `index` comes from parseOkrIndex; `budgets` from readBudgets (optional — omit to skip the
197
197
  * registry check, which callers that only need the FORM can do).
198
198
  */
199
+ /** Strip ONE layer of matching surrounding quotes.
200
+ *
201
+ * `parent_kr` is a YAML scalar, and a value whose prose contains a colon-space MUST be quoted or
202
+ * the document does not parse. The flat frontmatter reader hands the value through with its quotes
203
+ * intact, so a legitimately-quoted anchor arrived here as `"health:security …` and matched no form.
204
+ *
205
+ * This defect occurred TWICE — `sink-discipline-backport` on 2026-08-04 and
206
+ * `separation-invariant-enforcement` on 2026-08-05 — and the first time it was fixed by unquoting
207
+ * the one file. That is the wave's own lesson landing on the person who wrote it down: the unit of
208
+ * attention was the file, the unit of the defect was the rule. "Do not quote it" is a rule that
209
+ * lives in prose and depends on an author remembering; tolerating the quoting is a rule that lives
210
+ * in code.
211
+ *
212
+ * Only a MATCHING outer pair, and only one layer: a stray leading quote is still malformed, and
213
+ * reporting it as such is the point. */
214
+ function unquote(s) {
215
+ const t = s.trim();
216
+ if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
217
+ return t.slice(1, -1).trim();
218
+ }
219
+ return t;
220
+ }
221
+
199
222
  function resolveAnchor(raw, index, budgets) {
200
- const original = String(raw == null ? '' : raw).trim();
223
+ const original = unquote(String(raw == null ? '' : raw).trim());
201
224
  const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null };
202
225
  if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
203
226
 
@@ -269,6 +292,7 @@ function canonical(resolved, fallbackPeriod) {
269
292
  }
270
293
 
271
294
  module.exports = {
295
+ unquote,
272
296
  parseOkrIndex, readBudgets, resolveAnchor, canonical,
273
297
  objectiveId, krId, stripCommentary, listOkrFiles,
274
298
  ACCEPTED_FORMS,
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* sink.js — the ONE place the append-only ledger discipline lives.
4
+ *
5
+ * ── WHY THIS FILE EXISTS ────────────────────────────────────────────────────────────────────────
6
+ * `record-eval.js` was PMOS's first validated writer. Three independent passes found four defects in
7
+ * it, and all four were fixed IN THAT FILE:
8
+ *
9
+ * 1. the sink was a caller-supplied allowlist -> a caller could name its own destination
10
+ * 2. the file was opened before validation -> N refused writes leaked N descriptors to EMFILE
11
+ * 3. a refused write still created the sink -> refusing to write left the file behind
12
+ * 4. the symlink check was a path-level stat -> a hardlink walked straight through it
13
+ *
14
+ * Two writers built afterwards inherited the fixes BY HAND, and each inheritance read as evidence
15
+ * that the pattern was holding. It was not holding — it was propagating FORWARD ONLY. The two
16
+ * writers that already existed when the defects were found were never revisited, because the process
17
+ * asks "does the new thing follow the rule?" and never "where else does this rule already live?"
18
+ *
19
+ * Measured by attempted write on 2026-08-04:
20
+ *
21
+ * scripts/work-item.js -> wrote 198 bytes through a symlink into a decoy
22
+ * scripts/verify-queue.js -> the same, AND wrote 202 bytes into a fresh tmpdir, because its
23
+ * allowlist compared `path.basename` alone. A basename is not a
24
+ * location; every directory on disk has one.
25
+ *
26
+ * Both are merged, PM-accepted, and load-bearing for D65 steps 1 and 2. So the discipline moves here
27
+ * and the writers call it. One wrong place that every assertion points at beats five where three
28
+ * happen to be right.
29
+ *
30
+ * ── THE RULE, STATED ONCE ───────────────────────────────────────────────────────────────────────
31
+ * Resolve before allowing. Validate before opening. Write to the descriptor that was vetted.
32
+ *
33
+ * The third clause is the one that looks redundant and is not. A path checked and then re-opened by
34
+ * path is two operations on two objects: whatever was approved may not be what gets written. The fd
35
+ * IS the approval, so it is what the caller must be handed.
36
+ */
37
+
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+
41
+ /**
42
+ * THE `ForbiddenWrite`. One class, exported, re-exported by every writer that uses this guard.
43
+ *
44
+ * There were five of these — one per writer that had a guard at all — and that is not a cosmetic duplication. Callers and
45
+ * assertions tell "refused on purpose" from "crashed" with `instanceof`, so a guard throwing its own
46
+ * private copy makes every one of those checks silently false: `scripts/tests/work-item.sh` reported
47
+ * `threw ForbiddenWrite, want ForbiddenWrite` on seventeen assertions the moment this module started
48
+ * throwing a second class with the same name. A refusal nobody can recognise as a refusal is not one.
49
+ *
50
+ * `detail` is always set (null when absent) rather than conditionally attached, because a property
51
+ * that sometimes exists is a second shape for callers to handle.
52
+ */
53
+ class ForbiddenWrite extends Error {
54
+ constructor(message, detail) {
55
+ super(message);
56
+ this.name = 'ForbiddenWrite';
57
+ this.detail = detail || null;
58
+ }
59
+ }
60
+
61
+ /** Resolve a path through its real directory. Kept separate so the sink and the target are compared
62
+ * the same way — comparing a resolved target against an unresolved sink is a mismatch by
63
+ * construction, and would refuse every legitimate write on a machine where /tmp is a symlink. */
64
+ function resolveThrough(p) {
65
+ return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p));
66
+ }
67
+
68
+ /**
69
+ * Build a guard bound to ONE sink.
70
+ *
71
+ * @param {object} spec
72
+ * @param {string} spec.sink absolute path of the only file this guard will ever open.
73
+ * @param {string} spec.what noun for error messages, e.g. 'work-item events'.
74
+ * @returns {{ open: function, sinkPath: function, __setSinkForTests: function }}
75
+ *
76
+ * The sink is a CONSTRUCTION-TIME constant, never an argument to `open`. That is defect 1 of the
77
+ * original four, and re-admitting it as "just a parameter" is how it would come back.
78
+ */
79
+ function createSinkGuard(spec) {
80
+ if (!spec || typeof spec.sink !== 'string' || !spec.sink) {
81
+ throw new ForbiddenWrite('a sink guard needs a declared `sink` path — refused (fail-closed)');
82
+ }
83
+ const what = spec.what || 'records';
84
+ const declared = spec.sink;
85
+ let overrideForTests = null;
86
+
87
+ /* A seam, not a door. Gated so it cannot be reached in a production process even if something
88
+ * calls it — the point of A4 is that no code path lets a caller choose the destination. */
89
+ function __setSinkForTests(p) {
90
+ /* An explicit allowed set. `!process.env.PMOS_TEST` treats the STRING '0' as truthy, so
91
+ * PMOS_TEST=0 — the natural way an operator turns test mode OFF — OPENED the production sink
92
+ * override instead of closing it. A flag whose "off" value means "on" is worse than no flag. */
93
+ const on = (v) => v === '1' || v === 'true' || v === 'yes';
94
+ if (process.env.NODE_ENV !== 'test' && !on(process.env.PMOS_TEST)) {
95
+ throw new ForbiddenWrite('__setSinkForTests is a test seam — set NODE_ENV=test or PMOS_TEST=1 to use it');
96
+ }
97
+ overrideForTests = p;
98
+ }
99
+
100
+ const sinkPath = () => overrideForTests || declared;
101
+
102
+ /**
103
+ * Pre-flight: validate the record and confirm the target RESOLVES to the sink — without opening
104
+ * anything. For callers that want to ask "would this be permitted?" without a side effect.
105
+ *
106
+ * NOT AUTHORITATIVE, and the gap is deliberate rather than an oversight. Whether the sink is a
107
+ * symlink or carries a second hard link cannot be established without a descriptor, and a `lstat`
108
+ * here would answer about a path that may differ from the file eventually opened — which is the
109
+ * check-then-write race this module exists to close. `open` is the authority; this is a courtesy.
110
+ * The suite asserts the difference: a symlinked sink passes `check` and is refused by `open`.
111
+ */
112
+ function check(targetPath, validate) {
113
+ if (typeof targetPath !== 'string' || !targetPath) {
114
+ throw new ForbiddenWrite(`no target path supplied for ${what} — refused (fail-closed)`);
115
+ }
116
+ if (typeof validate === 'function') {
117
+ const v = validate();
118
+ if (!v || !v.ok) {
119
+ const errs = (v && v.errors) || ['invalid'];
120
+ throw new ForbiddenWrite(`invalid ${what} — refused: ${errs.join('; ')}`, { errors: errs });
121
+ }
122
+ }
123
+ let resolved;
124
+ try { resolved = resolveThrough(targetPath); }
125
+ catch { throw new ForbiddenWrite(`the sink directory does not resolve for '${targetPath}' — refused (fail-closed)`); }
126
+ let sinkResolved = sinkPath();
127
+ try { sinkResolved = resolveThrough(sinkPath()); } catch { /* not created yet */ }
128
+ if (resolved !== sinkResolved) {
129
+ throw new ForbiddenWrite(
130
+ `${what} are written only to ${sinkResolved}; '${targetPath}' resolves to '${resolved}'`,
131
+ { targetPath, resolved, sink: sinkResolved });
132
+ }
133
+ return true;
134
+ }
135
+
136
+ /**
137
+ * Vet `targetPath` against the declared sink and return a descriptor open for append.
138
+ *
139
+ * The caller MUST close it. Everything that can refuse, refuses before the file is opened, so a
140
+ * refusal creates nothing and leaks nothing.
141
+ *
142
+ * @param {string} targetPath where the caller believes it is writing.
143
+ * @param {function} [validate] returns { ok, errors } — runs BEFORE the open (defects 2 and 3).
144
+ * @returns {number} a vetted file descriptor.
145
+ */
146
+ function open(targetPath, validate) {
147
+ if (typeof targetPath !== 'string' || !targetPath) {
148
+ throw new ForbiddenWrite(`no target path supplied for ${what} — refused (fail-closed)`);
149
+ }
150
+
151
+ /* BEFORE the open. Ordering is the whole of defects 2 and 3: validating after opening leaks a
152
+ * descriptor per refusal, and O_CREAT means the refused write has already made the file. */
153
+ if (typeof validate === 'function') {
154
+ const v = validate();
155
+ if (!v || !v.ok) {
156
+ const errs = (v && v.errors) || ['invalid'];
157
+ throw new ForbiddenWrite(`invalid ${what} — refused: ${errs.join('; ')}`, { errors: errs });
158
+ }
159
+ }
160
+
161
+ let resolved;
162
+ try {
163
+ resolved = resolveThrough(targetPath);
164
+ } catch {
165
+ throw new ForbiddenWrite(`the sink directory does not resolve for '${targetPath}' — refused (fail-closed)`);
166
+ }
167
+ let sinkResolved = sinkPath();
168
+ try { sinkResolved = resolveThrough(sinkPath()); } catch { /* not created yet — compare literally */ }
169
+
170
+ /* A RESOLVED comparison, not a basename one. `verify-queue.js` compared basenames and therefore
171
+ * accepted every directory on disk that happened to contain the right filename. */
172
+ if (resolved !== sinkResolved) {
173
+ throw new ForbiddenWrite(
174
+ `${what} are written only to ${sinkResolved}; '${targetPath}' resolves to '${resolved}'`,
175
+ { targetPath, resolved, sink: sinkResolved });
176
+ }
177
+
178
+ let fd;
179
+ try {
180
+ fd = fs.openSync(
181
+ resolved,
182
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND
183
+ | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
184
+ 0o644);
185
+ } catch (e) {
186
+ /* O_NOFOLLOW turns "the sink is a symlink" into ELOOP here rather than a silent write to
187
+ * wherever it pointed. That is defect 4, and it is refused at open rather than by a stat that
188
+ * a hardlink does not answer.
189
+ *
190
+ * O_NONBLOCK is not about performance. Opening a FIFO for writing BLOCKS until a reader
191
+ * arrives, so a named pipe left at the sink path hangs the writer forever — found by the
192
+ * assertion below, which hung this suite past its timeout instead of failing. A refusal that
193
+ * never returns is worse than a wrong answer: nothing reports it and nothing times it out.
194
+ * With O_NONBLOCK the same case returns ENXIO immediately and lands in the refusal path, and
195
+ * on a regular file the flag does nothing at all. */
196
+ throw new ForbiddenWrite(
197
+ `cannot open the ${what} ledger at '${targetPath}' safely (${e.code || e.message}) — refused (fail-closed)`,
198
+ { code: e.code });
199
+ }
200
+
201
+ try {
202
+ /* fstat on the FD, not stat on the path: for the FINAL component this genuinely closes the
203
+ * check-then-open race — an independent pass swapped the target inside the resolve→open window
204
+ * with perfect timing and got ELOOP, nlink=2 and a refusal every time.
205
+ *
206
+ * It does NOT cover the PARENT directory. `resolveThrough` realpaths the directory and
207
+ * `openSync` then re-walks that resolved path, so a local process able to rename the sink's
208
+ * parent and drop a directory symlink in its place inside that window can redirect the append
209
+ * — measured, 195 bytes into a foreign directory. That needs a concurrently-racing local
210
+ * attacker with write access to the parent, a strictly larger capability than the symlink
211
+ * defect this module was built to fix, so it is stated rather than claimed away. Closing it
212
+ * properly needs openat-style descent, which Node does not expose. The header's "the fd IS the
213
+ * approval" is true of the file, not of the path walked to reach it. */
214
+ const st = fs.fstatSync(fd);
215
+ if (!st.isFile()) {
216
+ throw new ForbiddenWrite(`'${targetPath}' is not a regular file — refused (fail-closed)`);
217
+ }
218
+ if (st.nlink !== 1) {
219
+ throw new ForbiddenWrite(
220
+ `'${targetPath}' has ${st.nlink} hard links; a ledger with a second name is not one this ` +
221
+ 'writer controls — refused (fail-closed)', { nlink: st.nlink });
222
+ }
223
+ } catch (e) {
224
+ fs.closeSync(fd);
225
+ throw e;
226
+ }
227
+
228
+ return fd;
229
+ }
230
+
231
+ /**
232
+ * The whole write, start to finish: vet, append one JSON line, close. Callers should prefer this
233
+ * over `open` — it is the shape that cannot forget to close, and every existing writer's append is
234
+ * exactly one line.
235
+ */
236
+ function appendLine(targetPath, record, validate) {
237
+ const fd = open(targetPath, validate);
238
+ const line = JSON.stringify(record) + '\n';
239
+ const want = Buffer.byteLength(line);
240
+ try {
241
+ /* THE RETURN VALUE IS NOT OPTIONAL. This was `fs.writeSync(fd, line)` with the result
242
+ * discarded, which reports a SHORT WRITE as success. Forced on a 64K tmpfs: a normal-shaped
243
+ * record returned true having appended a 3901-byte fragment with no trailing newline, and the
244
+ * next append CONCATENATED onto it — so one ENOSPC destroys two records and leaves a single
245
+ * line that no reader can parse. This is the append-only system of record, every reader
246
+ * JSON.parses line by line, and all six writers route through this one call.
247
+ *
248
+ * Loop, because a partial write is not necessarily an error — then verify the total. A torn
249
+ * tail is worse than a refused write, so if the bytes did not all land, say so loudly rather
250
+ * than returning true over a corrupted ledger. */
251
+ /* The size BEFORE this append, taken from the vetted fd. This is what makes the failure path a
252
+ * real refusal instead of a report: if the write tears, the file is truncated back to here and
253
+ * the ledger is left exactly as it was found. `nlink === 1` was already proven above, so no
254
+ * other name for this file exists and the rollback cannot reach anyone else's data. */
255
+ const before = fs.fstatSync(fd).size;
256
+ const buf = Buffer.from(line, 'utf8');
257
+ let written = 0;
258
+ let cause = null;
259
+ try {
260
+ while (written < want) {
261
+ const n = fs.writeSync(fd, buf, written, want - written);
262
+ if (!(n > 0)) break; // no progress: stop rather than spin
263
+ written += n;
264
+ }
265
+ } catch (e) {
266
+ /* ENOSPC does NOT arrive as a short write. Measured on a full tmpfs: the first writeSync
267
+ * short-writes 376 of 440 bytes with no error and the SECOND one throws — so a version that
268
+ * only checked `written !== want` never reached its own error path, and a raw Error escaped
269
+ * with name 'Error'. Everything in this repo tells a deliberate refusal from a crash with
270
+ * `instanceof ForbiddenWrite` (workspace/test.js:1772 checks e.name), so an unwrapped throw
271
+ * on the worst outcome this module has is the one place that signal must not be missing. */
272
+ cause = e;
273
+ }
274
+
275
+ if (cause || written !== want) {
276
+ /* THE ROLLBACK MUST NOT DESTROY SOMEONE ELSE'S RECORD, and the first version did.
277
+ *
278
+ * It called ftruncateSync(fd, before) unconditionally. `before` was measured at the top of
279
+ * this function, so ANY append by another process in between — a complete, successful,
280
+ * returned-true append — was inside the range being truncated away. Measured by an
281
+ * independent pass across 5 of 5 rounds: records whose write returned `true` were missing
282
+ * from disk afterwards, and the ledger was NUL-padded. A rollback that eats a committed
283
+ * record is worse than the torn line it was added to prevent.
284
+ *
285
+ * O_APPEND makes the arithmetic decidable: our bytes are the last `written` of the file if
286
+ * and only if nobody else appended. So truncate ONLY when the file is exactly the size our
287
+ * own write would have made it. Otherwise the tail is not ours to remove, and the honest
288
+ * outcome is to say the ledger is torn rather than to fix it by deleting a stranger's data. */
289
+ let rolledBack = false;
290
+ let intervened = false;
291
+ try {
292
+ const now = fs.fstatSync(fd).size;
293
+ if (now === before + written) { fs.ftruncateSync(fd, before); rolledBack = true; }
294
+ else intervened = true; // another writer appended; the tail is not ours
295
+ } catch { /* reported below */ }
296
+
297
+ const detail = { written, want, before, rolledBack, intervened, code: cause && cause.code };
298
+ throw new ForbiddenWrite(
299
+ `partial append to the ${what} ledger: ${written} of ${want} bytes landed` +
300
+ (cause ? ` (${cause.code || cause.message})` : '') + '. ' +
301
+ (rolledBack
302
+ ? 'The ledger was truncated back to its pre-write size, so nothing was written and the ' +
303
+ 'next append will not concatenate onto a fragment.'
304
+ : intervened
305
+ ? 'NOT ROLLED BACK ON PURPOSE — another writer appended while this write was failing, ' +
306
+ 'so the bytes at the end of the file are not this one\'s to remove. The ledger holds ' +
307
+ 'a torn fragment that must be repaired by hand; truncating it here would have ' +
308
+ 'deleted a record that committed successfully.'
309
+ : 'THE ROLLBACK ALSO FAILED — the ledger now ends in a torn line with no trailing ' +
310
+ 'newline, and the next append WILL concatenate onto it, destroying a second record. ' +
311
+ 'Repair it by hand before writing again.'),
312
+ detail);
313
+ }
314
+ } finally { fs.closeSync(fd); }
315
+ return true;
316
+ }
317
+
318
+ return { check, open, appendLine, sinkPath, __setSinkForTests };
319
+ }
320
+
321
+ module.exports = { createSinkGuard, ForbiddenWrite, resolveThrough };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.28.0",
3
+ "version": "0.33.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.28.0",
9
+ "version": "0.33.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.31.1",
3
+ "version": "0.33.0",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
5
+ "description": "PMOS Terminal Workspace — 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 \u2014 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 — it just stops measuring pixels, and says so.",
33
33
  "devDependencies": {
34
34
  "pixelmatch": "^7.1.0",
35
35
  "pngjs": "^7.0.0"
@@ -61,6 +61,7 @@ function ensureSpawnHelperExecutable() {
61
61
  ensureSpawnHelperExecutable();
62
62
 
63
63
  const { checkSurface } = require('./lib/surface-contract');
64
+ const { createSinkGuard } = require('./lib/sink.js');
64
65
  const scheduleLogic = require('./lib/schedule-logic');
65
66
  const harness = require('./lib/harness-parse');
66
67
  const transcript = require('./lib/transcript-read');
@@ -4678,9 +4679,25 @@ async function handleRequest(req, res) {
4678
4679
  // A swallowed append + {ok:true} would light the gate green over a record that never landed —
4679
4680
  // exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
4680
4681
  // {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
4682
+ /* THROUGH THE GUARD, not a bare append.
4683
+ *
4684
+ * This was `fs.appendFileSync(sink, ...)`. An independent pass attacked it through this very
4685
+ * endpoint's write path: **42 bytes landed through a symlink and it returned {ok:true}**. And
4686
+ * 37 of 49 live acceptance rows carry a `workspace` key that only THIS endpoint writes — so the
4687
+ * guarded writer (scripts/record-acceptance.js) was the one nothing used, while the unguarded
4688
+ * one had written three quarters of the system of record.
4689
+ *
4690
+ * The guard is constructed per resolved sink rather than as one module constant, because the
4691
+ * acceptance ledger legitimately moves with the workspace — kit layout, or the install's own
4692
+ * state/ on fallback. The allowlist therefore degenerates here (target === declared by
4693
+ * construction) and that is stated rather than glossed: what this buys is O_NOFOLLOW, the
4694
+ * `nlink === 1` check, and the torn-append rollback, which is precisely the attack that landed.
4695
+ *
4696
+ * `pm_label` is NOT validated against record-acceptance.js's stricter schema: that writer
4697
+ * REQUIRES the label, this surface must not, and requiring it here would block the PM's click. */
4681
4698
  try {
4682
4699
  if (fallback) fs.mkdirSync(LEGACY_STATE, { recursive: true }); // the install's state/ may not exist yet
4683
- fs.appendFileSync(sink, JSON.stringify(rec) + '\n');
4700
+ createSinkGuard({ sink, what: 'PM acceptances' }).appendLine(sink, rec);
4684
4701
  } catch (e) {
4685
4702
  warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);
4686
4703
  return json(res, { ok: false, error: 'could not record the acceptance — the file-mode system of record is unwritable; the decision was NOT saved', sink, fallback }, 500);