@piercebarney/whs-eleventy 2026.9.2 → 2026.9.3

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/lib/_project.js CHANGED
@@ -22,6 +22,20 @@ function tryProjectRequire(rel, fallback = null) {
22
22
  }
23
23
  }
24
24
 
25
+ // The content-arm -> build-arm escalation drawer (core.md#content-model): one
26
+ // `YYYY-MM-DD-<slug>.md` per blocked structural request, triaged and deleted by
27
+ // the build arm. Returns the open notes' filenames (README.md excluded), sorted.
28
+ function openRequests(root = ROOT) {
29
+ try {
30
+ return fs
31
+ .readdirSync(path.join(root, "requests"))
32
+ .filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md")
33
+ .sort();
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
25
39
  // Locate the standard's text (core.md, CHANGELOG.md) for the drift check:
26
40
  // 1. WHS_STANDARD env var (the authoring repo points this at itself)
27
41
  // 2. the copy bundled into this package at publish time (standard/)
@@ -39,4 +53,4 @@ function resolveStandard() {
39
53
  return null;
40
54
  }
41
55
 
42
- module.exports = { ROOT, projectRequire, tryProjectRequire, resolveStandard };
56
+ module.exports = { ROOT, projectRequire, tryProjectRequire, openRequests, resolveStandard };
package/lib/compliance.js CHANGED
@@ -152,7 +152,9 @@ const CHECKS = {
152
152
  }
153
153
  const protocol =
154
154
  has("CONTENT.md") && /"content-check"/.test(read("package.json") || "")
155
- ? "agent content-ops protocol present"
155
+ ? has("requests")
156
+ ? "agent content-ops protocol present (CONTENT.md, content-check, requests/)"
157
+ : "CONTENT.md + content-check present, but no requests/ escalation drawer"
156
158
  : "no CONTENT.md / content-check — add it if content is agent-edited";
157
159
  return {
158
160
  status: MANUAL,
@@ -412,14 +414,22 @@ const CHECKS = {
412
414
  },
413
415
 
414
416
  privacy: () => {
415
- const p = read("src/privacy.njk") || "";
416
- if (!has("src/privacy.njk")) return { status: FAIL, note: "no privacy page" };
417
- if (!/for\s+t\s+in\s+thirdparties/.test(p))
417
+ // The privacy page may be a standalone src/privacy.njk or a pages.json
418
+ // entry rendered by a src/pages/ shell check the built output, with a
419
+ // source fallback for when _site isn't built yet.
420
+ const built = has("_site/privacy/index.html");
421
+ const source =
422
+ has("src/privacy.njk") ||
423
+ /["']slug["']\s*:\s*["']privacy["']/.test(read("src/content/pages.json") || "");
424
+ if (!built && !source) return { status: FAIL, note: "no privacy page" };
425
+ // The third-party disclosure must be generated from _data/thirdparties.js,
426
+ // never hand-maintained — the loop lives in the privacy shell or an include.
427
+ if (grepSrc(/for\s+t\s+in\s+thirdparties/).length === 0)
418
428
  return {
419
429
  status: FAIL,
420
430
  note: "privacy third-party list not rendered from _data/thirdparties.js",
421
431
  };
422
- return { status: PASS, note: "third-party list rendered from the manifest" };
432
+ return { status: PASS, note: "privacy page present; third-party list from the manifest" };
423
433
  },
424
434
 
425
435
  ads: () => {
@@ -4,42 +4,94 @@
4
4
  //
5
5
  // whs content-check
6
6
  //
7
- // Also warns does not block when the working tree has changes outside the
8
- // content set, so a stray code edit doesn't ride along in a `content:` commit.
7
+ // A **staged** file outside the reserved content set is an error a `content:`
8
+ // commit can't carry a code/structure change. An unstaged stray only warns (it
9
+ // won't be in the commit unless staged). Also reports the open requests/ notes
10
+ // (the content-arm → build-arm escalation channel, core.md#content-model).
9
11
 
10
12
  const { execSync } = require("node:child_process");
13
+ const { openRequests } = require("./_project.js");
11
14
 
12
- // Files an agent following CONTENT.md is expected to touch.
13
- const CONTENT_SET = [/^src\/content\//, /^src\/_data\/nav\.js$/, /^src\/_data\/glossary\.js$/];
15
+ // Files an agent following CONTENT.md may touch the reserved content set plus
16
+ // its own escalation drawer. Everything else (the *.schema.json editor aids,
17
+ // schema.js, the page shells, the includes, config) is a build-arm change.
18
+ const RESERVED_SET = [
19
+ /^src\/content\/(?!.*\.schema\.json$)[^/]+\.json$/,
20
+ /^src\/_data\/nav\.js$/,
21
+ /^src\/_data\/glossary\.js$/,
22
+ /^requests\//,
23
+ ];
14
24
 
15
- function changedFiles() {
25
+ // One `git status --porcelain` line → { path, staged }. `staged` is true when
26
+ // the index column (X) holds a real status letter — i.e. the change is part of
27
+ // the next commit. Untracked ("??") and worktree-only (" M") are not staged.
28
+ function parsePorcelain(out) {
29
+ return out
30
+ .split("\n")
31
+ .filter(Boolean)
32
+ .map((l) => {
33
+ const x = l[0];
34
+ const rest = l.slice(3);
35
+ const path = rest.includes(" -> ") ? rest.split(" -> ")[1] : rest;
36
+ return { path: path.trim(), staged: x !== " " && x !== "?" };
37
+ });
38
+ }
39
+
40
+ function changedEntries() {
16
41
  try {
17
- const out = execSync("git status --porcelain", { encoding: "utf8" });
18
- return out
19
- .split("\n")
20
- .map((l) => l.slice(3).trim())
21
- .filter(Boolean)
22
- .map((f) => (f.includes(" -> ") ? f.split(" -> ")[1] : f));
42
+ return parsePorcelain(execSync("git status --porcelain", { encoding: "utf8" }));
23
43
  } catch {
24
44
  return [];
25
45
  }
26
46
  }
27
47
 
28
- const stray = changedFiles().filter((f) => !CONTENT_SET.some((re) => re.test(f)));
29
- if (stray.length) {
30
- console.warn("\n⚠ changes outside the content set — these are code changes, not content:");
31
- for (const f of stray) console.warn(` ${f}`);
32
- console.warn(" Commit them separately (a Claude Code task), or confirm they're intentional.\n");
48
+ function strayFiles(paths) {
49
+ return paths.filter((f) => !RESERVED_SET.some((re) => re.test(f)));
33
50
  }
34
51
 
35
- const steps = ["lint", "validate", "build", "links"];
36
- for (const s of steps) {
37
- process.stdout.write(`content-check: npm run ${s}\n`);
38
- try {
39
- execSync(`npm run ${s}`, { stdio: "inherit" });
40
- } catch {
41
- console.error(`\ncontent-check: '${s}' failed — fix it before committing.`);
52
+ function splitStray(entries) {
53
+ return {
54
+ staged: strayFiles(entries.filter((e) => e.staged).map((e) => e.path)),
55
+ unstaged: strayFiles(entries.filter((e) => !e.staged).map((e) => e.path)),
56
+ };
57
+ }
58
+
59
+ function main() {
60
+ const { staged: stagedStray, unstaged: unstagedStray } = splitStray(changedEntries());
61
+
62
+ if (stagedStray.length) {
63
+ console.error("\n✗ staged changes outside the reserved content set (CONTENT.md):");
64
+ for (const f of stagedStray) console.error(` ${f}`);
65
+ console.error(
66
+ "\n A `content:` commit can't carry a code or structure change. Unstage them\n" +
67
+ " (git restore --staged <file>), or file a requests/ note for the build arm.\n",
68
+ );
42
69
  process.exit(1);
43
70
  }
71
+ if (unstagedStray.length) {
72
+ console.warn("\n⚠ uncommitted changes outside the reserved set (not staged):");
73
+ for (const f of unstagedStray) console.warn(` ${f}`);
74
+ console.warn(" They won't ride a `content:` commit unless you stage them.\n");
75
+ }
76
+
77
+ const reqs = openRequests();
78
+ if (reqs.length) {
79
+ console.log(`\nℹ ${reqs.length} open request(s) in requests/ awaiting the build arm.\n`);
80
+ }
81
+
82
+ const steps = ["lint", "validate", "build", "links"];
83
+ for (const s of steps) {
84
+ process.stdout.write(`content-check: npm run ${s}\n`);
85
+ try {
86
+ execSync(`npm run ${s}`, { stdio: "inherit" });
87
+ } catch {
88
+ console.error(`\ncontent-check: '${s}' failed — fix it before committing.`);
89
+ process.exit(1);
90
+ }
91
+ }
92
+ console.log("\ncontent-check: ok — lint · validate · build · links");
44
93
  }
45
- console.log("\ncontent-check: ok — lint · validate · build · links");
94
+
95
+ module.exports = { strayFiles, parsePorcelain, splitStray, RESERVED_SET };
96
+
97
+ if (require.main === module) main();
package/lib/doctor.js CHANGED
@@ -21,7 +21,7 @@
21
21
  const fs = require("node:fs");
22
22
  const path = require("node:path");
23
23
  const { execSync } = require("node:child_process");
24
- const { ROOT, projectRequire } = require("./_project.js");
24
+ const { ROOT, projectRequire, openRequests } = require("./_project.js");
25
25
  const { asRegexMap } = require("./header-expect.js");
26
26
 
27
27
  const CACHE = path.join(ROOT, ".cache", "doctor.json");
@@ -376,6 +376,26 @@ function main() {
376
376
  }
377
377
 
378
378
  if (preflight) {
379
+ // Open content-arm → build-arm requests gate the deploy: a conditional
380
+ // hard stop (core.md#content-model). Zero → ship; one or more → refuse
381
+ // unless --ack-requests says the operator has reviewed them.
382
+ const reqs = openRequests();
383
+ if (reqs.length && !args.includes("--ack-requests")) {
384
+ console.error(
385
+ `\ndoctor: ${reqs.length} open request(s) in requests/ — the content arm is ` +
386
+ `waiting on\nthe build arm. Clear them, or re-run the deploy with ` +
387
+ `--ack-requests to ship anyway:`,
388
+ );
389
+ reqs.forEach((r) => console.error(` - requests/${r}`));
390
+ console.error("\nRefusing to continue.");
391
+ process.exit(1);
392
+ }
393
+ console.log(
394
+ reqs.length
395
+ ? `\nOpen requests: ${reqs.length} — acknowledged (--ack-requests).`
396
+ : "\nOpen requests: 0.",
397
+ );
398
+
379
399
  const cr = complianceRegressions();
380
400
  if (!cr.checked) {
381
401
  console.log(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piercebarney/whs-eleventy",
3
- "version": "2026.9.2",
3
+ "version": "2026.9.3",
4
4
  "description": "The web house style's Eleventy + Netlify tooling — the compliance sweep, the infra doctor, the link/CSP integrity check, and the a11y scan, shared by every project on the stack.",
5
5
  "bin": {
6
6
  "whs": "cli.js"
@@ -8,6 +8,463 @@ Format: `## <date>` → `### core: <slug>` / `### <stack>` entries.
8
8
 
9
9
  ---
10
10
 
11
+ ## 2026-09-03 — the content-arm interface: a project and its coding arm
12
+
13
+ A project built to the house style is often only part of a larger operation —
14
+ an editor-in-chief (a Claude Project, ideation + editorial context) that hands
15
+ work to a **coding arm** (Claude Code for structure, Cowork for content) in the
16
+ repo. This release makes that hand-off a defined interface rather than a set of
17
+ conventions the operator holds in their head. The model: editor-in-chief →
18
+ typesetter → press. The typesetter turns editorial intent into validated data
19
+ plus a `content:` commit on `main`; only a person runs the press (deploy —
20
+ `#ci-cd`, unchanged).
21
+
22
+ Grew out of a design conversation, filed and worked through in
23
+ `feedback/2026-09-02-content-arm-interface.md` (now consumed into this entry).
24
+ Six changes shipped together as one release; each is summarised below.
25
+
26
+ **Rejected along the way**, recorded so they aren't re-litigated: standalone-page
27
+ prose as **Markdown files** (lost to JSON — reuses the existing validator and
28
+ render path, zero new parsing; Markdown ergonomics are what `add-ons/pages/` is
29
+ for); an **always-on "I reviewed the requests" checkbox** on deploy (lost to a
30
+ conditional hard-stop — friction only when the drawer is non-empty, so the
31
+ checkbox never trains into a reflex); **Cowork as the default content owner**
32
+ (lost to "Claude Code owns everything unless a project reserves a slice" — right
33
+ for the common small-site case); a **`schedule` trigger** on the deploy workflow
34
+ (lost — a timed deploy removes the person the open-requests gate assumes).
35
+
36
+ ### core: content-model
37
+
38
+ - **Content-surface split.** Every user-visible string is data — standalone
39
+ pages (About, a policy page, a marketing page) included, not just collection
40
+ entries. The only prose that may stay in a template is copy bound to a
41
+ mechanism that same template implements (a generated disclosure list's
42
+ heading, a consent notice that must track real behaviour); the impl doc names
43
+ those cases. Test: could a non-developer rewrite this sentence without risking
44
+ a guarantee?
45
+ - **Ownership states.** By default the party that builds the site may also edit
46
+ its content. A project running a *separate* content operation **reserves** it:
47
+ one authoritative file at the repo root names the reserving party and the
48
+ exact set it owns (any granularity — all content, or named paths). The builder
49
+ treats the reserved set as read-only and routes changes it needs there through
50
+ an escalation channel, never a direct edit.
51
+ - The agent content-ops protocol now specifies: a pre-commit content check that
52
+ **errors** on a *staged* file outside the reserved set (an unstaged stray only
53
+ warns — so an `npm update` pull can't hard-fail a live project on incidental
54
+ worktree churn), and an escalation channel so a blocked structural request is
55
+ recorded, not lost.
56
+
57
+ ### core: adopting
58
+
59
+ - **Editorial operation** spec: where an LLM agent maintains content after
60
+ launch, the project ships `CONTENT.md` (reserved set + protocol) and a voice
61
+ guide; the editor-in-chief connects to the repo through a *thin, stable*
62
+ instruction (read those two files each session, follow `CONTENT.md`, escalate,
63
+ never deploy) with the detail kept in the repo, re-read each session.
64
+ `CONTENT.md` carries a paste-once "setting up" section.
65
+ - A concept brief that cannot honestly fit an offered stack is a `feedback/`
66
+ note *before* the build — resolved as a new binding or the nearest stack with
67
+ the compromise recorded. `concept-brief.md`'s `framework:` gains a `needs
68
+ discussion` option.
69
+
70
+ ### stacks/eleventy-netlify.md
71
+
72
+ - `#content-model`: two content files — `content/guides.json` (a collection,
73
+ rendered via a `<dir>/<dir>.11tydata.js` directory-data file) and
74
+ `content/pages.json` (the standalone pages; `src/pages/` holds a one-line
75
+ shell each, prose is the JSON `body`). Mechanism-bound sections are
76
+ `{ include }` markers in `body`, dispatched by `src/_includes/prose.njk` and
77
+ validated against a known set. `add-ons/pages/` extends `body` with typed
78
+ layout blocks. The `src/` tree snippet and adopting narrative corrected to
79
+ match (no more `src/index.njk`).
80
+ - New **escalation channel**: a `requests/` directory (one
81
+ `YYYY-MM-DD-<slug>.md` per blocked structural ask; the build arm makes the
82
+ change and deletes the note). `whs content-check` counts open requests;
83
+ `bin/deploy`'s pre-flight refuses to ship while any are open unless
84
+ `--ack-requests`.
85
+ - `#ci-cd`: one sanctioned five-step release reached two ways — the
86
+ **`deploy.yml` `workflow_dispatch` button** (recommended: off your machine,
87
+ logged) or `bin/deploy` locally (the offline path). `workflow_dispatch` only,
88
+ no `schedule`. Secrets as repo secrets. The workflow's open-requests gate
89
+ writes the count to the run summary and fails pending `acknowledge_requests`.
90
+
91
+ ### templates/eleventy-netlify
92
+
93
+ - `src/{index,about,privacy}.njk` → `src/pages/{home,about,privacy}.njk` thin
94
+ shells + `src/content/pages.json` + `pages.schema.json`; `schema.js` gains a
95
+ per-domain validator dispatch. `src/_includes/{prose,guide-list,thirdparty-disclosure}.njk`.
96
+ - New root files: `CONTENT.md` (rewritten — reserved-set framing, the "setting
97
+ up the editorial project" section, the `requests/` escalation flow),
98
+ `VOICE.md` (seeded from the brief's *Feel* line, read by both arms),
99
+ `requests/README.md`.
100
+ - `bin/deploy` accepts `--ack-requests`; `.github/workflows/deploy.yml` reworked
101
+ (see `#ci-cd`); `bin/init` / `TEMPLATE.md` / `README.md` updated for the new
102
+ layout and the editorial-project hand-off.
103
+ - New add-on **`add-ons/pages/`** — typed marketing blocks (`hero` /
104
+ `feature-list` / `cta`) the content arm composes into a landing page with no
105
+ template change; the build arm owns the block types.
106
+ - `add-ons/glossary/` + `add-ons/calculator/` corrected for the `pages.json`
107
+ split (the removed `policy` tag → `collections.page`; no `src/index.njk`).
108
+
109
+ ### tooling
110
+
111
+ `@piercebarney/whs-eleventy` 2026.9.2 → 2026.9.3:
112
+
113
+ - `content-check`: errors on a **staged** file outside the reserved set (unstaged
114
+ → warn); prints the open-`requests/` count. `RESERVED_SET` is `content/*.json`
115
+ (not the `*.schema.json` aids or `schema.js`) + `_data/{nav,glossary}.js` +
116
+ `requests/`. **Projects pulling this via `npm update` get the stricter
117
+ behaviour** — a `content:` commit can no longer carry a staged code change.
118
+ - `doctor --deploy-preflight`: a conditional hard stop on open `requests/` —
119
+ refuses unless `--ack-requests`.
120
+ - `_project.js` `openRequests()`; `compliance` `content-model` note also reports
121
+ whether `requests/` is present.
122
+ - The `privacy` compliance check no longer hard-codes `src/privacy.njk` — it
123
+ checks the built `/privacy/` output and the third-party loop wherever it lives.
124
+ - New fixture tests: the `privacy` check, `openRequests()`, the `content-check`
125
+ reserved-set + porcelain-parse + staged/unstaged split.
126
+
127
+ ### stacks/phoenix.md · sveltekit.md
128
+
129
+ - `phoenix.md#content-model`: the "a reviewed component carrying editorial prose
130
+ is acceptable" escape removed — it contradicted the sharpened core Spec.
131
+ - Filed `feedback/2026-09-03-pages-model-diverges-across-stacks.md`: the
132
+ standalone-pages model is now three shapes (eleventy rich `body` list,
133
+ sveltekit flat `string[]`, phoenix none) — converge or document the split, at
134
+ triage.
135
+
136
+ ## 2026-09-02 — a starter template for the SvelteKit binding, moved out of parked
137
+
138
+ `stacks/sveltekit.md` was a parked stub — every mirrored section's "How
139
+ (SvelteKit)" was `TODO`, not offered by the `#adopting` flow. A real need
140
+ (plain Svelte, static output, deployed to Netlify) forced the decisions:
141
+ there is no durable, officially-maintained framework for "pure Svelte, no
142
+ SvelteKit, multi-page static site" (the community tools that do this are
143
+ small single-maintainer projects), so the resolution is SvelteKit +
144
+ `adapter-static`, used minimally as a build tool with no server ever
145
+ touched. Built `templates/sveltekit/` the same way `templates/phoenix/` was
146
+ built: every mechanism claim verified against a real `sv create` scaffold
147
+ and real `vite build` output, not written from the binding's prose alone.
148
+
149
+ ### core: adopting
150
+
151
+ - Q1's framework list gains SvelteKit as a real (draft) option — "only when
152
+ a project specifically needs it; a project with no Svelte requirement
153
+ should still default to Eleventy + Netlify" — replacing the previous
154
+ "treat as other" parked-stub instruction.
155
+
156
+ ### stacks/sveltekit.md
157
+
158
+ - **Every `TODO` "How (SvelteKit)" section resolved or explicitly marked as
159
+ a real, flagged gap** — not left as a hint. The `PARKED STUB` banner and
160
+ `open-questions-for-later` section are both removed; every open question
161
+ they held is now answered inline in the relevant section, with the
162
+ reasoning kept (see `where-sveltekit-fights-the-grain`'s per-item
163
+ resolutions).
164
+ - **A real, previously-undocumented SvelteKit architecture change found and
165
+ corrected:** a separate `svelte.config.js` is no longer generated or
166
+ required — `sv create`'s current scaffold (`@sveltejs/kit@2.63`) passes
167
+ the full `KitConfig` (adapter, `csp`, everything) directly into the
168
+ `sveltekit()` Vite plugin call in `vite.config.ts`. The old
169
+ `sveltekit-config` section (titled "svelte.config.js & vite.config.js")
170
+ assumed two files; corrected to one.
171
+ - **Content pipeline resolved: plain JSON + zod**, not
172
+ `@content-collections/core` or `mdsvex` — checked
173
+ `templates/eleventy-netlify/src/content/schema.js` directly first: its
174
+ proven pattern is a hand-rolled, zero-dependency validator, so this is a
175
+ deliberate deviation kept at the shape level (JSON + a build-time
176
+ validator that hard-fails printing every error), not a copy of the exact
177
+ mechanism. Verified the "print every error" contract by deliberately
178
+ breaking content three ways at once and confirming all five resulting
179
+ errors surfaced together — an earlier draft of the validator only ran its
180
+ structural (duplicate-slug / dangling-reference) checks inside the
181
+ schema-success branch, silently dropping them whenever a field-level error
182
+ also fired; fixed to run unconditionally.
183
+ - **CSS default resolved: Pico classless**, not Tailwind — matching
184
+ `eleventy-netlify`'s default; Svelte's component-scoped `<style>` blocks
185
+ compile to a real extracted stylesheet (verified in build output, not
186
+ inlined), so they coexist cleanly with a strict `style-src 'self'` and no
187
+ Tailwind/PostCSS step is needed.
188
+ - **Netlify Forms compatibility with `adapter-static` verified end to end**,
189
+ not assumed from a blog post: a `data-netlify="true"` form with
190
+ `netlify-honeypot` and a hidden `form-name` input, inspected in the actual
191
+ prerendered `contact.html` output and confirmed to survive Svelte's
192
+ compilation intact. This is load-bearing, not incidental — Netlify's
193
+ deploy-time bot only ever parses the built static HTML.
194
+ - **`csp: { mode: 'hash' }` verified against real build output**: a real
195
+ computed `sha256-` hash appears in the built `<meta
196
+ http-equiv="content-security-policy">` tag. Also found and documented:
197
+ `frame-ancestors` is silently absent from that tag regardless of being
198
+ listed in the config — the CSP spec doesn't allow that directive in a
199
+ `<meta>` tag at all — so `netlify.toml`'s global header block is the only
200
+ place it (and every other baseline header) actually takes effect.
201
+ - **The Netlify per-context-headers landmine inherited and fixed
202
+ identically** to `stacks/eleventy-netlify.md#noindex`'s already-fixed
203
+ version, same root cause (a pre-built-artifact deploy bypasses Netlify's
204
+ build-context detection): `noindex`'s layer 2 (the header backstop)
205
+ doesn't apply to this stack's deploy model, stated plainly. Distinguished
206
+ from `security-headers`, which is **not** broken the same way — a global,
207
+ non-context header block doesn't need context detection at all, and is
208
+ confirmed working in production on `eleventy-netlify`'s own live
209
+ `netlify.toml`.
210
+ - **A real, previously-undocumented SvelteKit 2.26+ feature found and
211
+ documented as a genuine advantage**: `resolve()` from `$app/paths` gives
212
+ compile-time internal-link validation (a typo'd route is a real
213
+ TypeScript error), enforced automatically by
214
+ `eslint-plugin-svelte`'s `no-navigation-without-resolve` rule (already
215
+ active from `sv create`'s own `eslint` add-on) — closer to Phoenix's `~p`
216
+ sigil than this binding's own prior "SvelteKit does not check `href`
217
+ strings at build time" assumption, which was wrong.
218
+ - OG-image generation, a brand-token layer, and a theme toggle are
219
+ documented as deliberately deferred/not-yet-built, matching
220
+ `templates/phoenix/`'s `og.gen`/`icons.gen` treatment — TODO banners, not
221
+ silently assumed done.
222
+
223
+ ### templates/sveltekit (new)
224
+
225
+ - New starter template: a real `sv create` scaffold (`--template minimal
226
+ --types ts --add prettier eslint vitest sveltekit-adapter=adapter:static`)
227
+ with plain-JSON+zod content (`guides`/`pages`, mirroring
228
+ `eleventy-netlify`'s collection shape at the structural level), the
229
+ verified Netlify-Forms contact page, prerendered `sitemap.xml`/
230
+ `robots.txt` routes, an env-gated `noindex` layer, a `netlify.toml` global
231
+ header block, and `bin/deploy` (Netlify CLI artifact upload, `--no-build`,
232
+ no bypass on prod — mirrors `eleventy-netlify`'s `bin/deploy` exactly, not
233
+ independently rediscovered). `npm run check` runs only real, off-the-shelf
234
+ steps (lint, content validation, `svelte-check`, vitest, build) —
235
+ `a11y`/link-check tooling stays `TODO`, matching `templates/phoenix/`'s
236
+ precedent; no tooling-package counterpart to `@piercebarney/whs-eleventy`
237
+ exists for this stack yet. No `bin/init` yet. Verified via a full
238
+ cold-cache `npm ci && npm run check` run, twice (mid-build and again from
239
+ the template's final repo location after the move).
240
+ - `index.json`'s `sveltekit.status`: `parked` → `draft`;
241
+ `sveltekit.template` → `templates/sveltekit/`.
242
+ - `README.md`'s doc-map table and framework-question wording updated for
243
+ both the stack-file row and the new template row.
244
+ - `templates/verify.sh` and `standard.yml` gain an `npm run check`-only
245
+ branch for SvelteKit-shaped templates (keyed on `vite.config.ts`, checked
246
+ ahead of the generic `package.json` branch) — no `compliance --strict`
247
+ call, same deferred-tooling reasoning as Phoenix's branch.
248
+ - `templates/new-project.sh`'s rsync excludes gain `build`/`.svelte-kit`.
249
+
250
+ ### tooling
251
+
252
+ - Not touched this phase — no `@piercebarney/whs-sveltekit` package exists;
253
+ `a11y`/link-check stay design-only TODOs, explicitly deferred, same as
254
+ Phoenix's.
255
+
256
+ ---
257
+
258
+ ## 2026-09-02 — Render as the Phoenix binding's default free-launch host
259
+
260
+ Netlify can't run this stack at all — LiveView needs a persistent BEAM node
261
+ (long-lived WebSocket connections, a warm Postgres pool), not a static
262
+ artifact or a stateless function. `stacks/phoenix.md` previously named only
263
+ Gigalixir/Fly, neither of which offers a genuinely free, no-credit-card,
264
+ no-domain-to-attach launch anymore (Fly deprecated its free tier; Gigalixir
265
+ requires a card at signup even though the free tier itself isn't charged).
266
+ Verified against Render's own docs (`render.com/docs/free`,
267
+ `/docs/blueprint-spec`, `/docs/deploy-phoenix`, `/docs/cli`) and its reference
268
+ app (`github.com/render-examples/phoenix_hello`) rather than asserted from
269
+ memory — pricing/free-tier terms move fast enough that a remembered claim
270
+ isn't trustworthy here.
271
+
272
+ ### stacks/phoenix.md
273
+
274
+ - **Render is now the documented default host** (`stack-baseline`, `ci-cd`,
275
+ `secrets`) — free with no credit card at signup, a live `onrender.com`
276
+ subdomain the moment the first deploy finishes, no domain to attach. The
277
+ real tradeoff, stated plainly: Render's free Postgres is **deleted 30 days
278
+ after creation**, and the free plan allows only **one** free database per
279
+ workspace — it doesn't comfortably fund the doc's existing `dev`/`staging`/
280
+ `prod` three-environment shape. Gigalixir/Fly stay documented as the
281
+ graduate-to-production path once that matters.
282
+ - `ci-cd`'s `git_hooks` auto-install description corrected in the same pass:
283
+ there is no `auto_install: true` config key (none exists in the library's
284
+ schema) — merely having `git_hooks` as a `:dev` dependency is what triggers
285
+ the install. Documented the gotcha this project hit directly: that install
286
+ walks up to whatever repo `git rev-parse --show-toplevel` finds, so running
287
+ `mix deps.get`/`mix compile` from a checkout that isn't its own git repo
288
+ yet installs hooks into the *enclosing* repo instead — the fix is a
289
+ `File.dir?(".git")` guard around the config.
290
+ - A new **"Not verifiable without a live Render account"** note in `ci-cd`
291
+ (same category as the existing Gigalixir-buildpack caveat): whether
292
+ `preDeployCommand` failure genuinely blocks promotion under every failure
293
+ mode, `RENDER_EXTERNAL_HOSTNAME`'s exact format, `force_ssl`/HSTS behind
294
+ Render's proxy, cold-start-vs-health-check timing, and Cachex/Hammer's
295
+ per-node behavior past one instance — flagged, not asserted.
296
+ - `bin/check`'s sterility allowlist gains `render.com` / `onrender.com`.
297
+
298
+ ### templates/phoenix
299
+
300
+ - **`render.yaml`** (new) — a Blueprint declaring the web service + Postgres
301
+ in-repo: `autoDeployTrigger: off` (a push to `main` never deploys on its
302
+ own — `bin/deploy` is the deliberate trigger), `buildCommand: ./build.sh`,
303
+ `startCommand` pointing at the release's `bin/server`, `preDeployCommand`
304
+ running the release's `Release.migrate` eval before the new instance is
305
+ promoted, `healthCheckPath` wired to the existing `HealthController`,
306
+ `envVars` using `generateValue`/`fromDatabase`/`sync: false` so no secret
307
+ value is ever written to the yaml.
308
+ - **`build.sh`** (new) — verified verbatim against
309
+ `github.com/render-examples/phoenix_hello`'s own script (`mix deps.get
310
+ --only prod`, `MIX_ENV=prod mix compile`, `assets.build` + `assets.deploy`,
311
+ `mix phx.gen.release`, `mix release --overwrite`). `rel/` added to
312
+ `.gitignore` — nothing it generates is committed.
313
+ - **`bin/deploy`** rewritten around the `render` CLI (`render deploys create
314
+ <service-id> --commit <sha> --wait --confirm`) in place of the previous
315
+ git-push-to-a-Gigalixir/Fly-remote mechanism; still no `$OPTS` on `prod` —
316
+ that omission is still the no-bypass enforcement, just against a different
317
+ underlying command. `bin/migrate` / `bin/remote` document the real (if not
318
+ fully non-interactive) `render ssh` + release-eval commands in place of
319
+ their previous host-agnostic stub text.
320
+ - **`config/runtime.exs`** now falls back to Render's auto-populated
321
+ `RENDER_EXTERNAL_HOSTNAME` when `PHX_HOST` isn't set, so the `onrender.com`
322
+ subdomain resolves correctly with zero manual config.
323
+ - `AGENTS.md`'s Deploy section and `TEMPLATE.md`'s "what a fresh checkout
324
+ still owns" updated to match; a stale `content/resources/article.ex` path
325
+ (the real file has no `resources/` segment) corrected in `AGENTS.md` while
326
+ in the same section.
327
+ - None of this has been verified against a live Render account — see
328
+ `stacks/phoenix.md#ci-cd`'s new "Not verifiable" note above for exactly
329
+ what that would still need to confirm.
330
+
331
+ ---
332
+
333
+ ## 2026-09-02 — a starter template for the Phoenix binding, and hardening the doc it's built from
334
+
335
+ Built `templates/phoenix/` the same way `templates/eleventy-netlify/` was
336
+ proven out: generic placeholder content, independent of any real business,
337
+ every mechanism claim held to compile-it-and-curl-it verification rather than
338
+ plausible-looking prose. That process found `stacks/phoenix.md` — despite
339
+ being "grounded in a real production codebase" — still carried several
340
+ fabricated or imprecise APIs the same way the earlier `Plug.CSP` finding did.
341
+
342
+ ### stacks/phoenix.md
343
+
344
+ - **`Oban.Cron` corrected to `Oban.Plugins.Cron`** (all three occurrences) —
345
+ the real plugin module, confirmed against Oban's fetched source.
346
+ - **`ash_phoenix`/`AshPhoenix.Form` now named** as the required bridge between
347
+ an Ash resource and a LiveView `<.form>` — the doc's `forms` section needed
348
+ it to write a contact form at all and never named it.
349
+ - `contexts-and-ash`: `config :app, ash_domains: [...]` is now documented as
350
+ required (a real compile warning otherwise, fatal under
351
+ `--warnings-as-errors`); `authorizers: [Ash.Policy.Authorizer]` is now
352
+ stated as required alongside a resource's `policies do` block;
353
+ `Ash.Seed.seed!/2` is documented as the policy-bypassing seed mechanism
354
+ (`Ash.Changeset.for_create` + `Ash.create!` correctly raises
355
+ `Ash.Error.Forbidden` with no actor — that's the policy working, not a bug,
356
+ but it means seed scripts need `Ash.Seed`); `simple_sat` recommended over
357
+ `picosat_elixir` as the SAT-solver backend for policy authorization — pure
358
+ Elixir, no C compiler dependency, more portable for a template baseline.
359
+ - `theme`: notes that `mix phx.new`'s generated anti-FOUC inline script ships
360
+ with no nonce, which a strict CSP rejects outright — the template's script
361
+ now carries one.
362
+ - `structured-data`: **critical finding** —
363
+ `<script type="application/ld+json">{raw(@json_ld)}</script>` renders the
364
+ literal text `{raw(@json_ld)}`, not the JSON, because HEEx's tokenizer
365
+ treats a `<script>` tag's body as opaque text and never invokes the `{}`
366
+ expression parser inside it. The classic EEx `<%= raw(@json_ld) %>` tag is
367
+ the fix — its tokenizer runs before HEEx's tag-aware parsing, so it still
368
+ works inside `<script>`. Verified by curling a real page and parsing the
369
+ response as JSON, both before (fails) and after (parses) the fix.
370
+ - `sitemap-robots`: documents Cachex's real child-spec form
371
+ (`{Cachex, name: :x}`), flags that its cache is per-node in-memory — an
372
+ `Ash.Notifier`-triggered invalidation on the node that handled a write does
373
+ not reach a sibling node on a horizontally-scaled deploy — and calls out
374
+ the `robots.txt`/`static_paths()` shadowing gotcha: `Plug.Static`'s default
375
+ generated `static_paths()` includes `"robots.txt"`, which silently serves
376
+ the static file over a dynamic `RobotsController` route with no error.
377
+ - `forms`: documents Hammer v7's real supervised-module API
378
+ (`use Hammer, backend: :ets`, `hit/3`) in place of the older functional
379
+ API; notes `get_connect_info/2` can only be called from `mount/3`, not from
380
+ a later `handle_event/3`; and flags that `Phoenix.LiveViewTest`'s simulated
381
+ connections don't derive `peer_data` from `conn.remote_ip` — a test-only
382
+ session key is the working escape hatch, not a `conn` override.
383
+ - `domain-tests`: documents the Ecto Sandbox + LiveView-process visibility
384
+ gap — an `async: true` test's exclusive sandbox ownership isn't
385
+ automatically shared with a LiveView's own GenServer process, so a test can
386
+ observe a write as having succeeded while its own query still sees zero
387
+ rows; `Ecto.Adapters.SQL.Sandbox.allow/3` after `live/2` is the fix.
388
+ - `the-gate`: documents two Mix config gotchas that make `mix check` behave
389
+ differently from `mix test` run directly — `preferred_envs: [check: :test]`
390
+ (without it, a custom alias's `mix test` step runs in `:dev`) and
391
+ `dialyzer: [plt_add_apps: [:ex_unit]]` (without it, Dialyzer fails once
392
+ `test/support/*.ex`'s `ExUnit` macro calls are analyzed under `:test`).
393
+ - `security-headers`: notes Sobelow's `Config.Headers` and `XSS.Raw` checks
394
+ false-positive on this stack's CSP plug and JSON-LD `raw()` call
395
+ respectively, and that `.sobelow-conf` is the documented way to silence
396
+ them with reasoning attached, not a blanket `--skip`.
397
+ - `repo-hygiene`: notes `mix phx.new` (1.8.x) now auto-generates its own
398
+ `AGENTS.md` with ecosystem usage-rules content — a project's house-style
399
+ block merges into that file rather than displacing it, still one prose-doc
400
+ slot.
401
+ - `stack-baseline`: `ash_phoenix` documented as a required separate
402
+ dependency (not auto-installed by `mix igniter.install ash`); Boundary
403
+ noted as optional, not baseline; Tailwind + DaisyUI noted as shipping by
404
+ default from `phx.new` 1.8.x; added a standing warning that a written
405
+ version pin in this doc can drift from what Hex resolves today — re-check
406
+ at scaffold time, don't trust the pin.
407
+ - `og-image` / `assets`: gain a `TODO — no reference implementation exists`
408
+ banner matching `a11y` / `internal-links` / `the-gate` / `compliance`'s
409
+ existing treatment — `mix og.gen` / `mix icons.gen` don't exist yet;
410
+ `templates/phoenix/` ships one hand-placed placeholder OG image instead.
411
+ - `adopting`: "No starter template for this stack yet" replaced with a
412
+ pointer to `templates/phoenix/`; the new-project checklist updated to
413
+ reference every fix above.
414
+
415
+ ### templates/phoenix (new)
416
+
417
+ - New starter template: an Ash domain layer (`Content.Article`, a policy,
418
+ and an `excerpt` calculation as the domain-tests sample), an `Inbox.Message`
419
+ contact-form target with a honeypot validation/change pair, the corrected
420
+ CSP-nonce security-headers plug, cached sitemap/robots controllers, an
421
+ `/audit` LiveView, an `AshPhoenix.Form`-backed contact form (honeypot +
422
+ Hammer rate limit), `.github/workflows/ci.yml`, `bin/deploy`. `mix check`
423
+ runs only real steps (compile --warnings-as-errors, format, credo --strict,
424
+ dialyzer, sobelow --config, deps.audit, ash.codegen --check, test) —
425
+ `a11y` / `links.check` / `whs.check` stay TODO, not faked into the alias.
426
+ No `bin/init` yet — `TEMPLATE.md` covers filling in the placeholders by
427
+ hand. Verified via a full cold-cache `mix check` run (fresh deps, fresh
428
+ Postgres, fresh compile) from the template's final repo location, twice.
429
+ - **`config/dev.exs`'s `git_hooks` config is now guarded on `File.dir?(".git")`.**
430
+ Found the hard way: `git_hooks`' `only: [:dev]` dependency auto-installs
431
+ hooks on `mix deps.get`/`mix compile` by walking up to whatever repo
432
+ `git rev-parse --show-toplevel` finds — since `templates/phoenix/` has no
433
+ `.git` of its own inside this monorepo, running `templates/verify.sh`
434
+ installed real `pre-commit`/`pre-push` hooks into *the standard's own*
435
+ `.git/hooks`, breaking `git commit` here until removed by hand. The guard
436
+ only configures `git_hooks` once the directory has been copied out into a
437
+ real project and `git init`'d — which is when the auto-install is actually
438
+ wanted.
439
+ - `index.json`'s `frameworks.phoenix.template` → `templates/phoenix/`;
440
+ `status` stays `draft` — that should mean full gate parity including a
441
+ compliance sweep, and the Elixir tooling package (the `whs-eleventy`
442
+ counterpart) is still a later phase.
443
+ - `README.md`'s doc-map table gets a dedicated `templates/phoenix/` row; the
444
+ `eleventy-netlify` row's "phoenix: no template" note is removed.
445
+ - `templates/verify.sh` and `.github/workflows/standard.yml` gain a
446
+ `mix.exs`-keyed branch (`mix deps.get`, `ecto.create`/`migrate` for dev and
447
+ test, `mix check`) — no `compliance --strict` call, since that tooling
448
+ doesn't exist for this stack yet.
449
+ - `templates/new-project.sh` now checks for `bin/init` before running it and
450
+ prints a clear message instead of crashing when a stack (Phoenix, for now)
451
+ doesn't have one yet.
452
+ - `bin/set-version` and `bin/check`'s version-agreement check now also cover
453
+ a template's `AGENTS.md` `standard-version` pin, not just `CLAUDE.md` —
454
+ needed because `templates/phoenix/` carries the house-style block in
455
+ `AGENTS.md` (`mix phx.new` generates its own `AGENTS.md`; the one-prose-doc
456
+ slot rule means the house-style block merges into it rather than adding a
457
+ second file), and the two checks previously only looked at `CLAUDE.md`.
458
+ - `bin/check`'s sterility allowlist gains the Phoenix-ecosystem domains the
459
+ template's docs legitimately reference (`daisyui.com`, `tailwindcss.com`,
460
+ `heroicons.com`, `phoenixframework.org`, `elixirforum.com`, `fly.io`,
461
+ `github.io`, `hexdocs.pm`, `hex.pm`, `mozilla.org`) and broadens the prior
462
+ single-path `github.com/piercebarney/web-house-style` entry to a generic
463
+ `github.com`, matching how `netlify.com`/`netlify.app` are already allowed
464
+ generically rather than scoped to one account.
465
+
466
+ ---
467
+
11
468
  ## 2026-09-02 — closing the gap between the compliance grid and the Contracts it checks
12
469
 
13
470
  A pass through every chapter's actual enforcement, prompted by an outside
@@ -31,6 +488,17 @@ consistency defects the same pass turned up.
31
488
  to `# recommended — enables standard-version drift tracking (#compliance)`
32
489
  — `#compliance`'s own check already treats it as required; the doc
33
490
  contradicted the code.
491
+ - **A "Feedback to the standard" spec, shipped as `feedback/README.md`** (a
492
+ minimal dated-note inbox — what is wrong · which project ·
493
+ `standard-version`), plus a House-style directive bullet and an
494
+ anti-pattern: a standard defect (a misfiring check, a `core.md` ↔ impl-doc
495
+ contradiction, a Contract that doesn't survive a real codebase) is a note
496
+ filed there, not a session-transcript casualty. `bin/check`'s sterile grep
497
+ covers the committed inbox; `bin/check` does not gate on it. Deliberately
498
+ minimal — no `bin/feedback` scaffold or CLI subcommand this pass; an
499
+ earlier, more automated version (a GitHub-issue subcommand) was built and
500
+ reverted as over-built for a one-adopting-project reality. Triage empties
501
+ the inbox into a `CHANGELOG` change or a one-line `wontfix`.
34
502
 
35
503
  ### docs — drift-guard registry sync
36
504
 
package/standard/core.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Web project house style — CORE (stack-agnostic)
2
2
 
3
- **Version:** 2026-09-02 · **Status:** active
3
+ **Version:** 2026-09-03 · **Status:** active
4
4
 
5
5
  This is the stack-agnostic contract every web project follows, regardless of
6
6
  framework, host, or CSS system. It says **what** must be true, with concrete
@@ -94,8 +94,13 @@ contents, and uses one mechanism per concern.
94
94
  a build-time schema gate hard-fails the build on invalid content.
95
95
 
96
96
  **Specs.**
97
- - User-visible copy (titles, descriptions, body prose, lists) is authored as
98
- data, not embedded in markup. Templates render data and nothing else.
97
+ - **Every** user-visible string is authored as data collection entries and
98
+ standalone pages alike (an About page, a policy page, a marketing page).
99
+ Templates render data and nothing else. The only prose that may live in a
100
+ template is copy bound to a mechanism that same template implements — a
101
+ generated disclosure list's heading, a consent notice whose wording must track
102
+ real behaviour; the impl doc names these cases. The test: could a non-developer
103
+ rewrite this sentence without risking a guarantee? If yes, it is data.
99
104
  - A validator runs before the build completes and **exits non-zero** on any
100
105
  error, printing every error (not just the first), each as `path: expected X`.
101
106
  - Validation covers: required keys present; correct types; enum values; **unique
@@ -107,11 +112,21 @@ a build-time schema gate hard-fails the build on invalid content.
107
112
  - Where non-developers edit content, provide editor-time validation (a schema
108
113
  the editor understands) in addition to the build gate; a Git-backed CMS over
109
114
  the same data files is acceptable — a separate database for site content is
110
- not. Where an **LLM agent** edits the data files directly, it follows a
111
- documented per-project content-ops protocol: what it may change (the content
112
- files, not code or structure), a pre-commit content check (the
113
- content-relevant slice of the gate), and a `content:` commit convention. See
114
- your stack's impl doc.
115
+ not.
116
+ - **Ownership.** By default the party that builds the site may also edit its
117
+ content. A project that runs a **separate content operation** — a non-developer,
118
+ or an LLM agent editing the data files directly **reserves** that content:
119
+ one file at the repo root names the reserving party and the exact set it owns,
120
+ and is authoritative for every party. The builder treats the reserved set as
121
+ read-only and routes any change it needs there through the project's escalation
122
+ channel, never a direct edit. The reserved set is enumerated at whatever
123
+ granularity the project needs — all content, or named collections/paths.
124
+ - An **LLM agent** editing reserved content follows a documented per-project
125
+ content-ops protocol: it changes only the reserved set; a pre-commit content
126
+ check (the content-relevant slice of the gate) **errors** on any staged change
127
+ outside that set; a `content:` commit convention; and an escalation channel for
128
+ anything structural, so a blocked request is recorded, not lost. See your
129
+ stack's impl doc.
115
130
  - **Drafts** are a data flag. A draft renders in local and preview builds
116
131
  (reviewable) but is excluded from the production build and the sitemap.
117
132
  Publishing is removing the flag.
@@ -121,7 +136,9 @@ a build-time schema gate hard-fails the build on invalid content.
121
136
 
122
137
  **Rationale.** Content that lives in templates can't be validated, can't be
123
138
  edited safely by non-developers, and drifts from its own stated facts (a
124
- calculator whose copy says "18%" while the code uses 15%).
139
+ calculator whose copy says "18%" while the code uses 15%). A written, authoritative
140
+ reserved set is what lets a builder and a separate content operation work the
141
+ same repo without overwriting each other or leaving a request unanswered.
125
142
 
126
143
  **Verify.** The gate fails the build on invalid content. The audit page shows
127
144
  validation status.
@@ -1283,12 +1300,14 @@ records the result. "The gate passing" is the definition of compliant.
1283
1300
  project), before non-trivial work. Ask five questions with defaults:
1284
1301
 
1285
1302
  1. **Framework?** Eleventy + Netlify *(default)* · Elixir/Phoenix *(when a
1286
- database or server-side state is in scope — `#beyond-this-standard`)* · other
1287
- *( no binding exists; use this file as principles and do what is idiomatic
1288
- for the technology)*. `stacks/sveltekit.md` is a **parked** stub its
1289
- friction points and open questions are written, the per-section "how" is not;
1290
- it is not on offer here until a real project forces those decisions, so
1291
- treat SvelteKit as "other" for now.
1303
+ database or server-side state is in scope — `#beyond-this-standard`)* ·
1304
+ SvelteKit *(only when a project specifically needs it Svelte components
1305
+ without SvelteKit has no durable static-site tooling, so SvelteKit +
1306
+ `adapter-static`, used minimally as a build tool with no server, is the
1307
+ fallback even for a "just static Svelte" need; a project with no Svelte
1308
+ requirement should still default to Eleventy + Netlify)* · other *(→ no
1309
+ binding exists; use this file as principles and do what is idiomatic for
1310
+ the technology)*.
1292
1311
  2. **CSS system?** Pico classless *(default)* · Pico class-based · Tailwind ·
1293
1312
  Tailwind + component library · vanilla tokens. (Options are bound per stack
1294
1313
  in the impl doc's `styling` section.)
@@ -1317,7 +1336,10 @@ stack with no template, and the annotated inventory of what the template
1317
1336
  contains. Only `eleventy-netlify` has a template today; the others follow the
1318
1337
  checklist. The recommended input to the flow is a filled
1319
1338
  `templates/concept-brief.md` (the idea, the answers above, a concept-level
1320
- brand, the content model, layout notes) — `README.md` has the hand-off.
1339
+ brand, the content model, layout notes) — `README.md` has the hand-off. If the
1340
+ brief cannot honestly fit an offered stack, that is a `feedback/` note (below)
1341
+ filed *before* the build — resolved as a new binding, or the nearest stack with
1342
+ the compromise recorded.
1321
1343
 
1322
1344
  **Record** the result near the top of the project's `.claude/CLAUDE.md` /
1323
1345
  `AGENTS.md` as a `## House style` section — a directive paragraph plus a data
@@ -1359,13 +1381,26 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
1359
1381
  - production-url: https://example.com
1360
1382
  - content-type: tool # tool | article (article => feeds)
1361
1383
  - publishing-rate: ~5 pages/week
1362
- - standard-version: 2026-09-02 # recommended — enables standard-version drift tracking (#compliance)
1384
+ - standard-version: 2026-09-03 # recommended — enables standard-version drift tracking (#compliance)
1363
1385
  ```
1364
1386
 
1365
1387
  If the section is absent, the assistant's first action is to run the flow above
1366
1388
  and propose it. Each impl doc's `adopting` section fills in the gate command and
1367
1389
  adds any stack-specific `CLAUDE.md` prose (e.g. the generated-assets note).
1368
1390
 
1391
+ **Editorial operation.** Where content is maintained after launch by an LLM
1392
+ agent rather than by in-repo development (`#content-model` — a *reserved*
1393
+ content set), two files ship at the project root: `CONTENT.md` (the reserved set
1394
+ plus the per-project content-ops protocol) and a voice guide (see the impl
1395
+ doc). The operation's editor-in-chief — the human's standing ideation/editorial
1396
+ context, typically a Claude Project — connects to the repo through a **thin,
1397
+ stable instruction**: check out the repo; read `CONTENT.md` and the voice guide
1398
+ at the start of each session; follow `CONTENT.md`; escalate through its channel;
1399
+ never deploy. The detail stays in the repo, re-read each session — not in the
1400
+ instruction — and `CONTENT.md` carries a paste-once "setting up" section for
1401
+ exactly this. The build arm treats the reserved set as read-only and escalates
1402
+ into the same channel.
1403
+
1369
1404
  **Onboarding an existing project** — after recording the section, run
1370
1405
  `#compliance`. Every `FAIL` and `MANUAL` becomes a migration-backlog item,
1371
1406
  worked incrementally (the impl doc's migration path is the ordered version of