@profile-as-code/cli 0.3.5 → 0.3.7

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
@@ -15,14 +15,13 @@ pasco --version # the bin — prints the installed version
15
15
  The install adds one global bin and nothing else: no post-install script, no
16
16
  server, no account.
17
17
 
18
- > **Published** [OFICIAL] [WEB: registry.npmjs.org, checked 2026-10-12]:
18
+ > **Published** [OFICIAL] [WEB: registry.npmjs.org, checked 2026-10-26 at release prep]:
19
19
  > `@profile-as-code/cli` is public on npm — the registry is the source of truth
20
- > (`npm view @profile-as-code/cli version`). The current release is `0.3.0`
21
- > (published 2026-10-11T03:43:27Z, dist-tag `latest`; tag `v0.3.0` → `21e4cde`)
22
- > and the bin was verified running (`node packages/cli/dist/index.js --version`
23
- > → `pasco 0.3.0` on `main`, 2026-10-12) — a **local-build** check, not a run of the
24
- > published tarball. No published version carries a
25
- > provenance attestation while the source repo is private — documented, not pending.
20
+ > (`npm view @profile-as-code/cli version`). At prep time the last published release is
21
+ > `0.3.5` (published 2026-10-24T03:50:04.822Z; tag `v0.3.5` → `c69e6b7`), which carries
22
+ > npm-publish attestations + SLSA provenance v1; `0.3.6` is prepared in this release
23
+ > (CHANGELOG `[0.3.6]`). The `0.3.0` tarball carries none because it was published while
24
+ > the source repository was private.
26
25
  > Source install: [User Guide §2](../../docs/USER-GUIDE.md#2-install-the-pasco-cli).
27
26
 
28
27
  ## Get a workspace
@@ -59,13 +58,23 @@ commands above are the standalone path.
59
58
  |---|---|---|
60
59
  | `pasco init [dir]` | Scaffold a profile-data workspace (`content/identity.yaml`, `README.md`, `.pascorc`) — `pasco validate`-clean | ✅ |
61
60
  | `pasco validate [--json]` | Schema **fail** (exit 1) + drift/geo **warn** (exit 0) — D12 | ✅ |
61
+ | `pasco doctor [--json]` | **Engine · schema · workspace** on one line (#348): the installed CLI version, the profile-contract version this build validates (from the schema `$id`), the resolved workspace and whether it loads, plus the **effective** `.pascorc` engine requirement (the strictest declaration in every config the resolution consulted, with its file). `--json` emits `{ engine, schema, workspace, requirement: { declared, met, problem?, source? } }`. Exit 0 healthy · 1 unhealthy · 2 usage; offline, no clock, no writes | ✅ v1 |
62
62
  | `pasco build [platform] [--lang]` | Deterministic base texts per platform+lang; platforms with a renderer (the eight registered in the generated renderer map) write `profiles/<platform>/README.md` + bundled media; renderer-less manual surfaces with a bio row write `profiles/<platform>.md` (#239). Empty render = no-trace unbuild of the generated output, preserving `profiles/<platform>/variants/` (chosen-variant input, #238; pure profiles/, §15.1 — v1.7). Media in `profiles/<platform>/assets/` + `assets/manifest.json` (M8/§18.6 — see `docs/media-pipeline.md`) | ✅ |
63
+ | `pasco preview [--out <dir>] [--no-open] [--lang <l>] [--now <iso>] [--json]` | The **local view** (#282): **one** self-contained HTML file of the surfaces `pasco build` writes — inline CSS, no `<script>`, no external asset, no HTTP server. Sections are the published surfaces (`#github-readme`, `#linkedin`, …) plus the ones this language does not publish, named with their reason; the check state is the same report `pasco check` prints, and the text is `build`'s own render decision tree. Default `<workspace>/.pasco/preview/index.html`, then opened in the browser; `--out <dir>` writes and opens **nothing** (the CI-artifact transport). A privacy gate refuses the write (exit 1) on an absolute machine path, a credential shape, an external asset, active content, or a source outside the workspace | ✅ v1 |
64
+ | `pasco edit <contract-path> [--set <text> [--check]]` | Resolve **one** contract field by contract path; `--set` **writes it** (comment-preserving range splice, validated, atomic) and `--set --check` previews the **published lines the edit changes** (or says it changes nothing). Epic #247, S1–S5 landed | ✅ S1–S5 (read · preview · write · surface impact · cap pre-flight · machine contract) |
63
65
  | `pasco diff [--json]` | Changes since the last `release-*` tag (git-based → CHANGELOG hint) | ✅ |
64
66
  | `pasco status [platform] [--mark <ledgerKey> <surface>] [--verify] [--write-checklist] [--completeness]` | Per-entry deploy report — origin · surfaces · in-sync/stale/never · **certification** (verified/attested/unverifiable/out-of-band-drift, §19.5) + the derived **publication truth** per surface (§20.1, #242) + ledger write paths (D18, §19) | ✅ |
67
+ | `pasco presence [--json]` / `pasco release plan [<id>] [--json]` | **Presence board** (D23/§22, #262): every surface a release has addressed, at its newest release, with the ledger-derived state (`in-sync`/`stale`/`never`), and the **release plan** — the ordered fan-out (`repo-meta` → `profile` → `distribution`, then slug), each surface's state and the per-kind **factual spine**. Copy per channel lives in the shipped skills, not the engine | ✅ v1 |
65
68
  | `pasco refine <platform> [--lang] [--variants N] [--choose <id>]` | Agent-driven variant flow (D10/D11/D13); the chosen body is `profiles/<platform>/variants/<id>.md` (a complete platform artifact), rendered by `pasco build` (#238) | 🚧 template + `--choose` (full flow M3) |
66
- | `pasco check [platform]` | User checks from `content/checks.yaml` (schema-validated catalog) + registry rules (M7/§18.8) + media-transport rules (M8/§18.6) + offline `max-chars`/`min-chars` over headlines/bios (#240) | 🚧 offline rules today (limits + registry + media; remaining catalog M3/M4) |
67
- | `pasco post <seed\|task\|choose\|check\|export\|import\|list>` | Week-1 post MVP (#264): a seed at `posts/<seed>/seed.md` → task bundle → chosen variant → check → optional Postiz draft payload; the CLI never calls an LLM | 🚧 experiment (not in the v1 contract; `docs/post-mvp.md`) |
68
- | `pasco skills:export <dir> [--force] [--list]` | Copy the platform skills this package ships into a directory you name (`--list` shows them; no per-agent paths are invented — #254) | ✅ |
69
+ | `pasco check [platform]` | User checks from `content/checks.yaml` (schema-validated catalog) + registry rules (M7/§18.8) + media-transport rules (M8/§18.6) + offline `max-chars`/`min-chars` over headlines/bios (#240) + `fact-present` (the built surface's fact tokens vs the SSOT render, #283) | 🚧 offline rules today (limits + registry + media + fact-present; remaining catalog M3/M4) |
70
+ | `pasco discover <url>... [--json] [--allow-host <hosts>]` | **Provisional profile** from public surfaces (#329, SPEC §7.10), for a prospect with no workspace: reads schema.org JSON-LD + links only (no prose mining), attaches per-fact provenance (`attested: false`) and lists `absent` fields; reports the audit's class names with discovery's own predicates (SPEC §7.10); closed host allowlist checked before any request (sequential, no retries, no credentials); **writes nothing** | ✅ v1 |
71
+ | `pasco audit [--json]` | Offline **diagnostic projection**, findings only (no score/prioritisation/branding, #283): three fixed classes — `dangling-ref` FAIL · `contradiction` FAIL · `unsourced-claim` WARN — exit 1 iff any FAIL; `--json` carries `{ schemaVersion: "1", scope: { declared, checked, notRun }, findings }` | ✅ v1 |
72
+ | `pasco entity [--json \| --jsonld [--out <file>] [--bare]]` | The **entity projection** for surfaces the engine does not render (#287): `--json` is the frozen view — `entity { id, type, name, sameAs, email }`, the structured-data `gaps` (geoCheck codes verbatim), `jsonld: null` and `faqJsonLd` (the `FAQPage` node, `null` when the workspace declares no `faq[]`); `--jsonld` is the **same assembler output** the renderers embed, as paste-ready `<script type="application/ld+json">` blocks — the entity first, then the `FAQPage` when it exists (#360). Exit 1 when `@id` is absent (never invented) | ✅ v1 |
73
+ | `pasco cite-check [<answers.ndjson>] [--json]` | The **citation matcher** over saved answer-engine answers (#291): reads the runner's NDJSON (stdin when no path), matches the workspace's declared entity and emits, **per answer and never aggregated**, `{ cited, confidence, spans, reasons }`. Ladder: `citation-url` (a citation on the entity's own page or its `sameAs` set — host, and the declared path or a descendant) and `exact-name` → **high**; `alias` (`identity.orgName`) and `handle` → **medium**; `partial` (any contiguous multi-token prefix, ≥ 2 tokens) → **low**, and low is never citable downstream. Refuses to guess: a one-word name with no declared disambiguator (`cited: false`, the longer capitalised reading named as `competing`), or a contrast mention, is not a citation; a declared qualifier or an adjacent capital never disqualifies a hit by itself | ✅ v1 |
74
+ | `pasco export ai-policy [--out <dir> \| --check] [--lang <l>]` | The M9 stance as an artifact (#290): `--out` writes `robots.txt` (**two named groups, no wildcard** — the listed retrieval crawlers allowed, the listed training crawlers disallowed, each UA commented with its registry entry/operator/source/date; the file declares that a crawler it does not name is **not restricted**), `llms.txt` (the workspace index: name, definition, canonical identity, `faq[]`, `publications[]`, with a coverage assertion) and `AI-POLICY.md` (allowed · blocked · that same explicit-list limit · who can change it · the date, quoting the stance verbatim). `--check` exits 1 on an incomplete or unsourced entry, a duplicate token (case-insensitive), a malformed/future/`>180`-day `as_of`, or a `policy.as_of` that is stale or older than its newest row; `--out` refuses to write one. The UA list is projected from the platform registry — never hardcoded in the CLI | ✅ v1 |
75
+ | `pasco export profile [--json] [--now <iso8601>]` | The **normalized profile** for programmatic consumers (#348): `{ schemaVersion, engine, source: { commit, generatedAt }, profile }` — `profile` is exactly what `core.load()` returns (**every root the workspace declares, plus every list root defaulted to `[]`**; an optional object root appears only when declared), never a platform slice (`compile()` makes that other object). `artifactRefs` ids resolve in `profile.artifacts[]`, `publications[].url` as declared, `deployments` included. No clock is read: `generatedAt` is `null` unless `--now` pins it, so two runs are byte-identical; `source.commit` is the workspace git HEAD (`null` outside a repo) | ✅ v1 |
76
+ | `pasco post <seed\|task\|choose\|check\|export\|import\|list>` | Post experiment (#264/#265, long-form #266): a seed at `posts/<seed>/` → task bundle → chosen variant → check → Postiz draft payload (API channels) or paste checklist (Substack/Ghost). Two seed formats (`short`/`longform`); a short child derived from an essay records `derivedFrom`. The CLI never calls an LLM | 🚧 experiment (not in the v1 contract; `docs/post-mvp.md`) |
77
+ | `pasco skills:export <dir> [--force] \| --list` | Copy the platform skills this package ships into a directory you name (`--list` shows them; no per-agent paths are invented — #254). A destination and `--list` are **separate invocations**: both together is usage (exit 2), never an ignored argument | ✅ |
69
78
 
70
79
  The partial commands are explicit, and each one says what still owns the rest:
71
80
 
@@ -77,21 +86,563 @@ The partial commands are explicit, and each one says what still owns the rest:
77
86
  #253). The full variant flow is M3 (`src/commands/refine.ts`).
78
87
  - **`check`** — executes the offline rules that exist today: the brand handle
79
88
  matrix (brand flavor only, D15/§17), the evidence-registry checks (M7/§18.8),
80
- the media-transport rules (M8/§18.6), and the `max-chars` / `min-chars` rows
89
+ the media-transport rules (M8/§18.6), the `max-chars` / `min-chars` rows
81
90
  declared in `content/checks.yaml` over the headline/bio rows that exist
82
- (offline, warn-class, #240). The remaining per-platform rules in the catalog
83
- ship with the skills `checks.yaml` union (M3) and the full `pasco check` (M4).
84
- It prints its `owner` line (`src/commands/check.ts`).
91
+ (offline, warn-class, #240), and **`fact-present`** — for the surfaces whose
92
+ catalog declares it (`content/checks.yaml` ∪ the shipped
93
+ `skills/<platform>/checks.yaml`): the artifact `pasco build` last wrote is
94
+ compared **symmetrically** against the SSOT render — a URL, ISO
95
+ date or number one side carries and the other does not, or a text difference no
96
+ fact token explains, is a finding — WARN in `check` (D12), because a published
97
+ surface that disagrees with the SSOT is a fact problem, not a schema one. A
98
+ targeted surface with no artifact reports the rule as **not run**, never as OK. The remaining
99
+ per-platform rules in the catalog ship with the skills `checks.yaml` union (M3)
100
+ and the full `pasco check` (M4). It prints its `owner` line
101
+ (`src/commands/check.ts`).
85
102
  - **`skills:export`** — copies the platform skills this package ships into the
86
103
  directory the user names (`--list` first; an existing non-empty directory needs
87
104
  `--force`). It replaces the earlier projection stub: the CLI never invents a
88
105
  per-agent destination and never points at tooling the reader cannot open
89
106
  (`src/commands/skills-export.ts`, #254).
90
- - **`post`** — the week-1 experiment (#264/#265): a CLI subcommand family over
91
- the `posts/` store; it never calls an LLM, emits the agent task, records the
92
- choice, checks limits/URLs/refs and exports a Postiz draft. It reads the
93
- `skills/` bundled with the package (set `PASCO_SKILLS_DIR` only to point at a
94
- custom tree), and it is **not part of the v1 contract yet** (`docs/post-mvp.md`).
107
+ - **`post`** — the post experiment (#264/#265, long-form #266): a CLI
108
+ subcommand family over the `posts/` store; it never calls an LLM, emits the
109
+ agent task, records the choice, checks limits/URLs/refs (plus title/dek and
110
+ the word band for long-form) and exports a Postiz draft for API channels or a
111
+ paste checklist for Substack/Ghost. A seed is `format: short | longform`; a
112
+ short child derived from an essay records `derivedFrom: <essay-id>`. It reads
113
+ the `skills/` bundled with the package (set `PASCO_SKILLS_DIR` only to point
114
+ at a custom tree), and it is **not part of the v1 contract yet**
115
+ (`docs/post-mvp.md`).
116
+
117
+ ### The audit (`pasco audit --json`, #283)
118
+
119
+ `audit` is an **offline JSON projection over existing checks** — not a second
120
+ analysis engine and not a scoring surface. It aggregates per identity (where
121
+ `check` is per document) and reports **findings only**: no score, no
122
+ prioritisation, no branding. Those belong to the consumer (`PROP-017 §Module
123
+ boundary`), and putting them in the engine would recreate the monolith the
124
+ module split exists to prevent.
125
+
126
+ ```sh
127
+ pasco audit # human list, one block per finding
128
+ pasco audit --json # machine contract
129
+ ```
130
+
131
+ ```json
132
+ {
133
+ "schemaVersion": "1",
134
+ "scope": { "catalog": "shipped", "declared": ["github-readme"], "checked": ["github-readme"], "notRun": [] },
135
+ "findings": [
136
+ {
137
+ "class": "dangling-ref",
138
+ "severity": "FAIL",
139
+ "path": "projects[hub].links[0]",
140
+ "detail": "drift/url-not-in-identity — URL https://github.com/someoneelse lives on a platform host but is not in identity.links"
141
+ }
142
+ ]
143
+ }
144
+ ```
145
+
146
+ `scope` is the artifact side (the SSOT-side rules always run): `catalog` is
147
+ `shipped` | `not-shipped` (a package without its skills tree cannot declare the
148
+ premise — a bundle state, not "nothing declared"); `declared` are the targeted
149
+ surfaces whose catalog declares the `fact-present` premise, `checked` the
150
+ declared ones whose built artifact was compared, `notRun` the declared ones with no
151
+ artifact on disk or no renderer for the flavor. **`declared: []` means the premise
152
+ is not declared anywhere; `checked: []` means it measured nothing** — either way the
153
+ audit says so instead of looking clean.
154
+
155
+ | Class | Fires when | Severity |
156
+ |---|---|---|
157
+ | `dangling-ref` | a pointer does not resolve in the current view | **FAIL** |
158
+ | `contradiction` | two surfaces assert incompatible values for one datum | **FAIL** |
159
+ | `unsourced-claim` | a quantitative claim carries no evidence | **WARN** |
160
+
161
+ `schemaVersion` is the contract version: a consumer refuses a version it does not
162
+ know rather than guessing at the shape. `path` is a `factRef` (the row `id`
163
+ replaces the array index when the row declares one); a finding about a built
164
+ surface addresses the artifact path (`profiles/<surface>/README.md`), because a
165
+ fact the SSOT does not carry has no `factRef` by definition.
166
+
167
+ Exit `1` iff a `FAIL` finding exists, `0` otherwise (WARN-only included); `2` is
168
+ a usage error, and a schema/load failure is the standard error envelope at exit
169
+ `1`. The audit never touches the network, and no finding is derived from
170
+ `content/deployments.yaml` (the ledger is not a fact source) — it works on a
171
+ workspace that has neither a ledger nor a built artifact.
172
+
173
+ What it composes (`docs/SPEC.md` §7.1): `load`/schema, `reconciledDrift` (drift
174
+ plus the #238 variant-store reconciliation `validate` applies), `geoCheck` (its
175
+ `faq-coverage` premise gates the claim rows; the codes themselves map to no class),
176
+ the completeness `evidence` premise, and `fact-present`.
177
+
178
+ It is **deliberately stricter than `validate`/`check`** on the two FAIL classes:
179
+ D12 governs validity (drift facts warn, `validate` exits 0), while the audit's
180
+ contract is publishability and the issue freezes the class severities. A drift fact
181
+ in a FAIL class therefore exits 1 here and 0 there — declared in SPEC §7.1 and
182
+ pinned by a test. Two inputs are deliberately NOT findings, declared in §7.1:
183
+ `drift/url-not-in-identity` (the URL resolves — a canonicalization rule `validate`
184
+ warns about) and `geoCheck`'s coverage codes.
185
+
186
+ ### The entity projection (`pasco entity`, #287)
187
+
188
+ `audit` diagnoses; `entity` is the artifact for a surface this engine does not
189
+ render (WordPress, Webflow, Next, a landing page). It exposes the **same** entity
190
+ the renderers embed — `core/src/structured-data.ts` is the one assembler, so there
191
+ is no CLI-local builder and no second truth (SPEC §7.2).
192
+
193
+ ```sh
194
+ pasco entity --json # the frozen view: facts, @id, sameAs, gaps
195
+ pasco entity --jsonld # paste-ready <script type="application/ld+json"> (+ FAQPage)
196
+ pasco entity --jsonld --bare # the bare JSON object (node array when faq[] exists)
197
+ pasco entity --jsonld --out entity.html # write the block (refuses an existing file)
198
+ pasco entity --jsonld --out entity.html --force # replace it (atomic write)
199
+ ```
200
+
201
+ ```json
202
+ {
203
+ "schemaVersion": "1",
204
+ "entity": { "id": null, "type": "Person", "name": "Elena Vasquez", "sameAs": ["https://github.com/elenavasquez"], "email": null },
205
+ "gaps": [
206
+ { "code": "geo/entity-id", "detail": "no role: identity link — all 5 link(s) are role: realization (they indicate the entity, not are it). Mark the one that represents you role: identity to make the entity citable." }
207
+ ],
208
+ "jsonld": null
209
+ }
210
+ ```
211
+
212
+ `entity.id` is the URL of the `role: identity` link (the first one wins) or
213
+ `null`: the engine **never invents an `@id`**. `gaps` are the `geoCheck` checks of
214
+ its structured-data section that scored below 1, with the code and detail
215
+ **verbatim** — no new vocabulary. `jsonld` is always `null` in `--json`; the
216
+ artifact has its own mode, and `faqJsonLd` carries the `FAQPage` node the same
217
+ mode emits when the workspace declares `faq[]` (`null` otherwise — never an empty
218
+ `FAQPage`, never an invented `@id`, `faq[].source` travels as
219
+ `acceptedAnswer.citation`). Parsing the `--jsonld` output yields exactly
220
+ `structuredData()`'s object (plus the `FAQPage` node when there is one) —
221
+ asserted by a test. `--bare` without `faq[]` stays byte-identical to the
222
+ pre-#360 artifact.
223
+
224
+ Both modes resolve **one** language (`--lang`, else `.pascorc` `defaults.lang`,
225
+ else `en`): the readiness gaps are measured in the same language the JSON-LD is
226
+ assembled, so a brand whose only populated `mission`/`tagline` is `es` cannot show
227
+ `description` as missing while the block emits it.
228
+
229
+ `--out` writes a temp file and renames it into place (atomic), and **refuses an
230
+ existing file** unless `--force` is passed — a mistyped path must not truncate the
231
+ SSOT. A write failure (existing file without `--force`, ENOENT, EACCES) is an error
232
+ envelope at exit `1`, never a stack.
233
+
234
+ Exit `0` when `@id` is present, `1` when it is absent (the entity has no declared
235
+ home; the JSON-LD is still emitted, without `@id`) or the `--out` write failed, `2`
236
+ for usage. It is offline and deterministic: no network, no clock, no AI.
237
+
238
+ ### The citation matcher (`pasco cite-check`, #291)
239
+
240
+ Every GEO number is priced in "prompts monitored per month", and the half that
241
+ decides whether the number is true is one question: **did this answer actually
242
+ cite this entity?** The network half belongs to the runner; this command answers
243
+ the matching question over the answers it already saved. It is a pure function of
244
+ the SSOT and the input bytes — no network, no model, no clock (D19).
245
+
246
+ ```sh
247
+ pasco cite-check answers.ndjson # one line per answer
248
+ cat answers.ndjson | pasco cite-check # stdin when no path is given
249
+ pasco cite-check answers.ndjson --json # the frozen per-answer contract
250
+ ```
251
+
252
+ Input — the runner's NDJSON, which this engine never produces:
253
+
254
+ ```json
255
+ { "engine": "chatgpt", "askedAt": "2026-10-23T15:00:00Z",
256
+ "question": "¿qué agencia GEO hay en Ecuador?",
257
+ "answer": "…", "citations": [ { "url": "https://…", "title": "…" } ] }
258
+ ```
259
+
260
+ `answer` and `citations[]` are required; `engine`/`askedAt`/`question` are carried
261
+ through. A blank line is ignored; any other malformed line is exit `2` naming the
262
+ line and field — **never a silent skip**, because a skipped line changes a rate.
263
+
264
+ The ladder is fixed, and each rung carries its own confidence:
265
+
266
+ | Rung | What matched | Confidence |
267
+ |---|---|---|
268
+ | `citation-url` | a saved citation on the entity's own page or its `sameAs` set — host, and the declared path or a descendant (`github.com/<you>` does not match `github.com/someone-else`) | **high** |
269
+ | `exact-name` | the full `identity.name`, word-bounded, case- and accent-insensitive (`Domínguez` = `Dominguez`) | **high** |
270
+ | `alias` | `identity.orgName` | **medium** |
271
+ | `handle` | `identity.handle` or a `socialHandles[]` row with `status: claimed` | **medium** |
272
+ | `partial` | **any** contiguous multi-token prefix of the name (`DA SEO` and `DA SEO Digital` of `DA SEO Digital Studio`) — a term a higher rung already claims is not repeated | **low** |
273
+
274
+ `confidence: "low"` is **never** a citation in a downstream report — the field
275
+ exists so that refusal is explicit. The matcher also refuses to guess: a one-word
276
+ name whose sentence has no declared disambiguator (`ambiguous-term:pasco` +
277
+ `no-qualifier`, with the longer capitalised reading the text carries named as
278
+ `competing:Pasco County School District`), a mention after a contrast marker
279
+ (`contrast-context`), a shorter declared term inside a longer one
280
+ (`longer-name:…`) and a citation URL the parser rejects (`url-invalid:<url>`, a
281
+ missing scheme being the common case — named, never dropped) are all reported in
282
+ `reasons[]` as **not** citations.
283
+
284
+ ```json
285
+ {
286
+ "schemaVersion": "1",
287
+ "entity": { "name": "Pasco", "aliases": [], "handles": ["pasco_cli"],
288
+ "partials": [], "hosts": ["github.com", "linkedin.com", "x.com"] },
289
+ "answers": [
290
+ { "line": 1, "engine": "chatgpt", "askedAt": "…", "question": "…",
291
+ "cited": true, "confidence": "high",
292
+ "spans": [ { "start": 0, "end": 5, "kind": "name" } ],
293
+ "reasons": ["exact-name"] }
294
+ ]
295
+ }
296
+ ```
297
+
298
+ One row per input line, in input order, **never aggregated**: no rate, no score,
299
+ no total — that belongs to the report layer. A citation span is
300
+ `{ start: 0, end: 0, kind: "citation-url", url }` because the URL is in the
301
+ answer's citation list, not in its text; text spans are offsets into the original
302
+ answer. `entity` is the declared vocabulary the matcher resolved, so a report can
303
+ say what it matched without re-reading the workspace.
304
+
305
+ Exit `0` on a successful read whatever the matches (a non-citation is data) · `1`
306
+ when the workspace or the input path cannot be read · `2` usage, or malformed
307
+ NDJSON. Contract: SPEC §7.4.
308
+
309
+ ### The AI policy artifacts (`pasco export ai-policy`, #290)
310
+
311
+ A profile publishes into a web where two kinds of machine reader arrive: the ones
312
+ whose output is a **citation** (search and answer indexing, a fetch a user asked
313
+ for) and the ones collecting material for **foundation-model training**. The
314
+ project's stance (SPEC §12 M9) is that the second kind does not get the content
315
+ unless the owner opts in — and until this command there was no file that said so.
316
+ One command now emits all three, from the workspace and the platform registry:
317
+
318
+ ```sh
319
+ pasco export ai-policy --out ./public # robots.txt + llms.txt + AI-POLICY.md
320
+ pasco export ai-policy --check # exit 1 when the UA list is stale or unsourced
321
+ ```
322
+
323
+ - **`robots.txt`** carries two *named* groups and never a wildcard: `ai-retrieval`
324
+ is `Allow: /`, `ai-training` is `Disallow: /`. Every `User-agent` line is
325
+ preceded by a comment naming its registry entry, operator, source URL and the
326
+ date that source was read, so a reader can tell **why** a crawler is in a group.
327
+ Because there is no `User-agent: *` group, the file restricts exactly the tokens
328
+ it lists: a crawler it does not name is **not restricted by it** (RFC 9309
329
+ §2.2.1). The artifacts **say that out loud** instead of claiming a default — the
330
+ list covers the crawlers whose classification has been checked against the
331
+ operator's own documentation, not the web.
332
+ - **`llms.txt`** is the workspace index in the shape the project site already
333
+ uses: name, one-line definition, the canonical identity URL, the `faq[]`
334
+ answers, the citable `publications[]`, and a dated stamp. The builder asserts
335
+ that **every document it claims to index appears in the text** — a document
336
+ missing from the index is a silent hole, not a cosmetic bug.
337
+ - **`AI-POLICY.md`** is the statement you send when a client asks "what is your AI
338
+ policy": what is allowed, what is blocked, who can change it, the date, and the
339
+ per-crawler table with each operator's own documentation URL.
340
+
341
+ The crawler list is **derived data**: it lives in the `ai:` block of the platform
342
+ registry, `pnpm gen:platforms` projects it into the engine, and the generator
343
+ refuses an entry with no `source` or no `as_of`. `--check` fails an unsourced
344
+ entry or a source older than **180 days**, and it gates the policy's own
345
+ `as_of` (real date, inside the window, not older than the newest row it seals) —
346
+ and `--out` runs the same check first, so a stale list becomes a failing command
347
+ instead of a file that rots. Tokens compare **case-insensitively** (RFC 9309
348
+ §2.2.1), so `GPTBot` and `gptbot` cannot slip in as two groups. No clock is
349
+ read when writing: the stamp is the registry's review date, so two runs on
350
+ different days produce byte-identical files.
351
+
352
+ `--out` never truncates a file it did not generate: an existing `robots.txt` that
353
+ is not ours needs `--force`. A refused write or a failed `--check` is exit `1`, a
354
+ usage error is exit `2`. The `--json` contract is frozen in SPEC §7.3.
355
+
356
+ ### The normalized profile (`pasco export profile`, #348)
357
+
358
+ `build` renders platform texts and `validate` checks the workspace, but neither
359
+ hands a program to the **profile itself**. A site build, a service or a script
360
+ that needs the facts used to re-implement `load()` + `normalize()` — and each copy
361
+ drifted (roots hardcoded to `[]`, `publications[].url` dropped, `experience[].org`
362
+ mistaken for a langMap). `export profile` is that object, assembled by the same
363
+ `load()` every other command uses:
364
+
365
+ ```sh
366
+ pasco export profile --json # the payload
367
+ pasco export profile --json --now 2026-10-28T00:00:00Z | jq . # pin the stamp
368
+ ```
369
+
370
+ ```json
371
+ {
372
+ "schemaVersion": "1",
373
+ "engine": { "name": "@profile-as-code/cli", "version": "<cli version>" },
374
+ "source": { "commit": "<workspace HEAD or null>", "generatedAt": null },
375
+ "profile": { "identity": {}, "…": "the normalized profile (see below)" }
376
+ }
377
+ ```
378
+
379
+ - `schemaVersion` is the payload's frozen version — the same key §7.1/§7.2 use: a
380
+ consumer **refuses a version it does not know** instead of guessing.
381
+ - `source.commit` is the workspace's git `HEAD` — the value a consumer pins for
382
+ freshness — or `null` when the workspace is not inside a git repository.
383
+ - `source.generatedAt` is **`null` unless you pass `--now <iso8601>`**, which is
384
+ emitted verbatim after a real-calendar check. The command reads no clock, so two
385
+ runs over one workspace are byte-identical (a malformed or impossible `--now` —
386
+ `2026-02-31` — is exit `1`, never a silent "now").
387
+ - `profile` is the normalized `load()` object: **every root the workspace declares,
388
+ plus every list root defaulted to `[]`**. An optional object root (`newsletter`,
389
+ `podcast`, `audiences`, `active`) appears only when declared, because the
390
+ contract has no valid empty value for it. `artifactRefs` stay ids and resolve
391
+ against `profile.artifacts[]` (`load()` already refuses a dangling ref),
392
+ `publications[].url` is emitted as declared (never replaced by a DOI link), and
393
+ `deployments` is included. It is the SSOT — **not** a rendered surface, so
394
+ `visibility` is not filtered (renderers own §18.5) and there is no platform
395
+ slice (that is `pasco build`, via `compile()`).
396
+
397
+ Exit `0` ok · `1` the workspace fails to load or `--now` is malformed · `2` usage
398
+ (`--out`/`--check`/`--force`/`--lang` belong to `export ai-policy`). The contract
399
+ is frozen in SPEC §7.5.
400
+
401
+ **A consumer that only needs to validate** does not need this package at all: the
402
+ released schema is published as a tag-pinned URL + sha256 in
403
+ [`schema/consumer-contract.json`](https://github.com/ChristianLuciani/profile-as-code/blob/main/schema/consumer-contract.json).
404
+ The manifest's `current` block is **not** a published artifact: schema v1.15 has
405
+ no release tag yet (`published: false`, `artifact: null`), and the only released
406
+ artifact is schema **v1.13 at tag v0.3.6**, which **rejects `publication.kind`**
407
+ (`additionalProperties: false`) and has no `releases[]`. A consumer that needs
408
+ either must use a v1.15 checkout (verify its sha256) until the next release. The reading guide is
409
+ [docs/SCHEMA-CONTRACT.md](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SCHEMA-CONTRACT.md) —
410
+ including what structural validation does **not** cover (the cross-file
411
+ invariants core re-checks, engine behavior, `.pascorc` and this payload's
412
+ envelope).
413
+
414
+ ### The local view (`pasco preview`, #282)
415
+
416
+ `profile-data` is private because its data is personal, so there is no way to
417
+ *see* the result without publishing something — and a private fork with Pages on
418
+ publishes the data by default (personal plans have no access control). What was
419
+ missing is a **view**, not hosting:
420
+
421
+ ```sh
422
+ pasco preview # <workspace>/.pasco/preview/index.html, then opened
423
+ pasco preview --no-open # write it, open nothing
424
+ pasco preview --out ./dist # write ./dist/index.html, open nothing (CI)
425
+ ```
426
+
427
+ **One file, two transports.** The artifact is the contract: one HTML document,
428
+ inline CSS, no `<script>`, no external asset (no font, no CDN, no badge fetch),
429
+ no HTTP server — it opens from `file://`, so nothing is published. A single file
430
+ has no base URL to resolve, which is why there are no per-platform or
431
+ per-language pages: one page with sections and anchors (`#github-readme`,
432
+ `#linkedin`, …). A remote image in an artifact (a shields.io badge) is rendered
433
+ as a text note instead of being fetched; workspace media is inlined as `data:`
434
+ URIs. The header reads `generated <date> from <N> facts` — freshness, not a
435
+ version — and `--now <iso8601>` pins that stamp so two runs are byte-identical.
436
+
437
+ **What it composes.** The text per platform comes from the same decision tree
438
+ `build` uses (active variant → renderer → shared paste source, with `build`'s
439
+ selection gate), and the checks come from the same report `pasco check` prints.
440
+ `preview` never touches `profiles/`; its default destination is
441
+ `<workspace>/.pasco/preview/`, untracked as long as the workspace ignores
442
+ `.pasco/` — a destination inside a git work tree that does not gets a warning
443
+ that also travels in the page's own notes.
444
+
445
+ **The privacy gate.** The public site publishes public entries only; the preview
446
+ carries your working data, so it **refuses** and writes **no file**:
447
+
448
+ | Rule | Refuses |
449
+ |---|---|
450
+ | `absolute-path` | a machine path (`/Users/…`, `C:\…`, `file:///…`) in the document |
451
+ | `credential` | a pasted token or private key (token shapes, `secret: <16+ non-space>` assignments) |
452
+ | `external-asset` | a `<link>`, a remote `src`, `url(https:…)`, `@import` |
453
+ | `active-content` | a `<script>`, an `on*=` handler, an `<iframe>`, a `javascript:` URL |
454
+ | `source-outside-declared-roots` | a source that resolves outside the workspace (a symlink escape) |
455
+ | `source-missing` · `credential-file` | a declared source that is gone; a dotfile-shaped asset (`.env`, `.netrc`, `id_rsa`, `*.pem`) |
456
+
457
+ Evidence-root media is never inlined (a per-machine path never reaches a render);
458
+ an oversized image is left out and listed, never silently dropped. The gate's
459
+ own refusal classes are exercised by the package test suite.
460
+
461
+ **Limits, stated.** `--out` into a git work tree without an ignore entry warns —
462
+ a preview committed by accident is the leak the command exists to avoid. The
463
+ preview does not edit, does not encrypt (that breaks the no-JS invariant), and
464
+ does not enforce: it reports the check state, `pasco check` remains the gate. The
465
+ CI-artifact transport is a workflow in your workspace template, `workflow_dispatch`
466
+ only, with no Pages; the artifact is readable by anyone with repository access.
467
+
468
+ ### Contract paths (`pasco edit`, epic #247 — read · preview · write)
469
+
470
+ One command answers *"what will the published text be?"* and, when asked, **writes
471
+ exactly one scalar** without touching a byte of anything else: the dogfood that
472
+ opened the epic had to hash `content/*.yaml` before and after and read a
473
+ `pasco build` scrollback to answer the same question.
474
+
475
+ ```sh
476
+ pasco edit positioning.statement.en # the current value
477
+ pasco edit positioning.statement.en --set "…" --check # the published lines this edit changes
478
+ pasco edit positioning.statement.en --set "…" # write it (one line, comments intact)
479
+ pasco edit headlines[platform=github-readme,lang=en].text --set "…" --check
480
+ pasco edit identity.links[platform=github].url # read-only (the URL SSOT)
481
+ ```
482
+
483
+ Addressable fields (by **contract path**, never by line/offset — the schema is the
484
+ authority on what a field is):
485
+
486
+ | Address | Resolves to |
487
+ |---|---|
488
+ | `positioning.statement.<lang>` | `positioning.statement[lang]` (`lang`: `en`/`es`/`fr`) |
489
+ | `headlines[platform=<p>,lang=<l>].text` | the matching `headlines[]` row |
490
+ | `bios[platform=<p>,lang=<l>,length=<len>].text` | the matching `bios[]` row |
491
+ | `identity.links[platform=<p>,label=<l>].url` | the row's URL — **read-only**; `label` disambiguates two links on one platform |
492
+
493
+ **The write (S2, #249).** A byte-range splice over the **original text**: the CST
494
+ locates the scalar and only those bytes are replaced, so comments, alignment and
495
+ layout survive *by construction* and the diff is **one line** (a document
496
+ re-serialization would normalize both — measured 2026-10-21). The new text goes to
497
+ a temp file in the same directory, is validated by `load()` + schema over a temp
498
+ copy of `content/`, and only then replaces the file atomically — **an invalid
499
+ edit never reaches disk**. CRLF and the final newline are preserved. Block scalars
500
+ (`|`, `>`) and multi-line scalars are **refused** in v1 (a replacement would change
501
+ the line count), and so are **anchored/aliased** scalars (rewriting one would
502
+ silently change every alias). The file's node must still hold the value the
503
+ command resolved — a row inserted or reordered since the address was resolved is
504
+ a **staleness** refusal (exit 1), not a silent write to the wrong row.
505
+ Concurrent `pasco edit` writers on one workspace serialize on
506
+ `content/.pasco-edit.lock` (a second waits 2 s, then refuses; a lock left by a
507
+ crash is broken after 30 s); an external editor is caught by the re-read
508
+ immediately before the atomic rename. A symlinked
509
+ content file keeps its link (the target is replaced) and the file mode is
510
+ preserved. Re-setting an identical value is a **noop** (exit 0, nothing written).
511
+ Rows the artifact does not emit can still be written — the command says *what* was
512
+ written and *whether it publishes*, separately.
513
+
514
+ **The preview (`--check`).** It renders the surface exactly the way `pasco build`
515
+ does — active variant (#238) → renderer → shared paste source — twice (current
516
+ profile, proposed profile) and prints the artifact lines that differ. That is why
517
+ it is honest where echoing the addressed row would not be: a paste surface
518
+ publishes exactly **one** bio (length priority `long > medium > short`), several
519
+ renderers never emit the statement, and a renderer-less surface carries no
520
+ headline. In those cases the artifact does not change and the preview says so —
521
+ with the reason, and exit `1`. It runs the writer's **eligibility and staleness
522
+ checks first**, so `--check` refuses the same ADDRESSED shapes `--set` refuses
523
+ (block/multi-line/anchored scalars, a stale row, a read-only **file**). A
524
+ read-only **directory**, a validation refusal or a concurrent change can still
525
+ fail at write time: the preview proves *what* would change, not that the
526
+ filesystem will accept it.
527
+
528
+ > v1 writes single-line scalars only — the recorded decision, not a bug. A
529
+ > workspace whose bios are wrapped by an editor must be reflowed to one line per
530
+ > value before `pasco edit` can write it; this repo's `minimal` fixture is already
531
+ > single-line for exactly that reason (it is the reference workspace the tests
532
+ > write through).
533
+
534
+ An address that resolves to **several** rows is refused with the rows printed: a
535
+ duplicate bio (`drift/duplicate-bio`), or two links on one platform (add
536
+ `label=<…>` — not drift: drift flags the same URL twice). An address that
537
+ resolves to **0 rows** is reported with the addresses the workspace does have;
538
+ `edit` never creates rows.
539
+
540
+ **What the edit invalidates (S3, #250).** Every run against a **resolved, writable
541
+ address** (a read, a preview, a write) reports the edited unit's **surface impact**: the §19 ledger is consulted by ledger key
542
+ (`bios:<platform>:<lang>:<length>`, `headlines:<platform>:<lang>`,
543
+ `positioning`), every **recorded** surface prints its state (`stale` when the
544
+ edit changes the unit's `contentHash`, `in-sync` when the record's hash still
545
+ matches), and a unit with no record reads `never` (the unit rollup). It is
546
+ **derived on every run and stored nowhere** (SPEC §20), from the same
547
+ `deployStatus` `pasco status` uses, so the two commands cannot disagree. The
548
+ block also carries the **drift count** — measured on every run, read included —
549
+ and the write's `validates:` line repeats it. The ledger tracks the SSOT **unit**,
550
+ the published block tracks the **artifact**: an edit can invalidate a recorded
551
+ surface while no artifact changes (a headline on a paste-only surface), and both
552
+ lines are true.
553
+
554
+ **The cap pre-flight (S4, #251).** Before a headline/bio value is accepted, the
555
+ edit measures it against the catalog's own `max-chars` cap — `content/checks.yaml`
556
+ when the workspace declares checks, the **sourced caps shipped with pasco**
557
+ otherwise — and prints the cap, its **cited source** and its provenance tier. Over-cap is a
558
+ **warning**, never a block (D12: facts warn, schema fails): `edit` says so, `check`
559
+ reports the same finding (same executor), and a platform with **no sourced cap**
560
+ says `cap: unknown` instead of guessing a number.
561
+
562
+ `--json` payload (inside the CLI's usual envelope, which also carries `command`,
563
+ `ok` and `exitCode`): `{ address, current, proposed, rendered, ok, noop, written,
564
+ warnings, stale, state, drift, limit, over, variant, reason }` — `stale` maps each **recorded** surface to
565
+ `in-sync|stale`, `state` is the §19 unit rollup (`never` when the unit has no
566
+ record; `null` for a read-only link or an address that is not one row), and
567
+ `drift` is the measured drift-check count (never a defaulted 0). `rendered` is the published text the edit produces (or `null`),
568
+ `noop` says the value was already there, `written` says the file changed, and
569
+ `warnings` carries the non-blocking findings a write proceeded through (a D22/§21
570
+ selection error that makes `pasco build` refuse the workspace — the SSOT write is
571
+ still valid, so it is reported, not blocked). Exit codes: **0** the write succeeded
572
+ (or the noop was a noop), or the preview changes the artifact · **1** the address
573
+ did not resolve to exactly one row, the target is read-only or changed under the
574
+ command (stale), the validation refused the result, or (preview only) the artifact
575
+ does not change or a D22/§21 selection error makes `build` refuse the workspace
576
+ (`kind: "selection"`) · **2** usage (malformed address, read-only field, block/multi-line
577
+ scalar, anchored/aliased scalar, a value with a line break, `--check` without
578
+ `--set`).
579
+
580
+ ### The machine contract (`--json`, S5 / #252)
581
+
582
+ The payload is **frozen** (additive-only from here): `command`, `ok` and `exitCode`
583
+ are the envelope; the edit's own fields are
584
+
585
+ | Field | Type | Meaning |
586
+ |---|---|---|
587
+ | `address` | string | the canonical contract path |
588
+ | `current` | string \| null | the value before the change (the epic's `old`) |
589
+ | `proposed` | string \| null | the value the caller passed (the epic's `new`) |
590
+ | `rendered` | string \| null | the published lines the edit produces, or `null` |
591
+ | `ok` | boolean | the operation succeeded — for a preview, that the artifact changes (the epic's `valid`) |
592
+ | `noop` | boolean | the value was already there; nothing was written |
593
+ | `written` | boolean | the file changed |
594
+ | `warnings` | string[] | non-blocking findings the run proceeded through (over-cap, a selection error, a malformed catalog row) |
595
+ | `stale` | `Record<surface, "in-sync" \| "stale">` | each **recorded** surface of the edited unit |
596
+ | `state` | `"never" \| "in-sync" \| "stale" \| null` | the §19 unit rollup (`null` when the address is not one writable unit) |
597
+ | `drift` | number | the measured drift-check count |
598
+ | `limit` | `{cap, source, tier} \| null` | the applicable published cap; `null` when the catalog has none **or** the address type has no cap (a statement or a link) |
599
+ | `over` | boolean | the value exceeds that cap |
600
+ | `variant` | string \| null | the active #238 variant shadowing the **addressed platform** (a statement names its variants in `warnings` instead) |
601
+ | `reason` | string \| null | why the preview does not reach the artifact (the same sentence the human output prints as `why:`) |
602
+
603
+ The epic's sketch maps onto this contract as `old → current`, `new → proposed`,
604
+ `valid → ok`, `limits → limit`, `exit → exitCode`; a surface with no ledger record
605
+ is not a per-surface `never` in core's `SyncState` — it is the unit rollup in
606
+ `state`.
607
+
608
+ **Failure payload.** A code-1 failure carries `error: {kind, message}` and **no
609
+ `data`**; `kind` is `usage` (no JSON at all — the message goes to stderr) for
610
+ code 2, and `schema` · `load` · `stale` · `refused` · `selection` for code 1. A
611
+ `selection` error on a **preview** is exit 1, on a **write** exit 0 with the
612
+ finding in `warnings` (the SSOT write is valid even when `build` refuses).
613
+
614
+ **Non-goal (explicit):** `edit` never decides *which variant is live* — that is
615
+ `pasco refine --choose` (#238). What `edit` does is **name** the active variant so
616
+ nobody edits a field the renderer ignores.
617
+
618
+ Exit codes (one row per user-visible state; the CLI rows are pinned in
619
+ `packages/cli/tests/edit.test.ts`, the write-path rows — stale, read-only,
620
+ validation, lock contention — at the writer seam in
621
+ `packages/cli/tests/edit-write.test.ts`):
622
+
623
+ | Code | When |
624
+ |---|---|
625
+ | **0** | a read · a write · a noop · a preview that changes the artifact (over-cap included — D12: facts warn) |
626
+ | **1** | the address is not exactly one row · the target is read-only or stale · the validation refused the result · a preview that does **not** reach the artifact |
627
+ | **2** | usage: malformed address, unknown platform/lang, `--set` on a read-only link, `--check` without `--set`, an empty `--set`, a value with a line break, a block/multi-line/anchored scalar |
628
+
629
+ **The agent loop ("verify my edit"):**
630
+
631
+ ```sh
632
+ # 1. preview — what will be published, and does it fit?
633
+ # (use a field the artifact CARRIES: a headline on a paste-only platform exits 1
634
+ # with rendered:null — see `reason`, which says why)
635
+ pasco edit 'headlines[platform=github-readme,lang=en].text' --set "$TEXT" --check --json
636
+ # data.rendered = the published lines · data.reason = why not, when null ·
637
+ # data.over = cap pre-flight · data.variant = #238 shadow
638
+
639
+ # 2. write only when the preview says what you meant (exit 0)
640
+ pasco edit 'headlines[platform=github-readme,lang=en].text' --set "$TEXT" --json
641
+ # data.written = the file changed · data.stale = the surfaces this invalidated
642
+
643
+ # 3. read back (no --set) — the cheap confirmation the dogfood was missing
644
+ pasco edit 'headlines[platform=github-readme,lang=en].text' --json
645
+ ```
95
646
 
96
647
  ### Publication truth (derived — SPEC §20.1, #242)
97
648
 
@@ -138,11 +689,22 @@ pasco status # …also prints the derived publication-t
138
689
  pasco refine linkedin --variants 3 # emits the agent task template (full flow M3)
139
690
  pasco refine linkedin --choose li-en-2 # records the chosen variant in content/active.yaml
140
691
  pasco check github-readme
692
+ pasco audit # findings only: dangling-ref · contradiction · unsourced-claim
693
+ pasco audit --json # { schemaVersion, findings } — exit 1 iff any FAIL
694
+ pasco entity --json # entity facts + @id + sameAs + gaps (exit 1 without @id)
695
+ pasco entity --jsonld # paste-ready <script type="application/ld+json"> (+ FAQPage)
696
+ pasco cite-check answers.ndjson # per answer: cited? high/medium/low + why
697
+ pasco cite-check answers.ndjson --json # { schemaVersion, entity, answers[] }
141
698
  pasco post seed demo --text seed.md # week-1 experiment: posts/<seed>/seed.md
699
+ pasco post seed essay --text essay.md --format longform # essay/newsletter seed
142
700
  pasco post task demo --channel linkedin,x # task bundles (no LLM call)
701
+ pasco post task essay --format longform --channel medium,substack
702
+ pasco post task demo-x --format short --from essay --channel x # derived family
143
703
  pasco post choose demo --channel linkedin --variant linkedin-1
144
704
  pasco post check demo # limits + URLs + {{fact:...}} refs
145
- pasco post export demo --target postiz # POST /posts draft payload
705
+ pasco post export demo --target postiz # Postiz payload, or a paste checklist
706
+ pasco export ai-policy --out ./public # robots.txt + llms.txt + AI-POLICY.md (M9)
707
+ pasco export ai-policy --check # exit 1 on an unsourced or >180-day UA list
146
708
  pasco skills:export --list # skill channels shipped with this package
147
709
  pasco skills:export ./pasco-skills # copy them to a directory you choose
148
710
  ```
@@ -157,6 +719,7 @@ file) > current directory.
157
719
  ```json
158
720
  {
159
721
  "workspace": ".",
722
+ "engine": "0.4.0",
160
723
  "defaults": { "lang": "en", "variants": 3 },
161
724
  "diff": { "tagPattern": "release-*" },
162
725
  "completeness": {
@@ -170,6 +733,18 @@ file) > current directory.
170
733
  }
171
734
  ```
172
735
 
736
+ The `engine` field is the **minimum** CLI version this workspace requires
737
+ (#348, SPEC §7.6), written as exactly `MAJOR.MINOR.PATCH` (a lower bound, not a
738
+ semver range). `validate`, `build`, `export profile` and `preview` check it before doing
739
+ any work: an older install exits `1` with the required version, the installed
740
+ version and the upgrade command; a malformed value exits `1` with the accepted
741
+ syntax. Absent means no requirement. Every `.pascorc` the resolution consulted is
742
+ read — with no `--workspace` flag, the cwd `.pascorc` that resolved the workspace
743
+ AND the workspace's own file — and the **stricter** declaration wins; a malformed
744
+ one in either file is refused. `pasco doctor` reports the same effective
745
+ requirement in one line (with the file it came from); it does not replace the
746
+ gate, and it exits `1` when the workspace is unhealthy.
747
+
173
748
  The `completeness` block is the policy for `pasco status --completeness` (issue
174
749
  #102): `target` names the milestone; `langs` sets which languages text coverage is
175
750
  measured in; `items` overrides the shipped weights per catalog id (a weight of `0`
@@ -182,7 +757,7 @@ written to the workspace; only this policy is stored.
182
757
  | Code | Meaning |
183
758
  |---|---|
184
759
  | `0` | OK — schema valid. Drift warnings still exit 0 (D12: facts warn, schema fails). |
185
- | `1` | Schema failure or operational failure (workspace not found, bad YAML, not a git repo for `diff`). |
760
+ | `1` | Schema or operational failure (workspace not found, bad YAML, not a git repo for `diff`), **an unmet or malformed `.pascorc` `engine` requirement** (`validate` · `build` · `export profile` · `preview`, before any work), **or a data finding that blocks the command** (`edit`: the address is not exactly one row, or the edit does not change the published artifact; `audit`: at least one `FAIL` finding; `export ai-policy`: an unsourced or stale UA list, or a refused write; `preview`: the privacy gate refused a document, or the write failed). |
186
761
  | `2` | Usage error (unknown command/platform/lang, bad flags). |
187
762
 
188
763
  Human output is EN by default (D8/§11); `--json` prints a machine-readable
@@ -198,3 +773,17 @@ pnpm -r build # strict TS, all packages
198
773
 
199
774
  Tests execute the **built binary** against the core fixtures (reused, not
200
775
  duplicated) plus a minimal invalid fixture — no mocks, real exit codes.
776
+
777
+ ## Support
778
+
779
+ `pasco` is free (MIT). Consider a tip ;) — one line in `--help`, no post-install
780
+ script, no nag on every command:
781
+
782
+ - [Ko-fi](https://ko-fi.com/christianluciani)
783
+ - [Patreon](https://www.patreon.com/cluciani)
784
+ - [GitHub Sponsors](https://github.com/sponsors/ChristianLuciani)
785
+
786
+ A tip funds the engine (`packages/core`) and this CLI — the schema contract
787
+ (`docs/SPEC.md`), the renderers and the platform skills. It buys **no** SLA, no
788
+ priority handling, and no influence over the roadmap: a proposal is weighed on
789
+ its merits, in public. The code stays MIT — it is yours whether you tip or not.