@profile-as-code/cli 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The `pasco` CLI — `@profile-as-code/cli`. Consumes `@profile-as-code/core`
4
4
  (`load`/`driftCheck`/`geoCheck`/`compile`) — it never re-implements them. The
5
- contract source is `schema/profile.schema.json` (D3); Zod is generated in core.
5
+ contract source is [the profile schema](https://github.com/ChristianLuciani/profile-as-code/blob/main/schema/profile.schema.json) (D3); Zod is generated in core.
6
6
 
7
7
  ## Install
8
8
 
@@ -22,14 +22,14 @@ server, no account.
22
22
  > npm-publish attestations + SLSA provenance v1; `0.3.6` is prepared in this release
23
23
  > (CHANGELOG `[0.3.6]`). The `0.3.0` tarball carries none because it was published while
24
24
  > the source repository was private.
25
- > Source install: [User Guide §2](../../docs/USER-GUIDE.md#2-install-the-pasco-cli).
25
+ > Source install: [User Guide §2](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/USER-GUIDE.md#2-install-the-pasco-cli).
26
26
 
27
27
  ## Get a workspace
28
28
 
29
29
  `pasco` reads a **workspace folder** (`content/*.yaml` + `.pascorc`) by configured
30
30
  path. That folder does not have to be a git repository, and it does not have to live
31
31
  on GitHub — a repo is the **recommended** place to keep it, not a requirement
32
- ([SPEC §9](../../docs/SPEC.md)). Three on-ramps, each with its capability difference
32
+ ([SPEC §9](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SPEC.md)). Three on-ramps, each with its capability difference
33
33
  stated up front:
34
34
 
35
35
  | On-ramp | What you get | Automation + history | Account + repo |
@@ -48,9 +48,44 @@ The two no-account on-ramps keep the whole engine command set except `pasco diff
48
48
  history — `pasco init` is minimal on purpose, and the complete package lives in
49
49
  the template. **Where each on-ramp is discovered:** the template repo's own README,
50
50
  this package page, and — in the repository — the
51
- [User Guide §3](../../docs/USER-GUIDE.md#3-create-your-workspace-profile-data-username)
52
- (EN). Links into `docs/` resolve in the repository, not on this npm page: the two
53
- commands above are the standalone path.
51
+ [User Guide §3](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/USER-GUIDE.md#3-create-your-workspace-profile-data-username)
52
+ (EN). The links point at the public repository; the two commands above are the
53
+ standalone path.
54
+
55
+ ## How the cycle resolves (three questions)
56
+
57
+ **What does `fact-present` compare?**
58
+
59
+ > **Symmetric**: a URL the surface adds → `dangling-ref`; a URL it omits, a date or
60
+ > number either side carries and the other does not, or a projected text difference
61
+ > with no token to attribute → `contradiction`.
62
+
63
+ > All are WARN (D12: facts warn), and a targeted surface with no artifact on disk
64
+ > reports the rule as **not run**, never as OK
65
+
66
+ Canonical home: [SPEC §7](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SPEC.md#7-core-interfaces-deep-module).
67
+
68
+ **In what order does `build` choose an artifact?**
69
+
70
+ > **The artifact source is resolved in ONE fixed order** (`renderArtifact`): an
71
+ > active variant body (#238, written verbatim as the platform artifact) → the
72
+ > platform renderer from the generated renderer map → the shared paste source
73
+ > (#239, a manual-paste surface writes `profiles/<platform>.md`). First match wins;
74
+ > `preview` and `edit --check` read the same tree.
75
+
76
+ Canonical home: [SPEC §7](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SPEC.md#7-core-interfaces-deep-module).
77
+
78
+ **Where do the per-platform character caps come from?**
79
+
80
+ > The **limit executor** measures the workspace's `content/checks.yaml` when it
81
+ > declares checks, else the caps shipped with the package (#240); the `max-chars`
82
+ > rows the shipped `skills/<platform>/checks.yaml` declare are **declared and not
83
+ > executed** — no skill row reaches that executor.
84
+
85
+ > a platform with no sourced cap reads `cap unknown` in text mode (`limit: null` in
86
+ > `--json`)
87
+
88
+ Canonical home: [SPEC §7](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SPEC.md#7-core-interfaces-deep-module). The gap these clauses state is tracked in #455.
54
89
 
55
90
  ## Commands
56
91
 
@@ -58,14 +93,24 @@ commands above are the standalone path.
58
93
  |---|---|---|
59
94
  | `pasco init [dir]` | Scaffold a profile-data workspace (`content/identity.yaml`, `README.md`, `.pascorc`) — `pasco validate`-clean | ✅ |
60
95
  | `pasco validate [--json]` | Schema **fail** (exit 1) + drift/geo **warn** (exit 0) — D12 | ✅ |
61
- | `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`) | ✅ |
62
- | `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) |
96
+ | `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, state, problem?, source? } }`, where `state` is `absent` · `met` · `NOT MET` · `INVALID` — an absent declaration is a state, never a silent pass. Exit 0 healthy · 1 unhealthy · 2 usage; offline, no clock, no writes | ✅ v1 |
97
+ | `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 [the media pipeline guide](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/media-pipeline.md)) | ✅ |
98
+ | `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 |
99
+ | `pasco edit <contract-path> [--set <text> [--check]]` · `--list [<block>]` · `--create <root> [--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). Covers the answer-engine surface too: `identity.definition.<lang>`, `faq[id=<slug>,lang=<l>].q|.a` and `faq[id=<slug>].source` (#435). `--list` is the read-only discovery surface: every addressable row, one per line, `--json` as `{ addresses: [{ address, kind, readOnly }], warnings }` (#411). `--create <root>` seeds a root the workspace does not declare yet (`content/faq.yaml`): schema-valid template, refused when the root exists, same validation + cap pre-flight, `--check` previews it (#435). Epic #247, S1–S5 landed | ✅ S1–S5 (read · preview · write · surface impact · cap pre-flight · machine contract) + `--list` + `--create` |
63
100
  | `pasco diff [--json]` | Changes since the last `release-*` tag (git-based → CHANGELOG hint) | ✅ |
64
- | `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) | ✅ |
101
+ | `pasco status [platform] [--mark <ledgerKey> <platform>] [--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) | ✅ |
102
+ | `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
103
  | `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) | ✅ |
104
+ | `pasco check [platform]` | The workspace's checks declared in `content/` (schema-validated catalog) — or the conservative caps shipped with pasco when it declares none (#240) — + 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) |
105
+ | `pasco discover <url>... [--json] [--allow-host <hosts>] [--observations <file>]` | **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). `--observations` takes the rows a human read by hand (`surface`, `field` = `name`/`link`/`experience`, `value`, `url`, `retrieved_at`) for surfaces the machine cannot read (LinkedIn 999, no `Person` node): no request is made, the rows enter the same provisional profile/findings, and `--json` (payload **v2**: `via`, nullable `status`) marks each surface `via: fetch|manual`; a disagreement between two surfaces is a `contradiction` (two different names included). **Writes nothing** | ✅ v1 |
106
+ | `pasco achievements [--targets] [--json]` | The **career impact log** (D24/§23, #446): one row per result — `metric` (`value`/`target`/`baseline`/`unit`/`direction`) or `milestone` — with the units it belongs to (`refs`), how the result is known (`basis`) and its required `visibility`. The status is **DERIVED** at read time (`planned` · `measured` · `achieved` · `partial`), never stored; `--targets` prints only the planned goals; `--json` emits `{ schemaVersion: "1", scope, achievements }`. Read-only | ✅ v1 |
107
+ | `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 |
108
+ | `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 |
109
+ | `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 |
110
+ | `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 |
111
+ | `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 |
112
+ | `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; [the post experiment notes](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/post-mvp.md)) |
113
+ | `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
114
 
70
115
  The partial commands are explicit, and each one says what still owns the rest:
71
116
 
@@ -74,24 +119,387 @@ The partial commands are explicit, and each one says what still owns the rest:
74
119
  `--choose` records the pick in `content/active.yaml`. The platform skill ships
75
120
  **inside this package**, so the template prints its real path on an npm install
76
121
  (and says `no skill for <platform>` vs `skills not shipped` distinctly —
77
- #253). The full variant flow is M3 (`src/commands/refine.ts`).
122
+ #253). The full variant flow is M3.
78
123
  - **`check`** — executes the offline rules that exist today: the brand handle
79
124
  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
81
- 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`).
125
+ the media-transport rules (M8/§18.6), the `max-chars` / `min-chars` rows
126
+ declared in the workspace's `content/` (or the package's conservative caps when
127
+ it declares none) over the headline/bio rows that exist
128
+ (offline, warn-class, #240), and **`fact-present`** — for the surfaces whose
129
+ catalog declares it (`content/checks.yaml` ∪ the shipped
130
+ `skills/<platform>/checks.yaml`): the artifact `pasco build` last wrote is
131
+ compared **symmetrically** against the SSOT render — a URL, ISO
132
+ date or number one side carries and the other does not, or a text difference no
133
+ fact token explains, is a finding — WARN in `check` (D12), because a published
134
+ surface that disagrees with the SSOT is a fact problem, not a schema one. A
135
+ targeted surface with no artifact reports the rule as **not run**, never as OK. The remaining
136
+ per-platform rules in the catalog ship with the skills `checks.yaml` union (M3)
137
+ and the full `pasco check` (M4).
85
138
  - **`skills:export`** — copies the platform skills this package ships into the
86
139
  directory the user names (`--list` first; an existing non-empty directory needs
87
140
  `--force`). It replaces the earlier projection stub: the CLI never invents a
88
141
  per-agent destination and never points at tooling the reader cannot open
89
- (`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`).
142
+ (#254).
143
+ - **`post`** — the post experiment (#264/#265, long-form #266): a CLI
144
+ subcommand family over the `posts/` store; it never calls an LLM, emits the
145
+ agent task, records the choice, checks limits/URLs/refs (plus title/dek and
146
+ the word band for long-form) and exports a Postiz draft for API channels or a
147
+ paste checklist for Substack/Ghost. A seed is `format: short | longform`; a
148
+ short child derived from an essay records `derivedFrom: <essay-id>`. It reads
149
+ the `skills/` bundled with the package (set `PASCO_SKILLS_DIR` only to point
150
+ at a custom tree), and it is **not part of the v1 contract yet**
151
+ ([the post experiment notes](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/post-mvp.md)).
152
+
153
+ ### The audit (`pasco audit --json`, #283)
154
+
155
+ `audit` is an **offline JSON projection over existing checks** — not a second
156
+ analysis engine and not a scoring surface. It aggregates per identity (where
157
+ `check` is per document) and reports **findings only**: no score, no
158
+ prioritisation, no branding. Those belong to the consumer (`PROP-017 §Module
159
+ boundary`), and putting them in the engine would recreate the monolith the
160
+ module split exists to prevent.
161
+
162
+ ```sh
163
+ pasco audit # human list, one block per finding
164
+ pasco audit --json # machine contract
165
+ ```
166
+
167
+ ```json
168
+ {
169
+ "schemaVersion": "1",
170
+ "scope": { "catalog": "shipped", "declared": ["github-readme"], "checked": ["github-readme"], "notRun": [] },
171
+ "findings": [
172
+ {
173
+ "class": "dangling-ref",
174
+ "severity": "FAIL",
175
+ "path": "projects[hub].links[0]",
176
+ "detail": "drift/url-not-in-identity — URL https://github.com/someoneelse lives on a platform host but is not in identity.links"
177
+ }
178
+ ]
179
+ }
180
+ ```
181
+
182
+ `scope` is the artifact side (the SSOT-side rules always run): `catalog` is
183
+ `shipped` | `not-shipped` (a package without its skills tree cannot declare the
184
+ premise — a bundle state, not "nothing declared"); `declared` are the targeted
185
+ surfaces whose catalog declares the `fact-present` premise, `checked` the
186
+ declared ones whose built artifact was compared, `notRun` the declared ones with no
187
+ artifact on disk or no renderer for the flavor. **`declared: []` means the premise
188
+ is not declared anywhere; `checked: []` means it measured nothing** — either way the
189
+ audit says so instead of looking clean.
190
+
191
+ | Class | Fires when | Severity |
192
+ |---|---|---|
193
+ | `dangling-ref` | a pointer does not resolve in the current view | **FAIL** |
194
+ | `contradiction` | two surfaces assert incompatible values for one datum | **FAIL** |
195
+ | `unsourced-claim` | a quantitative claim carries no evidence | **WARN** |
196
+
197
+ `schemaVersion` is the contract version: a consumer refuses a version it does not
198
+ know rather than guessing at the shape. `path` is a `factRef` (the row `id`
199
+ replaces the array index when the row declares one); a finding about a built
200
+ surface addresses the artifact path (`profiles/<surface>/README.md`), because a
201
+ fact the SSOT does not carry has no `factRef` by definition.
202
+
203
+ Exit `1` iff a `FAIL` finding exists, `0` otherwise (WARN-only included); `2` is
204
+ a usage error, and a schema/load failure is the standard error envelope at exit
205
+ `1`. The audit never touches the network, and no finding is derived from
206
+ `content/deployments.yaml` (the ledger is not a fact source) — it works on a
207
+ workspace that has neither a ledger nor a built artifact.
208
+
209
+ What it composes ([SPEC §7.1](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SPEC.md)): `load`/schema, `reconciledDrift` (drift
210
+ plus the #238 variant-store reconciliation `validate` applies), `geoCheck` (its
211
+ `faq-coverage` premise gates the claim rows; the codes themselves map to no class),
212
+ the completeness `evidence` premise, and `fact-present`.
213
+
214
+ It is **deliberately stricter than `validate`/`check`** on the two FAIL classes:
215
+ D12 governs validity (drift facts warn, `validate` exits 0), while the audit's
216
+ contract is publishability and the issue freezes the class severities. A drift fact
217
+ in a FAIL class therefore exits 1 here and 0 there — declared in SPEC §7.1 and
218
+ pinned by a test. Two inputs are deliberately NOT findings, declared in §7.1:
219
+ `drift/url-not-in-identity` (the URL resolves — a canonicalization rule `validate`
220
+ warns about) and `geoCheck`'s coverage codes.
221
+
222
+ ### The entity projection (`pasco entity`, #287)
223
+
224
+ `audit` diagnoses; `entity` is the artifact for a surface this engine does not
225
+ render (WordPress, Webflow, Next, a landing page). It exposes the **same** entity
226
+ the renderers embed — the engine's single assembler, so there
227
+ is no CLI-local builder and no second truth (SPEC §7.2).
228
+
229
+ ```sh
230
+ pasco entity --json # the frozen view: facts, @id, sameAs, gaps
231
+ pasco entity --jsonld # paste-ready <script type="application/ld+json"> (+ FAQPage)
232
+ pasco entity --jsonld --bare # the bare JSON object (node array when faq[] exists)
233
+ pasco entity --jsonld --out entity.html # write the block (refuses an existing file)
234
+ pasco entity --jsonld --out entity.html --force # replace it (atomic write)
235
+ ```
236
+
237
+ ```json
238
+ {
239
+ "schemaVersion": "1",
240
+ "entity": { "id": null, "type": "Person", "name": "Elena Vasquez", "sameAs": ["https://github.com/elenavasquez"], "email": null },
241
+ "gaps": [
242
+ { "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." }
243
+ ],
244
+ "jsonld": null
245
+ }
246
+ ```
247
+
248
+ `entity.id` is the URL of the `role: identity` link (the first one wins) or
249
+ `null`: the engine **never invents an `@id`**. `gaps` are the `geoCheck` checks of
250
+ its structured-data section that scored below 1, with the code and detail
251
+ **verbatim** — no new vocabulary. `jsonld` is always `null` in `--json`; the
252
+ artifact has its own mode, and `faqJsonLd` carries the `FAQPage` node the same
253
+ mode emits when the workspace declares `faq[]` (`null` otherwise — never an empty
254
+ `FAQPage`, never an invented `@id`, `faq[].source` travels as
255
+ `acceptedAnswer.citation`). Parsing the `--jsonld` output yields exactly
256
+ `structuredData()`'s object (plus the `FAQPage` node when there is one) —
257
+ asserted by a test. `--bare` without `faq[]` stays byte-identical to the
258
+ pre-#360 artifact.
259
+
260
+ Both modes resolve **one** language (`--lang`, else `.pascorc` `defaults.lang`,
261
+ else `en`): the readiness gaps are measured in the same language the JSON-LD is
262
+ assembled, so a brand whose only populated `mission`/`tagline` is `es` cannot show
263
+ `description` as missing while the block emits it.
264
+
265
+ `--out` writes a temp file and renames it into place (atomic), and **refuses an
266
+ existing file** unless `--force` is passed — a mistyped path must not truncate the
267
+ SSOT. A write failure (existing file without `--force`, ENOENT, EACCES) is an error
268
+ envelope at exit `1`, never a stack.
269
+
270
+ Exit `0` when `@id` is present, `1` when it is absent (the entity has no declared
271
+ home; the JSON-LD is still emitted, without `@id`) or the `--out` write failed, `2`
272
+ for usage. It is offline and deterministic: no network, no clock, no AI.
273
+
274
+ ### The citation matcher (`pasco cite-check`, #291)
275
+
276
+ Every GEO number is priced in "prompts monitored per month", and the half that
277
+ decides whether the number is true is one question: **did this answer actually
278
+ cite this entity?** The network half belongs to the runner; this command answers
279
+ the matching question over the answers it already saved. It is a pure function of
280
+ the SSOT and the input bytes — no network, no model, no clock (D19).
281
+
282
+ ```sh
283
+ pasco cite-check answers.ndjson # one line per answer
284
+ cat answers.ndjson | pasco cite-check # stdin when no path is given
285
+ pasco cite-check answers.ndjson --json # the frozen per-answer contract
286
+ ```
287
+
288
+ Input — the runner's NDJSON, which this engine never produces:
289
+
290
+ ```json
291
+ { "engine": "chatgpt", "askedAt": "2026-10-23T15:00:00Z",
292
+ "question": "¿qué agencia GEO hay en Ecuador?",
293
+ "answer": "…", "citations": [ { "url": "https://…", "title": "…" } ] }
294
+ ```
295
+
296
+ `answer` and `citations[]` are required; `engine`/`askedAt`/`question` are carried
297
+ through. A blank line is ignored; any other malformed line is exit `2` naming the
298
+ line and field — **never a silent skip**, because a skipped line changes a rate.
299
+
300
+ The ladder is fixed, and each rung carries its own confidence:
301
+
302
+ | Rung | What matched | Confidence |
303
+ |---|---|---|
304
+ | `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** |
305
+ | `exact-name` | the full `identity.name`, word-bounded, case- and accent-insensitive (`Domínguez` = `Dominguez`) | **high** |
306
+ | `alias` | `identity.orgName` | **medium** |
307
+ | `handle` | `identity.handle` or a `socialHandles[]` row with `status: claimed` | **medium** |
308
+ | `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** |
309
+
310
+ `confidence: "low"` is **never** a citation in a downstream report — the field
311
+ exists so that refusal is explicit. The matcher also refuses to guess: a one-word
312
+ name whose sentence has no declared disambiguator (`ambiguous-term:pasco` +
313
+ `no-qualifier`, with the longer capitalised reading the text carries named as
314
+ `competing:Pasco County School District`), a mention after a contrast marker
315
+ (`contrast-context`), a shorter declared term inside a longer one
316
+ (`longer-name:…`) and a citation URL the parser rejects (`url-invalid:<url>`, a
317
+ missing scheme being the common case — named, never dropped) are all reported in
318
+ `reasons[]` as **not** citations.
319
+
320
+ ```json
321
+ {
322
+ "schemaVersion": "1",
323
+ "entity": { "name": "Pasco", "aliases": [], "handles": ["pasco_cli"],
324
+ "partials": [], "hosts": ["github.com", "linkedin.com", "x.com"] },
325
+ "answers": [
326
+ { "line": 1, "engine": "chatgpt", "askedAt": "…", "question": "…",
327
+ "cited": true, "confidence": "high",
328
+ "spans": [ { "start": 0, "end": 5, "kind": "name" } ],
329
+ "reasons": ["exact-name"] }
330
+ ]
331
+ }
332
+ ```
333
+
334
+ One row per input line, in input order, **never aggregated**: no rate, no score,
335
+ no total — that belongs to the report layer. A citation span is
336
+ `{ start: 0, end: 0, kind: "citation-url", url }` because the URL is in the
337
+ answer's citation list, not in its text; text spans are offsets into the original
338
+ answer. `entity` is the declared vocabulary the matcher resolved, so a report can
339
+ say what it matched without re-reading the workspace.
340
+
341
+ Exit `0` on a successful read whatever the matches (a non-citation is data) · `1`
342
+ when the workspace or the input path cannot be read · `2` usage, or malformed
343
+ NDJSON. Contract: SPEC §7.4.
344
+
345
+ ### The AI policy artifacts (`pasco export ai-policy`, #290)
346
+
347
+ A profile publishes into a web where two kinds of machine reader arrive: the ones
348
+ whose output is a **citation** (search and answer indexing, a fetch a user asked
349
+ for) and the ones collecting material for **foundation-model training**. The
350
+ project's stance (SPEC §12 M9) is that the second kind does not get the content
351
+ unless the owner opts in — and until this command there was no file that said so.
352
+ One command now emits all three, from the workspace and the platform registry:
353
+
354
+ ```sh
355
+ pasco export ai-policy --out ./public # robots.txt + llms.txt + AI-POLICY.md
356
+ pasco export ai-policy --check # exit 1 when the UA list is stale or unsourced
357
+ ```
358
+
359
+ - **`robots.txt`** carries two *named* groups and never a wildcard: `ai-retrieval`
360
+ is `Allow: /`, `ai-training` is `Disallow: /`. Every `User-agent` line is
361
+ preceded by a comment naming its registry entry, operator, source URL and the
362
+ date that source was read, so a reader can tell **why** a crawler is in a group.
363
+ Because there is no `User-agent: *` group, the file restricts exactly the tokens
364
+ it lists: a crawler it does not name is **not restricted by it** (RFC 9309
365
+ §2.2.1). The artifacts **say that out loud** instead of claiming a default — the
366
+ list covers the crawlers whose classification has been checked against the
367
+ operator's own documentation, not the web.
368
+ - **`llms.txt`** is the workspace index in the shape the project site already
369
+ uses: name, one-line definition, the canonical identity URL, the `faq[]`
370
+ answers, the citable `publications[]`, and a dated stamp. The builder asserts
371
+ that **every document it claims to index appears in the text** — a document
372
+ missing from the index is a silent hole, not a cosmetic bug.
373
+ - **`AI-POLICY.md`** is the statement you send when a client asks "what is your AI
374
+ policy": what is allowed, what is blocked, who can change it, the date, and the
375
+ per-crawler table with each operator's own documentation URL.
376
+
377
+ The crawler list is **derived data**: it lives in the `ai:` block of the platform
378
+ registry, `pnpm gen:platforms` projects it into the engine, and the generator
379
+ refuses an entry with no `source` or no `as_of`. `--check` fails an unsourced
380
+ entry or a source older than **180 days**, and it gates the policy's own
381
+ `as_of` (real date, inside the window, not older than the newest row it seals) —
382
+ and `--out` runs the same check first, so a stale list becomes a failing command
383
+ instead of a file that rots. Tokens compare **case-insensitively** (RFC 9309
384
+ §2.2.1), so `GPTBot` and `gptbot` cannot slip in as two groups. No clock is
385
+ read when writing: the stamp is the registry's review date, so two runs on
386
+ different days produce byte-identical files.
387
+
388
+ `--out` never truncates a file it did not generate: an existing `robots.txt` that
389
+ is not ours needs `--force`. A refused write or a failed `--check` is exit `1`, a
390
+ usage error is exit `2`. The `--json` contract is frozen in SPEC §7.3.
391
+
392
+ ### The normalized profile (`pasco export profile`, #348)
393
+
394
+ `build` renders platform texts and `validate` checks the workspace, but neither
395
+ hands a program to the **profile itself**. A site build, a service or a script
396
+ that needs the facts used to re-implement `load()` + `normalize()` — and each copy
397
+ drifted (roots hardcoded to `[]`, `publications[].url` dropped, `experience[].org`
398
+ mistaken for a langMap). `export profile` is that object, assembled by the same
399
+ `load()` every other command uses:
400
+
401
+ ```sh
402
+ pasco export profile --json # the payload
403
+ pasco export profile --json --now 2026-10-28T00:00:00Z | jq . # pin the stamp
404
+ ```
405
+
406
+ ```json
407
+ {
408
+ "schemaVersion": "1",
409
+ "engine": { "name": "@profile-as-code/cli", "version": "<cli version>" },
410
+ "source": { "commit": "<workspace HEAD or null>", "generatedAt": null },
411
+ "profile": { "identity": {}, "…": "the normalized profile (see below)" }
412
+ }
413
+ ```
414
+
415
+ - `schemaVersion` is the payload's frozen version — the same key §7.1/§7.2 use: a
416
+ consumer **refuses a version it does not know** instead of guessing.
417
+ - `source.commit` is the workspace's git `HEAD` — the value a consumer pins for
418
+ freshness — or `null` when the workspace is not inside a git repository.
419
+ - `source.generatedAt` is **`null` unless you pass `--now <iso8601>`**, which is
420
+ emitted verbatim after a real-calendar check. The command reads no clock, so two
421
+ runs over one workspace are byte-identical (a malformed or impossible `--now` —
422
+ `2026-02-31` — is exit `1`, never a silent "now").
423
+ - `profile` is the normalized `load()` object: **every root the workspace declares,
424
+ plus every list root defaulted to `[]`**. An optional object root (`newsletter`,
425
+ `podcast`, `audiences`, `active`) appears only when declared, because the
426
+ contract has no valid empty value for it. `artifactRefs` stay ids and resolve
427
+ against `profile.artifacts[]` (`load()` already refuses a dangling ref),
428
+ `publications[].url` is emitted as declared (never replaced by a DOI link), and
429
+ `deployments` is included. It is the SSOT — **not** a rendered surface, so
430
+ `visibility` is not filtered (renderers own §18.5) and there is no platform
431
+ slice (that is `pasco build`, via `compile()`).
432
+
433
+ Exit `0` ok · `1` the workspace fails to load or `--now` is malformed · `2` usage
434
+ (`--out`/`--check`/`--force`/`--lang` belong to `export ai-policy`). The contract
435
+ is frozen in SPEC §7.5.
436
+
437
+ **A consumer that only needs to validate** does not need this package at all: the
438
+ released schema is published as a tag-pinned URL + sha256 in
439
+ [the consumer contract](https://github.com/ChristianLuciani/profile-as-code/blob/main/schema/consumer-contract.json).
440
+ This CLI releases schema **v1.16** at tag `v0.4.0`, so the tag-pinned artifact is
441
+ `https://raw.githubusercontent.com/ChristianLuciani/profile-as-code/v0.4.0/schema/profile.schema.json`
442
+ (the tag is the release trigger, so it exists before this tarball is published). A
443
+ consumer that needs `achievements[]` reads that tag; one that does not can keep pinning
444
+ `v0.3.7` unchanged. The reading guide is
445
+ [the schema contract reading guide](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SCHEMA-CONTRACT.md) —
446
+ including what structural validation does **not** cover (the cross-file
447
+ invariants core re-checks, engine behavior, `.pascorc` and this payload's
448
+ envelope).
449
+
450
+ ### The local view (`pasco preview`, #282)
451
+
452
+ `profile-data` is private because its data is personal, so there is no way to
453
+ *see* the result without publishing something — and a private fork with Pages on
454
+ publishes the data by default (personal plans have no access control). What was
455
+ missing is a **view**, not hosting:
456
+
457
+ ```sh
458
+ pasco preview # <workspace>/.pasco/preview/index.html, then opened
459
+ pasco preview --no-open # write it, open nothing
460
+ pasco preview --out ./dist # write ./dist/index.html, open nothing (CI)
461
+ ```
462
+
463
+ **One file, two transports.** The artifact is the contract: one HTML document,
464
+ inline CSS, no `<script>`, no external asset (no font, no CDN, no badge fetch),
465
+ no HTTP server — it opens from `file://`, so nothing is published. A single file
466
+ has no base URL to resolve, which is why there are no per-platform or
467
+ per-language pages: one page with sections and anchors (`#github-readme`,
468
+ `#linkedin`, …). A remote image in an artifact (a shields.io badge) is rendered
469
+ as a text note instead of being fetched; workspace media is inlined as `data:`
470
+ URIs. The header reads `generated <date> from <N> facts` — freshness, not a
471
+ version — and `--now <iso8601>` pins that stamp so two runs are byte-identical.
472
+
473
+ **What it composes.** The text per platform comes from the same decision tree
474
+ `build` uses (active variant → renderer → shared paste source, with `build`'s
475
+ selection gate), and the checks come from the same report `pasco check` prints.
476
+ `preview` never touches `profiles/`; its default destination is
477
+ `<workspace>/.pasco/preview/`, untracked as long as the workspace ignores
478
+ `.pasco/` — a destination inside a git work tree that does not gets a warning
479
+ that also travels in the page's own notes.
480
+
481
+ **The privacy gate.** The public site publishes public entries only; the preview
482
+ carries your working data, so it **refuses** and writes **no file**:
483
+
484
+ | Rule | Refuses |
485
+ |---|---|
486
+ | `absolute-path` | a machine path (`/Users/…`, `C:\…`, `file:///…`) in the document |
487
+ | `credential` | a pasted token or private key (token shapes, `secret: <16+ non-space>` assignments) |
488
+ | `external-asset` | a `<link>`, a remote `src`, `url(https:…)`, `@import` |
489
+ | `active-content` | a `<script>`, an `on*=` handler, an `<iframe>`, a `javascript:` URL |
490
+ | `source-outside-declared-roots` | a source that resolves outside the workspace (a symlink escape) |
491
+ | `source-missing` · `credential-file` | a declared source that is gone; a dotfile-shaped asset (`.env`, `.netrc`, `id_rsa`, `*.pem`) |
492
+
493
+ Evidence-root media is never inlined (a per-machine path never reaches a render);
494
+ an oversized image is left out and listed, never silently dropped. The gate's
495
+ own refusal classes are exercised by the package test suite.
496
+
497
+ **Limits, stated.** `--out` into a git work tree without an ignore entry warns —
498
+ a preview committed by accident is the leak the command exists to avoid. The
499
+ preview does not edit, does not encrypt (that breaks the no-JS invariant), and
500
+ does not enforce: it reports the check state, `pasco check` remains the gate. The
501
+ CI-artifact transport is a workflow in your workspace template, `workflow_dispatch`
502
+ only, with no Pages; the artifact is readable by anyone with repository access.
95
503
 
96
504
  ### Contract paths (`pasco edit`, epic #247 — read · preview · write)
97
505
 
@@ -105,7 +513,10 @@ pasco edit positioning.statement.en # the current value
105
513
  pasco edit positioning.statement.en --set "…" --check # the published lines this edit changes
106
514
  pasco edit positioning.statement.en --set "…" # write it (one line, comments intact)
107
515
  pasco edit headlines[platform=github-readme,lang=en].text --set "…" --check
516
+ pasco edit identity.definition.en --set "…" # the canonical definition
517
+ pasco edit 'faq[id=what-is-elena,lang=en].a' --set "…" # one answer
108
518
  pasco edit identity.links[platform=github].url # read-only (the URL SSOT)
519
+ pasco edit --create faq # seed a missing root (content/faq.yaml)
109
520
  ```
110
521
 
111
522
  Addressable fields (by **contract path**, never by line/offset — the schema is the
@@ -116,8 +527,51 @@ authority on what a field is):
116
527
  | `positioning.statement.<lang>` | `positioning.statement[lang]` (`lang`: `en`/`es`/`fr`) |
117
528
  | `headlines[platform=<p>,lang=<l>].text` | the matching `headlines[]` row |
118
529
  | `bios[platform=<p>,lang=<l>,length=<len>].text` | the matching `bios[]` row |
530
+ | `identity.definition.<lang>` | the canonical per-language definition (#288, v1.10) |
531
+ | `faq[id=<slug>,lang=<l>].q` / `.a` | one `faq[]` row's question/answer for that language — `q`/`a` are langMaps, so `lang` is **required** |
532
+ | `faq[id=<slug>].source` | the row's optional http(s) pointer (`lang` does not apply) |
119
533
  | `identity.links[platform=<p>,label=<l>].url` | the row's URL — **read-only**; `label` disambiguates two links on one platform |
120
534
 
535
+ `faq[]` is selected by its stable `id`, never by array index: a repeated id matches
536
+ more than one row and the command refuses to choose, exactly like a duplicate
537
+ bio. An optional field the row does not carry (`source`, or `q.es`) is **0 rows** —
538
+ `edit` never creates it; the row itself must already exist.
539
+
540
+ **Finding the path (`--list`, #411).** `pasco edit --list` prints every
541
+ addressable row of the loaded workspace — read-only and deterministic, built by
542
+ the same constructors resolution uses, never a second grammar:
543
+
544
+ ```sh
545
+ pasco edit --list # every block, one address per line
546
+ pasco edit --list headlines # scope: positioning | headlines | bios | identity.definition | identity.links | faq
547
+ pasco edit --list --json # { addresses: [{ address, kind, readOnly }], warnings }
548
+ ```
549
+
550
+ Link rows are marked `(read-only)`. A link row whose platform cannot be
551
+ expressed as a selector (empty, padded, or carrying `,`, `]`, `=`, `"` or a
552
+ control character) is a **warning**; a **label** the grammar cannot express is
553
+ dropped and the row is listed by its platform when that resolves to one row (the
554
+ warning says so). Any address that resolves to **more than one row** (a duplicate
555
+ headline/bio, a label-less link sharing its platform) is a warning too: the list
556
+ advertises only paths that resolve to exactly one row, the bar `edit` enforces. `--list` never locates a CST
557
+ node, never takes `content/.pasco-edit.lock` and writes nothing; an empty
558
+ workspace prints the header alone (exit 0). Combining `--list` with `--set`,
559
+ `--check`, `--create` or an address is usage (exit 2), never a silently ignored
560
+ argument.
561
+
562
+ **Creating a missing root (`--create`, #435).** The splice never invents bytes, so
563
+ it cannot create `content/faq.yaml` — the answer-engine surface the product
564
+ exists to be quoted through. `--create <root>` is a **different operation**, not
565
+ a rewrite of the writer: it writes a schema-valid **template** for a root the
566
+ workspace does not declare yet, refuses when the root already exists (idempotent:
567
+ nothing is written, exit 1), and validates the result through the same `load()`
568
+ gate over a temp copy before the file reaches disk. It runs the same cap
569
+ pre-flight as `--set`, and `--check` previews the seed (validates it, writes
570
+ nothing). Both the root name and the file form are accepted:
571
+ `pasco edit --create faq` / `pasco edit --create content/faq.yaml`. Seedable
572
+ roots today: `faq` (3 placeholder pairs — the schema minimum). Every value is a
573
+ `<placeholder>` the owner replaces: the engine never invents facts (D11/D14).
574
+
121
575
  **The write (S2, #249).** A byte-range splice over the **original text**: the CST
122
576
  locates the scalar and only those bytes are replaced, so comments, alignment and
123
577
  layout survive *by construction* and the diff is **one line** (a document
@@ -132,7 +586,8 @@ command resolved — a row inserted or reordered since the address was resolved
132
586
  a **staleness** refusal (exit 1), not a silent write to the wrong row.
133
587
  Concurrent `pasco edit` writers on one workspace serialize on
134
588
  `content/.pasco-edit.lock` (a second waits 2 s, then refuses; a lock left by a
135
- crash is broken after 30 s); an external editor is caught by the re-read
589
+ crash is broken after 30 s — `--create` takes the same lock and re-checks the
590
+ root immediately before its exclusive create); an external editor is caught by the re-read
136
591
  immediately before the atomic rename. A symlinked
137
592
  content file keeps its link (the target is replaced) and the file mode is
138
593
  preserved. Re-setting an identical value is a **noop** (exit 0, nothing written).
@@ -146,7 +601,11 @@ it is honest where echoing the addressed row would not be: a paste surface
146
601
  publishes exactly **one** bio (length priority `long > medium > short`), several
147
602
  renderers never emit the statement, and a renderer-less surface carries no
148
603
  headline. In those cases the artifact does not change and the preview says so —
149
- with the reason, and exit `1`. It runs the writer's **eligibility and staleness
604
+ with the reason, and exit `1`. Two #435 fields have a different published
605
+ surface: `identity.definition` reaches the entity JSON-LD that `github-readme`
606
+ and `github-org` embed (the preview shows that block), and `faq[]` has **no**
607
+ `pasco build` artifact — its published surface is the `FAQPage` node
608
+ `pasco entity --jsonld` emits (#360), so `--check` previews that node's bytes. It runs the writer's **eligibility and staleness
150
609
  checks first**, so `--check` refuses the same ADDRESSED shapes `--set` refuses
151
610
  (block/multi-line/anchored scalars, a stale row, a read-only **file**). A
152
611
  read-only **directory**, a validation refusal or a concurrent change can still
@@ -185,7 +644,12 @@ when the workspace declares checks, the **sourced caps shipped with pasco**
185
644
  otherwise — and prints the cap, its **cited source** and its provenance tier. Over-cap is a
186
645
  **warning**, never a block (D12: facts warn, schema fails): `edit` says so, `check`
187
646
  reports the same finding (same executor), and a platform with **no sourced cap**
188
- says `cap: unknown` instead of guessing a number.
647
+ says `cap unknown` instead of guessing a number. The answer-engine fields are
648
+ pre-flighted the same way against the **contract's** own caps (#435): 160 chars
649
+ per `identity.definition` value, 90 per `faq[].q`, 320 per `faq[].a` (v1.10,
650
+ #288, `[OFICIAL]`). Those are schema `maxLength`s, so over one the pre-flight
651
+ warns and the **write is refused by validation** — the one place the cap is a
652
+ failure, not a warning (D12).
189
653
 
190
654
  `--json` payload (inside the CLI's usual envelope, which also carries `command`,
191
655
  `ok` and `exitCode`): `{ address, current, proposed, rendered, ok, noop, written,
@@ -196,14 +660,17 @@ record; `null` for a read-only link or an address that is not one row), and
196
660
  `noop` says the value was already there, `written` says the file changed, and
197
661
  `warnings` carries the non-blocking findings a write proceeded through (a D22/§21
198
662
  selection error that makes `pasco build` refuse the workspace — the SSOT write is
199
- still valid, so it is reported, not blocked). Exit codes: **0** the write succeeded
200
- (or the noop was a noop), or the preview changes the artifact · **1** the address
201
- did not resolve to exactly one row, the target is read-only or changed under the
202
- command (stale), the validation refused the result, or (preview only) the artifact
203
- does not change or a D22/§21 selection error makes `build` refuse the workspace
204
- (`kind: "selection"`) · **2** usage (malformed address, read-only field, block/multi-line
205
- scalar, anchored/aliased scalar, a value with a line break, `--check` without
206
- `--set`).
663
+ still valid, so it is reported, not blocked). `--create` carries its own payload
664
+ (`{ mode: "create", root, file, created, preview, seed, limits, warnings }`). Exit
665
+ codes: **0** the write succeeded (or the noop was a noop), the preview changes the
666
+ artifact, or the seed was written/previewed · **1** the address did not resolve to
667
+ exactly one row, the target is read-only or changed under the command (stale), the
668
+ validation refused the result, `--create` found the root already present (nothing
669
+ written), or (preview only) the artifact does not change or a D22/§21 selection
670
+ error makes `build` refuse the workspace (`kind: "selection"`) · **2** usage
671
+ (malformed address, read-only field, block/multi-line scalar, anchored/aliased
672
+ scalar, a value with a line break, `--check` without `--set`, `--create` with an
673
+ unknown root or next to `--set`/`--list`/an address).
207
674
 
208
675
  ### The machine contract (`--json`, S5 / #252)
209
676
 
@@ -223,7 +690,7 @@ are the envelope; the edit's own fields are
223
690
  | `stale` | `Record<surface, "in-sync" \| "stale">` | each **recorded** surface of the edited unit |
224
691
  | `state` | `"never" \| "in-sync" \| "stale" \| null` | the §19 unit rollup (`null` when the address is not one writable unit) |
225
692
  | `drift` | number | the measured drift-check count |
226
- | `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) |
693
+ | `limit` | `{cap, source, tier} \| null` | the applicable published cap (the catalog's for headline/bio, the contract's for definition/faq); `null` when there is none **or** the address type has no text cap (a statement, a link, `faq[].source`) |
227
694
  | `over` | boolean | the value exceeds that cap |
228
695
  | `variant` | string \| null | the active #238 variant shadowing the **addressed platform** (a statement names its variants in `warnings` instead) |
229
696
  | `reason` | string \| null | why the preview does not reach the artifact (the same sentence the human output prints as `why:`) |
@@ -244,9 +711,9 @@ finding in `warnings` (the SSOT write is valid even when `build` refuses).
244
711
  nobody edits a field the renderer ignores.
245
712
 
246
713
  Exit codes (one row per user-visible state; the CLI rows are pinned in
247
- `packages/cli/tests/edit.test.ts`, the write-path rows — stale, read-only,
714
+ [the edit command tests](https://github.com/ChristianLuciani/profile-as-code/blob/main/packages/cli/tests/edit.test.ts), the write-path rows — stale, read-only,
248
715
  validation, lock contention — at the writer seam in
249
- `packages/cli/tests/edit-write.test.ts`):
716
+ [the edit write tests](https://github.com/ChristianLuciani/profile-as-code/blob/main/packages/cli/tests/edit-write.test.ts)):
250
717
 
251
718
  | Code | When |
252
719
  |---|---|
@@ -294,8 +761,9 @@ It is **derived and never stored**: `--json` carries it as the additive
294
761
  `publication` array, `--completeness` folds it in as *coverage of publication*
295
762
  (the weighted percentage never moves because a surface drifted), and
296
763
  `--write-checklist` writes the coverage phrase into the generated `CHECKLIST.md`.
297
- The path is `src/publication.ts` (one home for artifact resolution and the three
298
- axes) and `src/limits.ts` (the cap executor `check` and `status` share).
764
+ The resolution lives in [the publication module](https://github.com/ChristianLuciani/profile-as-code/blob/main/packages/cli/src/publication.ts)
765
+ (one home for artifact resolution and the three axes), and the cap executor `check`
766
+ and `status` share lives in [the limits module](https://github.com/ChristianLuciani/profile-as-code/blob/main/packages/cli/src/limits.ts).
299
767
 
300
768
  ## Usage
301
769
 
@@ -315,13 +783,24 @@ pasco status --write-checklist # regenerate CHECKLIST.md from the ledger
315
783
  pasco status # …also prints the derived publication-truth line:
316
784
  # github-readme: in-sync · artifact: matches-SSOT · limits: ok
317
785
  pasco refine linkedin --variants 3 # emits the agent task template (full flow M3)
318
- pasco refine linkedin --choose li-en-2 # records the chosen variant in content/active.yaml
786
+ pasco refine linkedin --choose linkedin-en-2 # records the chosen variant in content/active.yaml
319
787
  pasco check github-readme
788
+ pasco audit # findings only: dangling-ref · contradiction · unsourced-claim
789
+ pasco audit --json # { schemaVersion, findings } — exit 1 iff any FAIL
790
+ pasco entity --json # entity facts + @id + sameAs + gaps (exit 1 without @id)
791
+ pasco entity --jsonld # paste-ready <script type="application/ld+json"> (+ FAQPage)
792
+ pasco cite-check answers.ndjson # per answer: cited? high/medium/low + why
793
+ pasco cite-check answers.ndjson --json # { schemaVersion, entity, answers[] }
320
794
  pasco post seed demo --text seed.md # week-1 experiment: posts/<seed>/seed.md
795
+ pasco post seed essay --text essay.md --format longform # essay/newsletter seed
321
796
  pasco post task demo --channel linkedin,x # task bundles (no LLM call)
797
+ pasco post task essay --format longform --channel medium,substack
798
+ pasco post task demo-x --format short --from essay --channel x # derived family
322
799
  pasco post choose demo --channel linkedin --variant linkedin-1
323
800
  pasco post check demo # limits + URLs + {{fact:...}} refs
324
- pasco post export demo --target postiz # POST /posts draft payload
801
+ pasco post export demo --target postiz # Postiz payload, or a paste checklist
802
+ pasco export ai-policy --out ./public # robots.txt + llms.txt + AI-POLICY.md (M9)
803
+ pasco export ai-policy --check # exit 1 on an unsourced or >180-day UA list
325
804
  pasco skills:export --list # skill channels shipped with this package
326
805
  pasco skills:export ./pasco-skills # copy them to a directory you choose
327
806
  ```
@@ -336,6 +815,7 @@ file) > current directory.
336
815
  ```json
337
816
  {
338
817
  "workspace": ".",
818
+ "engine": "0.4.0",
339
819
  "defaults": { "lang": "en", "variants": 3 },
340
820
  "diff": { "tagPattern": "release-*" },
341
821
  "completeness": {
@@ -349,6 +829,18 @@ file) > current directory.
349
829
  }
350
830
  ```
351
831
 
832
+ The `engine` field is the **minimum** CLI version this workspace requires
833
+ (#348, SPEC §7.6), written as exactly `MAJOR.MINOR.PATCH` (a lower bound, not a
834
+ semver range). `validate`, `build`, `export profile` and `preview` check it before doing
835
+ any work: an older install exits `1` with the required version, the installed
836
+ version and the upgrade command; a malformed value exits `1` with the accepted
837
+ syntax. Absent means no requirement. Every `.pascorc` the resolution consulted is
838
+ read — with no `--workspace` flag, the cwd `.pascorc` that resolved the workspace
839
+ AND the workspace's own file — and the **stricter** declaration wins; a malformed
840
+ one in either file is refused. `pasco doctor` reports the same effective
841
+ requirement in one line (with the file it came from); it does not replace the
842
+ gate, and it exits `1` when the workspace is unhealthy.
843
+
352
844
  The `completeness` block is the policy for `pasco status --completeness` (issue
353
845
  #102): `target` names the milestone; `langs` sets which languages text coverage is
354
846
  measured in; `items` overrides the shipped weights per catalog id (a weight of `0`
@@ -361,7 +853,7 @@ written to the workspace; only this policy is stored.
361
853
  | Code | Meaning |
362
854
  |---|---|
363
855
  | `0` | OK — schema valid. Drift warnings still exit 0 (D12: facts warn, schema fails). |
364
- | `1` | Schema or operational failure (workspace not found, bad YAML, not a git repo for `diff`), **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). |
856
+ | `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). |
365
857
  | `2` | Usage error (unknown command/platform/lang, bad flags). |
366
858
 
367
859
  Human output is EN by default (D8/§11); `--json` prints a machine-readable
@@ -387,7 +879,6 @@ script, no nag on every command:
387
879
  - [Patreon](https://www.patreon.com/cluciani)
388
880
  - [GitHub Sponsors](https://github.com/sponsors/ChristianLuciani)
389
881
 
390
- A tip funds the engine (`packages/core`) and this CLI — the schema contract
391
- (`docs/SPEC.md`), the renderers and the platform skills. It buys **no** SLA, no
882
+ A tip funds the engine and this CLI — the [schema contract](https://github.com/ChristianLuciani/profile-as-code/blob/main/docs/SPEC.md), the renderers and the platform skills. It buys **no** SLA, no
392
883
  priority handling, and no influence over the roadmap: a proposal is weighed on
393
884
  its merits, in public. The code stays MIT — it is yours whether you tip or not.